diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 6657576ea..000000000 --- a/.dockerignore +++ /dev/null @@ -1,15 +0,0 @@ -.git -.github -.venv -.pytest_cache -.ruff_cache -**/__pycache__ -**/*.pyc -apps/showcase_ui/.angular -apps/showcase_ui/dist -apps/showcase_ui/node_modules -build -dist -traces -.env -*.log diff --git a/.env.example b/.env.example index 8b23d0a53..3515a13ad 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,11 @@ ANTHROPIC_API_KEY= OPEN_ROUTER_API_KEY= XAI_API_KEY= +# Custom endpoints: every node in config/artemis.jsonc (default/nodes/presets) +# accepts "api_base", "api_key" and "api_key_env". Priority: +# config api_base > OPENAI_BASE_URL below > provider official endpoint +# OPENAI_BASE_URL=http://127.0.0.1:1234/v1 + # Google Cloud Vision OCR (Optional - for advanced OCR processing) OCR_API_KEY= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..b6fb09732 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,18 @@ +# Line endings are a repository fact, not a per-machine default. Without this file the +# working tree depends on each clone's core.autocrlf, so byte-based checks disagree +# across machines (changelogs #45 measured it, #47 closes it). +* text=auto eol=lf + +# cmd.exe has a known failure surface with LF batch files (labels / goto). +*.bat text eol=crlf +*.cmd text eol=crlf + +# No EOL conversion, no diff. +*.png binary +*.jpg binary +*.ico binary +*.gif binary +*.mp4 binary +*.mp3 binary +*.apk binary +*.keystore binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d1ce0c5d..142d4efa4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,12 +55,66 @@ jobs: - name: Lint Python run: uv run ruff check . + # Kept un-gated on purpose: these are the mechanical keepers of the "one derived + # fact, one authority" rule (changelogs #31, #45) and must run on every event that + # produces a run. They finally did: run 38296414103 (event: push, 2026-10-12) executed + # this step on both OSes and it passed - which is what closed the "never observed in a + # run" blank left by #46 (changelogs #67). + - name: Check documentation gates + run: | + uv run python -X utf8 scripts/check_doc_registry.py + uv run python -X utf8 scripts/check_doc_tables.py + uv run python -X utf8 scripts/check_doc_tables.py --selftest + +# Both of these are pure-python and cheap, so they sit BEFORE the Node/pnpm block: a step that +# fails there aborts the job, and that is exactly how the ratchet stayed invisible for six weeks +# (changelogs #69) - the first CI run that ever reached this job died two steps later. - name: Enforce quality ratchets run: uv run python scripts/quality_ratchet.py - name: Type-check protected core modules run: uv run pyright --project pyright-core.json + # Node is needed only by the fixture generator below; the runner image is not the authority + # for which version it gets. Set up pnpm first so setup-node can wire the pnpm store cache + # (same order as the plugin-contracts job). + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22.22.3" + cache: pnpm + cache-dependency-path: plugins/dsh-autogamer/pnpm-lock.yaml + + # The drift check imports the plugin's OWN MCP client, and that client only exists in + # `dist/` - a build product, not repository content. Without these two steps the step below + # dies with ERR_MODULE_NOT_FOUND, which is exactly how it first failed on the runner + # (run 38296414103) while passing on any dev machine that happens to have a stale dist + # lying around (changelogs #68). + - name: Build the plugin for the fixture check + working-directory: plugins/dsh-autogamer + run: | + pnpm install --frozen-lockfile + pnpm run build + + # G62 (changelogs #52) made the py action surface runnable with no adb and no device, so the + # plugin's generated fixture can be re-derived here instead of being hand-carried -- until now + # no CI job ran scripts/spike-mcp.mjs at all (changelogs #53). Its default mode is the drift + # check: any difference between the live listing and the checked-in fixture exits non-zero. + # Reproduce both halves locally before changing this step - the runner's view is the one + # without a build product: + # rm -rf plugins/dsh-autogamer/dist + # SPIKE_COMMAND="uv run artemis mcp --type mock" node plugins/dsh-autogamer/scripts/spike-mcp.mjs # exit 1, ERR_MODULE_NOT_FOUND + # npm --prefix plugins/dsh-autogamer run build # then the same command exits 0 + - name: Check the py action surface against the plugin fixture + env: + SPIKE_COMMAND: uv run artemis mcp --type mock + run: node plugins/dsh-autogamer/scripts/spike-mcp.mjs + # Device, cloud, manual, and end-to-end suites require dedicated # infrastructure and intentionally do not run on GitHub-hosted runners. - name: Run deterministic Python tests @@ -77,104 +131,44 @@ jobs: --cov=third_party --cov-fail-under=60 - frontend: - name: Frontend tests and production build - runs-on: ubuntu-latest + plugin-contracts: + name: DSH plugin contracts / ${{ matrix.os }} + runs-on: ${{ matrix.os }} timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-latest defaults: run: - working-directory: apps/showcase_ui - - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: "22.22.3" - cache: npm - cache-dependency-path: apps/showcase_ui/package-lock.json - - - name: Install locked frontend dependencies - run: npm ci - - - name: Run frontend tests - run: npm test -- --watch=false --browsers=ChromeHeadless - - - name: Build frontend for production - run: npm run build - - package: - name: Build and smoke-test Python packages - runs-on: ubuntu-latest - timeout-minutes: 30 + working-directory: plugins/dsh-autogamer steps: - name: Check out repository uses: actions/checkout@v4 - - name: Set up Python 3.12 - uses: actions/setup-python@v5 + # Before setup-node, so setup-node can wire the pnpm store cache. + - name: Set up pnpm + uses: pnpm/action-setup@v4 with: - python-version: "3.12" - - - name: Set up uv - uses: astral-sh/setup-uv@v6 - with: - enable-cache: true - cache-dependency-glob: uv.lock + version: 10 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: "22.22.3" - cache: npm - cache-dependency-path: apps/showcase_ui/package-lock.json + cache: pnpm + cache-dependency-path: plugins/dsh-autogamer/pnpm-lock.yaml - - name: Build frontend assets for the wheel - run: | - npm ci --prefix apps/showcase_ui - npm run build --prefix apps/showcase_ui + # The @deepseek-ai/* devDependencies are the real DSH runtime the + # conformance suite boots; no desktop install, no model, no device. + - name: Install locked plugin dependencies + run: pnpm install --frozen-lockfile - - name: Build client and Artemis distributions - run: | - uv build --package artemis-client --wheel --out-dir dist - uv build --package artemis --wheel --sdist --out-dir dist - - - name: Install wheels in a clean environment - run: | - uv venv .smoke-venv --python 3.12 - uv pip install --python .smoke-venv/bin/python dist/artemis_client-*.whl dist/artemis-*.whl + - name: Build the plugin + run: pnpm run build - - name: Smoke-test installed package and CLI - shell: bash - run: | - smoke_dir="$RUNNER_TEMP/artemis-wheel-smoke" - mkdir -p "$smoke_dir" - cd "$smoke_dir" - source "$GITHUB_WORKSPACE/.smoke-venv/bin/activate" - python - <<'PY' - from importlib.metadata import files, version - - import artemis - from artemis.resources import get_bundled_config_path, get_bundled_showcase_dist - - bundled_config = get_bundled_config_path("artemis.jsonc") - bundled_ui = get_bundled_showcase_dist() - assert artemis.__version__ == version("artemis") - assert bundled_config is not None and bundled_config.is_file() - assert bundled_ui is not None and (bundled_ui / "index.html").is_file() - assert not any("node_modules" in str(path) for path in (files("artemis") or ())) - print(f"Installed Artemis {artemis.__version__} with bundled runtime resources") - PY - artemis --version - artemis --help - - - name: Upload distributions - uses: actions/upload-artifact@v4 - with: - name: python-distributions - path: dist/* - if-no-files-found: error - retention-days: 7 + - name: Run plugin contract tests + run: pnpm test diff --git a/.gitignore b/.gitignore index 31cf3e936..4819f127f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,13 +22,14 @@ share/python-wheels/ *.egg MANIFEST -# Virtual Environments +# Virtual Environments (all anchored: unanchored names also match subdirectories +# case-insensitively on Windows, which once hid src/env/preflight.ts from git) .venv/ -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ +/venv/ +/ENV/ +/venv.bak/ +/env/ +/env.bak/ # Testing & Coverage .pytest_cache/ @@ -76,9 +77,7 @@ dmypy.json .settings/ # Node & Frontend (Showcase UI) -apps/showcase_ui/node_modules/ -apps/showcase_ui/.angular/ -apps/showcase_ui/.angular/cache/ +apps/showcase_ui_v2/node_modules/ .npm/ npm-debug.log* yarn-debug.log* @@ -105,6 +104,8 @@ live_screenshot*.png .env .env.* !.env.example +credential_bindings.json +endpoint_library.json *.pem *.key diff --git a/.quality-baseline.json b/.quality-baseline.json index 5b53f91a6..c46b7f3c1 100644 --- a/.quality-baseline.json +++ b/.quality-baseline.json @@ -1,5 +1,5 @@ { - "broad_exception_handlers": 754, + "broad_exception_handlers": 763, "silent_broad_exception_handlers": 0, - "type_ignore_comments": 18 + "type_ignore_comments": 16 } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..8215cfb37 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,208 @@ +# AGENTS.md + +Instructions for ZCode agents working in this repository. + +## What this is + +ARTEMIS — an autonomous Android mobile agent. Python backend (uv-managed, py312) that drives +real/mock Android devices through task workers, plus a web console (FastAPI + Vue 3). + +## Layout + +- `artemis/` — core package: `data_engine/` (session/step persistence, SQLite), `drivers/` + (ADB + `drivers/mock/mock_driver.py`), `runtime/` (daemon, device locks, server lifecycle), + `interfaces/cli/`, `sdk/` +- `apps/admin_console/` — FastAPI server: `routers/tasks.py` (`/api/run`, `/api/status`, + `/api/sessions`), `services/task_queue_service.py` (queue worker spawning task subprocesses), + `core/state.py` (in-process server state + submission registry) +- `apps/showcase_ui_v2/` — Vue 3 + Arco Design Vue + Pinia console (Vite 7, TS strict) +- `mcp_server/`, `packages/artemis-client/`, `tests/unit|integration|e2e/`, `docs/research/` + (design docs worth reading before touching sensitive areas) + +## DSH plugin track (`plugins/dsh-autogamer`) — current mainline + +项目正按 R2' 收缩成一个 DSH 插件。方案、决策和进度都在 `docs/TODO.md`(活文档),**动手前先读它和守则,别从代码倒推约定**。 + +**文档地图(按"什么时候必须读")** + +| 文档 | 什么时候读 | +|---|---| +| `docs/TODO.md` | 任何 DSH 侧改动之前。方案本体:决策表 `D*`、对接语义 `S*`、差距清单 `G*`、P0-P4 checklist 与「P0 执行状态」 | +| `docs/migration/plugin-contract-rules.md` | 改插件、preset 或任一 `*.patch.yml` 之前。守则逐条带出处口径与可执行判据(**现行条款与范围只看 `docs/migration/registry.md`**);尤其 **R6 改 patch 必跑 `--dump-config` 自检**、**R7 复验必须开新会话**、**R9 判「生效」只认官方检查器**、**R20 遇 DSH 行为问题先查官方一手资料(subsystems 服务/事件参考、cordis-primer、cookbook、三个官方 skill、公开 packages 源码)再动手,不许先写猜测实现**、**R21 手镜像的宿主契约必须标出处与版本,并区分「单测证明」与「live 证明」**、**R22 功能实现三步序:查文档 → 写方案 → 才写码(方案提交早于实现提交)**、**R23 不得在 session/event 的 emit 里 append(注入类调用先推出发布边界)**、**R24 判「某事没发生」只认会话日志(wire 看不到终结回合的最后一次工具调用,插件 logger 不进 stdout)**、**R25 定根因先读机制文档、一次只测一个变量(推荐方案前先核它是否已是现状)**、**R26 gh 的写操作必须显式 --repo(默认解析可能打到上游别人的仓库)**、**R27 判据必须经变异校验证明它会红(样本量、执行位置、以及「变异本身没生效」都能把红绿读反),字节类口径写明量 blob 还是工作树,未进 CI 的门禁不许当验收**、**R28 实测/调研结论必须当轮回写台账,被推翻的旧表述不许静默保留(说「已完成 X」要能指到 commit 或命令输出)** | +| `docs/CHANGELOGS.md` | **只记已发生并结案的变更**(`### #N` 三段:问题 / 修法 / 验收),按分类组织、顶部有索引;**已关闭差距 `G*` 的正文在这里**(「已关闭差距史」节)。编号 `#N` 发布后不得改写——守则与 TODO 都按 `changelogs #N` 引用 | +| `docs/migration/registry.md` | **要引用或新增任何编号(D/S/G/R)之前**。编号唯一权威,含 `inventory/*` 与 `TODO.md` 的 G 编号撞车冲突表;`scripts/check_doc_registry.py` 机械守着 | +| `docs/migration/glossary.md` | 读方案时卡住词了:`realm`、`parking`、`PTC`、`roster`,尤其是 **`inject` 有两个意思**(服务依赖 vs 不唤醒的上下文追加) | +| `docs/migration/README.md` | 第一次接触本迁移时的入口页;也是「哪几处别当真」的清单(旧 py 智能栈 prompt 文档、旧控制台研究、docker 材料等) | +| `docs/migration/upstream-plugin-forms.md` | 需要知道官方到底怎么说插件与工具形态时。调研正文、逐条口径标注(文档/源码/实测)、出处链接,以及被撤回的错误归因 | +| `docs/migration/dsh-verification.md` | 查 rc.2 各子系统结论时。注意其中 37 处包内代码行引用属 D15 之前的 asar 口径,沿用前须按声明面重验 | +| `docs/migration/feature-inventory.md` 与 `inventory/01-05` | 担心丢 fork 现有能力时;设备与智能栈部分是上游 canonical 契约 | +| `docs/migration/upstream-rethink.md` | 要回到"为什么基线是 google/artemis"时 | +| `docs/migration/ts-fullstack-spec.md` | 动 py 侧、或问"py 到底要不要全删、怎么删"时。**D19 终态规格**:TS 全栈、py 归零;能力迁移矩阵 + 四期分期;流程与勾选在 `docs/TODO.md` §七 | +| `plugins/dsh-autogamer/README.md` | 安装流与包内形状说明;**状态与阻塞点只在 `docs/TODO.md` §零**,别处不复制 | + +上游一手资料(声明面之外唯一允许的补充来源,公开仓库):`github.com/deepseek-ai/deepseek-harness` +@ `master` —— 三个官方 skill 在 `packages/preset/agent-preset/skills/`( +`cordis-plugin-development` / `editing-cordis-compositions` / `cordis-composition-reference`), +加上 `packages/preset/agent-preset{,-registry}/README.zh.md`、 +`packages/preset/agent-preset-registry/src/mount.ts`、`packages/bundle/web-app/presets/*.patch.yml`、 +`docs/cli-help.zh.md`。**解包 `app.asar` 取证一律禁止(R1)**。 + +**插件侧命令**(在 `plugins/dsh-autogamer/` 内) + +- `npm run build`(tsc,插件走 `dist/`,**改 TS 后必须重新 build 再冷重启 dsh**)/ `npm run test`(vitest) +- **本机装依赖与跑脚本要带旗标**(CI 用 pnpm 10 不受影响): + `pnpm --config.verify-deps-before-run=false install --frozen-lockfile --config.autoInstallPeers=false`, + `pnpm --config.verify-deps-before-run=false run build|test`。不带前者时 pnpm 11 会在每次 `run` 前隐式 + install 并**重写 `pnpm-lock.yaml`**(`autoInstallPeers` 翻转,实测 +507 行)——那是环境产物,别提交 +- 声明面自检:`dsh --profile web --patch --dump-config`(`dsh` 取 PATH,不要写死安装盘符路径——写死曾导致找不到宿主),退出码必须为 0 且输出 grep 不到 `unmatched`(R6);schema 侧用 `--dump-config-schema` +- `dsh plugin` 只是 **pnpm 转发垫片**,只证明"装了",不证明"生效"(R9) +- spike 现场与转储在 `plugins/dsh-autogamer/spike/`:`web-live.patch.yml` 是 live overlay, + `wire-tools.py` 解析 `scripts/proxy-dump.log` 数请求里的 `tools`;`dsh-dump*` / `dsh-schema*` / + `dump-config.*` / `web-server*.log` / token 类文件全部 gitignored,**不要提交含 token 的转储** + +## Docs: a derived fact has exactly one authority + +Anything that can be **derived** from another file (an id range, an id count, a doc's +line/KB size, "what we are blocked on right now") must live in **one** place; everywhere +else is a *pointer*, never a copy. A copy has no mechanism to change when its source does, +so it is wrong on the next edit while still passing every gate. + +- id ranges/counts → `docs/migration/registry.md`; status & blockers → `docs/TODO.md` §零; + closed-change prose → `docs/CHANGELOGS.md`; doc sizes → generated, never hand-typed, and measured + on **LF-normalized bytes** (blob-equivalent) — never raw worktree bytes, which depend on the + checkout (`core.autocrlf`), not on the repository +- Gates enforce it mechanically: `scripts/check_doc_registry.py` fails on a "from id #1 to the + current max" total-range claim outside the authority (pointing ranges like `R2-R8` are fine); + `scripts/check_doc_tables.py` verifies `docs/migration/README.md` §三's size table and rewrites + it with `--refresh-sizes` +- Both gates now run in CI as an un-gated step (`Check documentation gates` in the `python-quality` + job, changelogs **#46**), and they have actually executed on a push (run `38296414103`, changelogs + **#67** — which also falsified `G54`'s "this fork's `on: push` never produces a run"; the cause of + the earlier silent window is still unknown, so don't restate either claim). Still tick them with the **re-runnable commands**, never with + "CI guarantees it": an attestation without a command and without the checkout environment is how + six closed entries (#32–#44) signed off a size table that had never matched on an LF checkout +- Why this rule exists (don't re-derive it): `docs/CHANGELOGS.md` **#30** — six drifted copies + were found at once, and #31 made them un-duplicable; **#45** is the third drift, caused by the + measurement itself rather than by a hand copy. See rule **R27**: a check must be proven able to + fail (mutation-check it) before it may be ticked + +## Commands + +- Backend tests (deterministic, no device needed): `make test` or + `uv run python -m pytest tests/unit/admin_console tests/unit/data_engine -q` +- Frontend: run inside `apps/showcase_ui_v2/` — `npm run test` (vitest), `npm run typecheck` + (vue-tsc), `npm run dev` (or `make dev` / `scripts/dev.sh`: backend :8000 + Vite :5180 with + linked teardown) +- Run the app: `uv run python -m artemis ui` serves API + built console on + `http://localhost:8000` (auto-rebuilds the Vue bundle at startup when sources changed); + `ARTEMIS_MOCK_DRIVER=1` forces the offline mock device driver (no real device required); + `make mock-ui` is the packaged shortcut for exactly that. The server **never auto-opens a + browser** (`--open` is opt-in; end-user launchers pass it explicitly) +- Dev-environment startup habit: start the server in the background with + `ARTEMIS_MOCK_DRIVER=1 uv run python -m artemis ui --no-open`, then open + `http://localhost:8000` in the **agent's in-app browser** (browser-use IAB tab) — never + wait for or trigger the system browser, and don't pass `--open` +- Lint: `uv run ruff check` / `uv run ruff format --check` (line length 100). `scripts/quality_ratchet.py` + is declared as a pre-commit hook but **the hook is not installed in this checkout** (`.git/hooks/pre-commit` + absent — `G69`), so run it explicitly: `uv run python scripts/quality_ratchet.py`. It is also an un-gated + CI step, ordered *before* the Node/plugin build so a build failure cannot mask it (changelogs **#69**). + Don't regress baselines in `.quality-baseline.json`; when raising one, write the measured number + (`--print-current`) and the attribution into `docs/CHANGELOGS.md` + +## Task lifecycle & session representations (high-regression area) + +A submission flows: `POST /api/run` → `TaskQueueService.enqueue_tasks` (assigns +`conversation_id` when absent, records submission metadata in `state.submission_meta` +registry) → worker subprocess (env `ARTEMIS_CONVERSATION_ID`, `ARTEMIS_SUBMITTED_AT`, +`ARTEMIS_SESSION_ID`) → `DataEngine.start_session` persists the session row (SQLite at +`traces/data_engine.db`; columns auto-migrate in `storage.py` init). + +While a task is queued/running it has no DB row; `/api/status` shows it via the queue payload +(task items + DeviceExecutionLock ticket views) and `active_tasks`. Rules learned the hard way: + +- Registry values (thread id, enqueue wall-clock, **raw goal**) must win over lock data: the + ticket view's `created_at` is the lock-file mtime (pick-up time, not submission time) and + lock `description` is formatted as `"frontend task: "` — never let either reach round + titles or ordering +- The engine persists `submitted_at`; chat rounds order by it, never `start_time` (which + drifts to the device-turn moment and reshuffles the queue as rounds launch) + +## Frontend console rules (`apps/showcase_ui_v2/src`) + +- `stores/session.ts` + `utils/session-merge.ts` implement a 4-step merge (DB rows → pending + queue → active tasks → tracking-map bridge). The non-reactive `tracking` map bridges the + queue→running gap; don't create fallback entries that shadow it +- `conversationThreadKey` falls back to a synthetic `round:` key — it is for + timeline/group **filtering only**; submissions must use `submitConversationId` (real + `conversation_id` or null), never a synthetic key +- Order/display anchors use `sessionChronoKey` (`submitted_at ?? start_time`); store + conversation grouping and `AgentTimeline` must share these helpers +- Pending/active session representations must carry `conversation_id` / `submitted_at` + (`mapPendingQueue`, merge step 3) or rounds flash as separate "ghost" conversations +- Specs are co-located `*.spec.ts` (vitest + jsdom); `en-US` locale keys are locked by tests — + add new keys to both `zh-CN` and `en-US` +- The Vite dev proxy strips the `Origin` header on `/api` — required by the backend's + `SameOriginBoundaryMiddleware` (otherwise 403). Keep this when editing `vite.config.ts` +- Built bundle goes to `dist/browser/` and is served by FastAPI (SPA fallback built in); + `npm run sync:resources` publishes it into the wheel fallback `artemis/resources/showcase_ui` + +## Change planning → todo ledger (habit) + +Applies to any non-trivial change. **Fixed order, no skipping (守则 R22): consult the official +DSH docs first, then write the confirmed spec into `docs/TODO.md`, then code - and the spec commit +must land before the implementation commit.** The moment the user confirms a plan/spec, **before** +implementation starts, write the confirmed spec into `docs/TODO.md` as a themed entry. The +entry must be self-sufficient: reading the entry alone must make clear what to do, how to do +it, how to verify it, and what counts as done — no re-deriving state from code or chat: + +- **What**: scope, deliverables, explicit out-of-scope +- **How**: approach and key decisions from the confirmed plan +- **Verify**: exact commands / tests / manual checks +- **Done means**: acceptance criteria as a checklist + +During implementation, after each checkpoint completes, update the entry in the same step — +tick checklist items and record deviations from the plan — proactively, without being asked. +Finished entries are deleted (docs no-old-archives rule); git history carries the narrative. + +**Incidental findings get recorded where they belong**: any problem noticed while working — +out of scope, in passing — is written down in the same step it is found. Not yet fixed → +`docs/TODO.md` as a gap row (numbered per `docs/migration/registry.md`, doc gates re-run); +already fixed on the spot → `docs/CHANGELOGS.md` as a new `### #N` entry (问题 / 修法 / 验收), +with `docs/TODO.md` keeping at most a one-line pointer. A chat-only mention or a silent fix +does not count as recorded. + +**Change-history rules (docs/CHANGELOGS.md「写入规则」is the authority; this is the summary)**: +- **One fact, one place**: TODO holds open work, plans and acceptance checklists; CHANGELOGS holds + closed changes only. When a gap closes, its prose moves to CHANGELOGS and its id gets one row in + the 已关闭差距史 table — TODO keeps no narrative for it. +- **Entry ids are permanent**: `### #N` takes max+1, never renumbered or reused; categorising moves + blocks, never numbers (other docs cite `changelogs #N`). +- **Every entry has the three sections**: 问题描述 (with measured evidence and the official source + text, per R20), 整改方案与实施 (which files and why this way), 验收标准 (re-runnable commands). +- **Bloat control**: past ~60 lines per entry, move the research body into the matching + `docs/migration/*.md` and keep conclusion + link here; new categories must update the index table. +- **Gates**: `scripts/check_doc_registry.py` reads closed `G*` ids from CHANGELOGS' table, so a closed + gap needs a row there **and** in `registry.md`; re-run both doc gates after any of these edits. + +## Change wrap-up + +When a unit of work is complete (tests green, behavior verified), finish it with a **local +commit right away**: `git add ` + a Conventional Commit message. Never push — +the user asks for pushes explicitly. Prefer one commit per coherent fix/feature over letting +unrelated changes pile up in the working tree. + +**Test-sync review habit**: behavioral changes always ride together with their specs — every +new behavior gets a locking test, and a test of removed behavior is removed in the same +commit. After a batch of changes (or before wrapping up a longer session), dispatch review +subagent(s) over the touched test suites (backend `tests/unit/...`, frontend `*.spec.ts`) to +verify tests still match the implementation, close coverage gaps (especially regression +guards for the bugs just fixed), and refactor stale/duplicated fixtures — then land the +follow-up test commit. + +## Environment notes + +- Primary dev OS is Windows (Git Bash): dev scripts handle msys-vs-win32 PID quirks; don't + assume POSIX process control +- `start.bat` / `start.sh` / `make start` are end-user launchers; prefer `uv run python -m + artemis ui --restart` when a stale server holds port 8000 +- Conventional Commits; recent history scopes frontend work as `showcase-v2` diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 61a8c1b43..000000000 --- a/Dockerfile +++ /dev/null @@ -1,68 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -FROM node:22-bookworm-slim AS frontend-builder - -WORKDIR /build/apps/showcase_ui -COPY apps/showcase_ui/package.json apps/showcase_ui/package-lock.json ./ -RUN npm ci -COPY apps/showcase_ui/angular.json apps/showcase_ui/tsconfig*.json ./ -COPY apps/showcase_ui/public/ ./public/ -COPY apps/showcase_ui/src/ ./src/ -RUN npm run build - - -FROM python:3.12-slim AS wheel-builder - -WORKDIR /build -COPY artemis/ ./artemis/ -COPY apps/ ./apps/ -COPY mcp_server/ ./mcp_server/ -COPY third_party/ ./third_party/ -COPY packages/artemis-client/ ./packages/artemis-client/ -COPY config/ ./config/ -COPY pyproject.toml setup.py README.md LICENSE ./ -COPY --from=frontend-builder /build/apps/showcase_ui/dist/ ./apps/showcase_ui/dist/ - -# Build wheels for the runtime image. -RUN python -m pip wheel --no-cache-dir --no-deps --wheel-dir /wheels ./packages/artemis-client \ - && python -m pip wheel --no-cache-dir --no-deps --wheel-dir /wheels . - - -FROM python:3.12-slim - -WORKDIR /app - -RUN apt-get update && apt-get install -y --no-install-recommends \ - adb \ - curl \ - git \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=wheel-builder /wheels/ /wheels/ -RUN pip install --no-cache-dir /wheels/artemis_client-*.whl \ - && pip install --no-cache-dir /wheels/artemis-*.whl \ - && rm -rf /wheels - -ENV PYTHONUNBUFFERED=1 -ENV ARTEMIS_APP_DIR=/app -ENV PORT=8080 - -EXPOSE 8080 - -# The console has no user authentication: publish the port to loopback only, -# e.g. `docker run -p 127.0.0.1:8080:8080 ...`, and reach it remotely through -# a Tailscale/SSH tunnel. (0.0.0.0 here is container-internal and required -# for the port mapping to work.) -CMD ["uvicorn", "apps.admin_console.server:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/Makefile b/Makefile index c3fca4a9a..5292d1177 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -.PHONY: help test test-integration test-device test-all install install-deps setup start ui restart stop status build-ui doctor clean precommit-install precommit lint format typecheck quality-ratchet +.PHONY: help test test-integration test-device test-all install install-deps setup start ui dev restart stop status build-ui release-ui doctor clean precommit-install precommit lint format typecheck quality-ratchet help: ## Show this help message @echo 'Usage: make [target]' @@ -20,12 +20,18 @@ help: ## Show this help message @echo 'Available targets:' @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST) +dev: ## One-command dev stack: FastAPI backend + Vite frontend (hot reload) + @bash scripts/dev.sh + start: ## One-click start Artemis Showcase UI and auto-open browser @bash start.sh ui: ## Launch the unified Showcase UI & Admin Console in browser @uv run artemis ui --open +mock-ui: ## Start the UI against the mock device driver (offline, no browser auto-open) + @ARTEMIS_MOCK_DRIVER=1 uv run python -m artemis ui --no-open + restart: ## Restart running Artemis Web UI & server @uv run artemis restart @@ -35,9 +41,14 @@ stop: ## Stop running Artemis Web UI & server status: ## Display Artemis Web UI & server status @uv run artemis status -build-ui: ## Build the Showcase UI Angular frontend +build-ui: ## Build the Showcase UI (Vue 3 + Vite) frontend @echo "🎨 Building Showcase UI..." - @cd apps/showcase_ui && npm install && npm run build + @cd apps/showcase_ui_v2 && npm install && npm run build + +release-ui: ## Build the Showcase UI and sync it into the wheel fallback resources + @echo "🚀 Releasing Showcase UI resources..." + @$(MAKE) build-ui + @cd apps/showcase_ui_v2 && npm run sync:resources doctor: ## Run system, device, and toolchain diagnostics @uv run artemis doctor @@ -106,7 +117,7 @@ precommit: ## Run pre-commit hooks manually on all files clean: ## Clean up generated files, caches, and traces @echo "🧹 Cleaning up caches and temporary files..." - @rm -rf .pytest_cache .ruff_cache .artemis_paused traces scratch .venv apps/showcase_ui/.angular build dist + @rm -rf .pytest_cache .ruff_cache .artemis_paused traces scratch .venv apps/showcase_ui_v2/node_modules/.vite build dist @find . -type d -name "outputs" -exec rm -rf {} + 2>/dev/null || true @find . -type f -name ".artemis_paused" -delete 2>/dev/null || true @find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true diff --git a/README.md b/README.md index 8683bcce3..103a525dd 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,17 @@ cd artemis > **Tip**: Opens `http://localhost:8000` in your default browser with a device connection wizard, live screen mirroring, prompt sandbox, and execution replays. You can also run directly from CLI: `uv run artemis run "Open Settings, find Battery and tell me current level" --profile flash`. +### Development (hot reload) + +Contributing to the web console? One command starts the FastAPI backend and the Vite dev server together, with linked teardown (Ctrl+C stops both): + +```bash +bash scripts/dev.sh # or: make dev +``` + +- **Frontend (Vite, hot reload)**: `http://127.0.0.1:5180` +- **Backend (FastAPI)**: `http://127.0.0.1:8000` — reused automatically when an instance is already running +
@@ -246,7 +257,7 @@ if __name__ == "__main__": Console Overview: ① View Switcher (Home / Workspace) · ② Model & Replay (Flash/Pro status & video replay) · ③ Live Agent Stream (Action perception, target coordinates & structured results) · ④ Prompt Dock (Natural language dispatch) · ⑤ Task Queue & Dashboard (Lifecycle & history)

-* **Web Visual Test Console (`uv run artemis ui`)**: Real-time screen projection and interactive panel, supporting natural language test dispatch, live reasoning telemetry, action trajectories, and execution replay; manage server lifecycle anytime from any terminal using `uv run artemis restart`, `uv run artemis stop`, and `uv run artemis status`; +* **Web Visual Test Console (`uv run artemis ui`)**: Real-time screen projection and interactive panel, supporting natural language test dispatch, live reasoning telemetry, action trajectories, and execution replay; manage server lifecycle anytime from any terminal using `uv run artemis restart`, `uv run artemis stop`, and `uv run artemis status`. The web frontend source lives in [`apps/showcase_ui_v2`](./apps/showcase_ui_v2) (Vue 3 + Arco Design Vue); * **MCP Server**: Connects **Antigravity, Claude Code, Windsurf**, and other MCP clients to real devices for bug reproduction and test execution; * **Developer CLI (`uv run artemis run`)**: Direct terminal execution for automated test cases, exploratory stability inspection, or AndroidWorld benchmarks with high-fidelity structured terminal output; * **Python SDK**: Integrates as a standard Python library into existing automated testing frameworks (e.g., pytest) or CI/CD pipelines with strongly typed Pydantic structured outputs and assertion support. diff --git a/README_CN.md b/README_CN.md index 67f461c58..ca72dafcf 100644 --- a/README_CN.md +++ b/README_CN.md @@ -106,6 +106,17 @@ cd artemis > **提示**:启动后将自动在默认浏览器中打开 Web 控制台(`http://localhost:8000`),提供设备连接向导、实时投屏、任务演练与状态回放面板。你也可以通过命令行直接运行:`uv run artemis run "打开系统设置,找到电池选项并告诉我当前电量" --profile flash`。 +### 开发模式(热更新) + +参与 Web 控制台开发?一条命令同时启动 FastAPI 后端与 Vite 前端,联动退出(Ctrl+C 一键全停): + +```bash +bash scripts/dev.sh # 或者:make dev +``` + +- **前端(Vite,热更新)**:`http://127.0.0.1:5180` +- **后端(FastAPI)**:`http://127.0.0.1:8000` —— 已有实例在运行时自动复用 +
@@ -247,7 +258,7 @@ if __name__ == "__main__": 控制台功能概览:① 视图切换(主页与工作区) · ② 运行模式与回放(Flash/Pro 状态与视频回放) · ③ 实时感知推理流(动作感知、目标坐标与结构化总结) · ④ 提示词输入坞 (Prompt Dock)(自然语言下发) · ⑤ 任务队列看板(生命周期与历史回溯)

-* **Web 可视化测试控制台 (`uv run artemis ui`)**:集成设备实时投屏与交互面板,支持通过自然语言下发测试用例,实时观测推理步骤、操作轨迹、截图留存与异常状态回放;支持在任意终端使用 `uv run artemis restart`、`uv run artemis stop`、`uv run artemis status` 一键重启、关停或查看服务状态; +* **Web 可视化测试控制台 (`uv run artemis ui`)**:集成设备实时投屏与交互面板,支持通过自然语言下发测试用例,实时观测推理步骤、操作轨迹、截图留存与异常状态回放;支持在任意终端使用 `uv run artemis restart`、`uv run artemis stop`、`uv run artemis status` 一键重启、关停或查看服务状态。Web 前端源码位于 [`apps/showcase_ui_v2`](./apps/showcase_ui_v2)(Vue 3 + Arco Design Vue); * **原生 MCP 协议 (IDE 协同)**:作为标准 MCP 服务器接入 **Antigravity、Claude Code、Windsurf** 等开发环境,在 IDE 中直接驱动真机完成自动化测试与 Bug 复现验证; * **开发者命令行 CLI (`uv run artemis run`)**:支持通过终端直接执行自动化测试用例、探索性稳定性巡检或 AndroidWorld 基准评测,提供高保真结构化终端输出; * **Python SDK**:作为标准 Python 库集成至现有自动化测试框架(如 pytest)或 CI/CD 流水线,提供基于 Pydantic 的强类型结构化结果与断言支持。 diff --git a/apps/__init__.py b/apps/__init__.py deleted file mode 100644 index 89afab843..000000000 --- a/apps/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Artemis Applications Package.""" diff --git a/apps/admin_console/__init__.py b/apps/admin_console/__init__.py deleted file mode 100644 index 352859e64..000000000 --- a/apps/admin_console/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Artemis Admin & Trace Console Package.""" - -from apps.admin_console.server import app - -__all__ = ["app"] diff --git a/apps/admin_console/core/__init__.py b/apps/admin_console/core/__init__.py deleted file mode 100644 index 58d482ea3..000000000 --- a/apps/admin_console/core/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. diff --git a/apps/admin_console/core/config.py b/apps/admin_console/core/config.py deleted file mode 100644 index b62b89a09..000000000 --- a/apps/admin_console/core/config.py +++ /dev/null @@ -1,43 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Admin Console configuration facade. - -All configuration and path management is centralized in `artemis.config`. -This module re-exports common constants for full backward compatibility. -""" - -from artemis.config import ( - DB_PATH, - IMAGES_DIR, - PAUSE_FILE, - REPLAY_BASE_DIR, - TEST_DATA_DIR, - TEST_OUTPUTS_DIR, - TRACES_PATH, - WORKSPACE_ROOT, - init_ls_address, -) - -__all__ = [ - "DB_PATH", - "IMAGES_DIR", - "PAUSE_FILE", - "REPLAY_BASE_DIR", - "TEST_DATA_DIR", - "TEST_OUTPUTS_DIR", - "TRACES_PATH", - "WORKSPACE_ROOT", - "init_ls_address", -] diff --git a/apps/admin_console/core/security.py b/apps/admin_console/core/security.py deleted file mode 100644 index a5b132cee..000000000 --- a/apps/admin_console/core/security.py +++ /dev/null @@ -1,178 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Same-origin security boundary for the Artemis console. - -Artemis runs without user accounts: whoever can reach the TCP port is the -operator. For that model to hold for a server that browsers also talk to, -two browser-borne attack vectors must be closed even on a loopback bind: - -- DNS rebinding: a page at ``attacker.example`` re-points its DNS record at - 127.0.0.1 so the victim's browser reads API responses under the attacker's - origin. Blocked by validating the ``Host`` header. -- Cross-site request forgery: any web page can fire side-effectful requests - at ``http://127.0.0.1:8000`` from a visitor's browser. Blocked by requiring - the ``Origin`` header, when a browser sends one, to match the request host. - -Non-browser clients (the SDK, curl, the CLI, MCP) send no ``Origin`` header -and pass through untouched. Remote access is expected to arrive over a -network-level tunnel (Tailscale, SSH port forward); a tunnel hostname that is -not an IP literal can be admitted via ``ARTEMIS_ALLOWED_HOSTS``. -""" - -from __future__ import annotations - -import ipaddress -import json -import os -from urllib.parse import urlsplit - -_SECURITY_HEADERS: tuple[tuple[bytes, bytes], ...] = ( - (b"x-content-type-options", b"nosniff"), - (b"referrer-policy", b"no-referrer"), - (b"x-frame-options", b"DENY"), - (b"cross-origin-opener-policy", b"same-origin"), -) - -# Paths whose responses may hold task data, media, or diagnostics and must not -# land in shared caches or survive on disk after the session. -_NO_STORE_PREFIXES = ("/api/",) - - -def _hostname(netloc: str) -> str: - """Extract the lowercase hostname from a ``host[:port]`` netloc.""" - netloc = netloc.strip().lower() - if netloc.startswith("["): # IPv6 literal, e.g. [::1]:8000 - return netloc.partition("]")[0].lstrip("[") - return netloc.rpartition(":")[0] if netloc.count(":") == 1 else netloc - - -def _is_ip_literal(host: str) -> bool: - """An IP-literal Host cannot be forged through DNS rebinding: a browser - only sends ``Host: 192.168.1.5`` when it actually connected to that IP.""" - try: - ipaddress.ip_address(host) - return True - except ValueError: - return False - - -def allowed_hostnames() -> frozenset[str]: - """Hostnames (not IP literals) accepted in Host and Origin headers.""" - names = {"localhost"} - extra = os.environ.get("ARTEMIS_ALLOWED_HOSTS", "") - names.update(part.strip().lower() for part in extra.split(",") if part.strip()) - return frozenset(names) - - -def host_is_allowed(host_header: str) -> bool: - host = _hostname(host_header) - if not host: - return False - return _is_ip_literal(host) or host in allowed_hostnames() - - -def origin_is_allowed(origin_header: str, host_header: str) -> bool: - """A browser request is acceptable when its page is same-origin with this - server, or was served from an explicitly allowed tunnel hostname.""" - origin = origin_header.strip().lower() - if not origin or origin == "null": - return False - origin_netloc = urlsplit(origin).netloc - if not origin_netloc: - return False - if origin_netloc == host_header.strip().lower(): - return True - return _hostname(origin_netloc) in allowed_hostnames() - - -class SameOriginBoundaryMiddleware: - """Pure ASGI middleware enforcing the Host/Origin boundary and appending - baseline security headers. Pure ASGI (not BaseHTTPMiddleware) so future - WebSocket endpoints cannot slip past the Origin check.""" - - def __init__(self, app): - self.app = app - - async def __call__(self, scope, receive, send): - if scope["type"] not in ("http", "websocket"): - await self.app(scope, receive, send) - return - - if os.environ.get("ARTEMIS_DISABLE_ORIGIN_GUARD", "").lower() in {"1", "true", "yes"}: - await self._forward(scope, receive, send) - return - - headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]} - host_header = headers.get("host", "") - origin_header = headers.get("origin") - - if not host_is_allowed(host_header): - await self._reject( - scope, - send, - "Unrecognized Host header. Access Artemis via localhost or an IP address, " - "or add your tunnel hostname to ARTEMIS_ALLOWED_HOSTS.", - ) - return - - if origin_header is not None and not origin_is_allowed(origin_header, host_header): - await self._reject( - scope, - send, - "Cross-origin browser requests are not accepted by the Artemis console.", - ) - return - - await self._forward(scope, receive, send) - - async def _forward(self, scope, receive, send): - if scope["type"] != "http": - await self.app(scope, receive, send) - return - - path = scope.get("path", "") - - async def send_with_headers(message): - if message["type"] == "http.response.start": - raw = list(message.get("headers", [])) - existing = {name.lower() for name, _ in raw} - for name, value in _SECURITY_HEADERS: - if name not in existing: - raw.append((name, value)) - if b"cache-control" not in existing and path.startswith(_NO_STORE_PREFIXES): - raw.append((b"cache-control", b"no-store")) - message = {**message, "headers": raw} - await send(message) - - await self.app(scope, receive, send_with_headers) - - @staticmethod - async def _reject(scope, send, detail: str): - if scope["type"] == "websocket": - await send({"type": "websocket.close", "code": 1008}) - return - body = json.dumps({"detail": detail}).encode("utf-8") - await send( - { - "type": "http.response.start", - "status": 403, - "headers": [ - (b"content-type", b"application/json"), - (b"content-length", str(len(body)).encode("latin-1")), - *_SECURITY_HEADERS, - ], - } - ) - await send({"type": "http.response.body", "body": body}) diff --git a/apps/admin_console/core/state.py b/apps/admin_console/core/state.py deleted file mode 100644 index cc2c4a33c..000000000 --- a/apps/admin_console/core/state.py +++ /dev/null @@ -1,243 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -from collections.abc import Callable -from typing import Any - -from artemis.config import PAUSE_FILE -from artemis.runtime.process_probe import pid_is_alive - - -class ServerState: - """Encapsulates all runtime states of the debug server.""" - - def __init__(self): - self.ipc_subscribers: list[Callable[[str, Any], None]] = [] - self.ipc_server: asyncio.Server | None = None - self.ipc_serve_task: asyncio.Task | None = None - self.ipc_port: int | None = None - self.port: int = 8000 - self.host: str = "127.0.0.1" - self.is_shutting_down: bool = False - - self.current_process: asyncio.subprocess.Process | None = None - self.current_goal: str | None = None - self.current_profile: str | None = None - self.active_connections: dict[str, dict[str, Any]] = {} - self.active_session_id: str | None = None - # Run keys (session id, or the synthetic run key of session-less runs) - # that were stopped manually. Tracked per run so a manual stop of one - # device's task never pollutes the terminal-status resolution of a - # concurrently running task on another device. Entries are discarded by - # each run's finalizer. - self.manually_stopped_run_ids: set[str] = set() - self.cancelled_session_ids: set[str] = set() - self.startup_progress: dict[str, list[dict[str, Any]]] = {} - - # Concurrent task executions keyed by session_id. Each value holds - # {"process", "device_id", "goal", "profile"}. `current_process` / - # `active_session_id` mirror the most recently launched run for - # backward compatibility with single-task consumers. - self.active_runs: dict[str, dict[str, Any]] = {} - - # Unified single source of truth for task queue - self.queue_items: list[dict[str, Any]] = [] - self._wake_event: asyncio.Event | None = None - self._shutdown_event: asyncio.Event | None = None - self.worker_task: asyncio.Task | None = None - - @property - def wake_event(self) -> asyncio.Event: - try: - loop = asyncio.get_running_loop() - except RuntimeError: - loop = None - - if self._wake_event is None or ( - loop - and getattr(self._wake_event, "_loop", None) is not None - and self._wake_event._loop != loop - ): - self._wake_event = asyncio.Event() - return self._wake_event - - @property - def shutdown_event(self) -> asyncio.Event: - """Event set as soon as the HTTP server receives a shutdown signal.""" - try: - loop = asyncio.get_running_loop() - except RuntimeError: - loop = None - - if self._shutdown_event is None or ( - loop - and getattr(self._shutdown_event, "_loop", None) is not None - and self._shutdown_event._loop != loop - ): - self._shutdown_event = asyncio.Event() - return self._shutdown_event - - @property - def task_queue(self) -> list[dict[str, Any]]: - """Backward compatibility alias for queue_items.""" - return self.queue_items - - @property - def queue_tasks(self) -> list[dict[str, Any]]: - """Returns all currently pending tasks in the queue.""" - return [t for t in self.queue_items if isinstance(t, dict) and t.get("status") == "pending"] - - @queue_tasks.setter - def queue_tasks(self, val: list[dict[str, Any]]): - # Compatibility setter - self.queue_items = list(val) - - @property - def queue_goals(self) -> list[str]: - """Backward compatibility helper for queue goals.""" - return [t.get("goal", "") for t in self.queue_tasks] - - @queue_goals.setter - def queue_goals(self, val: list[Any]): - pass - - def prune_finished_runs(self) -> None: - """Drop finished entries from active_runs. - - Besides reaped processes (returncode set), also drops entries whose pid no - longer exists: if a run coroutine dies without reaping its child, the entry - must not permanently block the scheduler. - """ - for sid, run in list(self.active_runs.items()): - proc = run.get("process") - if proc is None or proc.returncode is not None: - self.active_runs.pop(sid, None) - continue - pid = getattr(proc, "pid", None) - if pid and not pid_is_alive(pid): - self.active_runs.pop(sid, None) - - @property - def busy_device_ids(self) -> set[str]: - """Device serials currently owned by an in-flight run.""" - self.prune_finished_runs() - return { - str(run.get("lock_key") or run["device_id"]) - for run in self.active_runs.values() - if run.get("device_id") - } - - @property - def is_running(self) -> bool: - self.prune_finished_runs() - if self.active_runs: - return True - has_proc = False - if self.current_process is not None: - if self.current_process.returncode is not None: - has_proc = False - self.current_process = None - else: - pid = getattr(self.current_process, "pid", None) - if pid and pid_is_alive(pid): - has_proc = True - else: - has_proc = False - self.current_process = None - - has_running_item = any( - isinstance(t, dict) and t.get("status") == "running" for t in self.queue_items - ) - - has_live_connection = False - for sid, conn in list(self.active_connections.items()): - c_pid = conn.get("pid") - if c_pid: - if pid_is_alive(c_pid): - has_live_connection = True - else: - self.active_connections.pop(sid, None) - - if not has_proc and not has_running_item and not has_live_connection: - self.active_session_id = None - self.current_process = None - return False - - return True - - @property - def is_paused(self) -> bool: - return PAUSE_FILE.exists() - - @property - def paused_error(self) -> str | None: - """Return the persisted pause reason for clients that missed the SSE event.""" - if not PAUSE_FILE.exists(): - return None - try: - message = PAUSE_FILE.read_text(encoding="utf-8", errors="replace").strip() - except OSError: - return "AI model request failed. The task is paused." - if message.startswith("LLM Error: "): - message = message[len("LLM Error: ") :] - return message or "AI model request failed. The task is paused." - - def add_subscriber(self, callback: Callable[[str, Any], None]): - if callback not in self.ipc_subscribers: - self.ipc_subscribers.append(callback) - - def remove_subscriber(self, callback: Callable[[str, Any], None]): - if callback in self.ipc_subscribers: - self.ipc_subscribers.remove(callback) - - def record_startup_progress(self, data: dict[str, Any]) -> None: - """Retain the short pre-trace timeline so late SSE clients can catch up.""" - session_id = data.get("session_id") - stage = data.get("stage") - if not session_id or not stage: - return - - key = str(session_id) - events = self.startup_progress.setdefault(key, []) - replacement_index = next( - (index for index, item in enumerate(events) if item.get("stage") == stage), - None, - ) - snapshot = dict(data) - if replacement_index is None: - events.append(snapshot) - else: - events[replacement_index] = snapshot - self.startup_progress[key] = events[-16:] - - def get_startup_progress(self, session_id: str) -> list[dict[str, Any]]: - return [dict(item) for item in self.startup_progress.get(str(session_id), [])] - - def clear_queue(self): - """Clears all pending items from the task queue.""" - self.queue_items = [t for t in self.queue_items if t.get("status") == "running"] - if self._wake_event: - self._wake_event.set() - - -# Global shared instance -state = ServerState() - -import sys - -if __name__ == "admin_console.core.state": - sys.modules["apps.admin_console.core.state"] = sys.modules[__name__] -elif __name__ == "apps.admin_console.core.state": - sys.modules["admin_console.core.state"] = sys.modules[__name__] diff --git a/apps/admin_console/database/__init__.py b/apps/admin_console/database/__init__.py deleted file mode 100644 index 58d482ea3..000000000 --- a/apps/admin_console/database/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. diff --git a/apps/admin_console/database/connection.py b/apps/admin_console/database/connection.py deleted file mode 100644 index 83eeedd85..000000000 --- a/apps/admin_console/database/connection.py +++ /dev/null @@ -1,60 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from contextlib import contextmanager -import logging -from pathlib import Path -import sqlite3 - -from artemis.config import DB_PATH - -logger = logging.getLogger(__name__) - -_initialized_dbs = set() - - -def get_db(db_path: Path | None = None) -> sqlite3.Connection: - """Returns a SQLite connection with timeout and Row row_factory.""" - path = db_path or DB_PATH - path_key = str(path) - if path_key not in _initialized_dbs: - path.parent.mkdir(parents=True, exist_ok=True) - try: - from artemis.data_engine.storage import StorageManager - - StorageManager(db_path=path, base_trace_dir=path.parent) - _initialized_dbs.add(path_key) - except (ImportError, OSError, sqlite3.Error) as exc: - # Schema bootstrap is best-effort here; queries against a missing - # schema will surface their own errors, but log the root cause. - logger.warning("Schema bootstrap failed for %s: %s", path, exc) - conn = sqlite3.connect(path, timeout=10.0) - conn.row_factory = sqlite3.Row - try: - conn.execute("PRAGMA journal_mode=WAL") - conn.execute("PRAGMA busy_timeout=10000") - except sqlite3.Error: - # Optional performance PRAGMAs; the connection works without them. - pass - return conn - - -@contextmanager -def db_session(db_path: Path | None = None): - """Context manager for SQLite connections ensuring clean closure.""" - conn = get_db(db_path) - try: - yield conn - finally: - conn.close() diff --git a/apps/admin_console/database/repositories/__init__.py b/apps/admin_console/database/repositories/__init__.py deleted file mode 100644 index 58d482ea3..000000000 --- a/apps/admin_console/database/repositories/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. diff --git a/apps/admin_console/database/repositories/session_repository.py b/apps/admin_console/database/repositories/session_repository.py deleted file mode 100644 index a12e86928..000000000 --- a/apps/admin_console/database/repositories/session_repository.py +++ /dev/null @@ -1,564 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json -import logging -import os -import sqlite3 -import time -from typing import Any - -from artemis.runtime import trace_store - -logger = logging.getLogger(__name__) - -try: - from admin_console.database.connection import db_session -except ImportError: - from apps.admin_console.database.connection import db_session - - -def _canonicalize_status(row: dict[str, Any]) -> dict[str, Any]: - """Map the legacy "success" terminal status to the canonical "completed". - - Rows written before the vocabulary was unified may still carry "success"; - API consumers must only ever see "completed". get_session_status is left - raw on purpose so the queue reconcile can detect and persist the rewrite. - """ - if row.get("status") == "success": - row["status"] = "completed" - return row - - -class SessionRepository: - """Repository handling all database queries and updates for Sessions.""" - - def __init__(self, db_path=None): - self.db_path = db_path - - @staticmethod - def process_is_alive(pid: Any) -> bool: - """Return whether a session's worker PID is still alive. - - Delegates to the repository-wide probe: indeterminate liveness - (e.g. AccessDenied) counts as alive so a running session is never - reaped by mistake. - """ - try: - pid_int = int(pid) - except (TypeError, ValueError): - return False - from artemis.runtime.process_probe import pid_is_alive - - return pid_is_alive(pid_int) - - def get_all_sessions(self) -> list[dict[str, Any]]: - with db_session(self.db_path) as conn: - cursor = conn.cursor() - cursor.execute("SELECT * FROM sessions ORDER BY start_time DESC") - return [_canonicalize_status(dict(r)) for r in cursor.fetchall()] - - def get_video_recordings_map(self) -> dict[str, str]: - with db_session(self.db_path) as conn: - cursor = conn.cursor() - cursor.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='video_recordings'" - ) - if cursor.fetchone() is None: - return {} - columns = {str(row[1]) for row in cursor.execute("PRAGMA table_info(video_recordings)")} - # Failed recordings may still be recoverable from disk. - # Callers exclude in-progress sessions before resolving paths. - ready_clause = ( - "status IN ('ready', 'failed')" if "status" in columns else "end_time IS NOT NULL" - ) - cursor.execute( - "SELECT session_id, local_video_path FROM video_recordings " - "WHERE session_id IS NOT NULL AND local_video_path IS NOT NULL " - f"AND {ready_clause} ORDER BY start_time ASC" - ) - return {str(r[0]): str(r[1]) for r in cursor.fetchall() if r[0] and r[1]} - - def get_video_recording_for_session(self, session_id: str) -> dict[str, Any] | None: - try: - with db_session(self.db_path) as conn: - cursor = conn.cursor() - cursor.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='video_recordings'" - ) - if cursor.fetchone() is None: - return None - cursor.execute( - "SELECT * FROM video_recordings WHERE session_id = ? " - "ORDER BY start_time DESC LIMIT 1", - (str(session_id),), - ) - row = cursor.fetchone() - if not row: - return None - result = dict(row) - if "status" not in result: - result["status"] = ( - "ready" if result.get("end_time") is not None else "recording" - ) - result.setdefault("error", None) - return result - except Exception: - return None - - def get_latest_video_recordings_map(self) -> dict[str, dict[str, Any]]: - """Return the newest recording row for every session in one query. - - The task-list endpoint renders every session at once. Calling - ``get_video_recording_for_session`` in that loop used to open a new - SQLite connection for every row, which made a refresh progressively - slower as history grew. - """ - try: - with db_session(self.db_path) as conn: - cursor = conn.cursor() - cursor.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='video_recordings'" - ) - if cursor.fetchone() is None: - return {} - - rows = cursor.execute( - "SELECT * FROM video_recordings WHERE session_id IS NOT NULL " - "ORDER BY start_time DESC" - ).fetchall() - latest: dict[str, dict[str, Any]] = {} - for row in rows: - recording = dict(row) - session_id = str(recording.get("session_id") or "") - if not session_id or session_id in latest: - continue - if "status" not in recording: - recording["status"] = ( - "ready" if recording.get("end_time") is not None else "recording" - ) - recording.setdefault("error", None) - latest[session_id] = recording - return latest - except Exception: - return {} - - def get_unfinalized_video_recordings(self) -> list[dict[str, Any]]: - """Recordings whose worker never finalized them, oldest first. - - Rows of sessions that are still ``running`` are skipped: their worker - (possibly owned by another daemon) may still be writing the file. - """ - try: - with db_session(self.db_path) as conn: - cursor = conn.cursor() - cursor.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='video_recordings'" - ) - if cursor.fetchone() is None: - return [] - columns = { - str(row[1]) for row in cursor.execute("PRAGMA table_info(video_recordings)") - } - pending_clause = ( - "v.status IN ('recording', 'finalizing', 'failed')" - if "status" in columns - else "v.end_time IS NULL" - ) - cursor.execute( - "SELECT v.session_id, v.local_video_path, v.start_time, " - "s.status AS session_status, s.start_time AS session_start_time " - "FROM video_recordings v LEFT JOIN sessions s ON s.session_id = v.session_id " - f"WHERE v.session_id IS NOT NULL AND v.local_video_path IS NOT NULL " - f"AND {pending_clause} ORDER BY v.start_time ASC" - ) - rows = [dict(r) for r in cursor.fetchall()] - except (OSError, sqlite3.Error): - return [] - return [row for row in rows if str(row.get("session_status") or "").lower() != "running"] - - def mark_recording_failed_if_pending(self, session_id: str, error: str) -> bool: - """Close a recording lifecycle when its worker exits before finalization.""" - try: - with db_session(self.db_path) as conn: - cursor = conn.cursor() - cursor.execute( - "UPDATE video_recordings SET status = 'failed', error = ?, " - "end_time = COALESCE(end_time, ?) " - "WHERE session_id = ? AND status IN ('recording', 'finalizing')", - (error, time.time(), str(session_id)), - ) - conn.commit() - return cursor.rowcount > 0 - except Exception: - return False - - def mark_recording_ready(self, session_id: str, local_video_path: str) -> bool: - """Mark a recording as ready with its finalized video path.""" - try: - with db_session(self.db_path) as conn: - cursor = conn.cursor() - cursor.execute( - "UPDATE video_recordings SET status = 'ready', error = NULL, " - "local_video_path = ?, end_time = COALESCE(end_time, ?) " - "WHERE session_id = ?", - (str(local_video_path), time.time(), str(session_id)), - ) - updated = cursor.rowcount > 0 - if os.path.exists(str(local_video_path)): - # Mirror the worker's own finalization so the session row - # and trace tooling see the recovered file too. - cursor.execute( - "UPDATE sessions SET video_filepath = ? WHERE session_id = ?", - (str(local_video_path), str(session_id)), - ) - conn.commit() - return updated - except Exception: - return False - - def get_llm_traces_for_profile(self, session_id: str, limit: int = 3) -> list[str]: - with db_session(self.db_path) as conn: - cursor = conn.cursor() - cursor.execute( - "SELECT payload FROM traces WHERE session_id = ? AND type = 'llm_call' LIMIT ?", - (session_id, limit), - ) - rows = cursor.fetchall() - return [r[0] for r in rows if r and r[0]] - - def get_llm_traces_for_profiles_map( - self, session_ids: list[str] | None = None, limit: int = 3 - ) -> dict[str, list[str]]: - """Return bounded LLM payloads for only the sessions needing fallback.""" - if session_ids is not None and not session_ids: - return {} - - session_filter = "" - params: list[Any] = [] - if session_ids is not None: - placeholders = ",".join("?" for _ in session_ids) - session_filter = f" AND session_id IN ({placeholders})" - params.extend(session_ids) - params.append(limit) - - with db_session(self.db_path) as conn: - cursor = conn.cursor() - cursor.execute( - "SELECT session_id, payload FROM (" - " SELECT session_id, payload, ROW_NUMBER() OVER (" - " PARTITION BY session_id ORDER BY timestamp ASC" - " ) AS row_number FROM traces WHERE type = 'llm_call'" - f"{session_filter}" - ") WHERE row_number <= ?", - params, - ) - result: dict[str, list[str]] = {} - for row in cursor.fetchall(): - session_id = str(row[0] or "") - payload = row[1] - if session_id and payload: - result.setdefault(session_id, []).append(payload) - return result - - def get_agent_trace_names(self, session_id: str) -> list[str]: - try: - with db_session(self.db_path) as conn: - cursor = conn.cursor() - cursor.execute( - "SELECT DISTINCT name FROM traces WHERE session_id = ? AND type IN ('agent', 'llm_call')", - (session_id,), - ) - rows = cursor.fetchall() - return [r[0] for r in rows if r and r[0]] - except Exception: - return [] - - def get_agent_trace_names_map(self) -> dict[str, list[str]]: - """Return distinct agent/LLM trace names grouped by session.""" - with db_session(self.db_path) as conn: - cursor = conn.cursor() - cursor.execute( - "SELECT session_id, name FROM traces " - "WHERE type IN ('agent', 'llm_call') AND session_id IS NOT NULL " - "GROUP BY session_id, name" - ) - result: dict[str, list[str]] = {} - for row in cursor.fetchall(): - session_id = str(row[0] or "") - name = row[1] - if session_id and name: - result.setdefault(session_id, []).append(name) - return result - - def get_session_usage(self, session_id: str) -> dict[str, Any]: - """Aggregate measured LLM usage for one session. - - Sums every ``llm_usage`` trace the token meter recorded (all agents, - Flash and Pro alike) and reports the prompt size of the latest - Operator/FlashRunner call as the live executor context. Also echoes - the profile and Pro tuning the run was started with, read from the - session's stored ``device_info``. - """ - from artemis.services.token_meter import ( - OPERATOR_CONTEXT_WINDOW_TOKENS, - OPERATOR_NODE_NAMES, - ) - - def _int(value: Any) -> int: - try: - return int(value or 0) - except (TypeError, ValueError): - return 0 - - llm_calls = prompt = completion = total = cached = 0 - operator_context: int | None = None - operator_context_at: float | None = None - with db_session(self.db_path) as conn: - cursor = conn.cursor() - cursor.execute( - "SELECT payload, timestamp FROM traces WHERE session_id = ?" - " AND type = 'llm_call' AND name = 'llm_usage' ORDER BY timestamp ASC", - (session_id,), - ) - for row in cursor.fetchall(): - raw = row["payload"] - try: - payload = json.loads(raw) if isinstance(raw, str) else raw - except (ValueError, TypeError): - continue - if not isinstance(payload, dict): - continue - p = _int(payload.get("prompt_tokens")) - c = _int(payload.get("completion_tokens")) - t = _int(payload.get("total_tokens")) or (p + c) - if t <= 0: - continue - llm_calls += 1 - prompt += p - completion += c - total += t - cached += _int(payload.get("cached_tokens")) - node = str(payload.get("node") or "").strip().lower() - if node in OPERATOR_NODE_NAMES: - operator_context = _int(payload.get("context_base_tokens")) or p - operator_context_at = row["timestamp"] - - cursor.execute("SELECT device_info FROM sessions WHERE session_id = ?", (session_id,)) - session_row = cursor.fetchone() - - profile: str | None = None - run_tuning: dict[str, Any] | None = None - if session_row and session_row["device_info"]: - try: - d_info = json.loads(session_row["device_info"]) - except (ValueError, TypeError): - d_info = None - if isinstance(d_info, dict): - profile = d_info.get("profile") or None - tuning = d_info.get("run_tuning") - if isinstance(tuning, dict) and tuning: - run_tuning = dict(tuning) - - return { - "session_id": session_id, - "llm_calls": llm_calls, - "prompt_tokens": prompt, - "completion_tokens": completion, - "total_tokens": total, - "cached_tokens": cached, - "operator_context_tokens": operator_context, - "operator_context_window_tokens": OPERATOR_CONTEXT_WINDOW_TOKENS, - "operator_context_updated_at": operator_context_at, - "profile": profile, - "run_tuning": run_tuning, - } - - def get_session_by_id(self, session_id: str) -> dict[str, Any] | None: - try: - with db_session(self.db_path) as conn: - cursor = conn.cursor() - cursor.execute( - "SELECT * FROM sessions WHERE session_id = ?", - (str(session_id),), - ) - row = cursor.fetchone() - return _canonicalize_status(dict(row)) if row else None - except Exception: - return None - - def harvest_orphaned_sessions(self, orphaned_ids: list[str]) -> int: - if not orphaned_ids: - return 0 - with db_session(self.db_path) as conn: - cursor = conn.cursor() - now = time.time() - count = 0 - for s_id in orphaned_ids: - row = cursor.execute( - "SELECT pid FROM sessions WHERE session_id = ? AND status = ?", - (s_id, "running"), - ).fetchone() - if row is None or self.process_is_alive(row["pid"]): - continue - cursor.execute( - "UPDATE sessions SET status = ?, end_time = ? " - "WHERE session_id = ? AND status = ?", - ("failed", now, s_id, "running"), - ) - count += cursor.rowcount - conn.commit() - return count - - def reconcile_orphaned_sessions(self) -> int: - """Mark running sessions with no live worker process as failed. - - This is safe to call both during startup and immediately after a - forced server stop: live workers, including workers owned by another - server instance, are left untouched. - """ - try: - with db_session(self.db_path) as conn: - cursor = conn.cursor() - rows = cursor.execute( - "SELECT session_id, pid FROM sessions WHERE status = ?", ("running",) - ).fetchall() - count = 0 - now = time.time() - for row in rows: - if self.process_is_alive(row["pid"]): - continue - cursor.execute( - "UPDATE sessions SET status = ?, end_time = ? " - "WHERE session_id = ? AND status = ?", - ("failed", now, row["session_id"], "running"), - ) - count += cursor.rowcount - try: - if trace_store.read_status(str(row["session_id"])): - trace_store.update_trace_status( - str(row["session_id"]), - "failed", - error="Process terminated prior to server startup.", - ) - except OSError as exc: - # read_status itself never raises; this guards the - # lock/write side of update_trace_status. - logger.warning( - "Could not mark trace %s failed during orphan reconciliation: %s", - row["session_id"], - exc, - ) - conn.commit() - return count - except Exception: - # Reconciliation is best-effort at startup, but a silent abort - # would leave every orphaned "running" row untouched -- log it. - logger.warning("Orphan-session reconciliation aborted", exc_info=True) - return 0 - - def cleanup_orphans_on_startup(self) -> int: - """Backward-compatible startup entrypoint for orphan reconciliation.""" - return self.reconcile_orphaned_sessions() - - def get_latest_session(self) -> dict[str, Any] | None: - try: - with db_session(self.db_path) as conn: - cursor = conn.cursor() - cursor.execute( - "SELECT session_id, status FROM sessions ORDER BY start_time DESC LIMIT 1" - ) - row = cursor.fetchone() - return _canonicalize_status(dict(row)) if row else None - except Exception: - return None - - def get_running_session_id(self) -> str | None: - try: - with db_session(self.db_path) as conn: - cursor = conn.cursor() - cursor.execute( - "SELECT session_id FROM sessions WHERE status = 'running' " - "ORDER BY start_time DESC LIMIT 1" - ) - row = cursor.fetchone() - return row["session_id"] if row else None - except Exception: - return None - - def get_session_status(self, session_id: str) -> str | None: - try: - with db_session(self.db_path) as conn: - cursor = conn.cursor() - cursor.execute( - "SELECT status FROM sessions WHERE session_id = ?", - (str(session_id),), - ) - row = cursor.fetchone() - return row["status"] if row else None - except Exception: - return None - - def update_session_status( - self, session_id: str, status: str, end_time: float | None = None - ) -> bool: - try: - with db_session(self.db_path) as conn: - cursor = conn.cursor() - t = end_time or time.time() - cursor.execute( - "UPDATE sessions SET status = ?, end_time = ? WHERE session_id = ?", - (status, t, str(session_id)), - ) - conn.commit() - return cursor.rowcount > 0 - except Exception: - return False - - def mark_all_running_cancelled(self) -> int: - try: - with db_session(self.db_path) as conn: - cursor = conn.cursor() - cursor.execute( - "UPDATE sessions SET status = ?, end_time = ? WHERE status = ?", - ("cancelled", time.time(), "running"), - ) - count = cursor.rowcount - conn.commit() - return count - except Exception: - return 0 - - def get_background_tasks(self, session_id: str) -> list[dict[str, Any]]: - try: - with db_session(self.db_path) as conn: - cursor = conn.cursor() - cursor.execute( - "SELECT * FROM background_tasks WHERE session_id = ? ORDER BY start_time ASC", - (session_id,), - ) - return [dict(r) for r in cursor.fetchall()] - except Exception: - return [] - - -session_repo = SessionRepository() - -# Give the base runtime package access to orphan-session reconciliation without -# letting it import this application-layer module (inverted dependency): any -# process that loads the admin console DB layer -- the server itself, or an -# entry point stopping one -- thereby arms stop_server()'s post-kill cleanup. -from artemis.runtime.server_lifecycle import register_session_reconciler # noqa: E402 - -register_session_reconciler(session_repo.reconcile_orphaned_sessions) diff --git a/apps/admin_console/database/repositories/step_repository.py b/apps/admin_console/database/repositories/step_repository.py deleted file mode 100644 index 515a98783..000000000 --- a/apps/admin_console/database/repositories/step_repository.py +++ /dev/null @@ -1,512 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json -import re -from typing import Any - -try: - from admin_console.database.connection import db_session -except ImportError: - from apps.admin_console.database.connection import db_session - - -class StepRepository: - """Repository handling querying and formatting of steps and associated traces.""" - - def __init__(self, db_path=None): - self.db_path = db_path - - def get_step_session_id(self, step_id: str) -> str | None: - with db_session(self.db_path) as conn: - cursor = conn.cursor() - cursor.execute("SELECT session_id FROM steps WHERE step_id = ?", (step_id,)) - row = cursor.fetchone() - return row["session_id"] if row else None - - @staticmethod - def _clean_value(val: Any) -> Any: - """Recursively cleans values to remove non-serializable and internal - Python runtime representations. - """ - if val is None: - return None - if isinstance(val, (bool, int, float)): - return val - if isinstance(val, str): - if ( - "object at 0x" in val - or val.startswith(" tuple[str, int]: - """Extracts readable text and count of image blocks from LLM content.""" - if not content: - return "", 0 - if isinstance(content, str): - if content.startswith("data:image/") or "base64," in content[:50]: - return "", 1 - return content, 0 - if isinstance(content, dict): - if content.get("type") == "image_url" or "image_url" in content: - return "", 1 - if content.get("type") == "text": - return str(content.get("text", "")), 0 - t = content.get("text") or content.get("thought") or "" - return str(t), 0 - if isinstance(content, list): - total_text = "" - num_images = 0 - for item in content: - txt, imgs = StepRepository._extract_clean_text_and_images(item) - total_text += txt - num_images += imgs - return total_text, num_images - return str(content), 0 - - @staticmethod - def _extract_token_usage_from_trace(payload_raw: Any) -> tuple[int, int, int]: - """Extract prompt, completion, and total tokens from an LLM trace payload with multimodal awareness.""" - if not payload_raw: - return 0, 0, 0 - payload_obj = payload_raw - if isinstance(payload_raw, str): - try: - payload_obj = json.loads(payload_raw) - except (ValueError, TypeError): - # Non-JSON payload: the isinstance(dict) guard below bails out. - pass - if not isinstance(payload_obj, dict): - return 0, 0, 0 - - u = payload_obj.get("token_usage") or payload_obj.get("usage_metadata") - if isinstance(u, dict): - pr = u.get("prompt_tokens") or u.get("prompt_token_count") or u.get("input_tokens") or 0 - co = ( - u.get("completion_tokens") - or u.get("candidates_token_count") - or u.get("output_tokens") - or 0 - ) - to = u.get("total_tokens") or u.get("total_token_count") or (pr + co) - if to > 0: - return int(pr), int(co), int(to) - - # Fallback for historical traces: estimate tokens from text and images - msgs = payload_obj.get("messages", []) - resp = payload_obj.get("response", []) - - prompt_text = "" - prompt_images = 0 - if isinstance(msgs, list): - for m in msgs: - if isinstance(m, dict): - t, imgs = StepRepository._extract_clean_text_and_images(m.get("content")) - prompt_text += t - prompt_images += imgs - else: - t, imgs = StepRepository._extract_clean_text_and_images(m) - prompt_text += t - prompt_images += imgs - else: - t, imgs = StepRepository._extract_clean_text_and_images(msgs) - prompt_text += t - prompt_images += imgs - - resp_text = "" - resp_images = 0 - if isinstance(resp, list): - for r in resp: - if isinstance(r, dict): - t, imgs = StepRepository._extract_clean_text_and_images(r.get("content")) - resp_text += t - resp_images += imgs - else: - t, imgs = StepRepository._extract_clean_text_and_images(r) - resp_text += t - resp_images += imgs - - # Standard Gemini multimodal token pricing: 258 tokens per image - pr = (len(prompt_text) // 4) + (prompt_images * 258) - co = (len(resp_text) // 4) + (resp_images * 258) - return int(pr), int(co), int(pr + co) - - def _clean_tool_payload( - self, - payload_raw: Any, - trace_type: str | None = None, - trace_name: str | None = None, - ) -> Any: - if not payload_raw: - return None - payload_obj = payload_raw - if isinstance(payload_raw, str): - try: - payload_obj = json.loads(payload_raw) - except (ValueError, TypeError): - # Non-JSON payload: the isinstance(dict) guard below bails out. - pass - - if not isinstance(payload_obj, dict): - return None - - if trace_type == "llm_call": - allowed_keys = ( - "error", - "delay", - "attempt", - "max_retries", - "provider", - "source", - "recoverable", - "pause", - "request_id", - "scheduled_at", - "waited_seconds", - "retries", - ) - cleaned = { - key: self._clean_value(payload_obj.get(key)) - for key in allowed_keys - if key in payload_obj - } - return cleaned or None - - args = payload_obj.get("args", payload_obj) - cleaned_args = ( - self._clean_value(args) if isinstance(args, dict) else self._clean_value(args) - ) - result_payload: dict[str, Any] = {"args": cleaned_args} - - # Extract structured action execution results. Video analysis keeps its - # result because the task stream needs to distinguish cached, partial, - # recovering, waiting, completed, and terminal outcomes. Other tools - # retain the historical compact payload to avoid sending large blobs. - res = payload_obj.get("result") - is_video_analysis = str(trace_name or "").lower() in { - "video_analysis", - "video_analyzer", - "video_analyzer_pure", - "spawn_sub_agent", - "analyze_audio_only", - } - if isinstance(res, dict): - if res.get("post_image_name"): - result_payload["post_image_name"] = res["post_image_name"] - if res.get("pre_image_name"): - result_payload["pre_image_name"] = res["pre_image_name"] - if res.get("outcome"): - result_payload["outcome"] = res["outcome"] - if is_video_analysis: - result_payload["result"] = self._clean_value(res) - elif res: - res_str = str(res) - if is_video_analysis: - result_payload["result"] = res_str - match = re.search(r"([a-f0-9]{64})", res_str) - if match: - result_payload["post_image_name"] = match.group(1) - - return result_payload - - @staticmethod - def _extract_legacy_pause_error(payload_raw: Any) -> str | None: - """Extract an LLM error from the warning trace used by older runners.""" - payload_obj = payload_raw - if isinstance(payload_raw, str): - try: - payload_obj = json.loads(payload_raw) - except Exception: - return None - if not isinstance(payload_obj, dict): - return None - - message = payload_obj.get("message") - if not isinstance(message, str) or "LLM Error:" not in message: - return None - pause_marker = ". Pausing execution" - if pause_marker not in message: - return None - - error = message.split("LLM Error:", 1)[1].split(pause_marker, 1)[0].strip() - return error or None - - def _normalize_display_trace(self, trace_dict: dict[str, Any]) -> dict[str, Any]: - """Normalize traces consumed by the task stream's tool/error cards.""" - if trace_dict.get("type") == "log": - error = self._extract_legacy_pause_error(trace_dict.get("payload")) - if error: - trace_dict.update( - { - "type": "llm_call", - "name": "llm_pause", - "status": "failed", - "payload": {"error": error, "pause": True}, - } - ) - return trace_dict - - trace_dict["payload"] = self._clean_tool_payload( - trace_dict.get("payload"), trace_dict.get("type"), trace_dict.get("name") - ) - return trace_dict - - def get_session_steps(self, session_id: str, client: str | None = None) -> list[dict[str, Any]]: - with db_session(self.db_path) as conn: - cursor = conn.cursor() - cursor.execute( - "SELECT * FROM steps WHERE session_id = ? ORDER BY step_number ASC", - (session_id,), - ) - rows = cursor.fetchall() - - steps = [] - for r in rows: - step_dict = dict(r) - - # Strip raw binary bytes for API efficiency (clients fetch images by url/hash) - step_dict["pre_screenshot_bytes"] = None - step_dict["post_screenshot_bytes"] = None - - # Parse JSON fields safely - for json_col in [ - "action_taken", - "last_execution_result", - "extra_metadata", - ]: - if step_dict.get(json_col): - try: - step_dict[json_col] = json.loads(step_dict[json_col]) - except (ValueError, TypeError): - # Non-JSON column value: keep the raw string. - pass - - # If post_image_name is identical to pre_image_name, normalize to None - pre_img = step_dict.get("pre_image_name") - if pre_img and step_dict.get("post_image_name") == pre_img: - step_dict["post_image_name"] = None - - # Fetch action and generic tool traces for this step - step_id = step_dict.get("step_id") - if step_id: - cursor.execute( - "SELECT t1.trace_id, t1.parent_trace_id, t1.step_id," - " t1.type, t1.name, t1.timestamp, t1.duration, t1.status," - " t1.payload, t2.name as agent_name FROM traces t1 LEFT" - " JOIN traces t2 ON t1.parent_trace_id = t2.trace_id WHERE" - " t1.step_id = ? AND (t1.type = 'tool' OR t1.type = 'action' OR (t1.type =" - " 'llm_call' AND t1.status IN ('failed', 'retrying')) OR (t1.type = 'log' AND" - " t1.payload LIKE '%LLM Error:%Pausing execution%' AND NOT EXISTS" - " (SELECT 1 FROM traces t3 WHERE t3.session_id = t1.session_id AND" - " t3.type = 'llm_call' AND t3.status = 'failed' AND" - " ABS(t3.timestamp - t1.timestamp) < 2))) ORDER BY" - " t1.timestamp ASC", - (step_id,), - ) - trace_rows = cursor.fetchall() - generic_tools = [] - for tr in trace_rows: - trace_dict = self._normalize_display_trace(dict(tr)) - generic_tools.append(trace_dict) - step_dict["generic_tools"] = generic_tools - - # Compute token usage for this step from all LLM call traces - cursor.execute( - "SELECT payload FROM traces WHERE step_id = ? AND type = 'llm_call'", - (step_id,), - ) - llm_rows = cursor.fetchall() - step_p, step_c, step_t = 0, 0, 0 - for lr in llm_rows: - p, c, t = self._extract_token_usage_from_trace(lr["payload"]) - step_p += p - step_c += c - step_t += t - - # If step_t is 0, also check extra_metadata for token_usage - if step_t == 0 and step_dict.get("extra_metadata"): - meta = ( - step_dict["extra_metadata"] - if isinstance(step_dict["extra_metadata"], dict) - else {} - ) - u = meta.get("token_usage") - if isinstance(u, dict): - step_p = int(u.get("prompt_tokens") or u.get("input_tokens") or 0) - step_c = int(u.get("completion_tokens") or u.get("output_tokens") or 0) - step_t = int(u.get("total_tokens") or (step_p + step_c)) - - step_dict["token_usage"] = { - "prompt_tokens": step_p, - "completion_tokens": step_c, - "total_tokens": step_t, - } - step_dict["total_tokens"] = step_t - else: - step_dict["generic_tools"] = [] - step_dict["token_usage"] = { - "prompt_tokens": 0, - "completion_tokens": 0, - "total_tokens": 0, - } - step_dict["total_tokens"] = 0 - - try: - from artemis.utils.coordinates import normalize_step_actions - - step_dict = normalize_step_actions(step_dict) - except ImportError: - pass - - steps.append(step_dict) - - # Fetch step-less tool traces (e.g. planner tools or turns without recorded step) - cursor.execute( - "SELECT t1.trace_id, t1.parent_trace_id, t1.step_id, t1.type," - " t1.name, t1.timestamp, t1.duration, t1.status, t1.payload," - " t2.name as agent_name FROM traces t1 LEFT JOIN traces t2 ON" - " t1.parent_trace_id = t2.trace_id WHERE t1.session_id = ? AND" - " (t1.step_id IS NULL OR t1.step_id = '' OR t1.step_id NOT IN (SELECT step_id FROM steps WHERE session_id = ?)) AND (t1.type = 'tool' OR" - " (t1.type = 'llm_call' AND t1.status IN ('failed', 'retrying')) OR (t1.type = 'log'" - " AND t1.payload LIKE '%LLM Error:%Pausing execution%' AND NOT EXISTS" - " (SELECT 1 FROM traces t3 WHERE t3.session_id = t1.session_id AND" - " t3.type = 'llm_call' AND t3.status = 'failed' AND" - " ABS(t3.timestamp - t1.timestamp) < 2))) ORDER BY" - " t1.timestamp ASC", - (session_id, session_id), - ) - stepless_rows = cursor.fetchall() - if stepless_rows: - unassigned_tools = [] - sorted_steps = sorted(steps, key=lambda s: s.get("timestamp") or 0) - - for tr in stepless_rows: - trace_dict = self._normalize_display_trace(dict(tr)) - - tr_time = trace_dict.get("timestamp") or 0 - target_step = None - for s in sorted_steps: - s_time = s.get("timestamp") or 0 - if s_time <= tr_time: - target_step = s - else: - break - - if target_step: - if "generic_tools" not in target_step or not target_step["generic_tools"]: - target_step["generic_tools"] = [] - target_step["generic_tools"].append(trace_dict) - target_step["generic_tools"].sort(key=lambda t: t.get("timestamp") or 0) - else: - unassigned_tools.append(trace_dict) - - if unassigned_tools: - unassigned_tools.sort(key=lambda t: t.get("timestamp") or 0) - first_timestamp = unassigned_tools[0].get("timestamp") or 0 - - # Calculate token usage ONLY for pre-planning LLM calls (before first step) - first_recorded_step_time = ( - sorted_steps[0].get("timestamp") if sorted_steps else float("inf") - ) - cursor.execute( - "SELECT payload FROM traces WHERE session_id = ? AND (step_id IS NULL OR step_id = '') AND timestamp < ? AND type = 'llm_call'", - (session_id, first_recorded_step_time), - ) - plan_llm_rows = cursor.fetchall() - plan_p, plan_c, plan_t = 0, 0, 0 - for lr in plan_llm_rows: - p, c, t = self._extract_token_usage_from_trace(lr["payload"]) - plan_p += p - plan_c += c - plan_t += t - - # Retrieve planner thinking / thoughts - cursor.execute( - "SELECT type, payload FROM traces WHERE session_id = ? AND (step_id IS NULL OR step_id = '') AND timestamp < ? AND type IN ('raw_thinking', 'thinking') ORDER BY timestamp ASC", - (session_id, first_recorded_step_time), - ) - think_rows = cursor.fetchall() - p_raw_th = [] - p_nat_th = [] - for thr in think_rows: - if thr["payload"]: - try: - th_obj = ( - json.loads(thr["payload"]) - if isinstance(thr["payload"], str) - else thr["payload"] - ) - th_text = th_obj.get("thought") or th_obj.get("text") or "" - if thr["type"] == "raw_thinking" and th_text: - p_raw_th.append(str(th_text).strip()) - elif thr["type"] == "thinking" and th_text: - p_nat_th.append(str(th_text).strip()) - except (ValueError, TypeError, AttributeError): - # Unparseable or non-dict thinking payload: skip it. - pass - - virtual_step = { - "step_id": "pre-planning", - "step_type": "planning", - "session_id": session_id, - "step_number": 0, - "timestamp": first_timestamp, - "operator_native_thinking": "\n\n".join(p_nat_th) if p_nat_th else "", - "operator_raw_thinking": "\n\n".join(p_raw_th) if p_raw_th else "", - "action_taken": None, - "last_execution_result": None, - "extra_metadata": None, - "generic_tools": unassigned_tools, - "token_usage": { - "prompt_tokens": plan_p, - "completion_tokens": plan_c, - "total_tokens": plan_t, - }, - "total_tokens": plan_t, - } - steps.append(virtual_step) - - steps.sort( - key=lambda x: ( - x.get("timestamp") or 0, - x.get("step_number") or 0, - ) - ) - - return steps - - -step_repo = StepRepository() diff --git a/apps/admin_console/database/repositories/trace_repository.py b/apps/admin_console/database/repositories/trace_repository.py deleted file mode 100644 index 7bc0dbc01..000000000 --- a/apps/admin_console/database/repositories/trace_repository.py +++ /dev/null @@ -1,133 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json -from typing import Any - -try: - from admin_console.database.connection import db_session -except ImportError: - from apps.admin_console.database.connection import db_session - - -class TraceRepository: - """Repository handling querying traces and constructing hierarchical trace trees.""" - - def __init__(self, db_path=None): - self.db_path = db_path - - def get_trace_by_id(self, trace_id: str, db_path=None) -> dict[str, Any] | None: - with db_session(db_path or self.db_path) as conn: - cursor = conn.cursor() - cursor.execute("SELECT * FROM traces WHERE trace_id = ?", (trace_id,)) - row = cursor.fetchone() - return dict(row) if row else None - - def get_trace_tree(self, session_id: str) -> list[dict[str, Any]]: - with db_session(self.db_path) as conn: - cursor = conn.cursor() - cursor.execute( - "SELECT trace_id, parent_trace_id, type, name, status, timestamp," - " duration, CASE WHEN type IN ('raw_thinking', 'thinking') THEN" - " payload ELSE NULL END AS payload FROM traces WHERE session_id = ?" - " ORDER BY timestamp ASC", - (session_id,), - ) - rows = cursor.fetchall() - - nodes = {} - for r in rows: - row_dict = dict(r) - if row_dict.get("payload"): - try: - row_dict["payload"] = json.loads(row_dict["payload"]) - except (ValueError, TypeError): - # Non-JSON payload: keep the raw string for display. - pass - nodes[row_dict["trace_id"]] = {**row_dict, "children": []} - - root_nodes = [] - for node_id, node in nodes.items(): - parent_id = node["parent_trace_id"] - if parent_id and parent_id in nodes: - nodes[parent_id]["children"].append(node) - else: - root_nodes.append(node) - - def sanitize_tree(nodes_list): - sanitized = [] - for node in nodes_list: - node["children"] = sanitize_tree(node["children"]) - if node["name"] == "node": - sanitized.extend(node["children"]) - else: - sanitized.append(node) - return sanitized - - return sanitize_tree(root_nodes) - - def get_step_traces_tree(self, session_id: str, step_id: str) -> list[dict[str, Any]]: - with db_session(self.db_path) as conn: - cursor = conn.cursor() - cursor.execute( - "SELECT trace_id, parent_trace_id, step_id, type, name, status," - " timestamp, duration, CASE WHEN type IN ('raw_thinking'," - " 'thinking') THEN payload ELSE NULL END AS payload FROM traces" - " WHERE session_id = ? ORDER BY timestamp ASC", - (session_id,), - ) - rows = cursor.fetchall() - - nodes = {} - for r in rows: - row_dict = dict(r) - if row_dict.get("payload"): - try: - row_dict["payload"] = json.loads(row_dict["payload"]) - except (ValueError, TypeError): - # Non-JSON payload: keep the raw string for display. - pass - nodes[row_dict["trace_id"]] = {**row_dict, "children": []} - - step_roots = [] - for n_id, node in nodes.items(): - if node.get("step_id") == step_id: - step_roots.append(node) - - for n_id, node in nodes.items(): - parent_id = node["parent_trace_id"] - if parent_id and parent_id in nodes: - nodes[parent_id]["children"].append(node) - - def sanitize_tree(nodes_list, visited=None): - if visited is None: - visited = set() - sanitized = [] - for node in nodes_list: - node_id = node.get("trace_id") - if node_id and node_id in visited: - continue - if node_id: - visited.add(node_id) - node["children"] = sanitize_tree(node["children"], visited.copy()) - if node["name"] == "node": - sanitized.extend(node["children"]) - else: - sanitized.append(node) - return sanitized - - return sanitize_tree(step_roots) - - -trace_repo = TraceRepository() diff --git a/apps/admin_console/index.html b/apps/admin_console/index.html deleted file mode 100644 index 0d60c4c06..000000000 --- a/apps/admin_console/index.html +++ /dev/null @@ -1,3787 +0,0 @@ - - - - - - - - Artemis Debug UI - - - - - - - - - -
-
-
- -
-

Select a session from the sidebar

-
-
- -
- - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/admin_console/replay_manager.py b/apps/admin_console/replay_manager.py deleted file mode 100644 index c708629dd..000000000 --- a/apps/admin_console/replay_manager.py +++ /dev/null @@ -1,2585 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json -import os -from pathlib import Path -import shutil -import sqlite3 -import sys -import traceback -from typing import Any -from uuid import UUID - -WORKSPACE_ROOT = Path(__file__).parent.parent.resolve() - -# Try to import settings from artemis -try: - from artemis.config import settings - - TRACES_PATH = Path(settings.TRACES_PATH) -except ImportError: - # Fallback to default path if run outside project environment or imports fail - TRACES_PATH = WORKSPACE_ROOT / "traces" - -DB_PATH = TRACES_PATH / "data_engine.db" -IMAGES_DIR = TRACES_PATH / "images" - -try: - from artemis.context import ArtemisContext - from artemis.graph.state import State -except ImportError: - ArtemisContext = Any - State = Any - -REPLAY_TOOLS_CONFIG = { - "ask_explorer": { - "display_name": "Ask Explorer", - "description": ( - "Visual parsing agent that executes visual ReAct search loops using multi-modal models." - ), - "module": "artemis.tools.explorer_tool", - "function": "_run_explorer_logic", - "agent_name": "explorer", - "denylist_args": ["ctx", "state"], - "fallback_mappings": {}, - "llm_span_name": "gemini_explorer_call", - }, - "ask_image_processor": { - "display_name": "Ask Image Processor", - "description": ( - "Visual parsing agent that writes Python code using OpenCV and" - " computer vision to analyze and modify screen images." - ), - "module": "artemis.tools.image_processor_tool", - "function": "_run_image_processor_logic", - "agent_name": "image_processor", - "denylist_args": ["ctx", "state"], - "fallback_mappings": {}, - "llm_span_name": "gemini_image_processor_call", - }, - # Future tools can easily be registered here using plain dictionaries: - # "ask_validator": { - # "display_name": "Ask Validator", - # "description": "Validation agent that checks if a step or action was completed successfully.", - # "module": "artemis.tools.validator_tool", - # ... - # } -} - - -class ReplayManager: - """Manages sandboxed execution and diagnostics of visual agent steps. - - The ReplayManager is the core orchestrator for the Artemis step replay system. - It reconstructs historical execution states (SQLite database records, trace - histories, and screenshots) and replays a single agent step in an isolated, - deterministic sandbox environment while interacting live with a physical - Android device and Gemini. - - Key Responsibilities: - - Reconstructs and preloads historical SQLite states up to a specific - step. - - Establishes sandboxed `ArtemisContext` and `DataEngine` instances. - - Integrates with physical devices via ADB and UIAutomator. - - Hooks and spies on the Gemini API to capture thoughts and raw outputs. - - Reconciles live and preloaded traces into unified chronological trees. - - Attributes: - workspace_root (Path): The absolute path to the root of the project - workspace. - replay_base_dir (Path): Base directory containing replay data and - outputs. - test_data_dir (Path): Directory containing chunked historical step - traces. - test_outputs_dir (Path): Sandbox directory where replay outputs are - written. - db_path (Path): Path to the master Data Engine SQLite database. - traces_path (Path): Path to the master traces directory. - images_dir (Path): Path to the master images directory. - """ - - def __init__( - self, - workspace_root: Path = WORKSPACE_ROOT, - device_id: str = None, - original_db_path: Path = None, - init_device: bool = False, - ): - """Initializes the ReplayManager and autodetects the connected Android device. - - Args: - workspace_root (Path, optional): The absolute path to the workspace - root. Defaults to WORKSPACE_ROOT. - device_id (str, optional): The serial ID of the target Android - device. If not specified, autodetects the first connected device. - original_db_path (Path, optional): Path to the master SQLite - database. Defaults to DB_PATH. - init_device (bool, optional): Whether to initialize the ADB device - connection on startup. - """ - self.workspace_root = workspace_root - self.traces_path = TRACES_PATH - self.replay_base_dir = self.traces_path / "replay" - self.test_data_dir = self.replay_base_dir / "data" - self.test_outputs_dir = self.replay_base_dir / "outputs" - self.db_path = Path(original_db_path) if original_db_path else DB_PATH - self.images_dir = IMAGES_DIR - - # Device connection and autodetection - self.device_id = None - self.w = None - self.h = None - self.adb = None - self.ui_client = None - - if init_device: - try: - self._init_device(device_id) - except Exception as e: - print( - "Warning: Connected device could not be initialized during" - f" ReplayManager startup: {e}" - ) - - def _init_device(self, device_id: str = None): - """Initializes connection to the target device and queries screen metrics.""" - from adbutils import AdbClient - - try: - from third_party.mobile_use.clients.ui_automator_client import UIAutomatorClient - except ImportError: - raise ImportError( - "Failed to import UIAutomatorClient. Ensure artemis package is installed in path." - ) - - self.adb = AdbClient(host="localhost", port=5037) - try: - devices = self.adb.device_list() - except Exception as adb_err: - raise ConnectionError( - f"Failed to query device list from ADB server: {adb_err}" - ) from adb_err - - if not devices: - raise ConnectionError( - "No active ADB devices connected. Please connect an Android device via ADB." - ) - - if device_id: - matched_device = next((d.serial for d in devices if d.serial == device_id), None) - if not matched_device: - raise ConnectionError( - f"Requested device '{device_id}' is not connected." - f" Connected devices: {[d.serial for d in devices]}" - ) - self.device_id = matched_device - else: - self.device_id = devices[0].serial - - self.ui_client = UIAutomatorClient(device_id=self.device_id) - ui_data = self.ui_client.get_screen_data() - self.w, self.h = ui_data.width, ui_data.height - print(f"Connected to device: {self.device_id} ({self.w}x{self.h})") - - def chunk_session_traces(self, session_id: str, output_dir: Path = None) -> Path: - """Chunks traces, screenshots, and metadata for a specific execution session. - - Queries the master SQLite database and exports step-by-step - subdirectories - (e.g., `step_00/`, `step_01/`) inside the output directory. - - Args: - session_id: The UUID string of the session to extract and chunk. - output_dir: Optional path to the directory where chunked results - should be saved. Defaults to `self.test_data_dir / - f"{session_id}_chunked"`. - """ - import sqlite3 - import json - import shutil - - db_path = self.db_path - traces_dir = self.traces_path - - if not db_path.exists(): - raise FileNotFoundError(f"Master database not found at {db_path}") - - if not output_dir: - output_dir = self.test_data_dir / f"{session_id}_chunked" - else: - output_dir = Path(output_dir) - - if output_dir.exists() or output_dir.is_symlink(): - # Safety check: only delete directories that are named *_chunked - if output_dir.name.endswith("_chunked"): - if output_dir.is_symlink(): - output_dir.unlink() - elif ( - self.test_data_dir in output_dir.parents - or output_dir.parent == self.test_data_dir - or output_dir.parent.name == "inputs" - ): - shutil.rmtree(output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - print(f"Chunking traces for session: {session_id}") - print(f"Using database: {db_path}") - print(f"Output directory: {output_dir}") - - conn = sqlite3.connect(db_path) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - - cursor.execute("SELECT * FROM sessions WHERE session_id = ?", (session_id,)) - session_row = cursor.fetchone() - - cursor.execute( - "SELECT * FROM steps WHERE session_id = ? ORDER BY step_number ASC", - (session_id,), - ) - steps = cursor.fetchall() - print(f"Found {len(steps)} steps for session {session_id}") - - if not session_row and not steps: - conn.close() - raise ValueError(f"Session {session_id} not found in database.") - - for step in steps: - step_number = step["step_number"] - step_id = step["step_id"] - step_dir = output_dir / f"step_{step_number:02d}" - step_dir.mkdir(parents=True, exist_ok=True) - - # 1. Process and save the step's primary metadata (step.json) - step_data = {} - for key in step.keys(): - val = step[key] - if ( - key - in ( - "action_taken", - "last_execution_result", - "extra_metadata", - ) - and val - ): - try: - step_data[key] = json.loads(val) - except Exception: - step_data[key] = val - else: - step_data[key] = val - - step_json_path = step_dir / "step.json" - with open(step_json_path, "w", encoding="utf-8") as f: - json.dump(step_data, f, indent=2, ensure_ascii=False) - - # 2. Retrieve and save trace logs recorded during this step (traces.json) - cursor.execute( - "SELECT * FROM traces WHERE step_id = ? ORDER BY timestamp ASC", - (step_id,), - ) - traces = cursor.fetchall() - - traces_data = [] - for trace_row in traces: - trace_dict = {} - for key in trace_row.keys(): - val = trace_row[key] - if key == "payload" and val: - try: - trace_dict[key] = json.loads(val) - except Exception: - trace_dict[key] = val - else: - trace_dict[key] = val - traces_data.append(trace_dict) - - traces_json_path = step_dir / "traces.json" - with open(traces_json_path, "w", encoding="utf-8") as f: - json.dump(traces_data, f, indent=2, ensure_ascii=False) - - # 3. Copy the step's screenshots (pre-action and post-action screenshots) - pre_image_name = step_data.get("pre_image_name") - post_image_name = step_data.get("post_image_name") - if pre_image_name and post_image_name == pre_image_name: - post_image_name = None - images_dir = traces_dir / "images" - - if pre_image_name: - src_pre = images_dir / f"{pre_image_name}.jpg" - if src_pre.exists(): - shutil.copy2(src_pre, step_dir / "pre.jpg") - else: - print(f"Warning: Pre-action screenshot not found at {src_pre}") - - if post_image_name: - src_post = images_dir / f"{post_image_name}.jpg" - if src_post.exists(): - shutil.copy2(src_post, step_dir / "post.jpg") - else: - print(f"Warning: Post-action screenshot not found at {src_post}") - - # 4. Save metadata of the pre-action screenshot (pre_image_meta.json) - if pre_image_name: - cursor.execute( - "SELECT * FROM images WHERE image_name = ?", - (pre_image_name,), - ) - img_row = cursor.fetchone() - if img_row: - img_meta = {} - for key in img_row.keys(): - val = img_row[key] - if key in ("ocr_result", "ui_tree", "extra_metadata") and val: - try: - img_meta[key] = json.loads(val) - except Exception: - img_meta[key] = val - else: - img_meta[key] = val - - pre_img_meta_path = step_dir / "pre_image_meta.json" - with open(pre_img_meta_path, "w", encoding="utf-8") as f: - json.dump(img_meta, f, indent=2, ensure_ascii=False) - else: - print( - f"Warning: Image metadata row for {pre_image_name} not" - " found in images table." - ) - - # 5. Save metadata of the post-action screenshot (post_image_meta.json) - if post_image_name: - cursor.execute( - "SELECT * FROM images WHERE image_name = ?", - (post_image_name,), - ) - img_row = cursor.fetchone() - if img_row: - img_meta = {} - for key in img_row.keys(): - val = img_row[key] - if key in ("ocr_result", "ui_tree", "extra_metadata") and val: - try: - img_meta[key] = json.loads(val) - except Exception: - img_meta[key] = val - else: - img_meta[key] = val - - post_img_meta_path = step_dir / "post_image_meta.json" - with open(post_img_meta_path, "w", encoding="utf-8") as f: - json.dump(img_meta, f, indent=2, ensure_ascii=False) - else: - print( - f"Warning: Image metadata row for {post_image_name} not" - " found in images table." - ) - - conn.close() - # Write sentinel file to indicate successful chunking - try: - (output_dir / ".chunked").touch() - except Exception as e: - print(f"Warning: Failed to write chunked sentinel file: {e}") - print("Chunking trace completion status: success") - return output_dir - - def list_devices(self) -> list[dict]: - """Dynamically queries the ADB server for connected Android devices.""" - try: - from adbutils import AdbClient - - adb = AdbClient(host="localhost", port=5037) - devices = adb.device_list() - return [{"serial": d.serial, "status": "online"} for d in devices] - except Exception as e: - print(f"Warning: Failed to query device list from ADB: {e}") - return [] - - def load_session_goal( - self, session_id: str, step_dir: Path, original_db_path: str = None - ) -> str: - """Loads the session goal from either session.json or the original session db.""" - session_json_path = step_dir.parent / "session.json" - if session_json_path.exists(): - try: - with open(session_json_path, encoding="utf-8") as f: - data = json.load(f) - if isinstance(data, dict) and "initial_goal" in data and data["initial_goal"]: - return data["initial_goal"] - except Exception as e: - print(f"Warning: Failed to load goal from session.json: {e}") - - if original_db_path and Path(original_db_path).exists(): - try: - conn = sqlite3.connect(original_db_path) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - cursor.execute( - "SELECT initial_goal FROM sessions WHERE session_id = ?", - (str(session_id),), - ) - row = cursor.fetchone() - conn.close() - if row and row["initial_goal"]: - return row["initial_goal"] - except Exception as e: - print(f"Warning: Failed to load goal from original database: {e}") - - raise ValueError( - f"Could not load initial_goal for session {session_id} from" - f" session.json or original database {original_db_path}." - ) - - def extract_replay_data( - self, - step_dir, - original_db_path=None, - agent_name="explorer", - tool_name="ask_explorer", - ): - """Extracts final answer and chronological tool call sequence from traces.json/db.""" - step_json_path = step_dir / "step.json" - if not step_json_path.exists(): - raise FileNotFoundError(f"step.json not found in {step_dir}") - - with open(step_json_path, encoding="utf-8") as f: - step_data = json.load(f) - step_id = step_data["step_id"] - - traces_json_path = step_dir / "traces.json" - traces = [] - if traces_json_path.exists(): - with open(traces_json_path, encoding="utf-8") as f: - traces = json.load(f) - - # If traces.json is empty/missing or lacks agent trace, check original DB - agent_trace = next( - (t for t in traces if t.get("type") == "agent" and t.get("name") == agent_name), - None, - ) - if (not agent_trace or not traces) and original_db_path and Path(original_db_path).exists(): - conn = sqlite3.connect(original_db_path) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - cursor.execute( - "SELECT * FROM traces WHERE step_id = ? ORDER BY timestamp ASC", - (step_id,), - ) - rows = cursor.fetchall() - traces = [] - for r in rows: - td = dict(r) - if td.get("payload"): - try: - td["payload"] = json.loads(td["payload"]) - except (ValueError, TypeError): - # Non-JSON payload: keep the raw string. - pass - traces.append(td) - conn.close() - agent_trace = next( - (t for t in traces if t.get("type") == "agent" and t.get("name") == agent_name), - None, - ) - - if not agent_trace: - print( - f"Warning: {agent_name} agent trace not found. Replay will" - " fallback to direct submit_answer." - ) - return ( - [], - { - "candidates": [], - "fallback_message": f"No {agent_name} trace found.", - }, - traces, - ) - - payload = agent_trace.get("payload") or {} - result_str = payload.get("result", "{}") - if isinstance(result_str, str): - try: - final_submit_args = json.loads(result_str) - except Exception: - final_submit_args = { - "candidates": [], - "fallback_message": result_str, - } - else: - final_submit_args = result_str - - # Build sequence of intermediate tool calls under the agent trace - agent_trace_id = agent_trace["trace_id"] - - def get_descendants(pid): - child_list = [] - for t in traces: - if t.get("parent_trace_id") == pid: - child_list.append(t) - child_list.extend(get_descendants(t["trace_id"])) - return child_list - - descendants = get_descendants(agent_trace_id) - descendants.sort(key=lambda x: x.get("timestamp", 0)) - - tool_calls_sequence = [] - for t in descendants: - if t.get("type") == "tool" and t.get("name") not in ( - tool_name, - "submit_answer", - ): - t_payload = t.get("payload") or {} - t_args = t_payload.get("args") or {} - tool_calls_sequence.append({"name": t.get("name"), "args": t_args}) - - # Preload traces up to the point when agent is called - traces_to_preload = [] - if agent_trace in traces: - idx = traces.index(agent_trace) - traces_to_preload = traces[:idx] - else: - agent_ts = agent_trace.get("timestamp", 0.0) - traces_to_preload = [t for t in traces if t.get("timestamp", 0.0) < agent_ts] - - return tool_calls_sequence, final_submit_args, traces_to_preload - - def resolve_step_data_path(self, data: dict, path: str): - """Resolves a value from a nested dict/list using a dot-separated path (e.g. - - 'action_taken.0.target_text'). - """ - parts = path.split(".") - curr = data - for p in parts: - if isinstance(curr, dict): - curr = curr.get(p) - elif isinstance(curr, list): - try: - idx = int(p) - if idx < len(curr): - curr = curr[idx] - else: - return None - except ValueError: - return None - else: - return None - return curr - - def setup_temporary_database( - self, - temp_db_path, - temp_traces_dir, - session_id, - step_data, - pre_image_meta, - initial_goal, - traces_to_preload=None, - post_image_meta=None, - replay_id=None, - ): - """Initializes a sandbox SQLite database with preloaded step and screen metadata.""" - if not initial_goal: - raise ValueError("initial_goal must be provided and cannot be empty.") - - if temp_db_path.exists(): - try: - temp_db_path.unlink() - except OSError: - pass - - if temp_traces_dir.exists(): - shutil.rmtree(temp_traces_dir) - temp_traces_dir.mkdir(parents=True, exist_ok=True) - - try: - from artemis.data_engine.storage import StorageManager - from artemis.data_engine.models import ( - SessionMetadata, - ImageRecord, - StepRecord, - TraceRecord, - ) - except ImportError: - raise ImportError( - "Failed to import Artemis Data Engine modules. Ensure you are" - " running in the correct virtual environment." - ) - - storage = StorageManager(temp_db_path, temp_traces_dir) - - # Preload session metadata - session_meta = SessionMetadata( - session_id=UUID(session_id) if isinstance(session_id, str) else session_id, - initial_goal=initial_goal, - status="running", - ) - storage.create_session(session_meta) - - if replay_id: - replay_session_meta = SessionMetadata( - session_id=UUID(replay_id) if isinstance(replay_id, str) else replay_id, - initial_goal=initial_goal, - status="running", - ) - storage.create_session(replay_session_meta) - - # Preload image metadata - if pre_image_meta: - image_record = ImageRecord( - image_name=pre_image_meta["image_name"], - timestamp=pre_image_meta.get("timestamp", 0.0), - ocr_result=pre_image_meta.get("ocr_result"), - ui_tree=pre_image_meta.get("ui_tree"), - extra_metadata=pre_image_meta.get("extra_metadata", {}), - ) - storage.create_image(image_record) - - if post_image_meta: - image_record = ImageRecord( - image_name=post_image_meta["image_name"], - timestamp=post_image_meta.get("timestamp", 0.0), - ocr_result=post_image_meta.get("ocr_result"), - ui_tree=post_image_meta.get("ui_tree"), - extra_metadata=post_image_meta.get("extra_metadata", {}), - ) - storage.create_image(image_record) - - # Preload step metadata - step_record = StepRecord( - step_id=step_data["step_id"], - session_id=step_data["session_id"], - step_number=step_data["step_number"], - timestamp=step_data.get("timestamp", 0.0), - pre_image_name=step_data.get("pre_image_name"), - post_image_name=step_data.get("post_image_name"), - summary=step_data.get("summary"), - action_taken=step_data.get("action_taken"), - operator_raw_thinking=step_data.get("operator_raw_thinking"), - operator_native_thinking=step_data.get("operator_native_thinking"), - last_execution_result=step_data.get("last_execution_result"), - extra_metadata=step_data.get("extra_metadata", {}), - ) - storage.create_step(step_record) - - # Preload trace history - if traces_to_preload: - for t in traces_to_preload: - try: - payload_val = t.get("payload") - if isinstance(payload_val, str): - try: - payload_val = json.loads(payload_val) - except Exception: - payload_val = {} - elif not isinstance(payload_val, dict): - payload_val = {} - - trace_rec = TraceRecord( - trace_id=UUID(t["trace_id"]) - if isinstance(t["trace_id"], str) - else t["trace_id"], - session_id=UUID(t["session_id"]) - if isinstance(t["session_id"], str) - else t["session_id"], - step_id=UUID(t["step_id"]) if t.get("step_id") else None, - parent_trace_id=UUID(t["parent_trace_id"]) - if t.get("parent_trace_id") - else None, - type=t["type"], - name=t["name"], - timestamp=t.get("timestamp", 0.0), - duration=t.get("duration"), - status=t.get("status", "success"), - payload=payload_val, - ) - storage.create_trace(trace_rec) - except Exception as ex: - print(f"Warning: Failed to preload trace {t.get('trace_id')}: {ex}") - - return storage - - def get_preloaded_trace_ids_for_step(self, session_id: str, step_num: int) -> set: - traces_json_path = ( - self.test_data_dir / f"{session_id}_chunked" / f"step_{step_num:02d}" / "traces.json" - ) - if traces_json_path.exists(): - try: - with open(traces_json_path, encoding="utf-8") as f: - traces = json.load(f) - return {str(t.get("trace_id")).lower() for t in traces if t.get("trace_id")} - except (OSError, ValueError) as e: - print( - f"Warning: Failed to read preloaded trace ids from {traces_json_path}: {e}", - file=sys.stderr, - ) - return set() - - def load_traces_from_db(self, db_path: Path, step_num: int, preloaded_trace_ids: set) -> list: - if not db_path.exists(): - return [] - try: - conn = sqlite3.connect(db_path) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - cursor.execute( - "SELECT trace_id, session_id, step_id, parent_trace_id, type," - " name, status, timestamp, duration, payload FROM traces ORDER" - " BY timestamp ASC" - ) - rows = cursor.fetchall() - conn.close() - - preloaded_set = {str(tid).lower() for tid in preloaded_trace_ids if tid} - step_traces = [] - for r in rows: - td = dict(r) - if td.get("payload"): - try: - td["payload"] = json.loads(td["payload"]) - except (ValueError, TypeError): - # Non-JSON payload: normalized to {} just below. - pass - if not isinstance(td.get("payload"), dict): - td["payload"] = {} - - tid = str(r["trace_id"]).lower() - if tid in preloaded_set: - td["is_preloaded"] = True - else: - td["is_preloaded"] = False - - td["step_number"] = step_num - step_traces.append(td) - return step_traces - except Exception as e: - print( - f"Warning: Failed to load traces from database {db_path}: {e}", - file=sys.stderr, - ) - return [] - - def normalize_trace_payload(self, t: dict) -> dict: - if t.get("type") == "tool": - payload = t.get("payload") or {} - args = payload.get("args") or {} - positional_args = payload.get("positional_args") or [] - - if (not args or args == {}) and positional_args: - name = t.get("name") - if name == "search_by_coords" and len(positional_args) >= 2: - try: - args = { - "nx": int(positional_args[0]), - "ny": int(positional_args[1]), - } - except ValueError: - args = { - "nx": positional_args[0], - "ny": positional_args[1], - } - elif name == "search_ui" and len(positional_args) >= 1: - args = {"search_query": positional_args[0]} - elif name == "detect_objects" and len(positional_args) >= 1: - args = {"labels": positional_args[0]} - else: - args = {"positional_args": positional_args} - - payload["args"] = args - t["payload"] = payload - return t - - def load_all_session_traces( - self, - temp_db_path: Path, - preloaded_trace_ids: set, - current_step_number: int, - ) -> list: - """Loads and merges all traces across all steps in the session.""" - session_id = None - try: - session_id = temp_db_path.parents[1].name.split("_")[0] - except IndexError: - # Path too shallow to carry a session dir: fall back to the DB probe. - pass - - if not session_id: - try: - conn = sqlite3.connect(temp_db_path) - cursor = conn.cursor() - cursor.execute("SELECT session_id FROM traces LIMIT 1") - row = cursor.fetchone() - if row: - session_id = str(row[0]) - conn.close() - except sqlite3.Error: - # Unreadable sandbox DB: fall back to single-step trace loading. - pass - - if not session_id: - return self.load_traces_from_db(temp_db_path, current_step_number, preloaded_trace_ids) - - session_chunked_dir = self.test_data_dir / f"{session_id}_chunked" - - all_traces = [] - if session_chunked_dir.exists(): - step_subdirs = sorted( - [ - d - for d in session_chunked_dir.iterdir() - if d.is_dir() and d.name.startswith("step_") - ], - key=lambda d: d.name, - ) - - for subdir in step_subdirs: - try: - step_num = int(subdir.name.split("_")[1]) - except (IndexError, ValueError): - continue - - step_outputs_dir = self.test_outputs_dir / f"{session_id}_step_{step_num:02d}" - step_db_path = step_outputs_dir / "temp_traces" / "data_engine.db" - - step_preloaded_ids = self.get_preloaded_trace_ids_for_step(session_id, step_num) - - if step_db_path.exists(): - step_traces = self.load_traces_from_db( - step_db_path, step_num, step_preloaded_ids - ) - all_traces.extend(step_traces) - else: - traces_json_path = subdir / "traces.json" - if traces_json_path.exists(): - try: - with open(traces_json_path, encoding="utf-8") as f: - step_traces = json.load(f) - for t in step_traces: - t["is_preloaded"] = True - t["step_number"] = step_num - if not isinstance(t.get("payload"), dict): - t["payload"] = {} - all_traces.append(t) - except Exception as e: - print( - f"Warning: Failed to load traces from {traces_json_path}: {e}", - file=sys.stderr, - ) - else: - all_traces = self.load_traces_from_db( - temp_db_path, current_step_number, preloaded_trace_ids - ) - - # Align live replay traces to the appropriate preloaded step/operator trace - for step_num in range(1, 100): - current_step_id = None - current_step_dir = self.test_data_dir / f"{session_id}_chunked" / f"step_{step_num:02d}" - if not current_step_dir.exists(): - continue - - step_json_path = current_step_dir / "step.json" - if step_json_path.exists(): - try: - with open(step_json_path, encoding="utf-8") as f: - step_data = json.load(f) - current_step_id = step_data.get("step_id") - except (OSError, ValueError) as e: - print( - f"Warning: Failed to read step id from {step_json_path}: {e}", - file=sys.stderr, - ) - - current_step_id_str = str(current_step_id).lower() if current_step_id else None - - # 1. Find live root trace (no parent_trace_id and not preloaded) - live_root_trace = None - for t in all_traces: - if t.get("step_number") != step_num: - continue - if not t.get("is_preloaded") and not t.get("parent_trace_id"): - live_root_trace = t - break - - # 2. Find all preloaded traces for this step - step_preloaded_traces = [ - t - for t in all_traces - if t.get("step_number") == step_num - and t.get("is_preloaded") - and str(t.get("step_id") or "").lower() == current_step_id_str - ] - - # Sort chronologically to identify the final response of the step - step_preloaded_traces.sort(key=lambda x: x.get("timestamp", 0.0)) - final_response_trace = step_preloaded_traces[-1] if step_preloaded_traces else None - - # 3. Branch immediately before the final response of the step - if live_root_trace and final_response_trace: - # Resolve the tool name from the live root trace name - tool_name = "ask_explorer" - agent_name = live_root_trace.get("name") - for t_name, cfg in REPLAY_TOOLS_CONFIG.items(): - if cfg.get("agent_name") == agent_name: - tool_name = t_name - break - - import uuid - - virtual_id = str(uuid.uuid4()) - - # Branch from the same parent as the final response - parent_id = final_response_trace.get("parent_trace_id") - - # Set timestamp to be immediately before the final response - virtual_timestamp = final_response_trace.get("timestamp", 0.0) - 0.001 - - live_query = None - live_cf = None - if live_root_trace.get("payload") and live_root_trace["payload"].get("args"): - args = live_root_trace["payload"]["args"] - live_query = ( - args.get("q") or args.get("query") or args.get("arg2") or args.get("goal") - ) - live_cf = ( - args.get("cf") or args.get("context_feedback") or args.get("arg3") or "" - ) - if not live_query: - live_query = "Replay Execution" - - virtual_tool_trace = { - "trace_id": virtual_id, - "session_id": session_id, - "step_id": current_step_id, - "parent_trace_id": parent_id, - "type": "tool", - "name": tool_name, - "status": live_root_trace.get("status", "success"), - "timestamp": virtual_timestamp, - "duration": live_root_trace.get("duration", 0.0), - "payload": { - "args": { - "query": live_query, - "context_feedback": live_cf, - }, - "result": (live_root_trace.get("payload", {}).get("result")), - }, - "is_preloaded": False, - "step_number": step_num, - } - - # Link the live root trace to our new virtual tool trace - live_root_trace["parent_trace_id"] = virtual_id - all_traces.append(virtual_tool_trace) - - all_traces = [self.normalize_trace_payload(t) for t in all_traces] - all_traces.sort(key=lambda t: t.get("timestamp", 0.0)) - return all_traces - - def enrich_all_session_traces(self, all_traces: list, session_id: str) -> list: - """Enriches all preloaded traces (across all steps) with file URLs to their corresponding screenshots.""" - session_chunked_dir = self.test_data_dir / f"{session_id}_chunked" - - step_dirs = {} - if session_chunked_dir.exists(): - for subdir in session_chunked_dir.iterdir(): - if subdir.is_dir() and subdir.name.startswith("step_"): - step_json_path = subdir / "step.json" - if step_json_path.exists(): - try: - with open(step_json_path, encoding="utf-8") as f: - step_data = json.load(f) - sid = step_data.get("step_id") - if sid: - step_dirs[str(sid).lower()] = subdir - except (OSError, ValueError): - # Unreadable step.json: screenshots for this step - # simply are not linked into the trace payloads. - pass - - for td in all_traces: - if td.get("is_preloaded"): - step_id = str(td.get("step_id") or "").lower() - if step_id and step_id in step_dirs: - step_dir = step_dirs[step_id] - if td.get("type") == "agent": - pre_jpg = step_dir / "pre.jpg" - if pre_jpg.exists(): - td["payload"]["pre_screenshot_path"] = f"file://{pre_jpg.resolve()}" - elif td.get("type") == "action": - post_jpg = step_dir / "post.jpg" - if post_jpg.exists(): - td["payload"]["post_screenshot_path"] = f"file://{post_jpg.resolve()}" - - return all_traces - - def load_steps_metadata(self, session_id: str) -> dict[int, dict]: - """Loads metadata for all steps in the session from the chunked data.""" - session_chunked_dir = self.test_data_dir / f"{session_id}_chunked" - - steps = {} - if session_chunked_dir.exists(): - for subdir in session_chunked_dir.iterdir(): - if subdir.is_dir() and subdir.name.startswith("step_"): - step_json_path = subdir / "step.json" - if step_json_path.exists(): - try: - with open(step_json_path, encoding="utf-8") as f: - step_data = json.load(f) - step_num = step_data.get("step_number") - if step_num is not None: - steps[int(step_num)] = step_data - except Exception as e: - print(f"Warning: Failed to load step data from {step_json_path}: {e}") - return steps - - def make_file_url(self, val): - if isinstance(val, str) and (val.endswith(".jpg") or val.endswith(".png") or "/" in val): - try: - p = Path(val) - if p.exists(): - abs_path = str(p.resolve()) - return ( - f"file://{abs_path}" if abs_path.startswith("/") else f"file:///{abs_path}" - ) - except (OSError, ValueError): - # Not a resolvable filesystem path: return the value verbatim. - pass - return val - - def clean_payload_paths(self, obj): - if isinstance(obj, dict): - return {k: self.clean_payload_paths(v) for k, v in obj.items()} - elif isinstance(obj, list): - return [self.clean_payload_paths(v) for v in obj] - elif isinstance(obj, str): - return self.make_file_url(obj) - return obj - - def sanitize_nodes(self, nodes_list): - sanitized = [] - for node in nodes_list: - node["children"] = self.sanitize_nodes(node["children"]) - if node["name"] == "node": - sanitized.extend(node["children"]) - else: - sanitized.append(node) - return sanitized - - def sort_tree_nodes_chronologically(self, nodes_list: list[dict]) -> list[dict]: - for node in nodes_list: - if "children" in node and node["children"]: - node["children"] = self.sort_tree_nodes_chronologically(node["children"]) - return sorted(nodes_list, key=lambda x: x.get("timestamp", 0.0) or 0.0) - - def extract_tool_calls_from_db(self, temp_db_path: Path, preloaded_trace_ids: set) -> list: - """Extracts tool calls executed during the current step replay from the sandbox database.""" - preloaded_set = {str(tid).lower() for tid in preloaded_trace_ids if tid} - - if not temp_db_path.exists(): - return [] - - try: - conn = sqlite3.connect(temp_db_path) - conn.row_factory = sqlite3.Row - cursor = conn.cursor() - cursor.execute( - "SELECT trace_id, name, status, payload FROM traces WHERE type" - " = 'tool' ORDER BY timestamp ASC" - ) - rows = cursor.fetchall() - conn.close() - except Exception as e: - print( - f"Warning: Failed to query tool calls from database: {e}", - file=sys.stderr, - ) - return [] - - tool_calls = [] - for r in rows: - tid = str(r["trace_id"]).lower() - if tid not in preloaded_set: - payload = {} - if r["payload"]: - try: - payload = json.loads(r["payload"]) - except (ValueError, TypeError): - # Non-JSON payload: treated as an empty tool payload. - pass - - response_val = payload.get("result") - if response_val is None: - response_val = payload.get("error") - else: - if isinstance(response_val, str) and ( - response_val.startswith("{") or response_val.startswith("[") - ): - try: - response_val = json.loads(response_val) - except ValueError: - # Looks like JSON but is not: keep the raw string. - pass - - args = payload.get("args", {}) - positional_args = payload.get("positional_args", []) - if positional_args: - if r["name"] == "search_by_coords" and len(positional_args) >= 2: - try: - args = { - "nx": int(positional_args[0]), - "ny": int(positional_args[1]), - } - except ValueError: - args = { - "nx": positional_args[0], - "ny": positional_args[1], - } - elif r["name"] == "search_ui" and len(positional_args) >= 1: - args = {"search_query": positional_args[0]} - elif r["name"] == "detect_objects" and len(positional_args) >= 1: - args = {"labels": positional_args[0]} - else: - args = {"positional_args": positional_args} - - tool_calls.append( - { - "name": r["name"], - "args": args, - "status": r["status"], - "response": response_val, - } - ) - return tool_calls - - def extract_state_prepopulation_data(self, step_dir, step_data, pre_image_meta): - """Dynamically extracts state prepopulation fields from chunked test step files.""" - latest_ui_hierarchy = None - if pre_image_meta and "ui_tree" in pre_image_meta: - latest_ui_hierarchy = pre_image_meta["ui_tree"] - - structured_decisions = None - traces_json_path = step_dir / "traces.json" - if traces_json_path.exists(): - try: - with open(traces_json_path, encoding="utf-8") as f: - traces = json.load(f) - operator_trace = next( - (t for t in traces if t.get("type") == "agent" and t.get("name") == "operator"), - None, - ) - if operator_trace: - payload = operator_trace.get("payload") or {} - result_val = payload.get("result") - if isinstance(result_val, str): - try: - res_dict = json.loads(result_val) - structured_decisions = res_dict.get("structured_decisions") - except Exception as e: - print(f"Warning: Failed to parse operator trace result JSON: {e}") - elif isinstance(result_val, dict): - structured_decisions = result_val.get("structured_decisions") - except Exception as e: - print(f"Warning: Failed to read structured_decisions from traces.json: {e}") - pass - - if not structured_decisions and step_data: - action_taken = step_data.get("action_taken") - if action_taken: - try: - structured_decisions = json.dumps(action_taken) - except Exception as e: - print(f"Warning: Failed to serialize action_taken fallback to JSON: {e}") - pass - - focused_app_info = None - if latest_ui_hierarchy: - system_packages = { - "com.android.systemui", - "android", - "com.google.android.apps.nexuslauncher", - } - packages = [] - for node in latest_ui_hierarchy: - if isinstance(node, dict): - pkg = node.get("package") - if pkg and pkg not in system_packages and "launcher" not in pkg.lower(): - packages.append(pkg) - if packages: - from collections import Counter - - focused_app_info = Counter(packages).most_common(1)[0][0] - - device_date = None - timestamp = None - if step_data and "timestamp" in step_data: - timestamp = step_data["timestamp"] - elif pre_image_meta and "timestamp" in pre_image_meta: - timestamp = pre_image_meta["timestamp"] - - if timestamp is not None: - from datetime import datetime, UTC - - try: - dt = datetime.fromtimestamp(timestamp, UTC) - device_date = dt.strftime("%a %b %d %H:%M:%S UTC %Y") - except Exception as e: - print( - f"Warning: Failed to parse timestamp {timestamp} into device date string: {e}" - ) - - return ( - latest_ui_hierarchy, - structured_decisions, - focused_app_info, - device_date, - ) - - def _ensure_session_chunked(self, session_id: str) -> Path: - """Ensures that the session traces are chunked and ready for replay.""" - session_chunked_dir = self.test_data_dir / f"{session_id}_chunked" - - db_steps_count = 0 - if self.db_path.exists(): - try: - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - cursor.execute( - "SELECT COUNT(*) FROM steps WHERE session_id = ?", - (session_id,), - ) - db_steps_count = cursor.fetchone()[0] - conn.close() - except Exception as e: - print(f"Warning: Failed to query step count from DB: {e}") - - chunked_steps_count = 0 - if session_chunked_dir.exists(): - chunked_steps_count = len(list(session_chunked_dir.glob("step_*"))) - - # Check if directory does not exist, does not contain the sentinel file, or count of chunked steps is out of sync - needs_chunking = ( - not session_chunked_dir.exists() - or not (session_chunked_dir / ".chunked").exists() - or db_steps_count != chunked_steps_count - ) - - if needs_chunking: - try: - print( - f"Chunked data needs update for session {session_id} (DB" - f" steps: {db_steps_count}, chunked steps:" - f" {chunked_steps_count}). Chunking on-the-fly..." - ) - self.chunk_session_traces(session_id, session_chunked_dir) - except Exception as e: - print(f"Failed to chunk session {session_id} on-the-fly: {e}") - return session_chunked_dir - - def resolve_master_video_path(self, db_recorded_path_str: str) -> Path | None: - """Resolves the physical master video recording path on disk, searching for renamed folders.""" - db_video_path = Path(db_recorded_path_str) - - # 1. Direct absolute path check - if db_video_path.exists(): - return db_video_path - - # 2. Check if relative to workspace - workspace_rel_path = self.workspace_root / db_video_path - if workspace_rel_path.exists(): - return workspace_rel_path.resolve() - - # 3. Check if under traces_path - traces_rel_path = self.traces_path / db_video_path.name - if traces_rel_path.exists(): - return traces_rel_path.resolve() - - # 4. Resolve folder renaming prefix - parent_dir_name = db_video_path.parent.name - filename = db_video_path.name - for search_dir in [ - self.traces_path, - self.workspace_root / "artemis-traces", - ]: - if parent_dir_name and search_dir.exists(): - try: - for child in search_dir.iterdir(): - if child.is_dir() and ( - child.name.startswith(parent_dir_name) or parent_dir_name in child.name - ): - candidate = child / filename - if candidate.exists(): - return candidate.resolve() - except Exception as e: - print(f"Warning: Error while scanning {search_dir} for renamed folder: {e}") - - # 5. Check if under traces_path or artemis-traces with parent_dir_name - for search_dir in [ - self.traces_path, - self.workspace_root / "artemis-traces", - ]: - fallback_path = search_dir / parent_dir_name / filename - if fallback_path.exists(): - return fallback_path.resolve() - - return None - - def calculate_virtual_start_time(self, session_id: str, step_number: int) -> float | None: - """Calculates the virtual start time based on the database's final response timestamp.""" - if not self.db_path.exists(): - return None - - try: - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - - # Fetch fallback current step timestamp - cursor.execute( - "SELECT timestamp FROM steps WHERE session_id = ? AND step_number = ?", - (session_id, step_number), - ) - row = cursor.fetchone() - step_timestamp = row[0] if (row and row[0] is not None) else None - - if step_number <= 1: - # First step starts at session start - cursor.execute( - "SELECT start_time FROM sessions WHERE session_id = ?", - (session_id,), - ) - row = cursor.fetchone() - db_session_start_time = row[0] if (row and row[0] is not None) else step_timestamp - conn.close() - return db_session_start_time - - # Get step_id of previous step - cursor.execute( - "SELECT step_id FROM steps WHERE session_id = ? AND step_number = ?", - (session_id, step_number - 1), - ) - row = cursor.fetchone() - if not row: - conn.close() - return step_timestamp - - prev_step_id = row[0] - - # Get maximum trace timestamp for that step (final response timestamp) - cursor.execute( - "SELECT MAX(timestamp) FROM traces WHERE step_id = ?", - (prev_step_id,), - ) - row = cursor.fetchone() - conn.close() - - if row and row[0] is not None: - db_final_response_timestamp = row[0] - return db_final_response_timestamp - 0.001 - - return step_timestamp - except Exception as e: - print(f"Warning: Failed to calculate virtual start time from database: {e}") - return None - - def _preemptive_clip_video( - self, - session_id: str, - step_number: int, - step_data: dict, - temp_db_path: Path, - outputs_dir: Path, - replay_id: str = None, - ): - """Locates the master recording, calculates step time bounds, trims the video and updates the temp DB.""" - import subprocess - import uuid - - # 1. Query the master database's video_recordings table - if not self.db_path.exists(): - print( - f"Warning: Master database not found at {self.db_path}." - " Skipping preemptive video clip." - ) - return - - try: - conn_master = sqlite3.connect(self.db_path) - cursor = conn_master.cursor() - cursor.execute( - "SELECT video_id, device_id, start_time, end_time," - " local_video_path FROM video_recordings WHERE session_id = ?", - (session_id,), - ) - row = cursor.fetchone() - conn_master.close() - except Exception as e: - print(f"Warning: Failed to query video_recordings from master database: {e}") - return - - if not row: - print( - f"No video recording found for session {session_id} in master" - " database. Skipping clip." - ) - return - - ( - video_id, - device_id, - video_start_time, - video_end_time, - db_recorded_path, - ) = row - if not db_recorded_path: - print("Warning: Recorded path is empty in video_recordings. Skipping clip.") - return - - # 2. Locate master recording on disk (renaming logic) - master_video_path = self.resolve_master_video_path(db_recorded_path) - if not master_video_path: - print( - f"Warning: Could not locate master recording on disk for path: {db_recorded_path}" - ) - return - - # 3. Calculate step bounds using virtual start time - step_start = self.calculate_virtual_start_time(session_id, step_number) - if step_start is None: - step_start = step_data.get("timestamp") - - if step_start is None: - print("Warning: Step start timestamp could not be resolved. Skipping clip.") - return - - # Look for next step start - step_dir = self.test_data_dir / f"{session_id}_chunked" / f"step_{step_number:02d}" - next_step_dir = step_dir.parent / f"step_{step_number + 1:02d}" - next_step_json_path = next_step_dir / "step.json" - - if next_step_json_path.exists(): - try: - with open(next_step_json_path, encoding="utf-8") as f: - next_step_data = json.load(f) - next_step_start = next_step_data.get("timestamp", step_start + 15.0) - except Exception as e: - print(f"Warning: Failed to read next step json: {e}") - next_step_start = step_start + 15.0 - else: - next_step_start = step_start + 15.0 - - # Calculate offsets - start_offset = step_start - video_start_time - # clamp start_offset to be non-negative - start_offset = max(0.0, start_offset) - end_offset = next_step_start - video_start_time - duration = max(0.1, end_offset - start_offset) - - trimmed_video_path = outputs_dir / "recording_trimmed.mp4" - - # 4. Execute FFmpeg trim - cmd = [ - "ffmpeg", - "-y", - "-i", - str(master_video_path), - "-ss", - f"{start_offset:.3f}", - "-t", - f"{duration:.3f}", - "-c:v", - "libx264", - str(trimmed_video_path), - ] - print( - f"Trimming video segment from {master_video_path}:" - f" offset={start_offset:.3f}s, duration={duration:.3f}s" - ) - try: - res = subprocess.run(cmd, capture_output=True, text=True) - if res.returncode != 0: - print( - f"Warning: ffmpeg failed with exit code {res.returncode}. stderr: {res.stderr}" - ) - return - except Exception as e: - print(f"Warning: Failed to execute ffmpeg command: {e}") - return - - # 5. Update Sandbox Database - try: - conn_temp = sqlite3.connect(temp_db_path) - conn_temp.execute(""" - CREATE TABLE IF NOT EXISTS video_recordings ( - video_id TEXT PRIMARY KEY, - session_id TEXT, - device_id TEXT, - start_time REAL, - end_time REAL, - local_video_path TEXT, - FOREIGN KEY(session_id) REFERENCES sessions(session_id) - ) - """) - - # Insert original metadata first if not present - conn_temp.execute( - """ - INSERT OR IGNORE INTO video_recordings (video_id, session_id, device_id, start_time, end_time, local_video_path) - VALUES (?, ?, ?, ?, ?, ?) - """, - ( - video_id, - session_id, - device_id, - video_start_time, - video_end_time, - db_recorded_path, - ), - ) - - # Update values - conn_temp.execute( - """ - UPDATE video_recordings - SET local_video_path = ?, start_time = ?, end_time = ? - WHERE session_id = ? - """, - ( - str(trimmed_video_path), - step_start, - next_step_start, - session_id, - ), - ) - - if replay_id: - # Also copy / update for replay_id - conn_temp.execute( - """ - INSERT OR IGNORE INTO video_recordings (video_id, session_id, device_id, start_time, end_time, local_video_path) - VALUES (?, ?, ?, ?, ?, ?) - """, - ( - str(uuid.uuid4()), - replay_id, - device_id, - video_start_time, - video_end_time, - db_recorded_path, - ), - ) - - conn_temp.execute( - """ - UPDATE video_recordings - SET local_video_path = ?, start_time = ?, end_time = ? - WHERE session_id = ? - """, - ( - str(trimmed_video_path), - step_start, - next_step_start, - replay_id, - ), - ) - - conn_temp.commit() - conn_temp.close() - print( - f"Successfully updated sandbox database at {temp_db_path} with" - " trimmed video segment." - ) - except Exception as e: - print(f"Warning: Failed to update sandbox SQLite database: {e}") - - def create_ctx( - self, - session_id: str, - step_number: int, - agent_name: str = "explorer", - replay_id: str = None, - ) -> ArtemisContext: - """Creates a sandboxed ArtemisContext and DataEngine for the given session and step.""" - self._ensure_session_chunked(session_id) - step_dir = self.test_data_dir / f"{session_id}_chunked" / f"step_{step_number:02d}" - if not step_dir.exists(): - raise FileNotFoundError(f"Step directory not found at {step_dir}") - - step_json_path = step_dir / "step.json" - pre_image_meta_path = step_dir / "pre_image_meta.json" - post_image_meta_path = step_dir / "post_image_meta.json" - - with open(step_json_path, encoding="utf-8") as f: - step_data = json.load(f) - - pre_image_meta = None - if pre_image_meta_path.exists(): - with open(pre_image_meta_path, encoding="utf-8") as f: - pre_image_meta = json.load(f) - - post_image_meta = None - if post_image_meta_path.exists(): - with open(post_image_meta_path, encoding="utf-8") as f: - post_image_meta = json.load(f) - - outputs_dir = self.test_outputs_dir / f"{session_id}_step_{step_number:02d}" - outputs_dir.mkdir(parents=True, exist_ok=True) - - temp_traces_dir = outputs_dir / "temp_traces" - temp_db_path = temp_traces_dir / "data_engine.db" - - _, _, traces_to_preload = self.extract_replay_data( - step_dir, str(self.db_path), agent_name=agent_name - ) - initial_goal = self.load_session_goal(session_id, step_dir, str(self.db_path)) - - self.setup_temporary_database( - temp_db_path, - temp_traces_dir, - session_id, - step_data, - pre_image_meta, - initial_goal, - traces_to_preload, - post_image_meta=post_image_meta, - replay_id=replay_id, - ) - - try: - self._preemptive_clip_video( - session_id=session_id, - step_number=step_number, - step_data=step_data, - temp_db_path=temp_db_path, - outputs_dir=outputs_dir, - replay_id=replay_id, - ) - except Exception as e: - print(f"Warning: Pre-emptive video clipping failed: {e}") - - try: - from artemis.context import ( - ArtemisContext, - DeviceContext, - DevicePlatform, - ExecutionSetup, - ) - from artemis.data_engine.engine import DataEngine - - ArtemisContext.model_rebuild() - except ImportError as import_err: - raise ImportError(f"Failed to import Artemis core modules: {import_err}") - - import sqlite3 - - conn = sqlite3.connect(self.db_path) - cursor = conn.cursor() - cursor.execute( - "SELECT device_info FROM sessions WHERE session_id = ?", - (session_id,), - ) - row = cursor.fetchone() - conn.close() - - device_info = {} - if row and row[0]: - try: - device_info = json.loads(row[0]) - except (ValueError, TypeError): - # Malformed device_info: simulate with the defaults below. - pass - - sim_device_id = device_info.get("device_id", "replay-device") - sim_w = device_info.get("device_width", 1080) - sim_h = device_info.get("device_height", 2400) - - device_context = DeviceContext( - host_platform="LINUX", - mobile_platform=DevicePlatform.ANDROID, - device_id=sim_device_id, - device_width=sim_w, - device_height=sim_h, - ) - print(f"Replay simulated device: {sim_device_id} ({sim_w}x{sim_h})") - - from artemis.config import get_default_llm_config - - try: - llm_cfg = get_default_llm_config() - except Exception as e: - print(f"Warning: Failed to load default LLM config: {e}") - llm_cfg = None - - ctx = ArtemisContext( - device=device_context, - adb_client=None, - ui_adb_client=None, - execution_setup=ExecutionSetup(traces_path=temp_traces_dir), - llm_config=llm_cfg, - ) - - data_engine = DataEngine(ctx) - data_engine.current_session_id = replay_id or session_id - data_engine.current_step_id = step_data["step_id"] - data_engine.current_step_dir = outputs_dir - ctx.data_engine = data_engine - - settings.DATA_ENGINE_DB_PATH = temp_db_path - settings.TRACES_PATH = temp_traces_dir - os.environ["DATA_ENGINE_DB_PATH"] = str(temp_db_path) - - return ctx - - def instantiate_state( - self, - session_id: str, - step_number: int, - ) -> State: - """Instantiates the State object for the given session and step number.""" - self._ensure_session_chunked(session_id) - step_dir = self.test_data_dir / f"{session_id}_chunked" / f"step_{step_number:02d}" - if not step_dir.exists(): - raise FileNotFoundError(f"Step directory not found at {step_dir}") - - step_json_path = step_dir / "step.json" - pre_image_meta_path = step_dir / "pre_image_meta.json" - - with open(step_json_path, encoding="utf-8") as f: - step_data = json.load(f) - - pre_image_meta = None - if pre_image_meta_path.exists(): - with open(pre_image_meta_path, encoding="utf-8") as f: - pre_image_meta = json.load(f) - - initial_goal = self.load_session_goal(session_id, step_dir, str(self.db_path)) - - # State has no fields for the app info or device date (extra="forbid"). - ui_hier, decisions, _app_info, _dev_date = self.extract_state_prepopulation_data( - step_dir, step_data, pre_image_meta - ) - - try: - from artemis.graph.state import State - except ImportError as import_err: - raise ImportError(f"Failed to import State module: {import_err}") - - state = State( - initial_goal=initial_goal, - latest_screenshot=str(step_dir / "pre.jpg") - if (step_dir / "pre.jpg").exists() - else str(step_dir / "post.jpg"), - operator_raw_data={"width": self.w, "height": self.h}, - current_step_id=step_data["step_id"], - subagent_calls=step_data.get("subagent_calls") or [], - latest_ui_hierarchy=ui_hier, - structured_decisions=decisions, - ) - return state - - def generate_replay_id(self) -> str: - """Generates a new unique replay ID.""" - import uuid - - return str(uuid.uuid4()) - - async def replay_step_tool( - self, - session_id: str, - step_number: int, - override_device_id: str = None, - user_submits: dict = None, - tool_name: str = "ask_explorer", - replay_id: str = None, - ) -> list[dict]: - """Replays a specific step tool in a sandboxed execution environment.""" - if not replay_id: - replay_id = self.generate_replay_id() - - replay_result = { - "session_id": session_id, - "step_number": step_number, - "replay_id": replay_id, - "success": False, - } - tool_cfg = REPLAY_TOOLS_CONFIG.get(tool_name) - if not tool_cfg: - raise ValueError(f"Unknown tool for replay: {tool_name}") - agent_name = tool_cfg["agent_name"] - - outputs_parent = self.test_outputs_dir - if outputs_parent.exists(): - for item in outputs_parent.iterdir(): - if item.is_dir() and item.name.startswith(f"{session_id}_step_"): - archive_dir = self.replay_base_dir / "data" / "older" - archive_dir.mkdir(parents=True, exist_ok=True) - - from datetime import datetime - - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - target_name = f"{item.name}_{timestamp}" - target_path = archive_dir / target_name - - counter = 1 - while target_path.exists(): - target_name = f"{item.name}_{timestamp}_{counter}" - target_path = archive_dir / target_name - counter += 1 - - print( - f"Archiving previous run output directory {item.name} to {target_path}..." - ) - try: - shutil.move(str(item), str(target_path)) - except Exception as archive_err: - print( - f"Warning: Failed to archive {item.name}: {archive_err}", - file=sys.stderr, - ) - - outputs_dir = self.test_outputs_dir / f"{session_id}_step_{step_number:02d}" - temp_db_path = outputs_dir / "temp_traces" / "data_engine.db" - - # Load step data locally for fallback mapping resolutions - step_dir = self.test_data_dir / f"{session_id}_chunked" / f"step_{step_number:02d}" - step_json_path = step_dir / "step.json" - with open(step_json_path, encoding="utf-8") as f: - step_data = json.load(f) - - # Retrieve preloaded trace IDs from chunked trace files to filter new tool calls - _, _, traces_to_preload = self.extract_replay_data( - step_dir, str(self.db_path), agent_name=agent_name - ) - preloaded_trace_ids = {t.get("trace_id") for t in traces_to_preload if t.get("trace_id")} - llm_calls_log = [] - - # Device initialization removed for step replay - - ctx = self.create_ctx( - session_id=session_id, - step_number=step_number, - agent_name=agent_name, - replay_id=replay_id, - ) - - state = self.instantiate_state( - session_id=session_id, - step_number=step_number, - ) - - data_engine = ctx.data_engine - - print("Executing using Live Gemini Client...") - - from google.genai.models import AsyncModels - from unittest.mock import patch - - original_generate_content = AsyncModels.generate_content - - async def wrapped_generate_content(self, *, model, contents, config=None, **kwargs): - system_instruction = None - if config and config.system_instruction: - if isinstance(config.system_instruction, str): - system_instruction = config.system_instruction - elif hasattr(config.system_instruction, "parts"): - parts = [] - for p in config.system_instruction.parts: - if hasattr(p, "text") and p.text: - parts.append(p.text) - system_instruction = " ".join(parts) - else: - system_instruction = str(config.system_instruction) - - prompt_data = { - "model": model, - "system_instruction": system_instruction, - "contents": [], - } - - contents_list = contents if isinstance(contents, list) else [contents] - for content in contents_list: - parts_desc = [] - if hasattr(content, "parts") and content.parts: - for part in content.parts: - if hasattr(part, "text") and part.text: - parts_desc.append({"type": "text", "text": part.text}) - elif hasattr(part, "inline_data") and part.inline_data: - parts_desc.append( - { - "type": "image", - "mime_type": part.inline_data.mime_type, - "length": len(part.inline_data.data), - } - ) - elif hasattr(part, "function_call") and part.function_call: - parts_desc.append( - { - "type": "function_call", - "name": part.function_call.name, - "args": ( - dict(part.function_call.args) - if part.function_call.args - else {} - ), - } - ) - elif hasattr(part, "function_call") and part.function_response: - parts_desc.append( - { - "type": "function_response", - "name": part.function_response.name, - "response": ( - dict(part.function_response.response) - if part.function_response.response - else {} - ), - } - ) - else: - parts_desc.append({"type": "raw", "value": str(content)}) - - prompt_data["contents"].append( - { - "role": getattr(content, "role", "unknown"), - "parts": parts_desc, - } - ) - - record = {"prompt": prompt_data, "response": None, "error": None} - llm_calls_log.append(record) - - try: - res = await original_generate_content( - self, - model=model, - contents=contents, - config=config, - **kwargs, - ) - - thinking_parts = [] - if hasattr(res, "candidates") and res.candidates and res.candidates[0].content: - for part in res.candidates[0].content.parts: - if getattr(part, "thought", False): - thinking_parts.append(part.text) - - resp_data = { - "text": res.text if hasattr(res, "text") else None, - "function_calls": [], - "thought": ("\n".join(thinking_parts) if thinking_parts else None), - } - if hasattr(res, "function_calls") and res.function_calls: - for fc in res.function_calls: - resp_data["function_calls"].append( - { - "name": fc.name, - "args": dict(fc.args) if fc.args else {}, - } - ) - record["response"] = resp_data - return res - except Exception as err: - err_str = str(err) - record["error"] = ( - f"{err.__class__.__name__}: {err_str}" if err_str else err.__class__.__name__ - ) - raise err - - try: - try: - import inspect - import importlib - - mod = importlib.import_module(tool_cfg["module"]) - tool_fn = getattr(mod, tool_cfg["function"]) - - user_submits = user_submits or {} - sig = inspect.signature(tool_fn) - tool_args = {} - denylist = tool_cfg.get("denylist_args", []) - fallback_mappings = tool_cfg.get("fallback_mappings", {}) - - user_submits = user_submits or {} - sig = inspect.signature(tool_fn) - tool_args = {} - for name, param in sig.parameters.items(): - if name in denylist: - if name == "ctx": - tool_args["ctx"] = ctx - elif name == "state": - tool_args["state"] = state - continue - - if name in user_submits: - val = user_submits[name] - if param.annotation is bool and isinstance(val, str): - val = val.lower() == "true" - tool_args[name] = val - else: - val = "" - if param.default is not inspect.Parameter.empty: - val = param.default - if name in fallback_mappings: - fallback_path = fallback_mappings[name] - resolved_val = self.resolve_step_data_path(step_data, fallback_path) - if resolved_val is not None: - val = resolved_val - tool_args[name] = val - - with patch.object(AsyncModels, "generate_content", wrapped_generate_content): - outcome = await tool_fn(**tool_args) - - replay_result["success"] = True - replay_result["result"] = outcome - print(f"Replay Execution complete. Outcome: {outcome}") - - except Exception as replay_err: - print(f"Error during step replay: {replay_err}", file=sys.stderr) - traceback.print_exc() - - replay_result["success"] = False - replay_result["error"] = ( - f"{replay_err.__class__.__name__}: {str(replay_err)}" - if str(replay_err) - else replay_err.__class__.__name__ - ) - raise replay_err - finally: - await data_engine.shutdown() - - tool_calls = [] - if temp_db_path.exists(): - try: - tool_calls = self.extract_tool_calls_from_db(temp_db_path, preloaded_trace_ids) - except Exception as ex: - print( - f"Warning: Failed to extract tool calls: {ex}", - file=sys.stderr, - ) - - replay_result["tool_calls"] = tool_calls - replay_result["llm_calls"] = llm_calls_log - - outcome_path = outputs_dir / "result.json" - try: - with open(outcome_path, "w", encoding="utf-8") as f: - json.dump(replay_result, f, indent=2, ensure_ascii=False) - except Exception as write_err: - print( - f"Warning: Failed to write result.json: {write_err}", - file=sys.stderr, - ) - - return llm_calls_log - - def get_replay_config(self, tool_name: str = "ask_explorer") -> list[dict]: - """Returns the parameter list and types of the selected tool logic.""" - tool_cfg = REPLAY_TOOLS_CONFIG.get(tool_name) - if not tool_cfg: - raise ValueError(f"Unknown tool for replay: {tool_name}") - - try: - import inspect - import importlib - - mod = importlib.import_module(tool_cfg["module"]) - tool_fn = getattr(mod, tool_cfg["function"]) - sig = inspect.signature(tool_fn) - - denylist = tool_cfg.get("denylist_args", []) - explorer_params = [] - for name, param in sig.parameters.items(): - if name in denylist: - continue - default_val = "" - if param.default is not inspect.Parameter.empty: - default_val = param.default - - input_type = "text" - if any( - keyword in name.lower() - for keyword in ( - "feedback", - "context", - "description", - "note", - ) - ): - input_type = "textarea" - elif param.annotation is bool: - input_type = "checkbox" - elif param.annotation in (int, float): - input_type = "number" - - explorer_params.append( - { - "name": name, - "default": default_val, - "input_type": input_type, - } - ) - return explorer_params - except Exception as e: - raise Exception(f"Failed to load signature for {tool_name}: {str(e)}") - - def get_replay_tools(self) -> list[dict]: - """Returns the list of registered tools available for replay.""" - return [ - { - "name": name, - "display_name": cfg["display_name"], - "description": cfg["description"], - } - for name, cfg in REPLAY_TOOLS_CONFIG.items() - ] - - def get_replay_steps(self, session_id: str) -> list[dict]: - """Loads and formats metadata for all chunked steps in the session.""" - session_chunked_dir = self._ensure_session_chunked(session_id) - if not session_chunked_dir.exists(): - raise FileNotFoundError("Step replay chunked data not found for this session.") - - try: - steps_metadata = self.load_steps_metadata(session_id) - step_numbers = sorted(list(steps_metadata.keys())) - - import inspect - import importlib - - # Pre-load signatures and configs for all registered tools - tool_signatures = {} - for t_name, t_cfg in REPLAY_TOOLS_CONFIG.items(): - try: - mod = importlib.import_module(t_cfg["module"]) - fn = getattr(mod, t_cfg["function"]) - tool_signatures[t_name] = inspect.signature(fn) - except Exception as e: - print( - f"Warning: Failed to load signature for tool {t_name}: {e}", - file=sys.stderr, - ) - - steps_list = [] - for num in step_numbers: - s_data = steps_metadata.get(num, {}) - sid = s_data.get("step_id") - - pre_url = None - post_url = None - step_dir = session_chunked_dir / f"step_{num:02d}" - if step_dir.exists(): - pre_jpg = step_dir / "pre.jpg" - if pre_jpg.exists(): - pre_url = f"file://{pre_jpg.resolve()}" - post_jpg = step_dir / "post.jpg" - if post_jpg.exists(): - post_url = f"file://{post_jpg.resolve()}" - if pre_url and post_url and pre_url == post_url: - post_url = None - - # Generate param_defaults for all registered tools - all_tool_defaults = {} - for t_name, sig in tool_signatures.items(): - t_cfg = REPLAY_TOOLS_CONFIG[t_name] - denylist = t_cfg.get("denylist_args", []) - fallback_mappings = t_cfg.get("fallback_mappings", {}) - - defaults = {} - for name, param in sig.parameters.items(): - if name in denylist: - continue - - val = "" - if param.default is not inspect.Parameter.empty: - val = param.default - - # Resolve fallback using mappings - if name in fallback_mappings: - mapping_path = fallback_mappings[name] - parts = mapping_path.split(".") - curr = s_data - for part in parts: - if isinstance(curr, dict): - curr = curr.get(part) - elif isinstance(curr, list): - try: - idx = int(part) - if idx < len(curr): - curr = curr[idx] - else: - curr = None - except ValueError: - curr = None - else: - curr = None - - if curr is not None: - val = curr - - defaults[name] = val - all_tool_defaults[t_name] = defaults - - # Check if this step has already been replayed by scanning outputs folder - step_outputs_dir = self.test_outputs_dir / f"{session_id}_step_{num:02d}" - step_db_path = step_outputs_dir / "temp_traces" / "data_engine.db" - is_replayed = step_db_path.exists() - - steps_list.append( - { - "step_id": str(sid).lower() if sid else None, - "step_number": num, - "timestamp": s_data.get("timestamp"), - "summary": s_data.get("summary", "No summary available."), - "action_taken": s_data.get("action_taken", []), - "query": "", - "param_defaults": all_tool_defaults, - "pre_screenshot": pre_url, - "post_screenshot": post_url, - "operator_raw_thinking": s_data.get("operator_raw_thinking"), - "operator_native_thinking": s_data.get("operator_native_thinking"), - "is_replayed": is_replayed, - } - ) - - return steps_list - except Exception as e: - traceback.print_exc() - raise e - - async def run_step_replay( - self, - session_id: str, - step_number: int, - device_id: str, - user_submits: dict, - tool_name: str = "ask_explorer", - replay_id: str = None, - ) -> dict: - """Triggers sandbox execution of step replay and returns the resulting traces trees.""" - tool_cfg = REPLAY_TOOLS_CONFIG.get(tool_name) - if not tool_cfg: - raise ValueError(f"Unknown tool for replay: {tool_name}") - agent_name = tool_cfg["agent_name"] - - # 1. Run live step replay - llm_calls_log = await self.replay_step_tool( - session_id=session_id, - step_number=step_number, - override_device_id=device_id, - user_submits=user_submits, - tool_name=tool_name, - replay_id=replay_id, - ) - - # 2. Extract freshly generated traces - outputs_dir = self.test_outputs_dir / f"{session_id}_step_{step_number:02d}" - temp_db_path = outputs_dir / "temp_traces" / "data_engine.db" - - self._ensure_session_chunked(session_id) - step_dir = self.test_data_dir / f"{session_id}_chunked" / f"step_{step_number:02d}" - _, _, traces_to_preload = self.extract_replay_data( - step_dir, str(self.db_path), agent_name=agent_name - ) - preloaded_trace_ids = {t.get("trace_id") for t in traces_to_preload if t.get("trace_id")} - - all_traces = self.load_all_session_traces(temp_db_path, preloaded_trace_ids, step_number) - - # Align LLM calls and extract final tool result from result.json if available - replay_outcome = None - replay_id = None - result_json_path = outputs_dir / "result.json" - if result_json_path.exists(): - try: - with open(result_json_path, encoding="utf-8") as f: - res_data = json.load(f) - replay_outcome = res_data.get("result") - replay_id = res_data.get("replay_id") - except (OSError, ValueError) as e: - print( - f"Warning: Failed to read replay result from {result_json_path}: {e}", - file=sys.stderr, - ) - - # Update virtual tool trace result with the actual formatted outcome if available - if replay_outcome is not None: - for node in all_traces: - if ( - not node.get("is_preloaded") - and node["type"] == "tool" - and node["name"] == tool_name - ): - node["payload"]["result"] = replay_outcome - - # Collect all valid LLM span names from REPLAY_TOOLS_CONFIG to align subagent calls too - all_llm_span_names = { - cfg.get("llm_span_name") - for cfg in REPLAY_TOOLS_CONFIG.values() - if cfg.get("llm_span_name") - } - - llm_call_index = 0 - for node in all_traces: - if ( - not node.get("is_preloaded") - and node["type"] == "span" - and node["name"] in all_llm_span_names - and llm_calls_log - and llm_call_index < len(llm_calls_log) - ): - node["type"] = "llm_call" - node["llm_call"] = llm_calls_log[llm_call_index] - llm_call_index += 1 - - # Clean payload paths - for node in all_traces: - if node.get("payload"): - node["payload"] = self.clean_payload_paths(node["payload"]) - if node.get("llm_call"): - node["llm_call"] = self.clean_payload_paths(node["llm_call"]) - - # Filter all_traces for ONLY this step's traces - step_traces = [t for t in all_traces if t.get("step_number") == step_number] - - preloaded_traces = [t for t in step_traces if t.get("is_preloaded")] - live_traces = [t for t in step_traces if not t.get("is_preloaded")] - - # Build preloaded hierarchy - preloaded_nodes = { - str(t["trace_id"]).lower(): {**t, "children": []} for t in preloaded_traces - } - preloaded_root_nodes = [] - for node_id, node in preloaded_nodes.items(): - parent_id = node.get("parent_trace_id") - if parent_id: - parent_id_str = str(parent_id).lower() - if parent_id_str in preloaded_nodes: - preloaded_nodes[parent_id_str]["children"].append(node) - else: - preloaded_root_nodes.append(node) - else: - preloaded_root_nodes.append(node) - preloaded_root_nodes = self.sanitize_nodes(preloaded_root_nodes) - preloaded_root_nodes = self.sort_tree_nodes_chronologically(preloaded_root_nodes) - - # Build live hierarchy - live_nodes = {str(t["trace_id"]).lower(): {**t, "children": []} for t in live_traces} - - # Add a virtual "Response" child node at the end of the virtual tool trace to show the final outcome at the bottom of the tree - if replay_outcome is not None: - virtual_tool_node_id = None - for node_id, node in live_nodes.items(): - if node.get("type") == "tool" and node.get("name") == tool_name: - virtual_tool_node_id = node_id - break - - if virtual_tool_node_id: - import uuid - - outcome_node_id = str(uuid.uuid4()) - max_ts = ( - max([t.get("timestamp", 0.0) for t in live_traces]) - if live_traces - else live_nodes[virtual_tool_node_id].get("timestamp", 0.0) - ) - - outcome_node = { - "trace_id": outcome_node_id, - "session_id": session_id, - "step_id": live_nodes[virtual_tool_node_id].get("step_id"), - "parent_trace_id": live_nodes[virtual_tool_node_id]["trace_id"], - "type": "tool", - "name": f"{tool_name} Response", - "status": (live_nodes[virtual_tool_node_id].get("status", "success")), - "timestamp": max_ts + 0.001, - "duration": 0.0, - "payload": {"result": replay_outcome}, - "is_preloaded": False, - "step_number": step_number, - "children": [], - } - live_nodes[outcome_node_id] = outcome_node - - live_root_nodes = [] - for node_id, node in live_nodes.items(): - parent_id = node.get("parent_trace_id") - if parent_id: - parent_id_str = str(parent_id).lower() - if parent_id_str in live_nodes: - live_nodes[parent_id_str]["children"].append(node) - else: - live_root_nodes.append(node) - else: - live_root_nodes.append(node) - live_root_nodes = self.sanitize_nodes(live_root_nodes) - live_root_nodes = self.sort_tree_nodes_chronologically(live_root_nodes) - - return { - "success": True, - "preloaded": preloaded_root_nodes, - "live": live_root_nodes, - "replay_id": replay_id, - } - - def get_step_replay_traces( - self, session_id: str, step_number: int, tool_name: str = "ask_explorer" - ) -> dict: - """Retrieves previously generated step replay traces if they exist.""" - tool_cfg = REPLAY_TOOLS_CONFIG.get(tool_name) - if not tool_cfg: - raise ValueError(f"Unknown tool for replay: {tool_name}") - agent_name = tool_cfg["agent_name"] - - outputs_dir = self.test_outputs_dir / f"{session_id}_step_{step_number:02d}" - temp_db_path = outputs_dir / "temp_traces" / "data_engine.db" - - if not temp_db_path.exists(): - return {"success": True, "live": []} - - self._ensure_session_chunked(session_id) - step_dir = self.test_data_dir / f"{session_id}_chunked" / f"step_{step_number:02d}" - preloaded_trace_ids = set() - if step_dir.exists(): - _, _, traces_to_preload = self.extract_replay_data( - step_dir, - str(self.db_path), - agent_name=agent_name, - tool_name=tool_name, - ) - preloaded_trace_ids = { - t.get("trace_id") for t in traces_to_preload if t.get("trace_id") - } - - all_traces = self.load_all_session_traces(temp_db_path, preloaded_trace_ids, step_number) - - llm_span_name = tool_cfg.get("llm_span_name") - if not llm_span_name: - raise ValueError(f"Tool {tool_name} config is missing 'llm_span_name'") - - # Align LLM calls and extract final tool result from result.json if available - llm_calls_log = [] - replay_outcome = None - replay_id = None - result_json_path = outputs_dir / "result.json" - if result_json_path.exists(): - try: - with open(result_json_path, encoding="utf-8") as f: - res_data = json.load(f) - llm_calls_log = res_data.get("llm_calls") or [] - replay_outcome = res_data.get("result") - replay_id = res_data.get("replay_id") - except (OSError, ValueError) as e: - print( - f"Warning: Failed to read replay result from {result_json_path}: {e}", - file=sys.stderr, - ) - - # Update virtual tool trace result with the actual formatted outcome if available - if replay_outcome is not None: - for node in all_traces: - if ( - not node.get("is_preloaded") - and node["type"] == "tool" - and node["name"] == tool_name - ): - node["payload"]["result"] = replay_outcome - - # Collect all valid LLM span names from REPLAY_TOOLS_CONFIG to align subagent calls too - all_llm_span_names = { - cfg.get("llm_span_name") - for cfg in REPLAY_TOOLS_CONFIG.values() - if cfg.get("llm_span_name") - } - - llm_call_index = 0 - for node in all_traces: - if ( - not node.get("is_preloaded") - and node["type"] == "span" - and node["name"] in all_llm_span_names - and llm_calls_log - and llm_call_index < len(llm_calls_log) - ): - node["type"] = "llm_call" - node["llm_call"] = llm_calls_log[llm_call_index] - llm_call_index += 1 - - # Clean payload paths - for node in all_traces: - if node.get("payload"): - node["payload"] = self.clean_payload_paths(node["payload"]) - if node.get("llm_call"): - node["llm_call"] = self.clean_payload_paths(node["llm_call"]) - - # Filter all_traces for ONLY this step's traces - step_traces = [t for t in all_traces if t.get("step_number") == step_number] - live_traces = [t for t in step_traces if not t.get("is_preloaded")] - - # Build live hierarchy - live_nodes = {str(t["trace_id"]).lower(): {**t, "children": []} for t in live_traces} - - # Add a virtual "Response" child node at the end of the virtual tool trace to show the final outcome at the bottom of the tree - if replay_outcome is not None: - virtual_tool_node_id = None - for node_id, node in live_nodes.items(): - if node.get("type") == "tool" and node.get("name") == tool_name: - virtual_tool_node_id = node_id - break - - if virtual_tool_node_id: - import uuid - - outcome_node_id = str(uuid.uuid4()) - max_ts = ( - max([t.get("timestamp", 0.0) for t in live_traces]) - if live_traces - else live_nodes[virtual_tool_node_id].get("timestamp", 0.0) - ) - - outcome_node = { - "trace_id": outcome_node_id, - "session_id": session_id, - "step_id": live_nodes[virtual_tool_node_id].get("step_id"), - "parent_trace_id": live_nodes[virtual_tool_node_id]["trace_id"], - "type": "tool", - "name": f"{tool_name} Response", - "status": (live_nodes[virtual_tool_node_id].get("status", "success")), - "timestamp": max_ts + 0.001, - "duration": 0.0, - "payload": {"result": replay_outcome}, - "is_preloaded": False, - "step_number": step_number, - "children": [], - } - live_nodes[outcome_node_id] = outcome_node - - live_root_nodes = [] - for node_id, node in live_nodes.items(): - parent_id = node.get("parent_trace_id") - if parent_id: - parent_id_str = str(parent_id).lower() - if parent_id_str in live_nodes: - live_nodes[parent_id_str]["children"].append(node) - else: - live_root_nodes.append(node) - else: - live_root_nodes.append(node) - live_root_nodes = self.sanitize_nodes(live_root_nodes) - live_root_nodes = self.sort_tree_nodes_chronologically(live_root_nodes) - - return { - "success": True, - "live": live_root_nodes, - "replay_id": replay_id, - } diff --git a/apps/admin_console/routers/__init__.py b/apps/admin_console/routers/__init__.py deleted file mode 100644 index 58d482ea3..000000000 --- a/apps/admin_console/routers/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. diff --git a/apps/admin_console/routers/media.py b/apps/admin_console/routers/media.py deleted file mode 100644 index ec9bd0c9a..000000000 --- a/apps/admin_console/routers/media.py +++ /dev/null @@ -1,198 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -from pathlib import Path -from fastapi import APIRouter, HTTPException -from fastapi.responses import FileResponse, HTMLResponse - -from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT - -try: - from admin_console.database.repositories.session_repository import session_repo - from admin_console.services.media_service import media_service -except ImportError: - from apps.admin_console.database.repositories.session_repository import session_repo - from apps.admin_console.services.media_service import media_service - -router = APIRouter(tags=["media"]) - -_VIDEO_MEDIA_TYPES = { - ".mp4": "video/mp4", - ".webm": "video/webm", - ".mkv": "video/x-matroska", -} - - -def _allowed_media_roots() -> list[Path]: - """Directories the media endpoints are permitted to serve from.""" - roots = [] - for root in (WORKSPACE_ROOT, TRACES_PATH): - try: - roots.append(Path(root).resolve()) - except OSError: - continue - return roots - - -def _resolve_media_path(raw_path: str, allowed_suffixes: set[str]) -> Path: - """Resolve a client-supplied path strictly inside the allowed media roots. - - Every candidate is fully resolved (symlinks, `..`, mixed separators) and - then re-checked against the allowed roots and an extension allowlist, so a - crafted URL can never address source code, databases, or dotfiles. - """ - roots = _allowed_media_roots() - candidate = Path(raw_path) - attempts = [candidate] if candidate.is_absolute() else [root / candidate for root in roots] - for attempt in attempts: - try: - resolved = attempt.resolve(strict=True) - except (OSError, ValueError): - continue - if not resolved.is_file(): - continue - if not any(resolved.is_relative_to(root) for root in roots): - continue - if resolved.suffix.lower() not in allowed_suffixes: - raise HTTPException(status_code=403, detail="File type is not served by the media API.") - return resolved - raise HTTPException(status_code=404, detail="Media file not found") - - -@router.get("/admin", response_class=HTMLResponse) -@router.get("/debug", response_class=HTMLResponse) -async def get_admin_index(): - admin_index = Path(__file__).resolve().parent.parent / "index.html" - if admin_index.exists(): - return admin_index.read_text(encoding="utf-8") - - admin_index_alt = WORKSPACE_ROOT / "apps" / "admin_console" / "index.html" - if admin_index_alt.exists(): - return admin_index_alt.read_text(encoding="utf-8") - - return "

Admin Console index.html not found

" - - -@router.get("/images/{image_name}") -@router.get("/api/images/{image_name}") -async def get_image(image_name: str): - if not image_name.endswith(".jpg"): - image_name += ".jpg" - try: - images_root = IMAGES_DIR.resolve() - image_path = (images_root / image_name).resolve(strict=True) - except (OSError, ValueError): - raise HTTPException(status_code=404, detail="Image not found") - if not image_path.is_file() or not image_path.is_relative_to(images_root): - raise HTTPException(status_code=404, detail="Image not found") - - return FileResponse(image_path, media_type="image/jpeg") - - -@router.get("/videos/{video_path:path}") -async def get_video(video_path: str): - path = _resolve_media_path(video_path, set(_VIDEO_MEDIA_TYPES)) - return FileResponse(path, media_type=_VIDEO_MEDIA_TYPES[path.suffix.lower()]) - - -@router.get("/api/sessions/{session_id}/video") -async def get_session_video(session_id: str): - # Blocking work (sqlite, filesystem scan, possible ffmpeg conversion) runs - # off the event loop. - return await asyncio.to_thread(_get_session_video_sync, session_id) - - -def _get_session_video_sync(session_id: str): - video_rec_map = session_repo.get_video_recordings_map() - video_idx = media_service.build_video_index() - row = session_repo.get_session_by_id(session_id) - row_dict = dict(row) if row else {"session_id": session_id} - recording = session_repo.get_video_recording_for_session(session_id) - recording_status = str((recording or {}).get("status") or "") - - if recording_status in ("recording", "finalizing"): - return { - "session_id": session_id, - "status": "processing", - "has_video": False, - "video_url": None, - "video_segments": [], - "retry_after_ms": 750, - } - - if recording_status == "failed": - v_url = media_service.resolve_video_url(row_dict, video_rec_map, video_idx) - if not v_url: - return { - "session_id": session_id, - "status": "failed", - "has_video": False, - "video_url": None, - "video_segments": [], - "message": recording.get("error") or "Recording finalization failed", - } - # If a video was recovered/found, update DB to ready and proceed to serve it - session_repo.mark_recording_ready(session_id, v_url) - - v_url = media_service.resolve_video_url(row_dict, video_rec_map, video_idx) - video_segments = media_service.resolve_video_segments(v_url) - if v_url: - version = int( - float((recording or {}).get("end_time") or row_dict.get("end_time") or 0) * 1000 - ) - separator = "&" if "?" in v_url else "?" - versioned_url = f"{v_url}{separator}v={version}" if version else v_url - for segment in video_segments: - segment_separator = "&" if "?" in segment["url"] else "?" - segment["url"] = ( - f"{segment['url']}{segment_separator}v={version}" if version else segment["url"] - ) - return { - "session_id": session_id, - "status": "ready", - "has_video": True, - "video_url": versioned_url, - "video_segments": video_segments, - } - - return { - "session_id": session_id, - "status": "unavailable", - "has_video": False, - "video_url": None, - "video_segments": [], - } - - -@router.get("/local_file") -async def get_local_file(path: str): - p, media_type = media_service.get_safe_local_file(path) - return FileResponse(p, media_type=media_type) - - -@router.get("/api/sessions/{session_id}/plan") -async def get_task_plan(session_id: str): - return {"plan": media_service.get_task_plan_content(session_id)} - - -@router.get("/api/sessions/{session_id}/notes") -async def get_all_notes(session_id: str): - return {"notes": media_service.get_session_notes_content(session_id)} - - -@router.get("/api/sessions/{session_id}/checks") -async def get_session_checks(session_id: str): - """Checker verdict ledger + run outcome (backfill for the Checker panel).""" - return media_service.get_session_checks(session_id) diff --git a/apps/admin_console/routers/replay.py b/apps/admin_console/routers/replay.py deleted file mode 100644 index 2a83b6e24..000000000 --- a/apps/admin_console/routers/replay.py +++ /dev/null @@ -1,91 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import traceback -from fastapi import APIRouter, HTTPException - -from artemis.config import WORKSPACE_ROOT - -try: - from admin_console.schemas.task_schema import ReplayRequest - from admin_console.replay_manager import ReplayManager -except ImportError: - from apps.admin_console.schemas.task_schema import ReplayRequest - from apps.admin_console.replay_manager import ReplayManager - -replay_manager = ReplayManager(WORKSPACE_ROOT) - - -router = APIRouter(tags=["replay"]) - - -@router.get("/api/devices") -async def list_devices(): - """Dynamically queries the ADB server for connected Android devices.""" - return replay_manager.list_devices() - - -@router.get("/api/replay/tools") -async def get_replay_tools(): - """Returns the list of registered tools available for replay.""" - return replay_manager.get_replay_tools() - - -@router.get("/api/replay/config") -async def get_replay_config(tool_name: str = "ask_explorer"): - """Returns the parameter list and types of the selected tool logic.""" - try: - return replay_manager.get_replay_config(tool_name) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/api/sessions/{session_id}/replay_steps") -async def get_replay_steps(session_id: str): - """Loads and formats metadata for all chunked steps in the session.""" - try: - return replay_manager.get_replay_steps(session_id) - except FileNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/api/sessions/{session_id}/steps/{step_number}/replay") -async def trigger_step_replay_endpoint(session_id: str, step_number: int, req: ReplayRequest): - """Triggers sandbox execution of step replay and returns the resulting traces trees.""" - try: - return await replay_manager.run_step_replay( - session_id=session_id, - step_number=step_number, - device_id=req.device_id, - user_submits=req.user_submits, - tool_name=req.tool_name, - replay_id=req.replay_id, - ) - except Exception as e: - traceback.print_exc() - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/api/sessions/{session_id}/steps/{step_number}/replay_traces") -async def get_step_replay_traces_endpoint( - session_id: str, step_number: int, tool_name: str = "ask_explorer" -): - """Retrieves previously generated step replay traces if they exist.""" - try: - return replay_manager.get_step_replay_traces(session_id, step_number, tool_name) - except Exception as e: - traceback.print_exc() - raise HTTPException(status_code=500, detail=str(e)) diff --git a/apps/admin_console/routers/sessions.py b/apps/admin_console/routers/sessions.py deleted file mode 100644 index 6d828b772..000000000 --- a/apps/admin_console/routers/sessions.py +++ /dev/null @@ -1,239 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -import json -import time -from uuid import UUID -from fastapi import APIRouter, HTTPException - -from artemis.config import DB_PATH, TRACES_PATH -from artemis.runtime import trace_store - -try: - from admin_console.core.state import state - from admin_console.database.repositories.session_repository import session_repo - from admin_console.database.repositories.trace_repository import trace_repo - from admin_console.services.media_service import media_service - from admin_console.services.model_service import model_service -except ImportError: - from apps.admin_console.core.state import state - from apps.admin_console.database.repositories.session_repository import session_repo - from apps.admin_console.database.repositories.trace_repository import trace_repo - from apps.admin_console.services.media_service import media_service - from apps.admin_console.services.model_service import model_service - - -router = APIRouter(tags=["sessions"]) - - -@router.get("/api/sessions") -async def list_sessions(): - # The body does blocking work (sqlite queries, filesystem scans, ffmpeg - # conversion on first sight of a new recording), so run it off the event - # loop — the frontend polls this endpoint and it must not stall other - # requests. - try: - return await asyncio.to_thread(_list_sessions_sync) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -def _list_sessions_sync(): - rows = session_repo.get_all_sessions() - video_rec_map = session_repo.get_video_recordings_map() - latest_recordings = session_repo.get_latest_video_recordings_map() - agent_names_by_session = session_repo.get_agent_trace_names_map() - video_idx = media_service.build_video_index() - default_model_info = model_service.get_active_model_info() - - orphaned_ids = [] - unresolved_profiles = [] - result = [] - - try: - from artemis.runtime import DeviceExecutionLock - - active_owners = DeviceExecutionLock.get_active_owners() - active_owner_sids = { - str(owner.session_id) for owner in active_owners.values() if owner.session_id - } - except Exception: - active_owner_sids = set() - - for row_dict in rows: - s_id = str(row_dict.get("session_id")) - recording = latest_recordings.get(s_id) - d_info_raw = row_dict.get("device_info") - device_id = None - if d_info_raw: - try: - d_info = json.loads(d_info_raw) if isinstance(d_info_raw, str) else d_info_raw - if isinstance(d_info, dict): - device_id = d_info.get("device_id") or d_info.get("device_serial") - except (ValueError, TypeError): - # Malformed device_info JSON: fall back to the recording's device_id. - pass - if not device_id and recording: - device_id = recording.get("device_id") - row_dict["device_id"] = device_id - row_dict["device_serial"] = device_id - - if row_dict.get("status") == "running": - is_active = ( - s_id in active_owner_sids - or s_id in state.active_connections - or (state.is_running and s_id == str(state.active_session_id)) - ) - worker_is_alive = session_repo.process_is_alive(row_dict.get("pid")) - if not is_active and not worker_is_alive: - row_dict["status"] = "failed" - row_dict["end_time"] = time.time() - orphaned_ids.append(s_id) - - recording_status = str((recording or {}).get("status") or "unavailable") - resolved_v_url = ( - media_service.resolve_video_url(row_dict, video_rec_map, video_idx) - if recording_status != "recording" - else None - ) - if resolved_v_url: - recording_status = "ready" - row_dict["video_url"] = resolved_v_url - else: - row_dict["video_url"] = None - row_dict["recording_status"] = recording_status - - agent_names = agent_names_by_session.get(s_id, []) - sess_profile = model_service.resolve_session_profile( - row_dict, None, state.current_profile, agent_names=agent_names - ) - row_dict["model_info"] = ( - model_service.get_active_model_info(sess_profile) - if sess_profile - else default_model_info - ) - if not sess_profile: - unresolved_profiles.append(row_dict) - result.append(row_dict) - - # Most sessions carry a profile in device_info or an agent trace name. - # Only inspect the much larger LLM payload for legacy rows that remain - # ambiguous after those cheap checks. - if unresolved_profiles: - unresolved_ids = [str(row.get("session_id")) for row in unresolved_profiles] - llm_traces_by_session = session_repo.get_llm_traces_for_profiles_map(unresolved_ids) - for row_dict in unresolved_profiles: - s_id = str(row_dict.get("session_id")) - llm_traces = llm_traces_by_session.get(s_id) - if not llm_traces: - continue - sess_profile = model_service.resolve_session_profile( - row_dict, llm_traces, state.current_profile - ) - if sess_profile: - row_dict["model_info"] = model_service.get_active_model_info(sess_profile) - - if orphaned_ids: - try: - harvested = session_repo.harvest_orphaned_sessions(orphaned_ids) - print(f"[list_sessions] Auto-harvested {harvested} orphaned running session(s).") - try: - for o_id in orphaned_ids: - if trace_store.read_status(str(o_id)): - trace_store.update_trace_status( - str(o_id), - "failed", - error="Process terminated unexpectedly (auto-harvested).", - ) - except OSError as e: - # read_status never raises; this guards the lock/write side - # of update_trace_status. - print(f"[list_sessions] Could not mark harvested traces failed: {e}") - except Exception as e: - print(f"[list_sessions] Failed to update orphaned sessions: {e}") - - return result - - -@router.get("/api/sessions/{session_id}") -async def get_session_details(session_id: str): - """Retrieve details for a single automation session.""" - row = session_repo.get_session_by_id(session_id) - if not row: - raise HTTPException(status_code=404, detail=f"Session {session_id} not found") - return dict(row) - - -@router.get("/api/sessions/{session_id}/usage") -async def get_session_usage(session_id: str): - """Session-wide LLM token totals, live executor context size and run tuning.""" - try: - return await asyncio.to_thread(session_repo.get_session_usage, session_id) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/api/sessions/{session_id}/tree") -async def get_tree(session_id: str): - try: - return trace_repo.get_trace_tree(session_id) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/api/sessions/{session_id}/background_tasks") -async def get_session_background_tasks(session_id: str): - try: - return session_repo.get_background_tasks(session_id) - except Exception: - return [] - - -@router.get("/api/sessions/{session_id}/startup_progress") -async def get_session_startup_progress(session_id: str): - try: - return state.get_startup_progress(session_id) - except Exception: - return [] - - -@router.post("/api/cleanup") -async def cleanup_history_endpoint(): - try: - from artemis.data_engine.storage import StorageManager - - storage = StorageManager(DB_PATH, TRACES_PATH) - storage.clear_all_data() - return { - "status": "success", - "message": "History cleaned up successfully", - } - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/api/sessions/{session_id}/delete") -async def delete_session_endpoint(session_id: str): - try: - from artemis.data_engine.storage import StorageManager - - storage = StorageManager(DB_PATH, TRACES_PATH) - storage.delete_session(UUID(session_id)) - return { - "status": "success", - "message": f"Session {session_id} deleted successfully", - } - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) diff --git a/apps/admin_console/routers/steps.py b/apps/admin_console/routers/steps.py deleted file mode 100644 index d6823966a..000000000 --- a/apps/admin_console/routers/steps.py +++ /dev/null @@ -1,109 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json -import traceback -from fastapi import APIRouter, HTTPException -from fastapi.responses import PlainTextResponse - -from artemis.config import TEST_OUTPUTS_DIR - -try: - from admin_console.database.repositories.step_repository import step_repo - from admin_console.database.repositories.trace_repository import trace_repo - from admin_console.services.media_service import media_service -except ImportError: - from apps.admin_console.database.repositories.step_repository import step_repo - from apps.admin_console.database.repositories.trace_repository import trace_repo - from apps.admin_console.services.media_service import media_service - - -router = APIRouter(tags=["steps"]) - - -@router.get("/api/sessions/{session_id}/steps") -async def get_session_steps(session_id: str, client: str | None = None): - try: - return step_repo.get_session_steps(session_id, client=client) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/api/steps/{step_id}/traces") -async def get_step_traces_endpoint(step_id: str): - try: - session_id = step_repo.get_step_session_id(step_id) - if not session_id: - raise HTTPException(status_code=404, detail="Step not found") - return trace_repo.get_step_traces_tree(session_id, step_id) - except HTTPException as e: - raise e - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -def _resolve_trace_db_path(session_id: str | None, step_number: int | None): - if session_id and step_number is not None: - sandbox_db_path = ( - TEST_OUTPUTS_DIR - / f"{session_id}_step_{step_number:02d}" - / "temp_traces" - / "data_engine.db" - ) - if sandbox_db_path.exists(): - return sandbox_db_path - return None - - -@router.get("/api/traces/{trace_id}") -async def get_trace(trace_id: str, session_id: str = None, step_number: int = None): - try: - db_path = _resolve_trace_db_path(session_id, step_number) - trace_dict = trace_repo.get_trace_by_id(trace_id, db_path=db_path) - if not trace_dict: - raise HTTPException(status_code=404, detail="Trace not found") - - if trace_dict.get("payload"): - try: - payload_obj = json.loads(trace_dict["payload"]) - trace_dict["payload"] = media_service.unwrap_payload(payload_obj) - except (ValueError, TypeError, KeyError, AttributeError): - # Non-JSON or unexpectedly shaped payload: serve it raw. - pass - - return trace_dict - except HTTPException as e: - raise e - except Exception as e: - traceback.print_exc() - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/api/traces/{trace_id}/download") -async def download_trace(trace_id: str, session_id: str = None, step_number: int = None): - try: - db_path = _resolve_trace_db_path(session_id, step_number) - trace_dict = trace_repo.get_trace_by_id(trace_id, db_path=db_path) - if not trace_dict or not trace_dict.get("payload"): - raise HTTPException(status_code=404, detail="Payload not found") - - return PlainTextResponse( - trace_dict["payload"], - headers={"Content-Disposition": f'attachment; filename="trace_{trace_id}.json"'}, - ) - except HTTPException as e: - raise e - except Exception as e: - traceback.print_exc() - raise HTTPException(status_code=500, detail=str(e)) diff --git a/apps/admin_console/routers/stream.py b/apps/admin_console/routers/stream.py deleted file mode 100644 index 5bc3c6889..000000000 --- a/apps/admin_console/routers/stream.py +++ /dev/null @@ -1,56 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Device Screen Live Streaming Router. - -Exposes real-time screen streaming endpoints for the Web UI. -""" - -from fastapi import APIRouter -from fastapi.responses import JSONResponse, StreamingResponse - -try: - from admin_console.services.device_stream_service import device_stream_service -except ImportError: - from apps.admin_console.services.device_stream_service import device_stream_service - -router = APIRouter(tags=["stream"]) - - -@router.get("/api/stream/device-live") -async def stream_device_live(): - """Streams live device screen frames as multipart MJPEG.""" - return StreamingResponse( - device_stream_service.mjpeg_frame_generator(), - media_type="multipart/x-mixed-replace; boundary=frame", - headers={ - "Cache-Control": "no-cache, no-store, must-revalidate, max-age=0", - "Pragma": "no-cache", - "Expires": "0", - "Connection": "keep-alive", - }, - ) - - -@router.get("/api/stream/device-state") -async def get_device_stream_state(): - """Returns whether an ADB device is connected and live streaming is available.""" - serial = await device_stream_service.get_device_serial() - return JSONResponse( - { - "connected": serial is not None, - "serial": serial, - "live_stream_url": "/api/stream/device-live" if serial else None, - } - ) diff --git a/apps/admin_console/routers/system.py b/apps/admin_console/routers/system.py deleted file mode 100644 index c8ce18343..000000000 --- a/apps/admin_console/routers/system.py +++ /dev/null @@ -1,626 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""System Readiness & Diagnostics Router for Artemis Admin Console.""" - -import ipaddress -import os -import secrets -from urllib.parse import urlsplit - -from fastapi import APIRouter, HTTPException, Request -from pydantic import BaseModel, Field - -from artemis.core.diagnostics import readiness_engine -from artemis.core.diagnostics.adb_server_connection import ( - InvalidAdbServerEndpoint, - adb_server_connection, -) -from artemis.core.diagnostics.schema import SystemReadinessReport - -router = APIRouter(prefix="/api/system", tags=["system"]) - - -def _require_local_admin_request(request: Request) -> None: - """Keep endpoint probing and mutation on the local administration boundary.""" - client_host = request.client.host if request.client else None - allow_remote = os.getenv("ARTEMIS_ALLOW_REMOTE_ADB_CONFIGURATION", "").lower() in { - "1", - "true", - "yes", - } - if client_host and not allow_remote: - try: - is_loopback = ipaddress.ip_address(client_host).is_loopback - except ValueError: - is_loopback = client_host.lower() == "localhost" - if not is_loopback: - raise HTTPException( - status_code=403, - detail=( - "ADB server settings are local-only. Set " - "ARTEMIS_ALLOW_REMOTE_ADB_CONFIGURATION=true to manage them from another " - "computer." - ), - ) - - origin = request.headers.get("origin") - host = request.headers.get("host") - if not origin or not host: - return - origin_host = urlsplit(origin).netloc.lower() - if origin_host != host.lower(): - raise HTTPException( - status_code=403, - detail="ADB server settings can only be changed from the Artemis console.", - ) - - -def _require_loopback_request(request: Request, detail: str) -> None: - """Reject requests whose TCP peer is not the local machine.""" - client_host = request.client.host if request.client else None - try: - is_loopback = bool(client_host and ipaddress.ip_address(client_host).is_loopback) - except ValueError: - is_loopback = bool(client_host and client_host.lower() == "localhost") - if not is_loopback: - raise HTTPException(status_code=403, detail=detail) - - -def _require_local_lifecycle_request(request: Request) -> None: - """Authorize a process-lifecycle request from the local CLI only.""" - _require_loopback_request(request, "Server lifecycle controls are local-only.") - - expected = getattr(request.app.state, "lifecycle_token", None) - supplied = request.headers.get("x-artemis-lifecycle-token") - if not ( - isinstance(expected, str) - and isinstance(supplied, str) - and secrets.compare_digest(expected, supplied) - ): - raise HTTPException(status_code=403, detail="Invalid server lifecycle token.") - - -class SelectDeviceRequest(BaseModel): - """Payload to select an active target Android device.""" - - serial: str = Field(description="Serial number or identifier of the Android device to select") - - -@router.get("/readiness", response_model=SystemReadinessReport) -async def get_system_readiness(force: bool = False) -> SystemReadinessReport: - """Execute all diagnostic probes and return a comprehensive system readiness report.""" - return await readiness_engine.run_all(force_refresh=force) - - -@router.post("/devices/select") -async def select_active_device(request: SelectDeviceRequest): - """Select the active Android device or emulator for subsequent automated tasks.""" - serial = request.serial.strip() - if not serial: - raise HTTPException(status_code=400, detail="Device serial cannot be empty.") - - readiness_engine.set_probe_target_serial(serial) - # Return updated readiness - report = await readiness_engine.run_all(force_refresh=True) - return { - "status": "success", - "selected_serial": serial, - "report": report, - } - - -@router.post("/adb/restart") -async def restart_adb_server(): - """Restart local ADB server and return an updated readiness check.""" - restart_result = await readiness_engine.restart_adb_server() - readiness_engine.invalidate_cache() - updated_report = await readiness_engine.run_all(force_refresh=True) - return { - "restart_result": restart_result, - "report": updated_report, - } - - -@router.post("/adb/heal-keys") -async def heal_adb_keys(): - """Auto-heal corrupted ADB authentication RSA keys and return updated readiness.""" - heal_result = await readiness_engine.heal_adb_keys() - updated_report = await readiness_engine.run_all() - return { - "heal_result": heal_result, - "report": updated_report, - } - - -class ConnectAdbRequest(BaseModel): - """Payload to connect to an Android device over Wi-Fi.""" - - host: str = Field(description="IP address of Android device") - port: int = Field(default=5555, description="Port number") - - -@router.post("/adb/connect") -async def connect_wireless_adb(request: ConnectAdbRequest): - """Connect to a device over Wi-Fi and return updated readiness.""" - connect_result = await readiness_engine.connect_wireless_adb(request.host, request.port) - readiness_engine.invalidate_cache() - updated_report = await readiness_engine.run_all(force_refresh=True) - return { - "connect_result": connect_result, - "report": updated_report, - } - - -class ConnectAdbServerRequest(BaseModel): - """Payload to select an ADB server endpoint accessible from this computer.""" - - host: str = Field(description="Host name or IP address of the ADB server") - port: int = Field(default=5037, ge=1, le=65535, description="ADB server port") - persist: bool = Field(default=True, description="Persist the endpoint for future launches") - - -@router.get("/adb/server") -async def get_adb_server_status(): - """Return the process-wide ADB server endpoint currently used by Artemis.""" - return adb_server_connection.status() - - -@router.post("/adb/server/connect") -async def connect_adb_server(payload: ConnectAdbServerRequest, request: Request): - """Validate and activate an ADB server endpoint.""" - _require_local_admin_request(request) - try: - connection_result = await adb_server_connection.connect( - payload.host, - payload.port, - persist=payload.persist, - ) - except InvalidAdbServerEndpoint as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - response: dict[str, object] = {"connection_result": connection_result} - if connection_result["success"]: - readiness_engine.set_probe_target_serial(None) - readiness_engine.invalidate_cache() - response["report"] = await readiness_engine.run_all(force_refresh=True) - return response - - -@router.post("/adb/server/probe") -async def probe_adb_server(payload: ConnectAdbServerRequest, request: Request): - """Test an ADB server endpoint without changing the active endpoint.""" - _require_local_admin_request(request) - try: - connection_result = await adb_server_connection.probe(payload.host, payload.port) - except InvalidAdbServerEndpoint as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - return {"connection_result": connection_result} - - -@router.post("/adb/server/local") -async def use_local_adb_server(request: Request, persist: bool = True): - """Restore the standard local ADB server without touching a remote daemon.""" - _require_local_admin_request(request) - connection_result = await adb_server_connection.use_local_server(persist=persist) - readiness_engine.set_probe_target_serial(None) - readiness_engine.invalidate_cache() - updated_report = await readiness_engine.run_all(force_refresh=True) - return { - "connection_result": connection_result, - "report": updated_report, - } - - -class LaunchEmulatorRequest(BaseModel): - """Payload to launch a local Android Virtual Device (AVD).""" - - avd_name: str = Field( - description="Name of the installed AVD emulator to launch (e.g. Android_2)" - ) - - -@router.post("/emulator/launch") -async def launch_emulator(request: LaunchEmulatorRequest): - """Launch an Android emulator in the background and return initiation status.""" - avd_name = request.avd_name.strip() - if not avd_name: - raise HTTPException(status_code=400, detail="AVD name cannot be empty.") - - launch_res = await readiness_engine.launch_emulator(avd_name) - return launch_res - - -@router.get("/emulator/status") -async def get_emulator_status(): - """Query real-time progress and logs of background emulator launch.""" - return readiness_engine.get_emulator_status() - - -@router.post("/emulator/stop") -async def stop_emulator(): - """Stop active emulator process.""" - return await readiness_engine.stop_emulator() - - -@router.post("/emulator/dismiss") -async def dismiss_emulator(): - """Dismiss emulator launch tracking state.""" - return readiness_engine.dismiss_emulator() - - -class UpdateCredentialsRequest(BaseModel): - """Payload to update and configure LLM or Vision OCR API credentials.""" - - provider: str = Field( - default="google", - description="Provider identifier (e.g. google, gemini, openai, anthropic, openrouter, ocr)", - ) - api_key: str = Field(description="The secret API key string to configure") - persist_to_env: bool = Field( - default=True, description="Whether to persist the key to .env file" - ) - - -class ValidateCredentialsRequest(BaseModel): - """Payload to test and verify LLM or Vision OCR API credentials without saving.""" - - provider: str = Field( - default="google", - description="Provider identifier (e.g. google, gemini, openai, anthropic, openrouter, ocr)", - ) - api_key: str = Field(description="The secret API key string to test") - base_url: str | None = Field(default=None, description="Optional custom base URL") - - -@router.get("/credentials") -async def get_credentials(): - """Report which providers have an API key configured. - - Secret values never leave the process: this endpoint intentionally returns - presence booleans only. Keys are written via POST /credentials and used - server-side. - """ - from artemis.config import settings - - providers = ("google", "openai", "anthropic", "openrouter", "ocr") - status = {name: bool(settings.get_api_key(name)) for name in providers} - status["gemini"] = status["google"] - return { - "providers": [ - {"name": name, "configured": configured} for name, configured in status.items() - ] - } - - -@router.post("/credentials/test") -async def test_credentials(request: ValidateCredentialsRequest): - """Test and verify whether an API key is valid and usable with the corresponding provider endpoint.""" - from artemis.utils.credentials_validator import validate_api_key - - provider = request.provider.strip().lower() - key = request.api_key.strip() - - if not key: - raise HTTPException(status_code=400, detail="API key cannot be empty.") - - is_valid, message = await validate_api_key( - provider=provider, - api_key=key, - base_url=request.base_url, - ) - if not is_valid: - raise HTTPException(status_code=400, detail=message) - - return { - "valid": True, - "provider": provider, - "message": message, - } - - -@router.post("/credentials") -async def update_credentials(request: UpdateCredentialsRequest): - """Dynamically configure and persist LLM or Vision API key, returning updated readiness report.""" - from artemis.utils.credentials_validator import validate_api_key - - provider = request.provider.strip().lower() - key = request.api_key.strip() - - # If a non-empty key is provided, verify it before saving - if key: - is_valid, validation_msg = await validate_api_key(provider=provider, api_key=key) - if not is_valid: - raise HTTPException( - status_code=400, - detail=f"API key verification failed: {validation_msg}", - ) - - try: - from artemis.config import settings - - settings.set_api_key(provider, key, persist_to_env=request.persist_to_env) - - # Re-run all diagnostic probes to build updated report - readiness_engine.invalidate_cache() - updated_report = await readiness_engine.run_all(force_refresh=True) - action_desc = ( - "successfully verified, updated, and applied" if key else "successfully cleared" - ) - return { - "status": "success", - "message": f"API key for {provider} {action_desc}.", - "provider": provider, - "report": updated_report, - } - except HTTPException: - raise - except Exception as exc: - raise HTTPException(status_code=500, detail=f"Failed to update credentials: {exc}") - - -@router.get("/model-config-env") -async def get_model_config_and_env(): - """Retrieve the current active artemis.jsonc configuration and .env status for custom setup.""" - import os - from artemis.config.paths import get_config_path, get_env_file - from artemis.config import settings - from third_party.mobile_use.utils.file import load_jsonc - - from artemis.config.settings import is_placeholder_key - - # 1. Config file resolution - config_path = None - config_content = "" - parsed_config = {} - try: - config_path_obj = get_config_path("artemis.jsonc") - config_path = str(config_path_obj) - config_content = config_path_obj.read_text(encoding="utf-8") - with open(config_path_obj, encoding="utf-8") as f: - parsed_config = load_jsonc(f) - except Exception as e: - config_content = f"// Error reading config: {e}" - - # 2. Env file resolution - env_path = get_env_file() - - # 3. Relevant Env Variables status - def get_real_env_value(key_name: str, provider: str) -> str | None: - k = settings.get_api_key(provider) - if k and not is_placeholder_key(k): - return k.get_secret_value() - val = os.environ.get(key_name) - if val and val.strip() and not is_placeholder_key(val): - return val.strip() - return None - - def mask_key(k: str | None) -> str | None: - # Expose only enough to recognize which key is active, never a usable - # fragment of the secret itself. - if not k: - return None - if len(k) <= 8: - return "****" - return f"****{k[-4:]}" - - gemini_real = get_real_env_value("GEMINI_API_KEY", "google") or get_real_env_value( - "GOOGLE_API_KEY", "google" - ) - openai_real = get_real_env_value("OPENAI_API_KEY", "openai") - anthropic_real = get_real_env_value("ANTHROPIC_API_KEY", "anthropic") - openrouter_real = get_real_env_value("OPEN_ROUTER_API_KEY", "openrouter") - xai_real = get_real_env_value("XAI_API_KEY", "xai") - base_url_val = settings.OPENAI_BASE_URL or os.environ.get("OPENAI_BASE_URL") - if base_url_val and is_placeholder_key(base_url_val): - base_url_val = None - ocr_real = get_real_env_value("OCR_API_KEY", "ocr") or get_real_env_value( - "VISION_API_KEY", "ocr" - ) - - env_vars = [ - { - "name": "GEMINI_API_KEY", - "provider": "google", - "is_set": bool(gemini_real), - "preview": mask_key(gemini_real), - "description": "Google Gemini multimodal vision API key (free tier available)", - }, - { - "name": "OPENAI_API_KEY", - "provider": "openai", - "is_set": bool(openai_real), - "preview": mask_key(openai_real), - "description": "OpenAI API key (GPT-4o, GPT-4o-mini)", - }, - { - "name": "ANTHROPIC_API_KEY", - "provider": "anthropic", - "is_set": bool(anthropic_real), - "preview": mask_key(anthropic_real), - "description": "Anthropic Claude API key (Claude 3.5 Sonnet, Claude 3.7)", - }, - { - "name": "OPEN_ROUTER_API_KEY", - "provider": "openrouter", - "is_set": bool(openrouter_real), - "preview": mask_key(openrouter_real), - "description": "OpenRouter unified API gateway key", - }, - { - "name": "XAI_API_KEY", - "provider": "xai", - "is_set": bool(xai_real), - "preview": mask_key(xai_real), - "description": "xAI Grok vision API key", - }, - { - "name": "OPENAI_BASE_URL", - "provider": "custom", - "is_set": bool(base_url_val), - "preview": base_url_val, - "description": "Custom API endpoint (for local Ollama, vLLM, DeepSeek, or proxies)", - }, - { - "name": "VISION_API_KEY", - "provider": "ocr", - "is_set": bool(ocr_real), - "preview": mask_key(ocr_real), - "description": "Google Cloud Vision OCR key for screen text detection (optional)", - }, - ] - - return { - "config_path": config_path or "config/artemis.jsonc", - "config_filename": "artemis.jsonc", - "config_content": config_content, - "default_model": parsed_config.get("default", {}), - "presets": parsed_config.get("presets", {}), - "env_path": str(env_path), - "env_filename": ".env", - "env_vars": env_vars, - } - - -@router.get("/server-status") -async def get_server_runtime_status(): - """Retrieve runtime status, PID, port, and uptime of the Artemis server. - - Answers from in-process state. ``server_lifecycle.get_server_status`` is - not used here: it runs ``lsof``/``fuser``, which can take over a second and - is too slow for the ``is_artemis_daemon`` probe. - """ - import os - import time - - from artemis.runtime.process_probe import pid_is_alive - from artemis.runtime.server_lifecycle import read_server_info - - try: - from apps.admin_console.core.state import state - except ImportError: - from admin_console.core.state import state - - port = getattr(state, "port", 8000) - current_pid = os.getpid() - pids = {current_pid} - started_at = None - - info = read_server_info() - if info and info.get("port") == port: - saved_pid = info.get("pid") - if isinstance(saved_pid, int) and saved_pid != current_pid and pid_is_alive(saved_pid): - pids.add(saved_pid) - if isinstance(info.get("started_at"), (int, float)): - started_at = float(info["started_at"]) - if started_at is None: - try: - import psutil - - started_at = psutil.Process(current_pid).create_time() - except Exception: # pylint: disable=broad-exception-caught - # psutil is optional; uptime is best-effort. - started_at = None - - uptime_seconds = max(0.0, time.time() - started_at) if started_at is not None else None - # Explicit DTO: the raw metadata file additionally holds the lifecycle - # token, cmdline, and filesystem paths, none of which belong on the wire. - return { - "running": True, - "port": port, - "pids": sorted(pids), - "active_pid": current_pid, - "uptime_seconds": uptime_seconds, - "url": f"http://localhost:{port}", - "admin_url": f"http://localhost:{port}/admin", - "current_pid": current_pid, - } - - -@router.post("/restart") -async def restart_server_endpoint(request: Request): - """Request a graceful restart of the Artemis server from thin clients/UI.""" - import asyncio - import os - import sys - import threading - - _require_loopback_request(request, "Server lifecycle controls are local-only.") - - try: - from apps.admin_console.core.state import state - except ImportError: - from admin_console.core.state import state - - port = getattr(state, "port", 8000) - current_pid = os.getpid() - - def _restart_worker(): - import time - - time.sleep(0.6) - if sys.platform != "win32": - try: - os.execv(sys.executable, [sys.executable] + sys.argv) - except Exception: - import subprocess - - subprocess.Popen([sys.executable] + sys.argv) - os._exit(0) - else: - import subprocess - - subprocess.Popen([sys.executable] + sys.argv) - os._exit(0) - - threading.Thread(target=_restart_worker, daemon=True).start() - - return { - "status": "restarting", - "message": "Artemis server is restarting. Client reconnection should occur in 2-3 seconds.", - "previous_pid": current_pid, - "port": port, - } - - -@router.post("/shutdown", status_code=202) -async def shutdown_server_endpoint(request: Request): - """Request a graceful shutdown of the Artemis server.""" - import asyncio - - try: - from apps.admin_console.core.state import state - except ImportError: - from admin_console.core.state import state - - _require_local_lifecycle_request(request) - server = getattr(request.app.state, "uvicorn_server", None) - if server is None: - raise HTTPException(status_code=503, detail="Server lifecycle controller is unavailable.") - - async def _shutdown_after_response() -> None: - # Let Starlette flush the accepted response before Uvicorn leaves its - # main loop and invokes the FastAPI shutdown lifecycle. - await asyncio.sleep(0.05) - state.is_shutting_down = True - state.shutdown_event.set() - server.should_exit = True - - asyncio.create_task(_shutdown_after_response()) - - return { - "status": "shutting_down", - "message": "Artemis server is shutting down.", - "pid": os.getpid(), - } diff --git a/apps/admin_console/routers/tasks.py b/apps/admin_console/routers/tasks.py deleted file mode 100644 index 26fca4611..000000000 --- a/apps/admin_console/routers/tasks.py +++ /dev/null @@ -1,517 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -from contextlib import suppress -import json -from typing import Any -from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import StreamingResponse -from artemis.core.diagnostics import readiness_engine -from artemis.runtime import DeviceExecutionLock, device_pool - -try: - from admin_console.core.state import state - from admin_console.database.repositories.session_repository import session_repo - from admin_console.schemas.task_schema import RunRequest - from admin_console.services.ipc_service import ipc_service - from admin_console.services.model_service import model_service - from admin_console.services.task_preset_catalog import task_recommendation_engine - from admin_console.services.task_queue_service import task_queue_service -except ImportError: - from apps.admin_console.core.state import state - from apps.admin_console.database.repositories.session_repository import session_repo - from apps.admin_console.schemas.task_schema import RunRequest - from apps.admin_console.services.ipc_service import ipc_service - from apps.admin_console.services.model_service import model_service - from apps.admin_console.services.task_preset_catalog import task_recommendation_engine - from apps.admin_console.services.task_queue_service import task_queue_service - - -router = APIRouter(tags=["tasks"]) - - -@router.get("/api/tasks/presets") -async def get_task_presets( - category: str = "recommended", - packages: str | None = None, - limit: int = 24, -): - """Retrieve smart task recommendations optionally tailored to detected device packages.""" - pkg_list = [p.strip() for p in packages.split(",") if p.strip()] if packages else None - return task_recommendation_engine.recommend_tasks( - installed_packages=pkg_list, - category=category, - limit=limit, - ) - - -@router.get("/api/tasks/catalog") -async def get_task_catalog(): - """Retrieve full catalog of predefined tasks and app package registry.""" - return { - "tasks": [t.model_dump() for t in task_recommendation_engine.get_all_tasks()], - "app_registry": task_recommendation_engine.get_app_registry(), - } - - -@router.post("/api/run") -async def run_task(request: RunRequest): - incoming_goals = [] - if request.goals: - incoming_goals = request.goals - elif request.goal: - incoming_goals = [request.goal] - - if not incoming_goals: - raise HTTPException( - status_code=400, - detail="Either 'goal' or 'goals' list must be provided.", - ) - - # Idempotent SDK retries must never re-run device readiness checks. A task - # can hold the device while its admission response is lost in transit; in - # that state, probing the same device again may fail or block even though - # the original task was accepted successfully. - if request.session_id and len(incoming_goals) == 1: - requested_sid = str(request.session_id) - existing_item = next( - ( - item - for item in state.queue_items - if isinstance(item, dict) and str(item.get("session_id")) == requested_sid - ), - None, - ) - persisted_session = session_repo.get_session_by_id(requested_sid) - is_active = ( - str(state.active_session_id) == requested_sid - or requested_sid in state.active_connections - ) - if existing_item or persisted_session or is_active: - task_payload = dict(existing_item or persisted_session or {}) - task_payload.setdefault("session_id", requested_sid) - task_payload.setdefault("goal", incoming_goals[0]) - task_payload.setdefault("profile", request.profile or "flash") - task_payload.setdefault("device_serial", request.device_serial) - task_payload.setdefault("status", "running" if is_active else "queued") - return { - "status": task_payload["status"], - "tasks": [task_payload], - "enqueued_count": 0, - "total_queued": len(state.queue_tasks), - } - - # Reject an explicit unknown/offline target before running the more - # expensive readiness probe. Besides producing a stable SDK response, - # this avoids probing the currently active device for a serial that can - # never be selected. Only a successful, non-empty enumeration may reject: - # an indeterminate one (adb blip, startup) lets the submission queue and - # fail downstream with a clear error instead. - if request.device_serial: - try: - rejection = await device_pool.validate_explicit_serial_async(request.device_serial) - except Exception: - rejection = None - if rejection: - return { - "status": "rejected", - "error": rejection, - "tasks": [], - "enqueued_count": 0, - "total_queued": len(state.queue_tasks), - } - - # Re-check immediately before enqueueing so a device locked between UI - # polling intervals cannot start through a stale Ready state. Use the - # bounded submission probe: the full diagnostics path also scans packages, - # emulator installations, Android version, and screen size. - # With no explicit serial the probe itself resolves a live target (it - # prefers the diagnostics target preference, then any unlocked ready - # device); the verified serial is bound below. - target_serial = request.device_serial - device_probe = await readiness_engine.run_device_submission_probe(target_serial=target_serial) - if device_probe and device_probe.summary in {"Device Locked", "Lock State Unknown"}: - locked_serial = ( - device_probe.metadata.get("active_device", {}).get("serial") or target_serial or "" - ) - detail = ( - f"Android device {locked_serial} is locked. Unlock it and enter the home screen before running a task.".replace( - " ", " " - ).strip() - if device_probe.summary == "Device Locked" - else f"Android device {locked_serial} lock state could not be verified. Keep it unlocked on the home screen and try again.".replace( - " ", " " - ).strip() - ) - raise HTTPException(status_code=409, detail=detail) - - if device_probe and device_probe.metadata.get("active_device"): - verified_serial = device_probe.metadata["active_device"].get("serial") - # Only auto-selected targets may be re-bound to the probed device. An - # explicitly requested serial is never silently replaced -- if it is - # invalid, enqueue_tasks rejects the submission with a clear error. - if verified_serial and not request.device_serial: - target_serial = verified_serial - - return await task_queue_service.enqueue_tasks( - incoming_goals, - profile=request.profile or "flash", - expected_output=request.expected_output, - enable_outputter=request.enable_outputter, - verification_level=request.verification_level, - explorer_mode=request.explorer_mode, - locked_app_package=request.locked_app_package, - app_path=request.app_path, - device_serial=target_serial, - ingress=request.ingress or "frontend", - session_id=request.session_id, - conversation_id=request.conversation_id, - ) - - -@router.get("/api/run/defaults") -async def get_run_defaults(): - """Effective Pro-profile tuning defaults from the agent config. - - The launcher's sliders start here so they reflect ``artemis.jsonc`` (and the - ``ARTEMIS_EXPLORER_VERSION`` override) instead of a hard-coded guess. - """ - from artemis.config import load_agent_config, verification_level_for_checker - - agent_cfg = load_agent_config() - return { - "verification_level": verification_level_for_checker(agent_cfg.checker), - "explorer_mode": agent_cfg.explorer.resolve(profile="pro"), - } - - -@router.get("/api/devices") -async def list_devices(): - """List all connected Android devices with their busy / idle status.""" - devices = await device_pool.list_devices_async() - return {"devices": [d.to_dict() for d in devices]} - - -@router.post("/api/stop") -async def stop_task( - request: Request, - all: bool = False, - session_id: str | None = None, - device_id: str | None = None, -): - target_all = all - target_sid = session_id - target_dev = device_id - - try: - body = await request.json() - if isinstance(body, dict): - if "all" in body: - target_all = bool(body["all"]) or target_all - if body.get("session_id"): - target_sid = str(body["session_id"]) - if body.get("device_id"): - target_dev = str(body["device_id"]) - except ValueError: - # Empty or non-JSON body: fall back to the query parameters. - pass - - # stop_tasks updates scheduler state and asyncio events owned by this loop. - stopped = task_queue_service.stop_tasks( - clear_all=target_all, - session_id=target_sid, - device_id=target_dev, - ) - if stopped: - return {"status": "stopped", "session_id": target_sid} - return {"status": "no_running_task"} - - -@router.post("/api/resume") -async def resume_task(): - resumed = task_queue_service.resume_task() - if resumed: - return {"status": "resumed"} - return {"status": "not_paused"} - - -@router.get("/api/status") -async def get_status(): - # Watchdog check to ensure background worker is alive - task_queue_service.ensure_worker_running() - - latest_session = session_repo.get_latest_session() - latest_session_id = latest_session.get("session_id") if latest_session else None - bg_tasks = session_repo.get_background_tasks(latest_session_id) if latest_session_id else [] - - global_owner = DeviceExecutionLock.get_active_owner() - is_running = state.is_running or global_owner is not None - running_task = next( - (t for t in state.queue_items if isinstance(t, dict) and t.get("status") == "running"), None - ) - if not running_task and is_running: - running_task = next( - (t for t in state.queue_items if isinstance(t, dict) and t.get("status") == "pending"), - None, - ) - - active_owners = DeviceExecutionLock.get_active_owners() - active_tasks = [ - { - "device_id": owner.device_id, - "session_id": owner.session_id, - "goal": owner.description, - "pid": owner.pid, - "ingress": owner.ingress, - "acquired_at": owner.acquired_at, - } - for owner in active_owners.values() - ] - - running_sid = ( - (global_owner.session_id if global_owner else None) - or state.active_session_id - or (running_task.get("session_id") if running_task else None) - or ( - active_tasks[0]["session_id"] - if active_tasks and active_tasks[0].get("session_id") - else None - ) - or (session_repo.get_running_session_id() if is_running else None) - ) - owner_connection = state.active_connections.get(str(running_sid), {}) if running_sid else {} - running_goal = ( - owner_connection.get("goal") - or (global_owner.description if global_owner else None) - or state.current_goal - or (running_task.get("goal") if running_task else None) - or (active_tasks[0]["goal"] if active_tasks else None) - ) - - active_profile = ( - owner_connection.get("profile") - or state.current_profile - or (running_task.get("profile") if running_task and not global_owner else None) - ) - if not active_profile and (running_sid or latest_session_id): - check_sid = running_sid or latest_session_id - sess_row = session_repo.get_session_by_id(check_sid) - if sess_row: - llm_traces = session_repo.get_llm_traces_for_profile(check_sid) - agent_names = session_repo.get_agent_trace_names(check_sid) - active_profile = model_service.resolve_session_profile( - sess_row, llm_traces, agent_names=agent_names - ) - - model_info = model_service.get_active_model_info(active_profile) - - # Unified Global Queue: merge web tasks and external SDK/CLI device queue tickets - global_queued = DeviceExecutionLock.get_queued_tasks() - seen_ids = set() - queue_data: list[dict[str, Any]] = [] - - for item in state.queue_tasks: - sid = item.get("session_id") - if sid: - seen_ids.add(str(sid)) - queue_data.append(item) - - for g_item in global_queued: - sid = str(g_item.get("session_id")) - if sid not in seen_ids: - seen_ids.add(sid) - queue_data.append(g_item) - - if is_running: - is_paused = state.is_paused - return { - "status": "paused" if is_paused else "running", - "paused_error": state.paused_error if is_paused else None, - "goal": running_goal, - "pid": ( - global_owner.pid - if global_owner - else state.current_process.pid - if state.current_process - else None - ), - "session_id": running_sid, - "background_tasks": bg_tasks, - "queue": queue_data, - "model_info": model_info, - "active_tasks": active_tasks, - } - - if latest_session_id and str(latest_session_id) in state.active_connections: - conn_info = state.active_connections[str(latest_session_id)] - is_paused = state.is_paused - conn_profile = conn_info.get("profile") or active_profile - conn_model_info = model_service.get_active_model_info(conn_profile) - return { - "status": "paused" if is_paused else "running", - "paused_error": state.paused_error if is_paused else None, - "goal": conn_info.get("goal"), - "pid": conn_info.get("pid"), - "session_id": latest_session_id, - "background_tasks": bg_tasks, - "queue": queue_data, - "model_info": conn_model_info, - "active_tasks": active_tasks, - "ipc_port": state.ipc_port, - } - - return { - "status": "idle", - "session_id": latest_session_id, - "background_tasks": bg_tasks, - "queue": queue_data, - "active_tasks": active_tasks, - "model_info": model_info, - "ipc_port": state.ipc_port, - } - - -@router.get("/api/stream") -@router.get("/api/stream/{session_id}") -async def stream_events(session_id: str = "active", client: str | None = None): - async def event_generator(): - queue = asyncio.Queue() - event_loop = asyncio.get_running_loop() - - def callback(event_type, data): - try: - # Global queue lifecycle events should always be delivered - if event_type not in ( - "session_started", - "session_ended", - "background_tasks_updated", - ): - # Filter events by session_id when subscribed to a specific session - if session_id and session_id not in ("all", "active"): - evt_session_id = None - if isinstance(data, dict): - evt_session_id = data.get("session_id") - - if evt_session_id and str(evt_session_id) != str(session_id): - return - - if ( - not evt_session_id - and state.active_session_id - and str(state.active_session_id) != str(session_id) - ): - return - - sanitized_data = ipc_service.sanitize_event_data(event_type, data) - event_loop.call_soon_threadsafe(queue.put_nowait, (event_type, sanitized_data)) - except RuntimeError: - pass - - state.add_subscriber(callback) - yield f'event: info\ndata: {{"message": "Subscribed to session {session_id}"}}\n\n' - - if session_id in ("all", "active"): - active_sid = state.active_session_id - if not active_sid: - running_item = next( - ( - t - for t in state.queue_items - if isinstance(t, dict) and t.get("status") == "running" - ), - None, - ) - if running_item: - active_sid = running_item.get("session_id") - if not active_sid: - owner = DeviceExecutionLock.get_active_owner() - if owner and owner.session_id: - active_sid = owner.session_id - - if active_sid: - goal = state.current_goal or "" - profile = state.current_profile or "flash" - yield ( - "event: session_started\n" - f"data: {json.dumps({'session_id': str(active_sid), 'initial_goal': goal, 'profile': profile}, default=str)}\n\n" - ) - for progress_event in state.get_startup_progress(str(active_sid)): - yield ( - "event: startup_progress\n" - f"data: {json.dumps(progress_event, default=str)}\n\n" - ) - try: - from apps.admin_console.database.repositories.step_repository import step_repo - - recorded_steps = step_repo.get_session_steps(str(active_sid)) - for step_dict in recorded_steps: - yield ( - f"event: step_recorded\ndata: {json.dumps(step_dict, default=str)}\n\n" - ) - except Exception as exc: - print(f"[Stream] Could not replay active steps: {exc}") - else: - for progress_event in state.get_startup_progress(session_id): - yield ( - f"event: startup_progress\ndata: {json.dumps(progress_event, default=str)}\n\n" - ) - - shutdown_waiter = asyncio.create_task(state.shutdown_event.wait()) - queue_waiter = None - try: - while not state.is_shutting_down: - queue_waiter = asyncio.create_task(queue.get()) - done, _ = await asyncio.wait( - {queue_waiter, shutdown_waiter}, - timeout=5.0, - return_when=asyncio.FIRST_COMPLETED, - ) - if shutdown_waiter in done: - queue_waiter.cancel() - with suppress(asyncio.CancelledError): - await queue_waiter - queue_waiter = None - break - if queue_waiter in done: - event_type, data = queue_waiter.result() - queue_waiter = None - yield f"event: {event_type}\ndata: {json.dumps(data, default=str)}\n\n" - else: - queue_waiter.cancel() - with suppress(asyncio.CancelledError): - await queue_waiter - queue_waiter = None - yield "event: keep-alive\ndata: {}\n\n" - except asyncio.CancelledError: - raise - finally: - waiters = (queue_waiter, shutdown_waiter) - for waiter in waiters: - if waiter is not None and not waiter.done(): - waiter.cancel() - for waiter in waiters: - if waiter is not None: - with suppress(asyncio.CancelledError): - await waiter - state.remove_subscriber(callback) - - return StreamingResponse( - event_generator(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache, no-transform", - "X-Accel-Buffering": "no", - }, - ) diff --git a/apps/admin_console/schemas/__init__.py b/apps/admin_console/schemas/__init__.py deleted file mode 100644 index 58d482ea3..000000000 --- a/apps/admin_console/schemas/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. diff --git a/apps/admin_console/schemas/task_schema.py b/apps/admin_console/schemas/task_schema.py deleted file mode 100644 index 144a97203..000000000 --- a/apps/admin_console/schemas/task_schema.py +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from pydantic import BaseModel - - -class RunRequest(BaseModel): - goal: str | None = None - goals: list[str] | None = None - profile: str | None = "flash" - expected_output: str | None = None - enable_outputter: bool | None = None - # Pro-profile tuning (ignored by the Flash profile): a coarse Checker preset - # ('off' | 'final' | 'checkpoints' | 'strict') and the Explorer perception - # version used by the Operator ('flash' | 'pro' | 'ultra'). - verification_level: str | None = None - explorer_mode: str | None = None - locked_app_package: str | None = None - app_path: str | None = None - device_serial: str | None = None - ingress: str | None = "frontend" - session_id: str | None = None - conversation_id: str | None = None - - -class ReplayRequest(BaseModel): - device_id: str - user_submits: dict - tool_name: str = "ask_explorer" - replay_id: str | None = None - - -class StopRequest(BaseModel): - session_id: str | None = None - device_id: str | None = None - all: bool = False diff --git a/apps/admin_console/server.py b/apps/admin_console/server.py deleted file mode 100644 index 151bb827b..000000000 --- a/apps/admin_console/server.py +++ /dev/null @@ -1,508 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Artemis Admin & Trace Console Server - -Modular entrypoint for full trace inspection, step replay, and task execution management. -""" - -import argparse -import asyncio -from contextlib import asynccontextmanager -import os -from pathlib import Path -import secrets -import signal -import sys -from types import FrameType - -# Bootstrap sys.path to allow running from any CWD -_current_p = Path(__file__).resolve().parent -while _current_p != _current_p.parent: - if (_current_p / "pyproject.toml").exists() or (_current_p / "artemis").is_dir(): - _workspace_root = _current_p - break - _current_p = _current_p.parent -else: - _workspace_root = Path(__file__).resolve().parent.parent.parent - -_apps_dir = _workspace_root / "apps" -_admin_console_dir = _apps_dir / "admin_console" -_cloud_service_dir = _apps_dir / "cloud_service" - -for _p in (str(_workspace_root), str(_apps_dir), str(_admin_console_dir), str(_cloud_service_dir)): - if _p not in sys.path: - sys.path.insert(0, _p) - -from fastapi import FastAPI -from fastapi.responses import FileResponse, HTMLResponse -import uvicorn - -from artemis.runtime import ( - DeviceExecutionLock, - clear_server_info, - device_pool, - shutdown_awake_service, - start_awake_service, - write_server_info, -) - -from artemis.config import ( - DB_PATH, - IMAGES_DIR, - REPLAY_BASE_DIR, - TEST_DATA_DIR, - TEST_OUTPUTS_DIR, - TRACES_PATH, - WORKSPACE_ROOT, - init_ls_address, -) -from artemis.resources import get_bundled_showcase_dist - -try: - from admin_console.core.security import SameOriginBoundaryMiddleware - from admin_console.core.state import ServerState, state - from admin_console.database.connection import db_session, get_db - from admin_console.database.repositories.session_repository import session_repo - from admin_console.database.repositories.step_repository import step_repo - from admin_console.database.repositories.trace_repository import trace_repo - from admin_console.routers import media, replay, sessions, steps, stream, system, tasks - from admin_console.routers.replay import replay_manager - from admin_console.services.ipc_service import ipc_service - from admin_console.services.media_service import media_service - from admin_console.services.model_service import model_service - from admin_console.services.task_queue_service import task_queue_service -except ImportError: - from apps.admin_console.core.security import SameOriginBoundaryMiddleware - from apps.admin_console.core.state import state - from apps.admin_console.database.repositories.session_repository import session_repo - from apps.admin_console.routers import media, replay, sessions, steps, stream, system, tasks - from apps.admin_console.routers.replay import replay_manager - from apps.admin_console.services.ipc_service import ipc_service - from apps.admin_console.services.model_service import model_service - from apps.admin_console.services.task_queue_service import task_queue_service - -# Initialize language server synchronization address -init_ls_address() - - -@asynccontextmanager -async def _lifespan(_app: "FastAPI"): - """Start server resources and release them on shutdown.""" - await on_startup() - try: - yield - finally: - await on_shutdown() - - -# Initialize FastAPI application -app = FastAPI(title="Artemis Admin & Trace Console", lifespan=_lifespan) -LIFECYCLE_TOKEN = os.environ.get("ARTEMIS_LIFECYCLE_TOKEN") or secrets.token_urlsafe(32) -app.state.lifecycle_token = LIFECYCLE_TOKEN - -# The console UI is served same-origin from this process, so no CORS grants -# exist at all; the boundary middleware rejects cross-origin browser traffic -# and unrecognized Host headers (DNS rebinding) instead. -app.add_middleware(SameOriginBoundaryMiddleware) - - -async def on_startup(): - """Startup lifecycle hooks.""" - state.is_shutting_down = False - state.shutdown_event.clear() - write_server_info( - port=getattr(state, "port", 8000), - host=getattr(state, "host", "127.0.0.1"), - lifecycle_token=LIFECYCLE_TOKEN, - ) - await asyncio.to_thread(start_awake_service) - cleaned_device_locks = DeviceExecutionLock.cleanup_stale_locks() - if cleaned_device_locks: - print(f"[ServerStartup] Removed {cleaned_device_locks} stale device lock(s).") - - # Warm the adb server and complete one enumeration before requests are - # served, so submissions in the first seconds never race an adb cold start - # and get a false "no devices attached" rejection. - try: - adb_warmed = await device_pool.warm_up_async() - except Exception as exc: - adb_warmed = False - print(f"[ServerStartup] ADB warm-up failed: {exc}") - if not adb_warmed: - print( - "[ServerStartup] ADB warm-up did not confirm a responding adb server; " - "device enumeration will retry on demand." - ) - asyncio.create_task(asyncio.to_thread(task_queue_service.archive_older_replays_on_launch)) - asyncio.create_task( - asyncio.to_thread(task_queue_service.verify_chunks_exist_on_launch, replay_manager) - ) - - cleaned = session_repo.cleanup_orphans_on_startup() - if cleaned > 0: - print(f"[ServerStartup] Marked {cleaned} orphaned running session(s) as failed.") - # Workers killed together with a previous daemon never remuxed their - # recordings; publish whatever raw files they left behind. - asyncio.create_task(asyncio.to_thread(task_queue_service.recover_orphaned_recordings_on_launch)) - - await ipc_service.start_server() - state.worker_task = asyncio.create_task(task_queue_service.queue_worker()) - - -async def on_shutdown(): - """Stop task and IPC children before the UI server exits.""" - state.is_shutting_down = True - state.shutdown_event.set() - task_queue_service._broadcast_event("server_shutdown", {"status": "stopping"}) - owned_session_ids = { - str(item["session_id"]) - for item in state.queue_items - if isinstance(item, dict) and item.get("status") == "running" and item.get("session_id") - } - - worker = state.worker_task - if worker is not None and not worker.done(): - worker.cancel() - try: - await asyncio.wait_for(worker, timeout=5.0) - except (asyncio.CancelledError, TimeoutError): - pass - state.worker_task = None - - # Cancel in-flight run coroutines and wait for their finalizers (DB status, - # session_ended broadcast, trace sync, recording recovery) to run. - run_tasks = [t for t in list(task_queue_service._run_tasks) if not t.done()] - for run_task in run_tasks: - run_task.cancel() - if run_tasks: - try: - await asyncio.wait_for(asyncio.gather(*run_tasks, return_exceptions=True), timeout=5.0) - except (TimeoutError, asyncio.CancelledError): - pass - for run in list(state.active_runs.values()): - run_proc = run.get("process") - if run_proc is not None and run_proc.returncode is None: - await task_queue_service._terminate_worker_process(run_proc) - state.active_runs.clear() - if state.current_process is not None and state.current_process.returncode is None: - await task_queue_service._terminate_worker_process(state.current_process) - for item in state.queue_items: - if isinstance(item, dict): - DeviceExecutionLock.cancel_reservation(item.get("queue_ticket")) - DeviceExecutionLock.cleanup_stale_locks() - state.current_process = None - state.queue_items.clear() - for session_id in owned_session_ids: - session_repo.update_session_status(session_id, "cancelled") - - await ipc_service.stop_server() - state.ipc_subscribers.clear() - await asyncio.to_thread(shutdown_awake_service) - clear_server_info( - port=getattr(state, "port", 8000), - lifecycle_token=LIFECYCLE_TOKEN, - ) - - -# Mount modular routers -app.include_router(stream.router) -app.include_router(media.router) -app.include_router(sessions.router) -app.include_router(steps.router) -app.include_router(tasks.router) -app.include_router(replay.router) -app.include_router(system.router) - -# Mount cloud gateway router for Frappe / Cloud integration if present -try: - from cloud_service.gateway import cloud_router - - app.include_router(cloud_router) -except ImportError: - try: - from apps.cloud_service.gateway import cloud_router - - app.include_router(cloud_router) - except ImportError: - pass - - -# ------------------------------------------------------------------------------ -# Showcase UI (Angular 19) Unified Single-Port SPA Static Hosting -# ------------------------------------------------------------------------------ -def _get_showcase_dist() -> Path: - """Return the source build, or the immutable build bundled in a wheel.""" - base_dist = _workspace_root / "apps" / "showcase_ui" / "dist" - candidates = [ - base_dist / "frontend" / "browser", - base_dist / "browser", - base_dist / "frontend", - base_dist, - ] - for candidate in candidates: - if candidate.is_dir() and (candidate / "index.html").exists(): - return candidate - bundled_dist = get_bundled_showcase_dist() - if bundled_dist is not None: - return bundled_dist - return base_dist / "frontend" / "browser" - - -def _resolve_static_file(root: Path, relative_path: str) -> Path | None: - """Return the requested file only when it truly lives under ``root``.""" - try: - root = root.resolve() - target = (root / relative_path).resolve() - except (OSError, ValueError): - return None - if target.is_file() and target.is_relative_to(root): - return target - return None - - -@app.get("/", include_in_schema=False) -async def serve_showcase_root(): - """Explicitly serve the Showcase UI at the root path by default.""" - return await serve_showcase_spa("") - - -@app.get("/{full_path:path}", include_in_schema=False) -async def serve_showcase_spa(full_path: str): - # Do not intercept API, media, or replay paths - if ( - full_path.startswith("api/") - or full_path.startswith("images/") - or full_path.startswith("videos/") - or full_path.startswith("local_file") - or full_path == "docs" - or full_path == "openapi.json" - or full_path.startswith("redoc") - ): - from fastapi import HTTPException - - raise HTTPException(status_code=404, detail="Endpoint not found") - - clean_path = full_path.strip("/") - - # Admin / Debug Console routes - if ( - clean_path in ("admin", "debug") - or clean_path.startswith("admin/") - or clean_path.startswith("debug/") - ): - admin_index = _admin_console_dir / "index.html" - if admin_index.exists(): - return HTMLResponse(admin_index.read_text(encoding="utf-8")) - - showcase_dist = _get_showcase_dist() - - # Exact static file match (js, css, images, fonts, favicon, logo). - # Resolve and re-anchor under the static root so encoded traversal - # sequences cannot address files outside the build directories. - if clean_path: - target_file = _resolve_static_file(showcase_dist, clean_path) - if target_file is not None: - return FileResponse(target_file) - - # Fallback for icons/logos if showcase UI hasn't been built yet - public_root = _workspace_root / "apps" / "showcase_ui" / "public" - public_file = _resolve_static_file(public_root, clean_path) - if public_file is not None: - return FileResponse(public_file) - - # Serve Showcase UI Angular SPA index.html - index_file = showcase_dist / "index.html" - if index_file.exists(): - return HTMLResponse(index_file.read_text(encoding="utf-8")) - - # Fallback message if Showcase UI is not built - fallback_html = """ - - - - - Artemis Showcase UI - Not Built - - - -
-

✨ Artemis Showcase UI

-

The Showcase UI (Angular frontend) has not been built yet.

-

To compile the Showcase UI, run:

-

cd apps/showcase_ui && npm install && npm run build

-

Or launch using ./start.sh or artemis ui to build automatically.

- Go to Admin Debug Console → -
- -""" - return HTMLResponse(fallback_html) - - -# ------------------------------------------------------------------------------ -# Backward compatibility exports -# ------------------------------------------------------------------------------ -get_active_model_info = model_service.get_active_model_info -start_ipc_server = ipc_service.start_server -queue_worker = task_queue_service.queue_worker -active_connections = state.active_connections -ipc_subscribers = state.ipc_subscribers -task_queue = state.task_queue -queue_goals = state.queue_goals - - -class ArtemisUvicornServer(uvicorn.Server): - """Notify application streams before Uvicorn waits for them to close.""" - - @staticmethod - def _task_is_active() -> bool: - """Check task state without doing process I/O from a signal handler.""" - proc = state.current_process - if proc is not None and proc.returncode is None: - return True - return any( - isinstance(item, dict) and item.get("status") == "running" for item in state.queue_items - ) - - @staticmethod - def _schedule_signal_report(message: str) -> None: - """Defer console output so a signal cannot re-enter Colorama writes.""" - try: - asyncio.get_running_loop().call_soon(print, message) - except RuntimeError: - try: - os.write(2, f"{message}\n".encode(errors="replace")) - except OSError: - pass - - def handle_exit(self, sig: int, frame: FrameType | None) -> None: - try: - signal_name = signal.Signals(sig).name - except ValueError: - signal_name = "UNKNOWN" - frame_location = "unknown" - if frame is not None: - frame_location = f"{frame.f_code.co_filename}:{frame.f_lineno}" - signal_report = ( - f"[ServerSignal] Received {signal_name} ({sig}); " - f"server PID={os.getpid()}, parent PID={os.getppid()}, frame={frame_location}" - ) - self._schedule_signal_report(signal_report) - - if sys.platform == "win32" and sig == signal.SIGINT and self._task_is_active(): - self._schedule_signal_report( - "[ServerSignal] Ignored Windows SIGINT while a mobile task is active. " - "Stop the task first, or press Ctrl+Break to force server shutdown." - ) - return - - state.is_shutting_down = True - state.shutdown_event.set() - super().handle_exit(sig, frame) - - -def run_ui_server(host: str, port: int, reload: bool = False) -> None: - """Run the UI server with bounded, signal-aware graceful shutdown.""" - state.host = host - state.port = port - write_server_info(port=port, host=host, lifecycle_token=LIFECYCLE_TOKEN) - - try: - if reload: - uvicorn.run( - "apps.admin_console.server:app", - host=host, - port=port, - reload=True, - timeout_graceful_shutdown=5, - ) - return - - config = uvicorn.Config( - app, - host=host, - port=port, - timeout_graceful_shutdown=5, - ) - server = ArtemisUvicornServer(config) - app.state.uvicorn_server = server - server.run() - finally: - app.state.uvicorn_server = None - clear_server_info(port=port, lifecycle_token=LIFECYCLE_TOKEN) - - -def main(argv: list[str] | None = None) -> None: - """Parse the daemon/standalone server bind address and run Uvicorn.""" - parser = argparse.ArgumentParser(description="Run the Artemis Admin Console server") - parser.add_argument( - "--host", - default=os.environ.get("ARTEMIS_SERVER_HOST", "127.0.0.1"), - help=( - "Address to bind the HTTP server to. Defaults to loopback; expose " - "remotely via a Tailscale/SSH tunnel rather than a wide bind." - ), - ) - parser.add_argument( - "--port", - type=int, - default=int(os.environ.get("ANTIGRAVITY_SIDECAR_WEB_PORT", "8000")), - help="TCP port for the HTTP server.", - ) - args = parser.parse_args(argv) - run_ui_server(host=args.host, port=args.port) - - -if __name__ == "__main__": - main() diff --git a/apps/admin_console/services/__init__.py b/apps/admin_console/services/__init__.py deleted file mode 100644 index 58d482ea3..000000000 --- a/apps/admin_console/services/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. diff --git a/apps/admin_console/services/device_stream_service.py b/apps/admin_console/services/device_stream_service.py deleted file mode 100644 index 6455a3e77..000000000 --- a/apps/admin_console/services/device_stream_service.py +++ /dev/null @@ -1,137 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Device Live Screen Streaming Service. - -Provides real-time, low-latency device screen frames over HTTP MJPEG and WebSocket. -Operates concurrently with ADB agent actions with zero interference. -""" - -import asyncio -import logging -import subprocess -import time -from collections.abc import AsyncGenerator - -from artemis.toolchain import find_adb - -logger = logging.getLogger("artemis.stream_service") - - -class DeviceStreamService: - """Manages real-time screen capture and distribution to web clients.""" - - def __init__(self): - self._active_listeners = 0 - self._lock = asyncio.Lock() - self._latest_frame: bytes | None = None - self._last_frame_time: float = 0.0 - self._is_capturing = False - self._capture_task: asyncio.Task | None = None - - async def get_device_serial(self) -> str | None: - """Find the currently connected active ADB device serial.""" - try: - adb_bin = find_adb() - proc = await asyncio.create_subprocess_exec( - adb_bin, - "devices", - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - stdout, _ = await proc.communicate() - lines = stdout.decode().strip().splitlines() - for line in lines[1:]: - parts = line.strip().split() - if len(parts) >= 2 and parts[1] == "device": - return parts[0] - except Exception as e: - logger.warning(f"Error checking adb devices: {e}") - return None - - async def _capture_loop(self): - """Background frame capture loop that runs while listeners > 0.""" - logger.info("[StreamService] Starting live screen capture loop...") - while self._active_listeners > 0: - try: - start_t = time.time() - serial = await self.get_device_serial() - adb_bin = find_adb() - cmd = ( - [adb_bin, "-s", serial, "exec-out", "screencap", "-p"] - if serial - else [adb_bin, "exec-out", "screencap", "-p"] - ) - proc = await asyncio.create_subprocess_exec( - *cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - stdout, _ = await proc.communicate() - if proc.returncode == 0 and len(stdout) > 1000: - self._latest_frame = stdout - self._last_frame_time = time.time() - - elapsed = time.time() - start_t - delay = max(0.03, 0.08 - elapsed) - await asyncio.sleep(delay) - except asyncio.CancelledError: - break - except Exception as e: - logger.error(f"[StreamService] Capture error: {e}") - await asyncio.sleep(0.5) - - logger.info("[StreamService] Stopping live screen capture loop (0 listeners).") - self._is_capturing = False - - async def start_capturing(self): - """Register a new listener and start background capture if needed.""" - async with self._lock: - self._active_listeners += 1 - if not self._is_capturing or self._capture_task is None or self._capture_task.done(): - self._is_capturing = True - self._capture_task = asyncio.create_task(self._capture_loop()) - - async def stop_capturing(self): - """Deregister a listener and stop capture loop when count reaches 0.""" - async with self._lock: - self._active_listeners = max(0, self._active_listeners - 1) - if self._active_listeners == 0 and self._capture_task and not self._capture_task.done(): - self._capture_task.cancel() - self._is_capturing = False - - async def mjpeg_frame_generator(self) -> AsyncGenerator[bytes, None]: - """Async generator streaming MJPEG multipart bytes to HTTP response.""" - await self.start_capturing() - try: - last_sent_time = 0.0 - while True: - if self._latest_frame and self._last_frame_time > last_sent_time: - last_sent_time = self._last_frame_time - frame_bytes = self._latest_frame - yield ( - b"--frame\r\n" - b"Content-Type: image/png\r\n" - b"Content-Length: " - + str(len(frame_bytes)).encode() - + b"\r\n\r\n" - + frame_bytes - + b"\r\n" - ) - await asyncio.sleep(0.04) - finally: - await self.stop_capturing() - - -device_stream_service = DeviceStreamService() diff --git a/apps/admin_console/services/ipc_service.py b/apps/admin_console/services/ipc_service.py deleted file mode 100644 index 712b1d805..000000000 --- a/apps/admin_console/services/ipc_service.py +++ /dev/null @@ -1,262 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -from contextlib import suppress -import json -import logging -from typing import Any - -logger = logging.getLogger(__name__) - -try: - from admin_console.core.state import state -except ImportError: - from apps.admin_console.core.state import state - - -class IPCService: - """Service managing internal IPC socket server and event distribution.""" - - @staticmethod - def _clean_value(val: Any) -> Any: - """Recursively cleans values to remove non-serializable and internal - Python runtime representations. - """ - if val is None: - return None - if isinstance(val, (bool, int, float)): - return val - if isinstance(val, str): - if ( - "object at 0x" in val - or val.startswith(" Any: - """Sanitizes stream event payloads for all clients, stripping bulky - raw bytes and internal memory references. - """ - if not isinstance(data, dict): - return data - - if event_type == "trace_recorded": - tr_type = data.get("type") - payload = data.get("payload") - if isinstance(payload, str): - try: - payload = json.loads(payload) - except ValueError: - # Non-JSON payload string: cleaned as-is below. - pass - - if tr_type in ("tool", "action") and isinstance(payload, dict): - args = payload.get("args", payload) - data["payload"] = {"args": cls._clean_value(args)} - elif tr_type == "llm_call" and isinstance(payload, dict): - data["payload"] = {"error": cls._clean_value(payload.get("error"))} - elif payload is not None: - data["payload"] = cls._clean_value(payload) - - elif event_type in ("step_recorded", "step_updated"): - # Strip heavy raw image bytes from SSE broadcast (clients fetch via image URLs) - data["pre_screenshot_bytes"] = None - data["post_screenshot_bytes"] = None - - if "generic_tools" in data and isinstance(data["generic_tools"], list): - sanitized_tools = [] - for tr in data["generic_tools"]: - if not isinstance(tr, dict): - continue - tr_dict = dict(tr) - pl = tr_dict.get("payload") - if isinstance(pl, str): - try: - pl = json.loads(pl) - except ValueError: - # Non-JSON payload string: cleaned as-is below. - pass - - if tr_dict.get("type") == "llm_call": - tr_dict["payload"] = ( - {"error": cls._clean_value(pl.get("error"))} - if isinstance(pl, dict) - else None - ) - elif isinstance(pl, dict): - args = pl.get("args", pl) - tr_dict["payload"] = {"args": cls._clean_value(args)} - else: - tr_dict["payload"] = cls._clean_value(pl) - sanitized_tools.append(tr_dict) - data["generic_tools"] = sanitized_tools - - try: - from artemis.utils.coordinates import normalize_step_actions - - data = normalize_step_actions(data) - except ImportError: - pass - - return data - - # Backward compatibility alias - filter_event_for_angular = sanitize_event_data - - @classmethod - async def start_server(cls): - async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter): - current_session_id = None - try: - while not state.is_shutting_down: - line = await reader.readline() - if not line: - break - try: - payload = json.loads(line.decode("utf-8")) - event_type = payload.get("event_type") - data = payload.get("data") - - if event_type == "session_started" and data: - incoming_sid = data.get("session_id") - if incoming_sid and str(incoming_sid) in getattr( - state, "cancelled_session_ids", set() - ): - print( - f"[IPC] Ignoring session_started for cancelled session: {incoming_sid}" - ) - continue - - state.active_session_id = incoming_sid - current_session_id = incoming_sid - pid = data.get("pid") - goal = data.get("initial_goal") - device_info = data.get("device_info") - if isinstance(device_info, dict) and device_info.get("profile"): - state.current_profile = device_info.get("profile") - if goal: - state.current_goal = goal - print( - f"[IPC] Session started: {state.active_session_id} (profile: {state.current_profile})" - ) - if current_session_id: - state.active_connections[str(current_session_id)] = { - "writer": writer, - "pid": pid, - "goal": goal, - "profile": state.current_profile, - } - elif event_type == "session_ended" and data: - ended_sid = data.get("session_id") - if state.active_session_id and str(state.active_session_id) == str( - ended_sid - ): - state.active_session_id = None - state.current_goal = None - state.current_profile = None - - if event_type == "startup_progress" and isinstance(data, dict): - state.record_startup_progress(data) - - for cb in list(state.ipc_subscribers): - try: - cb(event_type, data) - except Exception: - # One broken subscriber must not block the - # others, but a silent drop hides it entirely. - logger.warning( - "IPC subscriber %r failed for event %s", - cb, - event_type, - exc_info=True, - ) - except (ValueError, UnicodeDecodeError, TypeError) as e: - print(f"IPC parse error: {e}") - except asyncio.CancelledError: - raise - except (ConnectionResetError, BrokenPipeError, OSError): - # Normal when a task or the UI server is shutting down on Windows. - pass - finally: - if current_session_id and str(current_session_id) in state.active_connections: - state.active_connections.pop(str(current_session_id), None) - writer.close() - with suppress(ConnectionResetError, BrokenPipeError, OSError): - await writer.wait_closed() - - server = await asyncio.start_server(handle_client, "127.0.0.1", 0, limit=1024 * 1024 * 100) - state.ipc_port = server.sockets[0].getsockname()[1] - state.ipc_server = server - state.ipc_serve_task = asyncio.create_task(server.serve_forever()) - print(f"Internal IPC server started on port {state.ipc_port}") - try: - from artemis.config import write_ipc_port - - write_ipc_port(state.ipc_port) - except Exception as e: - print(f"Failed to write IPC port file: {e}") - - @classmethod - async def stop_server(cls): - """Close IPC listeners and task connections during application shutdown.""" - for connection in list(state.active_connections.values()): - writer = connection.get("writer") - if writer is not None: - writer.close() - with suppress(ConnectionResetError, BrokenPipeError, OSError): - await writer.wait_closed() - state.active_connections.clear() - - if state.ipc_server is not None: - state.ipc_server.close() - await state.ipc_server.wait_closed() - state.ipc_server = None - - if state.ipc_serve_task is not None: - state.ipc_serve_task.cancel() - with suppress(asyncio.CancelledError): - await state.ipc_serve_task - state.ipc_serve_task = None - state.ipc_port = None - try: - from artemis.config import clear_ipc_port - - clear_ipc_port() - except Exception as e: - print(f"Failed to clear IPC port state: {e}") - - -ipc_service = IPCService() diff --git a/apps/admin_console/services/media_service.py b/apps/admin_console/services/media_service.py deleted file mode 100644 index e114d043e..000000000 --- a/apps/admin_console/services/media_service.py +++ /dev/null @@ -1,623 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import base64 -from datetime import datetime -import hashlib -import json -import logging -import os -from pathlib import Path -import subprocess -import threading -import time -from typing import Any -import urllib.parse -from fastapi import HTTPException - -from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT - -logger = logging.getLogger(__name__) - - -class MediaService: - """Service handling media indexing, local file security, payload unwrapping and notes/plans.""" - - # Conversion results keyed by source path -> (mtime, size, resolved path). - # Failed conversions are cached too (resolved path == source path) so a broken - # file doesn't re-run ffmpeg (up to 45s) on every /api/sessions poll. - _playable_cache: dict[str, tuple[float, int, str]] = {} - _playable_cache_lock = threading.Lock() - - @classmethod - def ensure_browser_playable_video(cls, p: Path) -> Path: - """Ensure the video file is in a browser-supported container (MP4). - - If given an MKV, check if a converted MP4 already exists or convert it. - """ - try: - if not p.exists(): - return p - suffix = p.suffix.lower() - if suffix == ".mp4": - return p - if suffix in (".mkv", ".webm"): - if cls.is_live_recording(p): - # scrcpy is still appending to this file: converting now - # would freeze a few-seconds partial as recording.mp4. - return p - src_stat = p.stat() - cache_key = str(p) - cached = cls._playable_cache.get(cache_key) - if cached and cached[0] == src_stat.st_mtime and cached[1] == src_stat.st_size: - return Path(cached[2]) - mp4_cand = p.with_suffix(".mp4") - if ( - mp4_cand.exists() - and mp4_cand.stat().st_size > 0 - and mp4_cand.stat().st_mtime >= src_stat.st_mtime - ): - # An MP4 older than its source is a stale partial left by a - # mid-run scan; fall through and convert again. - cls._playable_cache[cache_key] = ( - src_stat.st_mtime, - src_stat.st_size, - str(mp4_cand), - ) - return mp4_cand - with cls._playable_cache_lock: - # Another thread may have converted this file while we waited. - cached = cls._playable_cache.get(cache_key) - if cached and cached[0] == src_stat.st_mtime and cached[1] == src_stat.st_size: - return Path(cached[2]) - try: - result = cls._convert_to_mp4(p, mp4_cand) - except Exception: - result = p - cls._playable_cache[cache_key] = ( - src_stat.st_mtime, - src_stat.st_size, - str(result), - ) - return result - except OSError: - # Filesystem probe failed (missing/locked file): serve the - # original path and let the media endpoint report the error. - pass - return p - - @staticmethod - def _convert_to_mp4(p: Path, mp4_cand: Path) -> Path: - from artemis.utils.video import get_ffmpeg_path - - ffmpeg = get_ffmpeg_path() - # 1. Attempt fast copy remux - subprocess.run( - [ - ffmpeg, - "-y", - "-fflags", - "+genpts", - "-i", - str(p), - "-c", - "copy", - "-movflags", - "+faststart", - str(mp4_cand), - ], - capture_output=True, - timeout=15, - ) - if mp4_cand.exists() and mp4_cand.stat().st_size > 0: - return mp4_cand - # 2. If copy remux failed, fallback to ultrafast re-encode - subprocess.run( - [ - ffmpeg, - "-y", - "-fflags", - "+genpts+discardcorrupt", - "-i", - str(p), - "-c:v", - "libx264", - "-preset", - "ultrafast", - "-pix_fmt", - "yuv420p", - "-movflags", - "+faststart", - str(mp4_cand), - ], - capture_output=True, - timeout=30, - ) - if mp4_cand.exists() and mp4_cand.stat().st_size > 0: - return mp4_cand - return p - - LIVE_RECORDING_GRACE_SECONDS = 10.0 - TRACE_STATUS_SUFFIXES = ("_PASS_", "_FAIL_", "_TESTFAIL_") - - @classmethod - def is_live_recording(cls, p: Path) -> bool: - """Whether ``p`` is a raw .mkv that scrcpy is still appending to. - - scrcpy touches the file continuously while recording; a raw file that - has not changed for a few seconds belongs to a finished (or killed) - recording and is safe to convert. - """ - if p.suffix.lower() != ".mkv": - return False - try: - return (time.time() - p.stat().st_mtime) < cls.LIVE_RECORDING_GRACE_SECONDS - except OSError: - return False - - @staticmethod - def is_browser_playable(p: Path) -> bool: - return p.suffix.lower() in (".mp4", ".webm") - - @classmethod - def strip_trace_status_suffix(cls, folder_name: str) -> str: - for marker in cls.TRACE_STATUS_SUFFIXES: - folder_name = folder_name.split(marker)[0] - return folder_name - - @classmethod - def _finalize_trace_folder_name(cls, folder: Path, session_start_time: float | None) -> Path: - """Give an unfinished temp trace folder the terminal name the worker uses.""" - try: - if folder.resolve().parent != Path(TRACES_PATH).resolve(): - return folder - except OSError: - return folder - if any(marker in folder.name for marker in cls.TRACE_STATUS_SUFFIXES): - return folder - try: - stamp = datetime.fromtimestamp(float(session_start_time or time.time())).strftime( - "%Y-%m-%dT%H-%M-%S" - ) - except (TypeError, ValueError, OSError, OverflowError): - stamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S") - target = folder.with_name(f"{folder.name}_FAIL_{stamp}") - if target.exists(): - return folder - try: - folder.rename(target) - except OSError as exc: - logger.warning("Could not rename orphaned trace folder %s: %s", folder, exc) - return folder - return target - - @classmethod - def recover_orphaned_recording( - cls, - local_video_path: str | Path | None, - session_start_time: float | None = None, - ) -> Path | None: - """Finalize the recording of a worker that died before its own stop path ran. - - A hard-killed worker leaves the raw scrcpy ``recording.mkv`` behind - (playable, but never remuxed) plus, possibly, a few-seconds partial - ``recording.mp4`` that a session-list scan converted mid-run. The raw - file is remuxed over that partial and removed on success, and the temp - trace folder is renamed the way the worker would have done it. - - Returns the final browser-playable path, or None when nothing usable - exists (the caller then records a terminal recording failure). - """ - if not local_video_path: - return None - source = Path(str(local_video_path)) - folder = source.parent - if not folder.is_dir(): - return None - raw = source if source.suffix.lower() == ".mkv" else folder / "recording.mkv" - mp4 = folder / "recording.mp4" - - def _non_empty(path: Path) -> bool: - try: - return path.is_file() and path.stat().st_size > 0 - except OSError: - return False - - if _non_empty(raw): - if cls.is_live_recording(raw): - return None - with cls._playable_cache_lock: - converted = cls._convert_to_mp4(raw, mp4) - cls._playable_cache.pop(str(raw), None) - if converted != mp4 or not _non_empty(mp4): - return None - try: - raw.unlink() - except OSError: - pass - final = mp4 - elif _non_empty(mp4): - final = mp4 - elif _non_empty(source) and cls.is_browser_playable(source): - final = source - else: - return None - - folder = cls._finalize_trace_folder_name(folder, session_start_time) - return folder / final.name - - @staticmethod - def path_to_video_url(p: Path) -> str: - resolved = p.resolve() - try: - rel = resolved.relative_to(WORKSPACE_ROOT) - return f"/videos/{urllib.parse.quote(rel.as_posix(), safe='/')}" - except ValueError: - return f"/videos/{urllib.parse.quote(resolved.as_posix().lstrip('/'), safe='/:')}" - - @classmethod - def build_video_index(cls) -> dict[str, str]: - idx = {} - dirs = [ - TRACES_PATH, - WORKSPACE_ROOT / "artemis-traces", - WORKSPACE_ROOT / ".benchmarks" / "diagnoser" / "outputs" / "artifacts", - ] - for d in dirs: - if not d.exists(): - continue - try: - entries = list(d.iterdir()) - except OSError as exc: - logger.warning("Video index scan failed under %s: %s", d, exc) - continue - for item in entries: - # One unreadable entry must not abort the rest of the directory. - try: - if item.is_dir(): - for ext in [".mp4", ".mkv", ".webm"]: - vfile = item / f"recording{ext}" - if vfile.exists(): - vfile = cls.ensure_browser_playable_video(vfile) - if not cls.is_browser_playable(vfile): - # Live (still recording) or unconvertible raw file. - break - url = cls.path_to_video_url(vfile) - idx[item.name] = url - prefix = cls.strip_trace_status_suffix(item.name) - idx[prefix] = url - break - elif item.suffix in [".mp4", ".mkv", ".webm"]: - item = cls.ensure_browser_playable_video(item) - if not cls.is_browser_playable(item): - continue - url = cls.path_to_video_url(item) - idx[item.stem] = url - idx[item.name] = url - except OSError as exc: - logger.warning("Skipping unindexable video entry %s: %s", item, exc) - return idx - - @classmethod - def resolve_video_url( - cls, - row_dict: dict[str, Any], - video_rec_map: dict[str, str], - video_idx: dict[str, str], - ) -> str | None: - raw_sid = row_dict.get("session_id") - s_id = ( - str(raw_sid).strip() - if raw_sid is not None and str(raw_sid).strip().lower() != "none" - else "" - ) - v_url = None - v_fp = row_dict.get("video_filepath") - if v_fp and os.path.exists(v_fp): - p = cls.ensure_browser_playable_video(Path(v_fp)) - if cls.is_browser_playable(p): - v_url = cls.path_to_video_url(p) - - orig_rec = video_rec_map.get(s_id) - if not v_url and orig_rec: - p = Path(orig_rec) - if p.exists(): - p = cls.ensure_browser_playable_video(p) - if cls.is_browser_playable(p): - v_url = cls.path_to_video_url(p) - if not v_url: - folder_name = p.parent.name - v_url = video_idx.get(folder_name) or video_idx.get(folder_name.split("_")[0]) - - if not v_url: - v_url = video_idx.get(s_id) - - if not v_url and s_id: - sess_dir = TRACES_PATH / s_id - if sess_dir.is_dir(): - for ext in [".mp4", ".webm", ".mkv"]: - vfile = sess_dir / f"recording{ext}" - if vfile.exists(): - vfile = cls.ensure_browser_playable_video(vfile) - if cls.is_browser_playable(vfile): - v_url = cls.path_to_video_url(vfile) - break - if not v_url: - try: - for item in sess_dir.iterdir(): - if item.is_file() and item.suffix.lower() in [".mp4", ".webm", ".mkv"]: - item = cls.ensure_browser_playable_video(item) - if not cls.is_browser_playable(item): - continue - v_url = cls.path_to_video_url(item) - break - except OSError: - # Unreadable session dir: no recording to resolve. - pass - - if not v_url and row_dict.get("initial_goal"): - goal_str = row_dict["initial_goal"] - task_hint = ( - goal_str.split("Task: ")[1].split()[0].strip() - if "Task: " in goal_str - else goal_str.split("\n")[0].strip() - ) - v_url = video_idx.get(task_hint) - - return v_url - - @classmethod - def resolve_video_segments(cls, video_url: str | None) -> list[dict[str, Any]]: - """Resolve a recording sidecar manifest into browser-safe segment URLs.""" - if not video_url or not video_url.startswith("/videos/"): - return [] - relative = urllib.parse.unquote(video_url.removeprefix("/videos/")) - video_path = (WORKSPACE_ROOT / relative).resolve() - manifest_path = video_path.parent / "recording.json" - if not manifest_path.is_file(): - return [] - try: - payload = json.loads(manifest_path.read_text(encoding="utf-8")) - segments = [] - for item in payload.get("segments", []): - segment_path = (manifest_path.parent / str(item["file"])).resolve() - if ( - segment_path.parent != manifest_path.parent.resolve() - or not segment_path.is_file() - ): - continue - segment = { - "url": cls.path_to_video_url(segment_path), - "start": float(item.get("start", 0)), - "duration": float(item.get("duration", 0)), - "width": int(item.get("width", 0)), - "height": int(item.get("height", 0)), - } - # Manifest v2 carries the real session-relative timeline so the - # UI can seek by step time across scrcpy restart gaps. - if item.get("offset_ms") is not None and item.get("duration_ms") is not None: - segment["offset_ms"] = int(item["offset_ms"]) - segment["duration_ms"] = int(item["duration_ms"]) - segments.append(segment) - return segments - except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError): - return [] - - @staticmethod - def unwrap_payload(obj: Any) -> Any: - """Unwraps trace payloads, storing base64 images as disk files and - truncating large dumps. - """ - if isinstance(obj, str): - try: - if (obj.startswith("{") and obj.endswith("}")) or ( - obj.startswith("[") and obj.endswith("]") - ): - return MediaService.unwrap_payload(json.loads(obj)) - except ValueError: - # Looks like JSON but is not: treat it as a plain string. - pass - - # Check if the string is a Base64 image - is_base64_img = False - base64_data = None - if obj.startswith("data:image/") and ";base64," in obj: - try: - _, base64_data = obj.split(";base64,", 1) - is_base64_img = True - except ValueError: - # Malformed data URI: fall through to plain-string handling. - pass - elif obj.startswith("iVBORw0KGgo") or obj.startswith("/9j/"): - is_base64_img = True - base64_data = obj - - if is_base64_img and base64_data: - try: - image_bytes = base64.b64decode(base64_data) - image_hash = hashlib.sha256(image_bytes).hexdigest() - - IMAGES_DIR.mkdir(parents=True, exist_ok=True) - image_path = IMAGES_DIR / f"{image_hash}.jpg" - if not image_path.exists(): - image_path.write_bytes(image_bytes) - - return f"image://{image_hash}" - except (ValueError, OSError) as exc: - # Invalid base64 or image cache write failure: fall back - # to the truncated raw string below. - logger.debug("Could not persist inline image: %s", exc) - - if len(obj) > 2000: - preview_start = obj[:100].replace("\n", " ") - preview_end = obj[-100:].replace("\n", " ") - return ( - f"" - ) - return obj - elif isinstance(obj, dict): - return {k: MediaService.unwrap_payload(v) for k, v in obj.items()} - elif isinstance(obj, list): - if len(obj) > 20 and any( - isinstance(x, dict) and ("bounds" in x or "resource-id" in x) for x in obj - ): - return [f""] - return [MediaService.unwrap_payload(x) for x in obj] - return obj - - _LOCAL_FILE_MEDIA_TYPES = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".webp": "image/webp", - ".mp4": "video/mp4", - ".webm": "video/webm", - ".mkv": "video/x-matroska", - } - - @classmethod - def get_safe_local_file(cls, path_str: str) -> tuple[Path, str]: - """Serve trace media only: resolved path must sit inside the workspace - or traces tree AND carry a media extension, so this endpoint can never - hand out .env, databases, or source files.""" - decoded_path = urllib.parse.unquote(path_str) - if decoded_path.startswith("file://"): - decoded_path = decoded_path[len("file://") :] - try: - p = Path(decoded_path).resolve(strict=True) - except (OSError, ValueError): - raise HTTPException(status_code=404, detail="File not found") - - if not p.is_file(): - raise HTTPException(status_code=404, detail="File not found") - - allowed_roots = [] - for root in (WORKSPACE_ROOT, TRACES_PATH): - try: - allowed_roots.append(Path(root).resolve()) - except OSError: - continue - if not any(p.is_relative_to(root) for root in allowed_roots): - raise HTTPException( - status_code=403, - detail="Access denied. Path is outside workspace root.", - ) - - media_type = cls._LOCAL_FILE_MEDIA_TYPES.get(p.suffix.lower()) - if media_type is None: - raise HTTPException( - status_code=403, - detail="Access denied. Only media files are served.", - ) - - return p, media_type - - @staticmethod - def get_task_plan_content(session_id: str) -> str: - plan_path = TRACES_PATH / session_id / "notes" / "task_plan.md" - if not plan_path.exists(): - plan_path = TRACES_PATH / "notes" / "task_plan.md" - - if plan_path.exists(): - try: - return plan_path.read_text(encoding="utf-8") - except Exception as e: - return f"Error reading task plan: {e}" - return "No task plan created yet." - - @staticmethod - def _read_jsonl_records(path: Path, label: str, session_id: str) -> list[dict[str, Any]]: - records: list[dict[str, Any]] = [] - if not path.is_file(): - return records - try: - for line in path.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if not line: - continue - try: - rec = json.loads(line) - except ValueError: - continue - if isinstance(rec, dict): - records.append(rec) - except (OSError, ValueError) as e: - logger.warning(f"Failed to read {label} for {session_id}: {e}") - return records - - @staticmethod - def get_session_checks(session_id: str) -> dict[str, Any]: - """Checker material of one session for UI backfill: the append-only - verdict ledger (``check_ledger.jsonl``), the per-attempt transcripts of - the Checker's streamed reasoning (``check_streams.jsonl``: one record - per attempt with timestamped ``segments``) and the machine-readable run - outcome (``run_outcome.json``), all written by the Checker / - ``artemis.graph.checkpoints``. Missing files mean "no checks ran" - (sessions recorded before transcripts existed simply have no streams). - """ - session_dir = TRACES_PATH / session_id - records = MediaService._read_jsonl_records( - session_dir / "check_ledger.jsonl", "check ledger", session_id - ) - streams = MediaService._read_jsonl_records( - session_dir / "check_streams.jsonl", "check streams", session_id - ) - - run_outcome: dict[str, Any] | None = None - outcome_path = session_dir / "run_outcome.json" - if outcome_path.is_file(): - try: - loaded = json.loads(outcome_path.read_text(encoding="utf-8")) - if isinstance(loaded, dict): - run_outcome = loaded - except (OSError, ValueError) as e: - logger.warning(f"Failed to read run outcome for {session_id}: {e}") - - return {"records": records, "streams": streams, "run_outcome": run_outcome} - - @staticmethod - def get_session_notes_content(session_id: str) -> dict[str, str]: - notes_dir = TRACES_PATH / session_id / "notes" - notes_content = {} - - if notes_dir.exists() and notes_dir.is_dir(): - for file in notes_dir.iterdir(): - if file.is_file() and file.suffix in [".md", ".txt", ".json", ".yaml"]: - try: - notes_content[file.name] = file.read_text(encoding="utf-8") - except Exception as e: - notes_content[file.name] = f"Error reading file: {e}" - - if not notes_content: - global_notes_dir = TRACES_PATH / "notes" - if global_notes_dir.exists() and global_notes_dir.is_dir(): - for file in global_notes_dir.iterdir(): - if file.is_file() and file.suffix in [ - ".md", - ".txt", - ".json", - ".yaml", - ]: - try: - notes_content[file.name] = file.read_text(encoding="utf-8") - except Exception as e: - notes_content[file.name] = f"Error reading file: {e}" - - return notes_content - - -media_service = MediaService() diff --git a/apps/admin_console/services/model_service.py b/apps/admin_console/services/model_service.py deleted file mode 100644 index 0ccc0bc04..000000000 --- a/apps/admin_console/services/model_service.py +++ /dev/null @@ -1,148 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json -import logging -import time -from typing import Any - -logger = logging.getLogger(__name__) - - -class ModelService: - """Service handling agent architecture profile detection and metadata formatting.""" - - # parse_llm_config re-reads and re-validates the config file on every call; - # /api/sessions calls this once per session row, so cache (provider, model_id) - # for a short TTL. Config edits take effect within _LLM_INFO_TTL seconds. - _LLM_INFO_TTL = 10.0 - _llm_info_cache: tuple[float, str, str] | None = None - - @classmethod - def _get_llm_provider_and_model(cls) -> tuple[str, str]: - cached = cls._llm_info_cache - now = time.monotonic() - if cached and now - cached[0] < cls._LLM_INFO_TTL: - return cached[1], cached[2] - provider = "google" - model_id = "gemini-3.7-flash" - try: - from artemis.config import parse_llm_config - - llm_cfg = parse_llm_config() - if llm_cfg and llm_cfg.operator: - provider = str(llm_cfg.operator.provider) - model_id = str(llm_cfg.operator.model) - elif llm_cfg and llm_cfg.default: - provider = str(llm_cfg.default.provider) - model_id = str(llm_cfg.default.model) - except Exception as exc: - # Unreadable/invalid LLM config: fall back to display defaults - # (cached for the TTL, so this logs at most once per window). - logger.warning("Could not resolve LLM config for display: %s", exc) - cls._llm_info_cache = (now, provider, model_id) - return provider, model_id - - @classmethod - def get_active_model_info(cls, profile: str | None = None) -> dict[str, str]: - """Return active architecture and underlying LLM model configuration.""" - # 1. Determine underlying LLM model and provider from config (cached) - provider, model_id = cls._get_llm_provider_and_model() - - # 2. Determine agent architecture name (Flash vs Pro) - arch_name = "Flash" - if profile: - p_lower = str(profile).lower() - if "pro" in p_lower: - arch_name = "Pro" - elif "flash" in p_lower: - arch_name = "Flash" - else: - arch_name = str(profile).capitalize() - - return { - "name": arch_name, - "id": model_id, - "provider": provider, - "architecture": f"ARTEMIS {arch_name}", - } - - @staticmethod - def resolve_session_profile( - row_dict: dict[str, Any], - llm_trace_payloads: list[str] | None = None, - running_profile: str | None = None, - agent_names: list[str] | None = None, - ) -> str | None: - """Resolve the Artemis agent architecture profile ('flash' or 'pro') for a session.""" - sess_profile = None - d_info_raw = row_dict.get("device_info") - if d_info_raw: - try: - d_info = json.loads(d_info_raw) if isinstance(d_info_raw, str) else d_info_raw - if isinstance(d_info, dict): - p = d_info.get("profile") - if p: - p_str = str(p).lower() - if "pro" in p_str: - return "pro" - elif "flash" in p_str: - return "flash" - except (ValueError, TypeError): - # Malformed device_info JSON: try the other profile sources. - pass - - if (row_dict.get("status") == "running") and running_profile: - p_str = str(running_profile).lower() - if "pro" in p_str: - return "pro" - elif "flash" in p_str: - return "flash" - - # Check Agent/Trace names - the most definitive indicator of architecture - if agent_names: - agent_names_lower = [str(a).lower() for a in agent_names] - if any( - name in ("planner", "validator", "summarizer", "operator", "checker", "diagnoser") - for name in agent_names_lower - ): - return "pro" - if any("flashrunner" in name for name in agent_names_lower): - return "flash" - - # Check LLM traces for agent name - if llm_trace_payloads: - for tr_payload in llm_trace_payloads: - if tr_payload: - try: - p_obj = ( - json.loads(tr_payload) if isinstance(tr_payload, str) else tr_payload - ) - if isinstance(p_obj, dict): - agent_name = str(p_obj.get("agent") or p_obj.get("name") or "").lower() - if "flashrunner" in agent_name: - return "flash" - if any( - x in agent_name - for x in ("planner", "validator", "operator", "checker") - ): - return "pro" - except (ValueError, TypeError): - # Malformed trace payload JSON: skip this trace. - pass - - return None - - -model_service = ModelService() diff --git a/apps/admin_console/services/task_preset_catalog.py b/apps/admin_console/services/task_preset_catalog.py deleted file mode 100644 index b87836056..000000000 --- a/apps/admin_console/services/task_preset_catalog.py +++ /dev/null @@ -1,514 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Preset Task Catalog & Dynamic Recommendation Engine for Artemis.""" - -from typing import Any, Literal -from pydantic import BaseModel, Field - - -class AppInfo(BaseModel): - """Application metadata.""" - - name: str - icon: str - pkg: str - category: str = "general" - - -class TaskPreset(BaseModel): - """Smart suggestion task definition.""" - - id: str - title: str - description: str - goal: str - profile: Literal["flash", "pro"] - category: Literal["flash", "pro", "cross_app", "monitor"] - tag: str - apps: list[AppInfo] - required_packages: list[str] = Field(default_factory=list) - match_mode: Literal["any", "all"] = "any" - priority: int = 50 - - -# ============================================================================ -# APP PACKAGE REGISTRY -# ============================================================================ - -APP_REGISTRY: dict[str, dict[str, str]] = { - # Google Suite & System - "com.google.android.apps.maps": {"name": "Maps", "icon": "explore", "category": "navigation"}, - "com.google.android.gm": {"name": "Gmail", "icon": "mail", "category": "productivity"}, - "com.android.chrome": {"name": "Chrome", "icon": "public", "category": "browser"}, - "com.google.android.youtube": { - "name": "YouTube", - "icon": "smart_display", - "category": "entertainment", - }, - "com.android.settings": {"name": "Settings", "icon": "settings", "category": "system"}, - "com.google.android.deskclock": {"name": "Clock", "icon": "timer", "category": "utility"}, - "com.android.deskclock": {"name": "Clock", "icon": "timer", "category": "utility"}, - "com.google.android.calculator": { - "name": "Calculator", - "icon": "calculate", - "category": "utility", - }, - "com.android.calculator2": {"name": "Calculator", "icon": "calculate", "category": "utility"}, - "com.google.android.apps.photos": { - "name": "Photos", - "icon": "photo_library", - "category": "media", - }, - "com.google.android.calendar": { - "name": "Calendar", - "icon": "calendar_month", - "category": "productivity", - }, - "com.google.android.keep": { - "name": "Keep Notes", - "icon": "note_alt", - "category": "productivity", - }, - "com.android.vending": {"name": "Play Store", "icon": "storefront", "category": "tools"}, - "com.google.android.apps.messaging": { - "name": "Messages", - "icon": "chat", - "category": "communication", - }, - # Popular Ecosystem Apps - "com.tencent.mm": {"name": "WeChat", "icon": "forum", "category": "social"}, - "com.xingin.xhs": {"name": "Xiaohongshu", "icon": "auto_stories", "category": "social"}, - "com.sankuai.meituan": {"name": "Meituan", "icon": "restaurant", "category": "lifestyle"}, - "com.dianping.v1": {"name": "Dianping", "icon": "star", "category": "lifestyle"}, - "tv.danmaku.bili": {"name": "Bilibili", "icon": "video_library", "category": "entertainment"}, - "com.eg.android.AlipayGphone": { - "name": "Alipay", - "icon": "account_balance_wallet", - "category": "finance", - }, - "com.netease.cloudmusic": { - "name": "NetEase Music", - "icon": "headphones", - "category": "entertainment", - }, - "com.spotify.music": {"name": "Spotify", "icon": "music_note", "category": "entertainment"}, -} - - -# ============================================================================ -# CURATED TASK PRESET LIBRARY (1-2 curated tasks per common app) -# ============================================================================ - -PRESET_TASK_CATALOG: list[TaskPreset] = [ - # 1. Google Maps - TaskPreset( - id="maps_coffee", - title="Find Specialty Coffee", - description="Search nearby top-rated cafes in Google Maps", - goal="Open Google Maps, search for top-rated specialty coffee shops nearby, and view the top result details.", - profile="flash", - category="flash", - tag="Maps", - apps=[ - AppInfo( - name="Maps", - icon="explore", - pkg="com.google.android.apps.maps", - category="navigation", - ) - ], - required_packages=["com.google.android.apps.maps"], - priority=95, - ), - TaskPreset( - id="pro_commute_share", - title="Commute ETA & Message Draft", - description="Check transit time on Maps and draft arrival ETA in Messages", - goal="Open Google Maps to check commute time to the International Airport, calculate arrival time, then open Messages and draft an ETA text message.", - profile="pro", - category="cross_app", - tag="Maps + Messages", - apps=[ - AppInfo( - name="Maps", - icon="explore", - pkg="com.google.android.apps.maps", - category="navigation", - ), - AppInfo( - name="Messages", - icon="chat", - pkg="com.google.android.apps.messaging", - category="communication", - ), - ], - required_packages=["com.google.android.apps.maps", "com.google.android.apps.messaging"], - match_mode="all", - priority=92, - ), - # 2. Gmail - TaskPreset( - id="gmail_receipts", - title="Search Order Receipts", - description="Find recent flight or delivery confirmation emails in Gmail", - goal="Open Gmail and search for recent flight or package delivery confirmation emails.", - profile="flash", - category="flash", - tag="Gmail", - apps=[ - AppInfo(name="Gmail", icon="mail", pkg="com.google.android.gm", category="productivity") - ], - required_packages=["com.google.android.gm"], - priority=90, - ), - TaskPreset( - id="pro_email_to_calendar", - title="Email Itinerary to Calendar", - description="Extract flight or event dates from Gmail and schedule in Calendar", - goal="Open Gmail to find the latest event invitation or itinerary, extract dates and location, then open Google Calendar and create a corresponding calendar event.", - profile="pro", - category="cross_app", - tag="Gmail + Calendar", - apps=[ - AppInfo( - name="Gmail", icon="mail", pkg="com.google.android.gm", category="productivity" - ), - AppInfo( - name="Calendar", - icon="calendar_month", - pkg="com.google.android.calendar", - category="productivity", - ), - ], - required_packages=["com.google.android.gm"], - priority=94, - ), - # 3. Chrome - TaskPreset( - id="chrome_research", - title="Search AI News Breakthroughs", - description="Search latest multimodal AI developments in Chrome browser", - goal="Open Chrome browser and search for latest breakthroughs in multimodal mobile AI agents.", - profile="flash", - category="flash", - tag="Chrome", - apps=[AppInfo(name="Chrome", icon="public", pkg="com.android.chrome", category="browser")], - required_packages=["com.android.chrome"], - priority=88, - ), - TaskPreset( - id="pro_research_keep", - title="Product Research & Notes Note", - description="Compare top 3 headphones on Chrome and record comparison in Keep", - goal="Open Chrome, research top 3 noise-cancelling headphones comparing price and battery life, then write a structured comparison summary note in Keep Notes.", - profile="pro", - category="pro", - tag="Chrome + Keep", - apps=[ - AppInfo(name="Chrome", icon="public", pkg="com.android.chrome", category="browser"), - AppInfo( - name="Keep Notes", - icon="note_alt", - pkg="com.google.android.keep", - category="productivity", - ), - ], - required_packages=["com.android.chrome"], - priority=91, - ), - # 4. YouTube - TaskPreset( - id="youtube_lofi", - title="Play Lo-Fi Music Radio", - description="Search and play a Lo-Fi hip hop live stream on YouTube", - goal='Open YouTube, search for "Lofi hip hop beats relaxing radio" and tap on the live stream.', - profile="flash", - category="flash", - tag="YouTube", - apps=[ - AppInfo( - name="YouTube", - icon="smart_display", - pkg="com.google.android.youtube", - category="entertainment", - ) - ], - required_packages=["com.google.android.youtube"], - priority=85, - ), - # 5. Settings - TaskPreset( - id="settings_display_wifi", - title="Dark Mode & Wi-Fi Check", - description="Toggle dark theme and verify network connection in Settings", - goal="Open Settings app, navigate to Display settings, ensure Dark theme is enabled, and check Wi-Fi connection status.", - profile="flash", - category="flash", - tag="Settings", - apps=[ - AppInfo(name="Settings", icon="settings", pkg="com.android.settings", category="system") - ], - required_packages=["com.android.settings"], - priority=87, - ), - TaskPreset( - id="pro_settings_qa", - title="Subsystem Health & Crash Probe", - description="Traverse Settings submenus to verify screens and check for crash dialogs", - goal="Explore Settings submenus (Network, Connected devices, Apps, Battery, Storage), verify each screen loads properly without ANR or crash dialogs, and summarize results.", - profile="pro", - category="monitor", - tag="Settings QA", - apps=[ - AppInfo(name="Settings", icon="settings", pkg="com.android.settings", category="system") - ], - required_packages=["com.android.settings"], - priority=93, - ), - # 6. Clock - TaskPreset( - id="clock_timer", - title="25-Min Pomodoro Timer", - description="Start a 25-minute focus countdown timer in Clock app", - goal="Open Clock app, switch to Timer tab, set 25 minutes and start the countdown timer.", - profile="flash", - category="flash", - tag="Clock", - apps=[ - AppInfo( - name="Clock", icon="timer", pkg="com.google.android.deskclock", category="utility" - ) - ], - required_packages=["com.google.android.deskclock", "com.android.deskclock"], - priority=86, - ), - # 7. Calculator - TaskPreset( - id="calc_gratuity", - title="Split Bill & Calculate Tip", - description="Calculate 18% gratuity on $186.40 for 3 people in Calculator", - goal="Open Calculator and calculate 18% tip on a bill of $186.40, then divide by 3 people.", - profile="flash", - category="flash", - tag="Calculator", - apps=[ - AppInfo( - name="Calculator", - icon="calculate", - pkg="com.google.android.calculator", - category="utility", - ) - ], - required_packages=["com.google.android.calculator", "com.android.calculator2"], - priority=84, - ), - # 8. Photos - TaskPreset( - id="photos_inspect", - title="Inspect Recent Screenshot", - description="Open Google Photos and review the latest screenshot taken", - goal="Open Google Photos and view the most recent screenshot in the screenshots album.", - profile="flash", - category="flash", - tag="Photos", - apps=[ - AppInfo( - name="Photos", - icon="photo_library", - pkg="com.google.android.apps.photos", - category="media", - ) - ], - required_packages=["com.google.android.apps.photos"], - priority=82, - ), - # 9. WeChat - TaskPreset( - id="wechat_browse", - title="Check WeChat Messages", - description="Open WeChat and view top recent chat conversations", - goal="Open WeChat and view the top recent chat messages.", - profile="flash", - category="flash", - tag="WeChat", - apps=[AppInfo(name="WeChat", icon="forum", pkg="com.tencent.mm", category="social")], - required_packages=["com.tencent.mm"], - priority=89, - ), - TaskPreset( - id="pro_wechat_to_calendar", - title="WeChat Notice to Calendar", - description="Extract meeting notice from WeChat chat and add to Calendar", - goal="Open WeChat, locate the latest meeting announcement or event message in the top chat, extract the time and topic, then open Calendar and schedule an event.", - profile="pro", - category="cross_app", - tag="WeChat + Calendar", - apps=[ - AppInfo(name="WeChat", icon="forum", pkg="com.tencent.mm", category="social"), - AppInfo( - name="Calendar", - icon="calendar_month", - pkg="com.google.android.calendar", - category="productivity", - ), - ], - required_packages=["com.tencent.mm"], - priority=93, - ), - # 10. Xiaohongshu - TaskPreset( - id="xhs_coffee_guide", - title="RED Cafe Guide Search", - description="Search trending specialty cafe reviews on Xiaohongshu", - goal="Open Xiaohongshu, search for top-rated specialty coffee shops, and view the top post.", - profile="flash", - category="flash", - tag="Xiaohongshu", - apps=[ - AppInfo( - name="Xiaohongshu", icon="auto_stories", pkg="com.xingin.xhs", category="social" - ) - ], - required_packages=["com.xingin.xhs"], - priority=87, - ), - # 11. Meituan / Dianping - TaskPreset( - id="meituan_ramen_search", - title="Meituan Food Search", - description="Search top-rated Ramen nearby on Meituan or Dianping", - goal="Open Meituan or Dianping, search for top-rated Ramen nearby, and view top restaurant rating.", - profile="flash", - category="flash", - tag="Meituan", - apps=[ - AppInfo( - name="Meituan", icon="restaurant", pkg="com.sankuai.meituan", category="lifestyle" - ) - ], - required_packages=["com.sankuai.meituan", "com.dianping.v1"], - priority=86, - ), - # 12. Bilibili - TaskPreset( - id="bilibili_stream", - title="Bilibili Tech Video", - description="Search and play an AI Agent tutorial video on Bilibili", - goal='Open Bilibili, search for "AI Agent Architecture", and play the top matching video.', - profile="flash", - category="flash", - tag="Bilibili", - apps=[ - AppInfo( - name="Bilibili", - icon="video_library", - pkg="tv.danmaku.bili", - category="entertainment", - ) - ], - required_packages=["tv.danmaku.bili"], - priority=85, - ), - # 13. Play Store - TaskPreset( - id="pro_playstore_review", - title="Play Store App Review Study", - description="Compare top task management apps and ratings on Google Play", - goal="Open Google Play Store, search for top rated task management apps, compare ratings and latest user reviews of the top 2 candidates, and record recommendations.", - profile="pro", - category="pro", - tag="Play Store", - apps=[ - AppInfo( - name="Play Store", icon="storefront", pkg="com.android.vending", category="tools" - ) - ], - required_packages=["com.android.vending"], - priority=88, - ), -] - - -# ============================================================================ -# RECOMMENDATION ENGINE -# ============================================================================ - - -class TaskRecommendationEngine: - """Intelligent recommendation engine matching device capabilities.""" - - def __init__(self, catalog: list[TaskPreset] | None = None): - self.catalog = catalog or PRESET_TASK_CATALOG - - def get_all_tasks(self) -> list[dict[str, Any]]: - return [task.model_dump() for task in self.catalog] - - def recommend_tasks( - self, installed_packages: list[str] | set[str], category: str = "all", limit: int = 12 - ) -> list[dict[str, Any]]: - pkgs_set = ( - set(installed_packages) if isinstance(installed_packages, list) else installed_packages - ) - - scored_tasks: list[tuple[int, TaskPreset, bool]] = [] - - for task in self.catalog: - # Check package presence - is_matched = False - if pkgs_set: - if not task.required_packages: - is_matched = True - elif task.match_mode == "all": - is_matched = all(pkg in pkgs_set for pkg in task.required_packages) - else: - is_matched = any(pkg in pkgs_set for pkg in task.required_packages) - else: - is_matched = False - - # Calculate score - score = task.priority - if pkgs_set: - if is_matched: - score += 100 - if len(task.required_packages) > 1 and task.match_mode == "all": - score += 30 - else: - score -= 40 - - # Filter by category - if category == "flash" and task.profile != "flash": - continue - elif category == "pro" and task.profile != "pro": - continue - elif category == "cross_app" and task.category != "cross_app": - continue - elif category == "monitor" and task.category != "monitor": - continue - - scored_tasks.append((score, task, is_matched)) - - # Sort descending by score - scored_tasks.sort(key=lambda item: item[0], reverse=True) - - results: list[dict[str, Any]] = [] - for _, task, matched in scored_tasks[:limit]: - d = task.model_dump() - d["is_device_matched"] = matched - results.append(d) - - return results - - -task_recommendation_engine = TaskRecommendationEngine() diff --git a/apps/admin_console/services/task_queue_service.py b/apps/admin_console/services/task_queue_service.py deleted file mode 100644 index b99b4c919..000000000 --- a/apps/admin_console/services/task_queue_service.py +++ /dev/null @@ -1,1719 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -from datetime import datetime -import logging -import os -from pathlib import Path -import shutil -import sys -import threading -import time -from typing import Any -import uuid - -try: - from admin_console.core.state import state - from admin_console.database.repositories.session_repository import session_repo - from admin_console.services import worker_process_io - from admin_console.services.media_service import media_service -except ImportError: - from apps.admin_console.core.state import state - from apps.admin_console.database.repositories.session_repository import session_repo - from apps.admin_console.services import worker_process_io - from apps.admin_console.services.media_service import media_service - -from artemis.config import ( - PAUSE_FILE, - TEST_DATA_DIR, - TEST_OUTPUTS_DIR, - WORKSPACE_ROOT, -) -from artemis.runtime import ( - AdbEndpoint, - AdbTarget, - DeviceExecutionLock, - clear_cancel_request, - current_adb_endpoint, - pid_is_alive, - process_supervisor, - request_cancel, - trace_store, -) - -logger = logging.getLogger(__name__) - - -class TaskQueueService: - """Service managing FIFO task execution, background worker, subprocess lifecycle, - and startup tasks. - """ - - # Strong references to in-flight _execute_task_item tasks (asyncio itself only - # keeps weak references to running tasks). - _run_tasks: set[asyncio.Task] = set() - # Deadline enforcers for graceful stops (see _stop_worker_gracefully). - _forced_stop_tasks: set[asyncio.Task] = set() - - DEFAULT_CANCEL_GRACE_SECONDS = 45.0 - - @classmethod - def _cancel_grace_seconds(cls) -> float: - """How long a worker may finalize itself before it is killed. - - ``ARTEMIS_CANCEL_GRACE_SECONDS=0`` restores the legacy immediate kill. - """ - raw = os.getenv("ARTEMIS_CANCEL_GRACE_SECONDS") - if raw is None or not raw.strip(): - return cls.DEFAULT_CANCEL_GRACE_SECONDS - try: - return max(0.0, float(raw)) - except ValueError: - return cls.DEFAULT_CANCEL_GRACE_SECONDS - - @staticmethod - def _hard_kill(pid: int, process_created_at: float = 0.0) -> bool: - if not pid: - return False - if process_created_at and process_created_at > 0: - return process_supervisor.terminate_tree_verified(pid, process_created_at) - try: - return process_supervisor.terminate_tree(pid) - except Exception: - return False - - @classmethod - def _stop_worker_gracefully( - cls, - pid: Any, - process_created_at: float = 0.0, - session_id: str | None = None, - reason: str = "Task stopped from the Artemis frontend.", - ) -> tuple[bool, bool]: - """Ask a worker to cancel itself; hard-kill it once the grace period lapses. - - Workers are isolated from the daemon's console (and may belong to - another ingress process), so instead of a signal the daemon drops a - cancel marker the worker polls for. Honouring it runs the worker's - normal cancellation path: the screen recording is stopped and remuxed, - the trace folder is compiled, and the device lease is released. A - worker that never picks the marker up is killed after the grace period. - - Returns ``(stopped, deferred)``: ``stopped`` mirrors the legacy kill - result, ``deferred`` is True when the kill was handed to the deadline - enforcer instead of happening now. - """ - try: - pid_int = int(pid) if pid else 0 - except (TypeError, ValueError): - pid_int = 0 - grace = cls._cancel_grace_seconds() - if not pid_int or grace <= 0: - return cls._hard_kill(pid_int, process_created_at), False - - created_at = float(process_created_at or 0.0) - if created_at <= 0: - # PID markers carry the creation time so a leftover marker can never - # cancel a future process that reuses this PID. - try: - import psutil - - created_at = float(psutil.Process(pid_int).create_time()) - except Exception: - created_at = 0.0 - if not pid_is_alive(pid_int, created_at or None): - return True, False - - written = request_cancel( - session_id=str(session_id) if session_id else None, - pid=pid_int, - process_created_at=created_at, - reason=reason, - ) - if not written: - return cls._hard_kill(pid_int, created_at), False - - print( - f"[stop_tasks] Cancel requested for worker {pid_int}" - f" (session {session_id or 'n/a'}); forcing termination after {grace:.0f}s" - ) - cls._schedule_forced_stop(pid_int, created_at, grace, session_id) - return True, True - - @classmethod - def _stop_proc_gracefully(cls, proc: Any, session_id: Any) -> bool: - """Graceful variant for a locally spawned process; True when deferred.""" - pid = getattr(proc, "pid", None) - if not pid or cls._cancel_grace_seconds() <= 0: - return False - try: - _stopped, deferred = cls._stop_worker_gracefully( - pid, session_id=str(session_id) if session_id else None - ) - except Exception: - return False - return deferred - - @classmethod - def _schedule_forced_stop( - cls, pid: int, process_created_at: float, grace: float, session_id: Any - ) -> None: - """Kill ``pid`` if it is still alive once ``grace`` seconds have passed.""" - - def _still_alive() -> bool: - return pid_is_alive(pid, process_created_at or None) - - def _force() -> None: - print( - f"[stop_tasks] Worker {pid} (session {session_id or 'n/a'}) did not exit" - f" within {grace:.0f}s of the cancel request; forcing termination." - ) - cls._hard_kill(pid, process_created_at) - - async def _enforce_async() -> None: - deadline = time.monotonic() + grace - while time.monotonic() < deadline: - if not _still_alive(): - break - await asyncio.sleep(0.5) - else: - _force() - clear_cancel_request(session_id=str(session_id) if session_id else None, pid=pid) - - def _enforce_sync() -> None: - deadline = time.monotonic() + grace - while time.monotonic() < deadline: - if not _still_alive(): - break - time.sleep(0.5) - else: - _force() - clear_cancel_request(session_id=str(session_id) if session_id else None, pid=pid) - - try: - loop = asyncio.get_running_loop() - except RuntimeError: - loop = None - if loop is not None: - task = loop.create_task(_enforce_async()) - cls._forced_stop_tasks.add(task) - task.add_done_callback(cls._forced_stop_tasks.discard) - return - threading.Thread( - target=_enforce_sync, name=f"artemis-forced-stop-{pid}", daemon=True - ).start() - - @staticmethod - def _task_target(task_item: dict[str, Any]) -> AdbTarget: - endpoint_data = task_item.get("adb_endpoint") - endpoint = ( - AdbEndpoint.from_mapping(endpoint_data) - if isinstance(endpoint_data, dict) - else current_adb_endpoint() - ) - serial = task_item.get("device_serial") - return AdbTarget(endpoint=endpoint, serial=str(serial) if serial else None) - - @classmethod - def _broadcast_event(cls, event_type: str, data: Any): - """Broadcasts an event safely to all registered subscribers.""" - for cb in list(state.ipc_subscribers): - try: - cb(event_type, data) - except Exception: - # One broken subscriber must not block the others, but a - # silent drop hides it entirely. - logger.warning( - "Event subscriber %r failed for event %s", - cb, - event_type, - exc_info=True, - ) - - @classmethod - def _broadcast_startup_progress(cls, session_id: str | None, stage: str, message: str) -> None: - if not session_id: - return - data = { - "session_id": str(session_id), - "stage": stage, - "message": message, - "timestamp": time.time(), - } - state.record_startup_progress(data) - cls._broadcast_event("startup_progress", data) - - @classmethod - def _get_next_pending_task(cls) -> dict[str, Any] | None: - """Finds and returns the first pending task from queue_items.""" - for item in state.queue_items: - if isinstance(item, dict) and item.get("status") == "pending": - return item - return None - - @classmethod - def _remove_task(cls, session_id: str | None): - """Removes a task from queue_items by session_id.""" - if not session_id: - return - removed_items = [ - t - for t in state.queue_items - if isinstance(t, dict) and t.get("session_id") == session_id - ] - for item in removed_items: - DeviceExecutionLock.cancel_reservation(item.get("queue_ticket")) - state.queue_items = [ - t - for t in state.queue_items - if not (isinstance(t, dict) and t.get("session_id") == session_id) - ] - - @staticmethod - def _resolve_terminal_status( - current_status: str | None, - returncode: int, - was_stopped_manually: bool, - ) -> tuple[str, bool]: - """Resolve final status while preserving an authoritative task result. - - The worker exit code is only a fallback for sessions that have not - reached a terminal state. ``success`` is the DataEngine alias for the - UI-facing ``completed`` status and is normalized here. - - Returns: - A tuple of ``(resolved_status, should_persist)``. - """ - if was_stopped_manually: - return "cancelled", True - - normalized = current_status.lower().strip() if isinstance(current_status, str) else None - if normalized == "success": - return "completed", True - if normalized in {"completed", "failed", "cancelled"}: - return normalized, False - return ("completed" if returncode == 0 else "failed"), True - - # Worker subprocess I/O plumbing lives in worker_process_io; the historical - # private names stay bound here so callers and tests keep working unchanged. - _subprocess_creation_kwargs = staticmethod(worker_process_io.subprocess_creation_kwargs) - _forward_worker_output = staticmethod(worker_process_io.forward_worker_output) - _finish_output_forwarder = staticmethod(worker_process_io.finish_output_forwarder) - _wait_for_worker_process = staticmethod(worker_process_io.wait_for_worker_process) - - @staticmethod - async def _terminate_worker_process(proc: asyncio.subprocess.Process | None) -> None: - """Stop a worker without changing the established POSIX behavior.""" - if proc is None or proc.returncode is not None: - return - if sys.platform == "win32": - await process_supervisor.stop_process(proc) - return - try: - proc.kill() - except ProcessLookupError: - pass - - @classmethod - def ensure_worker_running(cls): - """Guarantees that the background queue worker task is active and running.""" - try: - loop = asyncio.get_running_loop() - if state.worker_task is None or state.worker_task.done(): - state.worker_task = loop.create_task(cls.queue_worker()) - try: - state.wake_event.set() - except AttributeError: - # Partially initialized state (unit tests): the worker - # loop polls anyway. - pass - except RuntimeError: - pass - - @classmethod - def _concurrency_limit(cls) -> int: - """Return 0 for per-device concurrency, or the global task limit.""" - return DeviceExecutionLock.resolve_env_concurrency() - - @classmethod - async def queue_worker(cls): - """Persistent dispatcher scheduling pending tasks onto devices. - - Scans the queue and launches each eligible task as an independent - coroutine. Admission is governed by _concurrency_limit(): 0 admits one - task per device so distinct devices execute concurrently, N>=1 admits - at most N tasks across all devices. Per-device FIFO ordering and - cross-process mutual exclusion remain enforced by DeviceExecutionLock - inside each worker process. - """ - print( - f"[QueueWorker] Dispatcher initialized (concurrency limit: {cls._concurrency_limit()})." - ) - try: - DeviceExecutionLock.cleanup_stale_locks() - except Exception as exc: - print(f"[QueueWorker] Initial stale lock cleanup notice: {exc}") - - try: - while True: - try: - cls._dispatch_pending_tasks() - except Exception as exc: - print(f"[QueueWorker] Dispatch error: {exc}") - try: - await asyncio.wait_for(state.wake_event.wait(), timeout=0.3) - state.wake_event.clear() - except TimeoutError: - pass - except asyncio.CancelledError: - print("[QueueWorker] Dispatcher received cancellation signal.") - for run in list(state.active_runs.values()): - run_proc = run.get("process") - if run_proc is not None and run_proc.returncode is None: - await cls._terminate_worker_process(run_proc) - raise - - @classmethod - def _dispatch_pending_tasks(cls) -> None: - """Launch every pending task admissible under the current concurrency limit.""" - limit = cls._concurrency_limit() - state.prune_finished_runs() - if limit == 1 and state.is_running: - return - - # Dispatched-but-not-yet-spawned runs are only visible as queue items in - # "running" state, so admission must count those too -- active_runs alone - # lags behind by the subprocess startup latency. - in_flight = [ - i for i in state.queue_items if isinstance(i, dict) and i.get("status") == "running" - ] - capacity = None - if limit >= 1: - # Registered workers still have running queue rows until finalization. - active_pids = { - getattr(run.get("process"), "pid", None) for run in state.active_runs.values() - } - {None} - starting = sum( - str(item.get("session_id")) not in state.active_runs - and item.get("pid") not in active_pids - for item in in_flight - ) - capacity = limit - len(state.active_runs) - starting - if capacity <= 0: - return - busy_devices = state.busy_device_ids | { - cls._task_target(i).lock_key for i in in_flight if i.get("device_serial") - } - dispatched_any = False - loop = asyncio.get_running_loop() - for item in list(state.queue_items): - if not (isinstance(item, dict) and item.get("status") == "pending"): - continue - sess_id = item.get("session_id") - if sess_id and str(sess_id) in state.cancelled_session_ids: - cls._remove_task(sess_id) - continue - - device = item.get("device_serial") - target = cls._task_target(item) - if limit == 0: - # A task without a resolved device may bind to any serial, so it - # only launches on an otherwise idle scheduler; the device lock - # then allocates freely without contending against active runs. - if device is None and (state.active_runs or in_flight or dispatched_any): - continue - if device is not None and target.lock_key in busy_devices: - continue - elif limit > 1 and device is not None and target.lock_key in busy_devices: - # A second worker for this device would wait on its lock. - continue - - item["status"] = "running" - dispatched_any = True - if device is not None: - busy_devices.add(target.lock_key) - run_task = loop.create_task(cls._execute_task_item(item)) - # Hold a strong reference: asyncio keeps only weak refs to running - # tasks, and a collected run would strand its queue item forever. - cls._run_tasks.add(run_task) - run_task.add_done_callback(cls._run_tasks.discard) - if capacity is not None: - capacity -= 1 - if capacity <= 0: - break - - @classmethod - def _begin_task_run( - cls, - task_item: dict[str, Any], - run_key: str, - sess_id: Any, - goal: str, - profile: str, - ) -> None: - """Mark the task as running and announce the launch to subscribers.""" - task_item["status"] = "running" - task_item["start_time"] = time.time() - - # A fresh launch clears a stale stop request left over for this run - # key from a previous task. Manual stops are tracked per run in - # manually_stopped_run_ids so stopping one device's task never - # affects concurrent runs. Per-session stops are tracked in - # cancelled_session_ids and are unaffected. - state.manually_stopped_run_ids.discard(run_key) - state.current_goal = goal - state.current_profile = profile - state.active_session_id = sess_id - - cls._broadcast_startup_progress(sess_id, "launching", "Starting the execution process") - - # Broadcast session_started so all connected clients know the task has started - cls._broadcast_event( - "session_started", - { - "session_id": sess_id, - "initial_goal": goal, - "profile": profile, - "device_serial": task_item.get("device_serial"), - }, - ) - - @classmethod - def _build_worker_invocation( - cls, - task_item: dict[str, Any], - run_key: str, - sess_id: Any, - goal: str, - profile: str, - target: AdbTarget, - ) -> tuple[list[str], dict[str, str]]: - """Assemble the worker subprocess command line and environment.""" - expected_output = task_item.get("expected_output") - enable_outputter = task_item.get("enable_outputter") - verification_level = task_item.get("verification_level") - explorer_mode = task_item.get("explorer_mode") - locked_app = task_item.get("locked_app_package") or task_item.get("locked_app") - app_path = task_item.get("app_path") - - test_name = f"web_{int(time.time())}_{run_key[:8]}" - env = os.environ.copy() - pythonpath_parts = [ - str(WORKSPACE_ROOT), - str(WORKSPACE_ROOT / "apps" / "admin_console"), - str(WORKSPACE_ROOT / "apps" / "cloud_service"), - env.get("PYTHONPATH", ""), - ] - env["PYTHONPATH"] = os.pathsep.join([p for p in pythonpath_parts if p]) - env["PYTHONUTF8"] = "1" - env["PYTHONUNBUFFERED"] = "1" - if state.ipc_port is not None: - env["ARTEMIS_IPC_PORT"] = str(state.ipc_port) - if sess_id: - env["ARTEMIS_SESSION_ID"] = str(sess_id) - env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend")) - env["ARTEMIS_TASK_WORKER"] = "1" - target.endpoint.apply_to_environment(env) - env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope - queue_ticket = task_item.get("queue_ticket") - if queue_ticket: - env[DeviceExecutionLock.QUEUE_TICKET_ENV] = str(queue_ticket) - - cmd = [ - sys.executable, - "-m", - "artemis.main", - goal, - "--profile", - profile, - "--test-name", - test_name, - ] - if sess_id: - cmd.extend(["--session-id", str(sess_id)]) - if expected_output: - cmd.extend(["--output-description", str(expected_output)]) - if enable_outputter is not None: - cmd.append("--enable-outputter" if enable_outputter else "--disable-outputter") - if verification_level: - cmd.extend(["--verification-level", str(verification_level)]) - if explorer_mode: - cmd.extend(["--explorer-pro-mode", str(explorer_mode)]) - if locked_app: - cmd.extend(["--locked-app", str(locked_app)]) - if app_path: - cmd.extend(["--app-path", str(app_path)]) - device_serial = task_item.get("device_serial") - if device_serial: - cmd.extend(["--device-serial", str(device_serial)]) - env["ADB_DEVICE_SERIAL"] = str(device_serial) - return cmd, env - - @classmethod - def _register_worker_run( - cls, - task_item: dict[str, Any], - run_key: str, - sess_id: Any, - goal: str, - profile: str, - target: AdbTarget, - proc: asyncio.subprocess.Process, - ) -> None: - """Record the spawned worker in shared state and hand it the device reservation.""" - device_serial = task_item.get("device_serial") - state.current_process = proc - task_item["pid"] = proc.pid - state.active_runs[run_key] = { - "process": proc, - "device_id": str(device_serial) if device_serial else None, - "lock_key": target.lock_key if device_serial else None, - "adb_endpoint": target.endpoint.to_dict(), - "goal": goal, - "profile": profile, - } - if sess_id: - try: - # Preserve status updates written concurrently by the worker. - trace_store.update_trace_pid(str(sess_id), proc.pid) - except OSError as exc: - # Status probes fall back to DB PID bookkeeping, but note it. - print( - f"[QueueWorker] Could not record worker pid in status.json for {sess_id}: {exc}" - ) - cls._broadcast_startup_progress(sess_id, "process_ready", "Execution process started") - ingress_type = str(task_item.get("ingress", "frontend")) - queue_ticket = task_item.get("queue_ticket") - if queue_ticket: - transferred = DeviceExecutionLock.transfer_reservation( - str(queue_ticket), - proc.pid, - description=f"{ingress_type} task: {goal[:120]}", - device_id=device_serial or "pending", - session_id=str(sess_id) if sess_id else None, - ingress=ingress_type, - lock_scope=target.lock_scope, - ) - if not transferred: - print( - f"[QueueWorker] Could not transfer queue ticket {queue_ticket} to worker" - f" pid {proc.pid}; the worker will queue a fresh ticket itself." - ) - - @classmethod - def _start_output_forwarder( - cls, sess_id: Any, proc: asyncio.subprocess.Process - ) -> asyncio.Task[None] | None: - """Start forwarding the worker's output; returns the forwarder task, if any.""" - # Forward the worker's combined output and tee it into the trace's - # stdout.log so the log paths advertised by the MCP API exist. - log_path = None - if sess_id: - try: - log_path = trace_store.get_trace_stdout_log_path(str(sess_id)) - except Exception: - log_path = None - if isinstance(proc.stdout, asyncio.StreamReader): - return asyncio.create_task(cls._forward_worker_output(proc.stdout, log_path)) - return None - - @classmethod - async def _terminate_if_cancelled_during_launch( - cls, run_key: str, sess_id: Any, proc: asyncio.subprocess.Process - ) -> None: - """Terminate a worker whose task was cancelled while it was being launched.""" - if sess_id and ( - str(sess_id) in getattr(state, "cancelled_session_ids", set()) - or run_key in state.manually_stopped_run_ids - ): - print(f"[QueueWorker] Task [{sess_id}] was cancelled during launch. Terminating.") - await cls._terminate_worker_process(proc) - - @classmethod - async def _persist_terminal_session_status( - cls, sess_id: Any, returncode: int, manual_stop: bool - ) -> str: - """Resolve and persist the session's terminal status in the DB and trace store.""" - current_status = session_repo.get_session_status(sess_id) - new_status, should_persist = cls._resolve_terminal_status( - current_status, - returncode, - manual_stop, - ) - if should_persist: - if session_repo.update_session_status(sess_id, new_status, time.time()): - print(f"[QueueWorker] Updated session {sess_id} status to '{new_status}'") - else: - # The DB row is the fallback MCP pollers reconcile - # against when status.json is stale, so a failed DB - # write must not pass silently. - logger.error( - "[QueueWorker] Could not persist terminal DB status '%s' for session %s", - new_status, - sess_id, - ) - else: - print(f"[QueueWorker] Preserved authoritative session {sess_id} status '{new_status}'") - # A stale status.json would leave MCP pollers seeing "running" - # until their next DB reconcile, so retry transient write - # failures before giving up. - for attempt in range(3): - try: - if trace_store.read_status(str(sess_id)): - canonical_mcp_status = ( - "completed" if new_status in ("completed", "success") else new_status - ) - trace_store.update_trace_status( - str(sess_id), - canonical_mcp_status, - ) - break - except OSError as exc: - if attempt < 2: - await asyncio.sleep(0.5 * (attempt + 1)) - continue - logger.error( - "[QueueWorker] Could not persist terminal MCP status " - "for %s after %d attempts: %s", - sess_id, - attempt + 1, - exc, - ) - return new_status - - @classmethod - async def _recover_or_fail_recording(cls, sess_id: Any) -> None: - """Finalize a recording whose worker died before it could, else mark it failed. - - A worker that stops gracefully finalizes its own recording and this is - a no-op. A hard-killed (or crashed) worker leaves the raw scrcpy file it - registered at recording start; remuxing that file is all that is - needed to publish the full video. - """ - rec_info = session_repo.get_video_recording_for_session(sess_id) - rec_status = (rec_info or {}).get("status") - if rec_status == "ready": - return - - recovered_url = None - # 1. Direct recovery from the recording row written at recording start. - try: - local_video_path = (rec_info or {}).get("local_video_path") - if local_video_path: - start_time = (rec_info or {}).get("start_time") - if not start_time: - session_row = session_repo.get_session_by_id(sess_id) - start_time = dict(session_row).get("start_time") if session_row else None - final_path = await asyncio.to_thread( - media_service.recover_orphaned_recording, - local_video_path, - start_time, - ) - if final_path: - session_repo.mark_recording_ready(sess_id, str(final_path)) - recovered_url = media_service.path_to_video_url(Path(final_path)) - except Exception as rec_err: - print(f"[QueueWorker] Error finalizing orphaned recording: {rec_err}") - - # 2. Fallback: locate an already finalized file by folder / session naming. - if not recovered_url: - try: - video_rec_map = session_repo.get_video_recordings_map() - video_idx = await asyncio.to_thread(media_service.build_video_index) - fallback_url = await asyncio.to_thread( - media_service.resolve_video_url, - {"session_id": sess_id}, - video_rec_map, - video_idx, - ) - if fallback_url: - session_repo.mark_recording_ready(sess_id, fallback_url) - recovered_url = fallback_url - except Exception as rec_err: - print(f"[QueueWorker] Error attempting recording recovery: {rec_err}") - - if recovered_url: - cls._broadcast_event( - "recording_ready", - {"session_id": sess_id, "video_url": recovered_url}, - ) - return - - recording_error = "Task worker exited before recording finalization completed" - if session_repo.mark_recording_failed_if_pending(sess_id, recording_error): - cls._broadcast_event( - "recording_failed", - {"session_id": sess_id, "error": recording_error}, - ) - - @classmethod - def _announce_session_end( - cls, - task_item: dict[str, Any], - sess_id: Any, - goal: str, - new_status: str, - manual_stop: bool, - ) -> None: - """Broadcast session_ended and dispatch the external completion notification.""" - state.active_connections.pop(sess_id, None) - cls._broadcast_event( - "session_ended", - { - "session_id": sess_id, - "status": new_status, - "was_stopped_manually": manual_stop, - }, - ) - - conversation_id = task_item.get("conversation_id") if task_item else None - if conversation_id or task_item.get("ingress") == "mcp": - try: - from mcp_server.notifiers import notify - - notify( - conversation_id=conversation_id or "", - message=f"Artemis autonomous task '{goal}' finished with status '{new_status}'.\nTrace ID: {sess_id}", - title=f"Task {new_status.capitalize()}: {goal[:40]}", - event_type=new_status, - payload={ - "trace_id": sess_id, - "session_id": sess_id, - "status": new_status, - "goal": goal, - }, - ) - except Exception as notif_err: - print(f"[QueueWorker] Notification dispatch notice: {notif_err}") - - @classmethod - def _release_run_slot( - cls, sess_id: Any, run_key: str, proc: asyncio.subprocess.Process | None - ) -> None: - """Clean up the finished task and release this run's scheduling slot.""" - if sess_id: - cls._remove_task(sess_id) - state.cancelled_session_ids.discard(str(sess_id)) - state.cancelled_session_ids.discard(run_key) - state.manually_stopped_run_ids.discard(run_key) - try: - clear_cancel_request( - session_id=str(sess_id) if sess_id else None, - pid=getattr(proc, "pid", None), - ) - except (OSError, TypeError, ValueError): - # Marker cleanup is best-effort: an unwritable temp dir or an odd - # pid value must not block releasing the run slot. - pass - state.active_runs.pop(run_key, None) - if proc is not None and state.current_process is proc: - state.current_process = None - if not state.active_runs: - state.active_session_id = None - state.current_goal = None - state.current_profile = None - # No runs left: any not-yet-consumed manual-stop markers are - # stale and must not leak into future runs. - state.manually_stopped_run_ids.clear() - state.wake_event.set() - - @classmethod - async def _execute_task_item(cls, task_item: dict[str, Any]) -> None: - """Run one queued task to completion in its own worker subprocess.""" - sess_id = task_item.get("session_id") - run_key = str(sess_id) if sess_id else uuid.uuid4().hex - goal = task_item.get("goal") - profile = task_item.get("profile", "flash") - proc: asyncio.subprocess.Process | None = None - output_task: asyncio.Task[None] | None = None - try: - if not isinstance(goal, str) or not goal.strip(): - raise ValueError("Queued task must contain a non-empty string goal.") - cls._begin_task_run(task_item, run_key, sess_id, goal, profile) - - target = cls._task_target(task_item) - cmd, env = cls._build_worker_invocation( - task_item, run_key, sess_id, goal, profile, target - ) - - device_serial = task_item.get("device_serial") - print( - f"[QueueWorker] Starting task [{sess_id}]: '{goal}' (profile: {profile}, device: {device_serial or 'auto'}, outputter: {bool(task_item.get('expected_output') or task_item.get('enable_outputter'))})" - ) - proc = await asyncio.create_subprocess_exec( - *cmd, - cwd=str(WORKSPACE_ROOT), - env=env, - **cls._subprocess_creation_kwargs(), - ) - cls._register_worker_run(task_item, run_key, sess_id, goal, profile, target, proc) - output_task = cls._start_output_forwarder(sess_id, proc) - await cls._terminate_if_cancelled_during_launch(run_key, sess_id, proc) - - # 3. Await subprocess completion - returncode = await cls._wait_for_worker_process(proc) - print(f"[QueueWorker] Task [{sess_id}] exited with returncode {returncode}") - - manual_stop = run_key in state.manually_stopped_run_ids or bool( - sess_id and str(sess_id) in state.cancelled_session_ids - ) - - # 4. Perform fallback database status update and notification - if sess_id: - new_status = await cls._persist_terminal_session_status( - sess_id, returncode, manual_stop - ) - await cls._recover_or_fail_recording(sess_id) - cls._announce_session_end(task_item, sess_id, goal, new_status, manual_stop) - - except asyncio.CancelledError: - print(f"[QueueWorker] Task [{sess_id}] received cancellation signal.") - if proc is not None and proc.returncode is None: - await cls._terminate_worker_process(proc) - raise - except Exception: - logger.exception(f"[QueueWorker] Unexpected error executing task [{sess_id}]") - # A spawn failure must also leave a terminal session status. - if sess_id: - try: - new_status = await cls._persist_terminal_session_status( - sess_id, returncode=1, manual_stop=False - ) - cls._announce_session_end(task_item, sess_id, goal or "", new_status, False) - except (OSError, RuntimeError, ValueError): - logger.exception( - f"[QueueWorker] Could not persist failure status for [{sess_id}]" - ) - finally: - await cls._finish_output_forwarder(output_task) - # 5. Clean up the finished task and release this run's scheduling slot - cls._release_run_slot(sess_id, run_key, proc) - - @classmethod - def _find_duplicate_submission( - cls, - goals: list[str], - session_id: str | None, - device_serial: str | None, - endpoint: AdbEndpoint, - now: float, - ) -> dict[str, Any] | None: - """Return the short-circuit response for a duplicate submission, if any.""" - # 1. Deduplication by session_id: if session_id is already running or queued, do not re-enqueue - if session_id and len(goals) == 1: - target_sid = str(session_id) - if state.active_session_id and str(state.active_session_id) == target_sid: - return { - "status": "started", - "tasks": [{"session_id": target_sid, "status": "running"}], - "enqueued_count": 0, - "total_queued": len(state.queue_tasks), - } - existing_item = next( - ( - item - for item in state.queue_items - if isinstance(item, dict) and str(item.get("session_id")) == target_sid - ), - None, - ) - if existing_item: - return { - "status": existing_item.get("status", "queued"), - "tasks": [existing_item], - "enqueued_count": 0, - "total_queued": len(state.queue_tasks), - } - - # 2. Debounce duplicate rapid submissions (e.g. UI double-click or network retry within 1s) - if len(goals) == 1: - first_goal = goals[0] - recent_duplicate = next( - ( - item - for item in reversed(state.queue_items) - if isinstance(item, dict) - and item.get("status") == "pending" - and item.get("goal") == first_goal - and (not device_serial or item.get("device_serial") == device_serial) - and item.get("adb_endpoint", {}).get("identity") == endpoint.identity - and (now - float(item.get("created_at", 0))) < 1.0 - ), - None, - ) - if recent_duplicate: - return { - "status": "queued", - "tasks": [recent_duplicate], - "enqueued_count": 0, - "total_queued": len(state.queue_tasks), - } - return None - - @classmethod - async def _reject_unavailable_device(cls, device_serial: str | None) -> dict[str, Any] | None: - """Return the rejection response for an unattached explicit serial, if any.""" - # Strict device binding: reject an explicitly requested serial that is not - # attached and authorized, instead of silently running on another device. - # The shared validator fails open on an indeterminate/empty enumeration so - # the task can proceed and fail downstream with a clear no-device error. - if device_serial: - try: - from artemis.runtime import device_pool - - rejection = await device_pool.validate_explicit_serial_async(device_serial) - except Exception: - rejection = None - if rejection: - return { - "status": "rejected", - "error": rejection, - "tasks": [], - "enqueued_count": 0, - "total_queued": len(state.queue_tasks), - } - return None - - @classmethod - def _create_queue_item( - cls, - goal: str, - index: int, - now: float, - endpoint: AdbEndpoint, - single_session_id: str | None, - profile: str, - expected_output: str | None, - enable_outputter: bool | None, - locked_app_package: str | None, - app_path: str | None, - device_serial: str | None, - ingress: str, - conversation_id: str | None, - verification_level: str | None = None, - explorer_mode: str | None = None, - ) -> dict[str, Any]: - """Reserve a device slot and build one pending queue item for a goal.""" - sess_id = single_session_id if single_session_id else str(uuid.uuid4()) - # enqueue_tasks resolves the device before creating queue items. - assigned_serial = device_serial - - queue_ticket = DeviceExecutionLock.reserve( - description=f"{ingress} task: {goal[:120]}", - device_id=assigned_serial or "pending", - session_id=sess_id, - ingress=ingress, - lock_scope=endpoint.identity, - ) - return { - "session_id": sess_id, - "goal": goal, - "profile": profile or "flash", - "expected_output": expected_output, - "enable_outputter": enable_outputter, - "verification_level": verification_level, - "explorer_mode": explorer_mode, - "locked_app_package": locked_app_package, - "app_path": app_path, - "device_serial": assigned_serial, - "adb_endpoint": endpoint.to_dict(), - "ingress": ingress, - "conversation_id": conversation_id, - "status": "pending", - "queue_ticket": queue_ticket, - "created_at": now + index * 0.001, - "start_time": now + index * 0.001, - } - - @classmethod - async def enqueue_tasks( - cls, - goals: list[str], - profile: str = "flash", - expected_output: str | None = None, - enable_outputter: bool | None = None, - locked_app_package: str | None = None, - app_path: str | None = None, - device_serial: str | None = None, - ingress: str = "frontend", - session_id: str | None = None, - conversation_id: str | None = None, - verification_level: str | None = None, - explorer_mode: str | None = None, - ) -> dict[str, Any]: - """Enqueues one or more goals and wakes up the background worker. - - ``verification_level`` and ``explorer_mode`` are Pro-profile tuning knobs - forwarded to the worker as ``--verification-level`` / ``--explorer-pro-mode``; - they are normalised here so the queue item and the CLI see one spelling. - """ - verification_level = ( - str(verification_level).strip().lower() or None if verification_level else None - ) - explorer_mode = str(explorer_mode).strip().lower() or None if explorer_mode else None - cls.ensure_worker_running() - - enqueued_tasks = [] - now = time.time() - endpoint = current_adb_endpoint() - - duplicate_response = cls._find_duplicate_submission( - goals, session_id, device_serial, endpoint, now - ) - if duplicate_response is not None: - return duplicate_response - - rejection_response = await cls._reject_unavailable_device(device_serial) - if rejection_response is not None: - return rejection_response - - single_session_id = session_id if (session_id and len(goals) == 1) else None - if not device_serial: - # Device enumeration may block on ADB. - from artemis.runtime import device_pool - - try: - device_serial = await device_pool.select_device_async() - except Exception: - device_serial = None - for i, goal in enumerate(goals): - task_item = cls._create_queue_item( - goal, - i, - now, - endpoint, - single_session_id, - profile, - expected_output, - enable_outputter, - locked_app_package, - app_path, - device_serial, - ingress, - conversation_id, - verification_level=verification_level, - explorer_mode=explorer_mode, - ) - state.queue_items.append(task_item) - enqueued_tasks.append(task_item) - cls._broadcast_startup_progress( - task_item["session_id"], "queued", "Task received and queued" - ) - - # Wake worker immediately - state.wake_event.set() - - return { - "status": "queued" if state.is_running else "started", - "tasks": enqueued_tasks, - "enqueued_count": len(goals), - "total_queued": len(state.queue_tasks), - } - - @staticmethod - def _clear_pause_file() -> None: - """Remove a leftover pause marker after a stop request.""" - if PAUSE_FILE.exists(): - try: - PAUSE_FILE.unlink() - except OSError: - # Best-effort cleanup of the pause marker; a leftover file - # only pauses until the next resume request. - pass - - @classmethod - def _terminate_all_device_owners(cls) -> None: - """Terminate every device lock owner and persist each cancellation.""" - active_owners: dict[str, Any] = {} - try: - active_owners = DeviceExecutionLock.get_active_owners() - except OSError as exc: - # Unreadable lock dir: fall back to the single-owner probe below. - print(f"[stop_tasks] Could not enumerate device owners: {exc}") - fallback = DeviceExecutionLock.get_active_owner() - if fallback and not active_owners: - active_owners["default"] = fallback - - for dev_owner in list(active_owners.values()): - if dev_owner and DeviceExecutionLock.is_active_owner(dev_owner): - cls._stop_worker_gracefully( - dev_owner.pid, - dev_owner.process_created_at, - session_id=dev_owner.session_id, - ) - DeviceExecutionLock.cleanup_stale_locks(dev_owner.device_id) - sid = dev_owner.session_id - if sid: - session_repo.update_session_status(str(sid), "cancelled", time.time()) - try: - is_mcp = bool(dev_owner and dev_owner.ingress == "mcp") - if is_mcp or trace_store.read_status(str(sid)): - trace_store.update_trace_status( - str(sid), - "cancelled", - error="Task stopped from the Artemis frontend.", - ) - except OSError as exc: - print( - f"[stop_tasks] Could not persist cancelled MCP status for {sid}: {exc}" - ) - cls._broadcast_event( - "session_ended", - { - "session_id": sid, - "status": "cancelled", - "was_stopped_manually": True, - }, - ) - - @classmethod - def _kill_all_local_runs(cls) -> None: - """Mark every locally-managed run manually stopped and kill its process.""" - for run_key, run in list(state.active_runs.items()): - # Mark each run individually: the global "stopped manually" - # boolean was shared process-wide and polluted the terminal - # status of unrelated concurrent runs. - state.manually_stopped_run_ids.add(str(run_key)) - # Also track it per session so each finalizer resolves its - # terminal status as cancelled. - state.cancelled_session_ids.add(str(run_key)) - run_proc = run.get("process") - if run_proc is not None and run_proc.returncode is None: - if not cls._stop_proc_gracefully(run_proc, run.get("session_id") or run_key): - try: - run_proc.kill() - except (ProcessLookupError, OSError): - # Process already exited between the check and the kill. - pass - # active_runs entries are popped by each run's finalizer once the - # process exit is observed; clearing them here would free the device - # slots before the processes are actually gone. - if state.current_process: - if not cls._stop_proc_gracefully(state.current_process, state.active_session_id): - try: - state.current_process.kill() - except (ProcessLookupError, OSError): - # Process already exited between the check and the kill. - pass - state.current_process = None - - @classmethod - def _stop_all_tasks(cls) -> bool: - """Terminate all active device owners and clear pending queue submissions.""" - # 1. Cancel local queue reservations - for item in state.queue_items: - if isinstance(item, dict) and item.get("status") != "running": - DeviceExecutionLock.cancel_reservation(item.get("queue_ticket")) - state.clear_queue() - - # 2. Terminate all active owners across all devices - cls._terminate_all_device_owners() - cls._kill_all_local_runs() - - state.active_connections.clear() - state.active_session_id = None - state.current_goal = None - state.current_profile = None - - cls._clear_pause_file() - - cls.ensure_worker_running() - state.wake_event.set() - return True - - @classmethod - def _resolve_stop_owner( - cls, - active_owners: dict[str, Any], - target_sid: str | None, - target_device: str | None, - ) -> Any: - """Resolve the device lock owner targeted by this stop request, if any.""" - owner = None - if target_sid: - for dev_owner in active_owners.values(): - if dev_owner.session_id and str(dev_owner.session_id) == target_sid: - owner = dev_owner - break - if owner is None and target_device: - clean_target_dev = DeviceExecutionLock._normalize_device_id(target_device) - for dev_key, dev_owner in active_owners.items(): - if dev_key == clean_target_dev or dev_owner.device_id == target_device: - owner = dev_owner - break - if owner is None: - # Covers scoped/legacy lock records that get_active_owners cannot - # key by session or device. A stale owner for a different session - # is never adopted: with no live owner, a session-targeted stop - # falls through to the queue/session-repository fallbacks below. - fallback_owner = DeviceExecutionLock.get_active_owner(target_device) - if fallback_owner and ( - not target_sid - or (fallback_owner.session_id and str(fallback_owner.session_id) == target_sid) - ): - owner = fallback_owner - return owner - - @classmethod - def _resolve_local_run( - cls, target_sid: str | None, target_device: str | None - ) -> tuple[str | None, dict[str, Any] | None, Any]: - """Resolve the locally-managed run for this target. - - Concurrent scheduling keeps one subprocess per run in - state.active_runs. Returns ``(local_run_key, local_run, local_proc)``. - """ - local_run = None - local_run_key: str | None = None - if target_sid: - local_run = state.active_runs.get(target_sid) - if local_run is not None: - local_run_key = target_sid - elif target_device: - local_run_key, local_run = next( - ( - (key, run) - for key, run in state.active_runs.items() - if run.get("device_id") and str(run["device_id"]) == target_device - ), - (None, None), - ) - elif len(state.active_runs) == 1: - # Untargeted stop (legacy single-device gesture): with exactly one - # live run the target is unambiguous. - local_run_key, local_run = next(iter(state.active_runs.items())) - local_proc = (local_run or {}).get("process") - if local_proc is None and not target_sid and not target_device: - # Untargeted legacy fallback only: the last-started process may - # stand in when no per-run entry resolved. An explicitly targeted - # stop that matched no run must never grab current_process -- it - # mirrors the newest run, which can be an unrelated concurrent one. - local_proc = state.current_process - if local_run is None and local_proc is not None: - # Legacy path: current_process without a resolved run entry. Find - # the run that owns this process so a manual stop can be attributed - # to it instead of to the whole scheduler. - local_run_key = next( - ( - str(key) - for key, run in state.active_runs.items() - if run.get("process") is local_proc - ), - None, - ) - return local_run_key, local_run, local_proc - - @classmethod - def _find_target_queue_item(cls, target_sid: str | None) -> dict[str, Any] | None: - """Find the running (preferred) or pending queue item for this target.""" - running_item = next( - ( - item - for item in state.queue_items - if isinstance(item, dict) - and item.get("status") == "running" - and (not target_sid or str(item.get("session_id")) == target_sid) - ), - None, - ) - return running_item or next( - ( - item - for item in state.queue_items - if isinstance(item, dict) - and item.get("status") == "pending" - and (not target_sid or str(item.get("session_id")) == target_sid) - ), - None, - ) - - @classmethod - def _resolve_stopped_session_id( - cls, - owner: Any, - target_sid: str | None, - local_run_key: str | None, - is_local_owner: bool, - local_item: dict[str, Any] | None, - ) -> Any: - """Attribute the stop request to a session id, if one can be resolved.""" - # active_session_id mirrors the most recently launched run, so with - # concurrent runs the resolved local run key (== its session id) must - # take precedence to avoid attributing the stop to an unrelated run. - return ( - owner.session_id - if owner and owner.session_id - else target_sid - if target_sid - else local_run_key - if local_run_key is not None - else state.active_session_id - if is_local_owner - else local_item.get("session_id") - if local_item - else None - ) - - @classmethod - def _terminate_stop_target( - cls, - owner: Any, - owner_record_exists: bool, - target_sid: str | None, - local_run: dict[str, Any] | None, - local_run_key: str | None, - local_proc: Any, - local_pid: Any, - local_item: dict[str, Any] | None, - is_local_owner: bool, - ) -> tuple[bool, bool, bool] | None: - """Terminate the resolved stop target. - - Returns ``(stopped, is_local_owner, reservation_cancelled)``, or - ``None`` when the caller must refuse the stop outright. - """ - stopped = False - reservation_cancelled = False - if owner and DeviceExecutionLock.is_active_owner(owner): - stopped, _deferred = cls._stop_worker_gracefully( - owner.pid, - owner.process_created_at, - session_id=owner.session_id or target_sid, - ) - DeviceExecutionLock.cleanup_stale_locks(owner.device_id) - elif owner is None and owner_record_exists and not target_sid: - # Never fall back to a frontend PID while another process has an - # owner record that is still being published or cannot be parsed. - return None - elif ( - owner is None - and local_proc is not None - and ( - local_run is not None - or not target_sid - or str(state.active_session_id) == target_sid - ) - ): - # The locally-managed worker can be stopped during its short - # initialization window before Agent acquires the device lease. - if target_sid: - state.cancelled_session_ids.add(target_sid) - elif local_run_key is not None: - state.manually_stopped_run_ids.add(str(local_run_key)) - deferred = False - if local_pid: - try: - stopped, deferred = cls._stop_worker_gracefully( - local_pid, - session_id=target_sid or (local_run or {}).get("session_id"), - ) - except Exception: - stopped = False - if not deferred: - try: - local_proc.kill() - stopped = True - except (ProcessLookupError, OSError): - # Process already exited; the stop above may have got it. - pass - stopped = True - is_local_owner = True - elif ( - owner is None - and local_item - and (not target_sid or str(local_item.get("session_id")) == target_sid) - ): - # Cancel a frontend submission before its worker has started. This - # does not touch pending reservations created by other ingresses. - DeviceExecutionLock.cancel_reservation(local_item.get("queue_ticket")) - reservation_cancelled = True - stopped = True - is_local_owner = True - elif target_sid: - # Fallback 1: check global device queue - global_queued = DeviceExecutionLock.get_queued_tasks() - for q_item in global_queued: - if str(q_item.get("session_id")) == target_sid: - # get_queued_tasks exposes the reservation as "token". - stopped = DeviceExecutionLock.cancel_reservation(q_item.get("token")) - break - # Fallback 2: check session repository for a running session with a live worker PID - if not stopped: - row = session_repo.get_session_by_id(target_sid) - if row and row.get("status") == "running": - row_pid = row.get("pid") - if row_pid and session_repo.process_is_alive(row_pid): - try: - cls._stop_worker_gracefully(int(row_pid), session_id=target_sid) - except Exception as exc: - # Report it: a surviving worker keeps the device busy. - print(f"[stop_tasks] Could not terminate worker pid {row_pid}: {exc}") - DeviceExecutionLock.cleanup_stale_locks() - stopped = True - return stopped, is_local_owner, reservation_cancelled - - @classmethod - def _finalize_targeted_stop( - cls, - stopped_session_id: Any, - owner: Any, - owner_pid: Any, - is_local_owner: bool, - local_run_key: str | None, - local_proc: Any, - ) -> None: - """Propagate the stop into shared state, the DB, and event subscribers.""" - if is_local_owner: - if stopped_session_id: - # Per-session cancellation: the run's own finalizer resolves the - # terminal status without affecting other concurrent runs. - state.cancelled_session_ids.add(str(stopped_session_id)) - elif local_run_key is not None: - state.manually_stopped_run_ids.add(str(local_run_key)) - if local_proc is not None and state.current_process is local_proc: - state.current_process = None - - for sid, conn_info in list(state.active_connections.items()): - if (stopped_session_id and str(sid) == str(stopped_session_id)) or ( - owner_pid and conn_info.get("pid") == owner_pid - ): - state.active_connections.pop(sid, None) - - if stopped_session_id: - session_repo.update_session_status(str(stopped_session_id), "cancelled", time.time()) - try: - is_mcp = bool(owner and owner.ingress == "mcp") - if is_mcp or trace_store.read_status(str(stopped_session_id)): - trace_store.update_trace_status( - str(stopped_session_id), - "cancelled", - error="Task stopped from the Artemis frontend.", - ) - except Exception as exc: - print(f"Failed to update MCP cancellation status: {exc}") - cls._broadcast_event( - "session_ended", - { - "session_id": stopped_session_id, - "status": "cancelled", - "was_stopped_manually": True, - }, - ) - - if state.active_session_id and ( - not stopped_session_id or str(state.active_session_id) == str(stopped_session_id) - ): - state.active_session_id = None - state.current_goal = None - state.current_profile = None - - @classmethod - def _remove_stopped_queue_item( - cls, stopped_session_id: Any, reservation_cancelled: bool - ) -> None: - """Drop the stopped session's queue item and cancel its reservation.""" - if not stopped_session_id: - return - stopped_item = next( - ( - item - for item in state.queue_items - if isinstance(item, dict) and str(item.get("session_id")) == str(stopped_session_id) - ), - None, - ) - if stopped_item and not reservation_cancelled: - DeviceExecutionLock.cancel_reservation(stopped_item.get("queue_ticket")) - state.queue_items = [ - item - for item in state.queue_items - if not ( - isinstance(item, dict) and str(item.get("session_id")) == str(stopped_session_id) - ) - ] - - @classmethod - def _stop_targeted_task(cls, target_sid: str | None, target_device: str | None) -> bool: - """Stop a specific task (or default single-device active task).""" - active_owners = {} - try: - active_owners = DeviceExecutionLock.get_active_owners() - except OSError as exc: - # Unreadable lock dir: the local-process fallbacks below still apply. - print(f"[stop_tasks] Could not enumerate device owners: {exc}") - - owner = cls._resolve_stop_owner(active_owners, target_sid, target_device) - - owner_record_exists = DeviceExecutionLock.has_owner_record(target_device) - owner_pid = owner.pid if owner else None - - local_run_key, local_run, local_proc = cls._resolve_local_run(target_sid, target_device) - local_pid = getattr(local_proc, "pid", None) - is_local_owner = bool(owner_pid and local_pid and owner_pid == local_pid) - - local_item = cls._find_target_queue_item(target_sid) - stopped_session_id = cls._resolve_stopped_session_id( - owner, target_sid, local_run_key, is_local_owner, local_item - ) - - outcome = cls._terminate_stop_target( - owner, - owner_record_exists, - target_sid, - local_run, - local_run_key, - local_proc, - local_pid, - local_item, - is_local_owner, - ) - if outcome is None: - return False - stopped, is_local_owner, reservation_cancelled = outcome - - if not stopped and not target_sid: - return False - - cls._finalize_targeted_stop( - stopped_session_id, - owner, - owner_pid, - is_local_owner, - local_run_key, - local_proc, - ) - cls._remove_stopped_queue_item(stopped_session_id, reservation_cancelled) - - cls._clear_pause_file() - - cls.ensure_worker_running() - state.wake_event.set() - return True - - @classmethod - def stop_tasks( - cls, - clear_all: bool = False, - session_id: str | None = None, - device_id: str | None = None, - ) -> bool: - """Stop the active task controlling a mobile device or all tasks. - - The active lease is shared by frontend, MCP, CLI, SDK, and other UI - processes across all connected devices. - - If ``session_id`` or ``device_id`` is specified, only the corresponding - running or queued task is cancelled. - - If ``clear_all`` is requested, all active device owners are terminated - and pending queue submissions are cleared. - - If no specific task is specified and ``clear_all`` is False, stops the - currently active task in single-device mode for backward compatibility. - """ - target_sid = str(session_id).strip() if session_id else None - target_device = str(device_id).strip() if device_id else None - - if clear_all: - return cls._stop_all_tasks() - - return cls._stop_targeted_task(target_sid, target_device) - - @classmethod - def resume_task(cls) -> bool: - if PAUSE_FILE.exists(): - PAUSE_FILE.unlink() - return True - return False - - @classmethod - def recover_orphaned_recordings_on_launch(cls) -> int: - """Finalize recordings left behind by workers that died with the daemon. - - The per-run finalizer handles workers that exit while the daemon is up; - this sweep covers everything else (daemon crash, machine reboot, tasks - cancelled before this recovery path existed). Returns the number of - recordings published. - """ - recovered = 0 - try: - pending = session_repo.get_unfinalized_video_recordings() - except Exception as exc: - print(f"[ServerStartup] Could not enumerate unfinalized recordings: {exc}") - return 0 - for row in pending: - sess_id = row.get("session_id") - try: - final_path = media_service.recover_orphaned_recording( - row.get("local_video_path"), - row.get("start_time") or row.get("session_start_time"), - ) - except Exception as exc: - print(f"[ServerStartup] Recording recovery failed for {sess_id}: {exc}") - continue - if not final_path: - continue - if session_repo.mark_recording_ready(str(sess_id), str(final_path)): - recovered += 1 - print(f"[ServerStartup] Recovered recording for session {sess_id}: {final_path}") - if recovered: - print(f"[ServerStartup] Recovered {recovered} orphaned recording(s).") - return recovered - - @staticmethod - def archive_older_replays_on_launch(): - """Archives all existing step replay output folders on server launch.""" - if TEST_OUTPUTS_DIR.exists(): - archive_dir = TEST_DATA_DIR / "older" - for item in TEST_OUTPUTS_DIR.iterdir(): - if item.is_dir(): - archive_dir.mkdir(parents=True, exist_ok=True) - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - target_name = f"{item.name}_{timestamp}" - target_path = archive_dir / target_name - - counter = 1 - while target_path.exists(): - target_name = f"{item.name}_{timestamp}_{counter}" - target_path = archive_dir / target_name - counter += 1 - - print( - f"Archiving older replay record on server launch: " - f"{item.name} -> {target_path}" - ) - try: - shutil.move(str(item), str(target_path)) - except Exception as e: - print( - f"Warning: Failed to archive {item.name} during server launch: {e}", - file=sys.stderr, - ) - - @staticmethod - def verify_chunks_exist_on_launch(replay_manager): - """Verifies that chunked directories exist for all sessions in the database.""" - print("Verifying session chunks on launch...") - try: - sessions = session_repo.get_all_sessions() - session_ids = [s.get("session_id") for s in sessions if s.get("session_id")] - print(f"Found {len(session_ids)} sessions in database to verify.") - for session_id in session_ids: - replay_manager._ensure_session_chunked(str(session_id)) - except Exception as e: - print( - f"Warning: Failed to verify chunks during server launch: {e}", - file=sys.stderr, - ) - - -task_queue_service = TaskQueueService() diff --git a/apps/admin_console/services/worker_process_io.py b/apps/admin_console/services/worker_process_io.py deleted file mode 100644 index f94ab8282..000000000 --- a/apps/admin_console/services/worker_process_io.py +++ /dev/null @@ -1,144 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Low-level worker subprocess I/O plumbing for the task queue. - -These helpers are deliberately free of admin-console state: they deal only -with spawning options, output forwarding, and process-exit observation for a -single worker subprocess. TaskQueueService exposes them as its private -static methods so existing callers and tests keep working unchanged. -""" - -import asyncio -import codecs -import os -import psutil -import subprocess -import sys -from typing import Any - - -def subprocess_creation_kwargs() -> dict[str, Any]: - """Isolate task workers from the UI server's Windows console. - - A new process group alone is insufficient on Windows: the worker still - shares the parent's console, so a CTRL_C_EVENT generated anywhere in - that console can reach the UI server. CREATE_NO_WINDOW removes that - shared console boundary. - - Output is captured on every platform so it can be forwarded to the - server terminal and teed into the trace's stdout.log (the daemon itself - is often spawned with its stdio discarded, so inheriting would lose the - worker's logs entirely). - """ - kwargs: dict[str, Any] = { - "stdout": asyncio.subprocess.PIPE, - "stderr": asyncio.subprocess.STDOUT, - } - if sys.platform == "win32": - kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW - return kwargs - - -async def forward_worker_output( - stream: asyncio.StreamReader | None, log_path: str | None = None -) -> None: - """Forward a worker's combined output without corrupting UTF-8. - - When ``log_path`` is given, the output is also teed into that file so - the trace's advertised stdout.log actually exists for diagnostics. - """ - if stream is None: - return - - log_file = None - if log_path: - try: - os.makedirs(os.path.dirname(log_path), exist_ok=True) - log_file = open(log_path, "w", buffering=1, encoding="utf-8", errors="replace") - except Exception as exc: - print(f"[QueueWorker] Could not open worker log file '{log_path}': {exc}") - - def _emit(text: str) -> None: - sys.stdout.write(text) - sys.stdout.flush() - if log_file is not None: - try: - log_file.write(text) - except (OSError, ValueError): - # Best-effort tee into stdout.log; console output above - # already carried the text. - pass - - try: - decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") - while True: - chunk = await stream.read(4096) - if not chunk: - break - text = decoder.decode(chunk) - if text: - _emit(text) - - tail = decoder.decode(b"", final=True) - if tail: - _emit(tail) - finally: - if log_file is not None: - try: - log_file.close() - except OSError: - # Flush-on-close of the best-effort tee failed; nothing to do. - pass - - -async def finish_output_forwarder(output_task: asyncio.Task[None] | None) -> None: - """Drain final worker output without allowing inherited handles to stall the queue.""" - if output_task is None: - return - try: - await asyncio.wait_for(asyncio.shield(output_task), timeout=2.0) - except asyncio.CancelledError: - if output_task.cancelled(): - return - raise - except TimeoutError: - output_task.cancel() - try: - await output_task - except asyncio.CancelledError: - pass - except Exception as exc: - print(f"[QueueWorker] Failed to forward detached worker output: {exc}") - - -async def wait_for_worker_process(proc: asyncio.subprocess.Process) -> int: - """Wait for worker process to exit, with watchdog fallback if PID was reaped externally.""" - while True: - try: - return await asyncio.wait_for(proc.wait(), timeout=1.0) - except TimeoutError: - pid = getattr(proc, "pid", None) - if pid: - try: - p = psutil.Process(pid) - if not p.is_running() or p.status() == psutil.STATUS_ZOMBIE: - return proc.returncode if proc.returncode is not None else -15 - except (psutil.NoSuchProcess, ProcessLookupError): - return proc.returncode if proc.returncode is not None else -15 - except psutil.Error: - # Transient probe failure (e.g. AccessDenied): keep waiting. - pass - else: - return proc.returncode if proc.returncode is not None else -15 diff --git a/apps/showcase_ui/README.md b/apps/showcase_ui/README.md deleted file mode 100644 index 09bad9861..000000000 --- a/apps/showcase_ui/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# ✨ Artemis Showcase & User Workspace UI - -A modern, highly aesthetic presentation and interaction frontend built with **Angular 19** and **SCSS** for showcasing the Artemis mobile autonomous agent. - -## 🎨 Visual Design Highlights - -* **Aurora Glow Dynamics**: Ambient background lighting with smooth gradients. -* **Glassmorphism Aesthetic**: Translucent frosted-glass panels with subtle borders. -* **Real-time Dual-Pane Workspace**: - - **Left Pane (`app-agent-stream`)**: Live streaming of Agent thought steps, plan breakdowns, tool actions, and status. - - **Right Pane (`app-chat-interface`)**: Natural language chat input sidebar with interactive feedback and status pills. - -## 🚀 How to Run - -### Prerequisites -- Node.js (>= 18) -- Backend API running on `http://localhost:8000` (via `apps/admin_console` or `artemis ui`) - -### Development Server -```bash -cd apps/showcase_ui -npm install -npm start -``` -This runs `ng serve --proxy-config proxy.conf.json` on **`http://localhost:4200/`**. - -### Production Build -```bash -npm run build -``` -Build artifacts will be emitted to `dist/`, which can be served statically by `apps.admin_console` or `artemis ui`. diff --git a/apps/showcase_ui/angular.json b/apps/showcase_ui/angular.json deleted file mode 100644 index 1ddf7d1d9..000000000 --- a/apps/showcase_ui/angular.json +++ /dev/null @@ -1,132 +0,0 @@ -{ - "$schema": "./node_modules/@angular/cli/lib/config/schema.json", - "version": 1, - "cli": { - "packageManager": "npm", - "analytics": false - }, - "newProjectRoot": "projects", - "projects": { - "frontend": { - "projectType": "application", - "schematics": { - "@schematics/angular:component": { - "style": "scss" - } - }, - "root": "", - "sourceRoot": "src", - "prefix": "app", - "architect": { - "build": { - "builder": "@angular/build:application", - "options": { - "outputPath": "dist/frontend", - "index": "src/index.html", - "browser": "src/main.ts", - "polyfills": [ - "zone.js" - ], - "tsConfig": "tsconfig.app.json", - "inlineStyleLanguage": "scss", - "assets": [ - { - "glob": "**/*", - "input": "public" - } - ], - "styles": [ - "src/styles.scss" - ], - "scripts": [] - }, - "configurations": { - "production": { - "budgets": [ - { - "type": "initial", - "maximumWarning": "2MB", - "maximumError": "4MB" - }, - { - "type": "anyComponentStyle", - "maximumWarning": "200kB", - "maximumError": "400kB" - } - ], - "outputHashing": "all" - }, - "development": { - "optimization": false, - "extractLicenses": false, - "sourceMap": true - } - }, - "defaultConfiguration": "production" - }, - "serve": { - "builder": "@angular/build:dev-server", - "configurations": { - "production": { - "buildTarget": "frontend:build:production" - }, - "development": { - "buildTarget": "frontend:build:development" - } - }, - "defaultConfiguration": "development" - }, - "extract-i18n": { - "builder": "@angular/build:extract-i18n" - }, - "test": { - "builder": "@angular/build:karma", - "options": { - "polyfills": [ - "zone.js", - "zone.js/testing" - ], - "tsConfig": "tsconfig.spec.json", - "inlineStyleLanguage": "scss", - "assets": [ - { - "glob": "**/*", - "input": "public" - } - ], - "styles": [ - "src/styles.scss" - ], - "scripts": [] - } - } - } - } - }, - "schematics": { - "@schematics/angular:component": { - "type": "component" - }, - "@schematics/angular:directive": { - "type": "directive" - }, - "@schematics/angular:service": { - "type": "service" - }, - "@schematics/angular:guard": { - "typeSeparator": "." - }, - "@schematics/angular:interceptor": { - "typeSeparator": "." - }, - "@schematics/angular:module": { - "typeSeparator": "." - }, - "@schematics/angular:pipe": { - "typeSeparator": "." - }, - "@schematics/angular:resolver": { - "typeSeparator": "." - } - } -} diff --git a/apps/showcase_ui/package-lock.json b/apps/showcase_ui/package-lock.json deleted file mode 100644 index bab81b328..000000000 --- a/apps/showcase_ui/package-lock.json +++ /dev/null @@ -1,9472 +0,0 @@ -{ - "name": "artemis-showcase-ui", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "artemis-showcase-ui", - "version": "1.0.0", - "dependencies": { - "@angular/cdk": "^22.1.5", - "@angular/common": "^22.1.4", - "@angular/compiler": "^22.1.4", - "@angular/core": "^22.1.4", - "@angular/forms": "^22.1.4", - "@angular/platform-browser": "^22.1.4", - "@angular/platform-browser-dynamic": "^22.1.4", - "@angular/router": "^22.1.4", - "rxjs": "~7.8.0", - "tslib": "^2.3.0", - "zone.js": "~0.15.0" - }, - "devDependencies": { - "@angular/build": "^22.1.6", - "@angular/cli": "^22.1.6", - "@angular/compiler-cli": "^22.1.4", - "@types/jasmine": "~5.1.0", - "istanbul-lib-instrument": "^6.0.3", - "jasmine-core": "~5.6.0", - "karma": "~6.4.0", - "karma-chrome-launcher": "~3.2.0", - "karma-coverage": "~2.2.0", - "karma-jasmine": "~5.1.0", - "karma-jasmine-html-reporter": "~2.1.0", - "typescript": "~6.0.3" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@angular-devkit/architect": { - "version": "0.2201.6", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2201.6.tgz", - "integrity": "sha512-oGQEdof2/1bZk58PN9dvpGi8pvtbgE5vkP1xRtCqN8dSVxwre1l0pKynOj/Uge1KxjCvs4c1QrQu0vsAnY0vpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "22.1.6", - "rxjs": "7.8.2" - }, - "bin": { - "architect": "bin/cli.js" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular-devkit/core": { - "version": "22.1.6", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.1.6.tgz", - "integrity": "sha512-KLBsZoc2RhOy0xSdeYcLoSOqV/fBP3feBmhY9o12ry2IuyW/17sCmWr6XbIfTIYQN6M98Y1ewmwVNFsvV5b0gA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "8.20.0", - "ajv-formats": "3.0.1", - "jsonc-parser": "3.3.1", - "picomatch": "4.0.5", - "rxjs": "7.8.2", - "source-map": "0.7.6" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "chokidar": "^5.0.0" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } - } - }, - "node_modules/@angular-devkit/schematics": { - "version": "22.1.6", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-22.1.6.tgz", - "integrity": "sha512-IbDO9KbQyQm20uinsfT8d9ZtQw41/WVutI/65mAw39WZfN8Ky+MOgOUJCGLiSccMprvZ9YdodBTnN9bLH149Ag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "22.1.6", - "jsonc-parser": "3.3.1", - "magic-string": "1.0.0", - "ora": "9.4.1", - "rxjs": "7.8.2" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular/build": { - "version": "22.1.6", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-22.1.6.tgz", - "integrity": "sha512-J7JBd3hDAV4dlGNMavDC0KjtvbDd4KOddaom1inwkq92tYnD1ljmhDDigAsUBnRvQhJ82cvagD8zi0s9Ki4rTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2201.6", - "@babel/core": "8.0.1", - "@babel/helper-annotate-as-pure": "8.0.0", - "@babel/helper-split-export-declaration": "7.24.7", - "@inquirer/confirm": "6.1.1", - "@vitejs/plugin-basic-ssl": "2.3.0", - "beasties": "0.4.3", - "browserslist": "^4.26.0", - "esbuild": "0.28.2", - "https-proxy-agent": "9.1.0", - "jsonc-parser": "3.3.1", - "listr2": "11.0.0", - "magic-string": "1.0.0", - "mrmime": "2.0.1", - "oxc-parser": "0.142.0", - "parse5-html-rewriting-stream": "8.0.1", - "picomatch": "4.0.5", - "piscina": "5.2.0", - "rolldown": "1.2.0", - "sass": "1.101.0", - "semver": "7.8.5", - "source-map-support": "0.5.21", - "tinyglobby": "0.2.17", - "vite": "8.1.5", - "watchpack": "2.5.2" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "optionalDependencies": { - "lmdb": "3.5.6" - }, - "peerDependencies": { - "@angular/compiler": "^22.0.0", - "@angular/compiler-cli": "^22.0.0", - "@angular/core": "^22.0.0", - "@angular/localize": "^22.0.0", - "@angular/platform-browser": "^22.0.0", - "@angular/platform-server": "^22.0.0", - "@angular/service-worker": "^22.0.0", - "@angular/ssr": "^22.1.6", - "istanbul-lib-instrument": "^6.0.0", - "karma": "^6.4.0", - "less": "^4.2.0", - "ng-packagr": "^22.0.0", - "postcss": "^8.4.0", - "rollup": "^4.0.0", - "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", - "tslib": "^2.3.0", - "typescript": ">=6.0 <6.1", - "vitest": "^4.0.8" - }, - "peerDependenciesMeta": { - "@angular/core": { - "optional": true - }, - "@angular/localize": { - "optional": true - }, - "@angular/platform-browser": { - "optional": true - }, - "@angular/platform-server": { - "optional": true - }, - "@angular/service-worker": { - "optional": true - }, - "@angular/ssr": { - "optional": true - }, - "istanbul-lib-instrument": { - "optional": true - }, - "karma": { - "optional": true - }, - "less": { - "optional": true - }, - "ng-packagr": { - "optional": true - }, - "postcss": { - "optional": true - }, - "rollup": { - "optional": true - }, - "tailwindcss": { - "optional": true - }, - "vitest": { - "optional": true - } - } - }, - "node_modules/@angular/cdk": { - "version": "22.1.5", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-22.1.5.tgz", - "integrity": "sha512-1tYAeI5KKIqNGNvyDuMM0Drs2FU3M4B25Q1BlAnwtZe3wvrpVETAGXgHaBX3Hs/pvWfgMNwgbcHkxlKVUBNXMA==", - "license": "MIT", - "dependencies": { - "parse5": "^8.0.0", - "tslib": "^2.3.0" - }, - "peerDependencies": { - "@angular/common": "^22.0.0 || ^23.0.0", - "@angular/core": "^22.0.0 || ^23.0.0", - "@angular/forms": "^22.0.0 || ^23.0.0", - "@angular/platform-browser": "^22.0.0 || ^23.0.0", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@angular/cli": { - "version": "22.1.6", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-22.1.6.tgz", - "integrity": "sha512-HT3OzYkSpCyXMTgD5G1tsS7vkrY8cYJ/JgSjRjrpwOAooSMtKF1hv7BgBcn79sKg4eaiPLrDpQLXP93vHxgSFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/architect": "0.2201.6", - "@angular-devkit/core": "22.1.6", - "@angular-devkit/schematics": "22.1.6", - "@inquirer/prompts": "8.5.2", - "@listr2/prompt-adapter-inquirer": "4.2.5", - "@modelcontextprotocol/sdk": "1.30.0", - "@schematics/angular": "22.1.6", - "jsonc-parser": "3.3.1", - "listr2": "11.0.0", - "npm-package-arg": "14.0.0", - "parse5-html-rewriting-stream": "8.0.1", - "semver": "7.8.5", - "yargs": "18.1.0", - "zod": "4.4.3" - }, - "bin": { - "ng": "bin/ng.js" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular/common": { - "version": "22.1.4", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.4.tgz", - "integrity": "sha512-UtWCloA/d0I5twV9Lu/Fcc7T6uQb40f/k0Hj5HLohIsiLTyvXVxWINBfa7LWWLtcq/0UOIY/yCs9Zq9Sj4dWqA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0" - }, - "peerDependencies": { - "@angular/core": "22.1.4", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@angular/compiler": { - "version": "22.1.4", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.1.4.tgz", - "integrity": "sha512-o+W0shjR5KmPPWchFmqN9bLINYAy14zIW//g9T5WqKDMOqjt4IbeY6AKOoE1db/dXffprLEH9nyPOmi19iKNxg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0" - } - }, - "node_modules/@angular/compiler-cli": { - "version": "22.1.4", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.1.4.tgz", - "integrity": "sha512-h9CcVG614TJLbk+dswlzSjYARYJh2MLOEwcnpt9zUGtOffhrrSS9BFJGjSwzdDSVM3J5mvf5ZuW+0hPAuRRoOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "8.0.1", - "@jridgewell/sourcemap-codec": "^1.4.14", - "chokidar": "^5.0.0", - "convert-source-map": "^1.5.1", - "reflect-metadata": "^0.2.0", - "semver": "^7.0.0", - "tslib": "^2.3.0", - "yargs": "^18.0.0" - }, - "bin": { - "ng-xi18n": "bundles/src/bin/ng_xi18n.js", - "ngc": "bundles/src/bin/ngc.js" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0" - }, - "peerDependencies": { - "@angular/compiler": "22.1.4", - "typescript": ">=6.0 <6.1" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@angular/core": { - "version": "22.1.4", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-22.1.4.tgz", - "integrity": "sha512-0dW+lw8nHo6RGBUOXtjs02vjHIPJ8rEbw4fd0NH3Dii4p+gIB4ZaTw+o7Se3Nf4m1oZ8+2qAYmWVHXlfhKcCDg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0" - }, - "peerDependencies": { - "@angular/compiler": "22.1.4", - "rxjs": "^6.5.3 || ^7.4.0", - "zone.js": "~0.15.0 || ~0.16.0" - }, - "peerDependenciesMeta": { - "@angular/compiler": { - "optional": true - }, - "zone.js": { - "optional": true - } - } - }, - "node_modules/@angular/forms": { - "version": "22.1.4", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.1.4.tgz", - "integrity": "sha512-w8ZXe4oURrb/DS5zAFrR4AmGXC84sdLeOKXRM76ndRRgSCzyRLEe4wDU1KHnDejNVIGvoSZYJJjOajG1lpj8TA==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "tslib": "^2.3.0", - "zod": "^4.0.10" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0" - }, - "peerDependencies": { - "@angular/common": "22.1.4", - "@angular/core": "22.1.4", - "@angular/platform-browser": "22.1.4", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@angular/platform-browser": { - "version": "22.1.4", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.4.tgz", - "integrity": "sha512-zhEWnRShG6GjnEvCVROD68XfnrV6+HJ9bPjwZx4LsAz7spX823Nhiml8Uqpvijsa1w7UdWLU7nK/magr9IEMOQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0" - }, - "peerDependencies": { - "@angular/animations": "22.1.4", - "@angular/common": "22.1.4", - "@angular/core": "22.1.4" - }, - "peerDependenciesMeta": { - "@angular/animations": { - "optional": true - } - } - }, - "node_modules/@angular/platform-browser-dynamic": { - "version": "22.1.4", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-22.1.4.tgz", - "integrity": "sha512-gtaQ05csHot468owe3AOXJYd2/VPlIQFr3tyXV8O2vhkfTokAyynpaMLc2q7EWbrDDuMvzETR2sBKBhgqIul+A==", - "deprecated": "@angular/platform-browser-dynamic is deprecated. Use `@angular/platform-browser` instead.", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0" - }, - "peerDependencies": { - "@angular/common": "22.1.4", - "@angular/compiler": "22.1.4", - "@angular/core": "22.1.4", - "@angular/platform-browser": "22.1.4" - } - }, - "node_modules/@angular/router": { - "version": "22.1.4", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-22.1.4.tgz", - "integrity": "sha512-vCwJLRnFBSRH5LxSF8OXKBhdmJ526hVGb/5sfeLTQW/oWCdcfXb2RvHfB077m5cdqKX9sBwFVlkJ8Z8kgx/pLQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0" - }, - "peerDependencies": { - "@angular/common": "22.1.4", - "@angular/core": "22.1.4", - "@angular/platform-browser": "22.1.4", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-8.0.0.tgz", - "integrity": "sha512-DOjnob/cXOUgDOozCDeq/aK2p5y8dUIVdf6tNhEV1HQRd6I8aQ4f4fbtHRVEvb6lP3BGomrKHiS8ICAASSVQSw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/core": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-8.0.1.tgz", - "integrity": "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^8.0.0", - "@babel/generator": "^8.0.0", - "@babel/helper-compilation-targets": "^8.0.0", - "@babel/helpers": "^8.0.0", - "@babel/parser": "^8.0.0", - "@babel/template": "^8.0.0", - "@babel/traverse": "^8.0.0", - "@babel/types": "^8.0.0", - "@types/gensync": "^1.0.5", - "convert-source-map": "^2.0.0", - "empathic": "^2.0.1", - "gensync": "^1.0.0-beta.2", - "import-meta-resolve": "^4.2.0", - "json5": "^2.2.3", - "obug": "^2.1.1", - "semver": "^7.7.3" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/@babel/code-frame": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", - "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^8.0.0", - "js-tokens": "^10.0.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/core/node_modules/@babel/helper-string-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", - "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/core/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", - "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/core/node_modules/@babel/parser": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", - "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^8.0.4" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/core/node_modules/@babel/types": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", - "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.4" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/core/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/core/node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/generator": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", - "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^8.0.0", - "@babel/types": "^8.0.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "@types/jsesc": "^2.5.0", - "jsesc": "^3.0.2" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/generator/node_modules/@babel/helper-string-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", - "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", - "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/generator/node_modules/@babel/parser": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", - "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^8.0.4" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/generator/node_modules/@babel/types": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", - "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.4" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-8.0.0.tgz", - "integrity": "sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^8.0.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure/node_modules/@babel/helper-string-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", - "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", - "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure/node_modules/@babel/types": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", - "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.4" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-8.0.0.tgz", - "integrity": "sha512-JwculLABZvyPvyLBpwU/E/IbH2uM3mnxNtIJpxnIfb24y1PrdVxK5Dqjle4DpgqpGRnwgC7G8IkzPdSXZrO1Ew==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^8.0.0", - "@babel/helper-validator-option": "^8.0.0", - "browserslist": "^4.24.0", - "lru-cache": "^11.0.0", - "semver": "^7.7.3" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.0.tgz", - "integrity": "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", - "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.24.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-8.0.0.tgz", - "integrity": "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helpers": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-8.0.0.tgz", - "integrity": "sha512-wfbi91pM3py96oIiJEz7qIpyXDytgr9zQC1HEWwlGNVRAEmItuU/0a41ZUKu1sJGyhhOIpc4t5vk4PYzt8wpsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^8.0.0", - "@babel/types": "^8.0.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helpers/node_modules/@babel/helper-string-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", - "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helpers/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", - "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helpers/node_modules/@babel/types": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", - "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.4" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/template": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", - "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^8.0.0", - "@babel/parser": "^8.0.0", - "@babel/types": "^8.0.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/template/node_modules/@babel/code-frame": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", - "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^8.0.0", - "js-tokens": "^10.0.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/template/node_modules/@babel/helper-string-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", - "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/template/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", - "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/template/node_modules/@babel/parser": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", - "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^8.0.4" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/template/node_modules/@babel/types": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", - "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.4" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/template/node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/traverse": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.4.tgz", - "integrity": "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^8.0.0", - "@babel/generator": "^8.0.0", - "@babel/helper-globals": "^8.0.0", - "@babel/parser": "^8.0.4", - "@babel/template": "^8.0.0", - "@babel/types": "^8.0.4", - "obug": "^2.1.1" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/code-frame": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", - "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^8.0.0", - "js-tokens": "^10.0.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/helper-string-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", - "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", - "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/parser": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", - "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^8.0.4" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/types": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", - "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.4" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/traverse/node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/@emnapi/core": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", - "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@harperfast/extended-iterable": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@harperfast/extended-iterable/-/extended-iterable-1.0.3.tgz", - "integrity": "sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==", - "dev": true, - "license": "Apache-2.0", - "optional": true - }, - "node_modules/@hono/node-server": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", - "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "hono": "^4" - } - }, - "node_modules/@inquirer/ansi": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", - "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - } - }, - "node_modules/@inquirer/checkbox": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.3.tgz", - "integrity": "sha512-XEYX2WA8SBkLPczL6/yXPHLPCvDoptmh9v56Cy05BSV1Smk1vWy19bTC4qJBuIffw7+6l4CcaYYzGqG60RfW1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/core": "^12.0.1", - "@inquirer/figures": "^2.0.8", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/checkbox/node_modules/@inquirer/core": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.1.tgz", - "integrity": "sha512-JMD5Jy/ScL5TZE18m83Nw25HjqGFLoWXwnEkW7IdwwAhZpB9Bus55/WU7zn3UqR1MOCjjTQOIYpiD4vjWA3LPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/figures": "^2.0.8", - "@inquirer/type": "^4.1.0", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/confirm": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", - "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.2.1", - "@inquirer/type": "^4.0.7" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", - "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/figures": "^2.0.7", - "@inquirer/type": "^4.0.7", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/editor": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.1.tgz", - "integrity": "sha512-y43COoyVUjPWIobn2Qep/uI1drPS78aaZZZ9kVi94Tyu/GuW2N8d8Q4rifJXGAXCEAXCPTTMjD8gC1HyvM5ukA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.1", - "@inquirer/external-editor": "^3.0.4", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/editor/node_modules/@inquirer/core": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.1.tgz", - "integrity": "sha512-JMD5Jy/ScL5TZE18m83Nw25HjqGFLoWXwnEkW7IdwwAhZpB9Bus55/WU7zn3UqR1MOCjjTQOIYpiD4vjWA3LPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/figures": "^2.0.8", - "@inquirer/type": "^4.1.0", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/expand": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.3.tgz", - "integrity": "sha512-3NQJiXNJ/aj9wiAsr7pECdp5Qe9J0X9YUJCKsaFXS+ddOxfL6J4AIl3w3T4Gq3kK0WsQY5GMoDokK5X94m6lHw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.1", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/expand/node_modules/@inquirer/core": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.1.tgz", - "integrity": "sha512-JMD5Jy/ScL5TZE18m83Nw25HjqGFLoWXwnEkW7IdwwAhZpB9Bus55/WU7zn3UqR1MOCjjTQOIYpiD4vjWA3LPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/figures": "^2.0.8", - "@inquirer/type": "^4.1.0", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/external-editor": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.4.tgz", - "integrity": "sha512-tZbbaK2ovq6vlrRBNQvjrypmrED/p5x2ncIHQ79cD55tei3dD96v5glMMA+6tiq7K104i/25DVYKWVPJuV6ptA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chardet": "^2.1.1", - "iconv-lite": "^0.7.2" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.8.tgz", - "integrity": "sha512-tApbon79GM9ry56ja/Ud3SY2CL4TQsao9fIwDQbgTeNY55025GdMzQ2+UdegV/lx51VNGUB59M0v0nMpybYY4Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - } - }, - "node_modules/@inquirer/input": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.4.tgz", - "integrity": "sha512-3xQkQrOvgOzpSN2ciTVdRDlg1FWMCA8l+0KfB6SNlILoTCGzJTzO/gc0Rwjcb3usuGyKdaGtI6OiyMdeMeLWkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.1", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/input/node_modules/@inquirer/core": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.1.tgz", - "integrity": "sha512-JMD5Jy/ScL5TZE18m83Nw25HjqGFLoWXwnEkW7IdwwAhZpB9Bus55/WU7zn3UqR1MOCjjTQOIYpiD4vjWA3LPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/figures": "^2.0.8", - "@inquirer/type": "^4.1.0", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/number": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.2.1.tgz", - "integrity": "sha512-5KaqwZNLRpUuWcoCrYghPP9TMaXL5v2Sk4xqePM7RCVegcJStoXdWibio60YIC1bec+z1fCyb67N6XPJIkZtGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.1", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/number/node_modules/@inquirer/core": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.1.tgz", - "integrity": "sha512-JMD5Jy/ScL5TZE18m83Nw25HjqGFLoWXwnEkW7IdwwAhZpB9Bus55/WU7zn3UqR1MOCjjTQOIYpiD4vjWA3LPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/figures": "^2.0.8", - "@inquirer/type": "^4.1.0", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/password": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.2.0.tgz", - "integrity": "sha512-CvVcW09emkBESEOW+4R8CjLNkP3fB3XrjeL8CDvfpjgrJN+V9oerXmJAXXM3l+4xqYPD5Yaujzy/Ph0PLOdDuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/core": "^12.0.1", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/password/node_modules/@inquirer/core": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.1.tgz", - "integrity": "sha512-JMD5Jy/ScL5TZE18m83Nw25HjqGFLoWXwnEkW7IdwwAhZpB9Bus55/WU7zn3UqR1MOCjjTQOIYpiD4vjWA3LPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/figures": "^2.0.8", - "@inquirer/type": "^4.1.0", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/prompts": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.5.2.tgz", - "integrity": "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/checkbox": "^5.2.1", - "@inquirer/confirm": "^6.1.1", - "@inquirer/editor": "^5.2.2", - "@inquirer/expand": "^5.1.1", - "@inquirer/input": "^5.1.2", - "@inquirer/number": "^4.1.1", - "@inquirer/password": "^5.1.1", - "@inquirer/rawlist": "^5.3.1", - "@inquirer/search": "^4.2.1", - "@inquirer/select": "^5.2.1" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/rawlist": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.3.tgz", - "integrity": "sha512-Mu7WrtmDLaXBDEyrRLS70SZgX9ZSm4Up1w0ZxiH8C1OOp9oaVCn2k8q3QGgmlnhsKYUhuaU3zFWhAP6wxkVIMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.1", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/rawlist/node_modules/@inquirer/core": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.1.tgz", - "integrity": "sha512-JMD5Jy/ScL5TZE18m83Nw25HjqGFLoWXwnEkW7IdwwAhZpB9Bus55/WU7zn3UqR1MOCjjTQOIYpiD4vjWA3LPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/figures": "^2.0.8", - "@inquirer/type": "^4.1.0", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/search": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.3.1.tgz", - "integrity": "sha512-0VWOvsHWI0rPj6CG70MoP4oXNCB6adcyN8bVFZXnh11eLDdPIK2f2XCmva78acPPDfJisfab7qNakwBh7hdBXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.1", - "@inquirer/figures": "^2.0.8", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/search/node_modules/@inquirer/core": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.1.tgz", - "integrity": "sha512-JMD5Jy/ScL5TZE18m83Nw25HjqGFLoWXwnEkW7IdwwAhZpB9Bus55/WU7zn3UqR1MOCjjTQOIYpiD4vjWA3LPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/figures": "^2.0.8", - "@inquirer/type": "^4.1.0", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/select": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.3.tgz", - "integrity": "sha512-KuRTodDa6xBXX2noIpjuitpX/QT7Sfav7dIZ/OfUY54Hxg95nrGoshSzxx6Ey7qqLbImKdiGkSDt7KjPXjQgmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/core": "^12.0.1", - "@inquirer/figures": "^2.0.8", - "@inquirer/type": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/select/node_modules/@inquirer/core": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.1.tgz", - "integrity": "sha512-JMD5Jy/ScL5TZE18m83Nw25HjqGFLoWXwnEkW7IdwwAhZpB9Bus55/WU7zn3UqR1MOCjjTQOIYpiD4vjWA3LPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/figures": "^2.0.8", - "@inquirer/type": "^4.1.0", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/type": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.1.0.tgz", - "integrity": "sha512-FMiJpuHUG3Dk0ex+UIXkre7i+i4OcwHWk9YdcVtZHFwb/r2rnrU2ipTCNAB7A+QOP0ryzIcqOfy76fRyyvOEAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", - "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@listr2/prompt-adapter-inquirer": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-4.2.5.tgz", - "integrity": "sha512-pYGy9dTdTwXdasPgyohkr0HoQ4FrkAzFnsUZl/gcnadDArbpZ8e+fgr+F9WBdNEl2y00mb9bCM4WgmoBkZJ27A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/type": "^4.0.7" - }, - "engines": { - "node": ">=22.13.0" - }, - "peerDependencies": { - "@inquirer/prompts": ">= 3 < 9", - "listr2": "11.0.0" - } - }, - "node_modules/@lmdb/lmdb-darwin-arm64": { - "version": "3.5.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.5.6.tgz", - "integrity": "sha512-mY5FG4TjPAkY4P0w+OhHaUka5mDh2TX2WKYIwuKzJ1zeW3VvRgxdam/lGJTquI+bthTx5CSHDW+BAQCnNAzkEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@lmdb/lmdb-darwin-x64": { - "version": "3.5.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.5.6.tgz", - "integrity": "sha512-foa+pwitysO8k+xhs7psBFfTKnVgR69NlZRRTHaFVDqphh7AdGpLeyRzKw/ofatr/sN6TiHRRW6mmop0ZrrppQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@lmdb/lmdb-linux-arm": { - "version": "3.5.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.5.6.tgz", - "integrity": "sha512-QR4YRyR5h5Z8eGXrNQjiyo2NNDfqi3tCc9dQG5Is1blCt+qWw1ZoBWhlWAr5d+jshkifMIJjVHzHGKbkKzF8Tw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@lmdb/lmdb-linux-arm64": { - "version": "3.5.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.5.6.tgz", - "integrity": "sha512-HmiyFFdJa38s1heCMSooSPaBSFTHJ3C+ERPp28xAPlDX1YiALJVOgbry065nXd8Y7KISWjnw05zpG1RX8IfftA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@lmdb/lmdb-linux-x64": { - "version": "3.5.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.5.6.tgz", - "integrity": "sha512-ADzCuCF2cTNiX9kDScqcz1fjnAkxPpQNneV3KFTdV3wWtVlI2sTGzySoMTgDpinkMMFj1NTJlxA6XR8fwc4hlA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@lmdb/lmdb-win32-arm64": { - "version": "3.5.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-arm64/-/lmdb-win32-arm64-3.5.6.tgz", - "integrity": "sha512-J7A9aEQsQiv0TYtBGL7NDIPp2lOS8nnl+zm4sWZm1xlsTTaQ4PgD096Adzdrk27rw3UxCkDXdCUa4ax41oztBQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@lmdb/lmdb-win32-x64": { - "version": "3.5.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.5.6.tgz", - "integrity": "sha512-1g7G0knRX2iV/voDu54yxrGqw5Dk0w2oIYb7dgJq8IkOi+m7wbD8Q3QpPFjh0C01G58S88dqGn03len6UPCXsg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", - "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.9 || ^2.0.5", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", - "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", - "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", - "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", - "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", - "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", - "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@napi-rs/nice": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz", - "integrity": "sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "optionalDependencies": { - "@napi-rs/nice-android-arm-eabi": "1.1.1", - "@napi-rs/nice-android-arm64": "1.1.1", - "@napi-rs/nice-darwin-arm64": "1.1.1", - "@napi-rs/nice-darwin-x64": "1.1.1", - "@napi-rs/nice-freebsd-x64": "1.1.1", - "@napi-rs/nice-linux-arm-gnueabihf": "1.1.1", - "@napi-rs/nice-linux-arm64-gnu": "1.1.1", - "@napi-rs/nice-linux-arm64-musl": "1.1.1", - "@napi-rs/nice-linux-ppc64-gnu": "1.1.1", - "@napi-rs/nice-linux-riscv64-gnu": "1.1.1", - "@napi-rs/nice-linux-s390x-gnu": "1.1.1", - "@napi-rs/nice-linux-x64-gnu": "1.1.1", - "@napi-rs/nice-linux-x64-musl": "1.1.1", - "@napi-rs/nice-openharmony-arm64": "1.1.1", - "@napi-rs/nice-win32-arm64-msvc": "1.1.1", - "@napi-rs/nice-win32-ia32-msvc": "1.1.1", - "@napi-rs/nice-win32-x64-msvc": "1.1.1" - } - }, - "node_modules/@napi-rs/nice-android-arm-eabi": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.1.1.tgz", - "integrity": "sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-android-arm64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.1.1.tgz", - "integrity": "sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-darwin-arm64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.1.1.tgz", - "integrity": "sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-darwin-x64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.1.1.tgz", - "integrity": "sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-freebsd-x64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.1.1.tgz", - "integrity": "sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.1.1.tgz", - "integrity": "sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-arm64-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.1.1.tgz", - "integrity": "sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-arm64-musl": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.1.1.tgz", - "integrity": "sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-ppc64-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.1.1.tgz", - "integrity": "sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-riscv64-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.1.1.tgz", - "integrity": "sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-s390x-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.1.1.tgz", - "integrity": "sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-x64-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.1.1.tgz", - "integrity": "sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-x64-musl": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.1.1.tgz", - "integrity": "sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-openharmony-arm64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-openharmony-arm64/-/nice-openharmony-arm64-1.1.1.tgz", - "integrity": "sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-win32-arm64-msvc": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.1.1.tgz", - "integrity": "sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-win32-ia32-msvc": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.1.1.tgz", - "integrity": "sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-win32-x64-msvc": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.1.1.tgz", - "integrity": "sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", - "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", - "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" - } - }, - "node_modules/@oxc-parser/binding-android-arm-eabi": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.142.0.tgz", - "integrity": "sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-android-arm64": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.142.0.tgz", - "integrity": "sha512-WZkvGRLNQTz8lR9zP5nLjUdlroRCopBu3g9zF1p/laE6DzT1UbQo8Rdz5MWhaJUPYg/6gp+jo7HUgsyKaN1FtQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-darwin-arm64": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.142.0.tgz", - "integrity": "sha512-l4khS8LQOOVYsGRVARo1gSaCT/aBSceUVXgtovWc2+drnxVuDr082WA3OCHVdVzIz5JIrP/y9CWsSKxBDNmYGg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-darwin-x64": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.142.0.tgz", - "integrity": "sha512-QBsNF3nqlXmcH2B1YOPqQYmCJoy4HuIjUxGbBO/k5JAJUl68ghU2psRY2zPk+RyBaWqKP/qfL4oaFgEMCdwskA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-freebsd-x64": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.142.0.tgz", - "integrity": "sha512-b7Q7m4Cqc6XqNhri3R+QhU+GVy646Pn+bkdhrDdWym/Fdi0ZUa+d73H9dm5H91JtbtAQ/z1d8XKMW3oOV8a4tQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.142.0.tgz", - "integrity": "sha512-3riVS5IhdH3uCZj1Y9ftDQlR0dvLsIlw/edrRqk8JhgNd5K0XSs+UBtgh50N13CAlW9/TXj6sVGXaKNBocd0Yg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.142.0.tgz", - "integrity": "sha512-NmXUOpgpTSkhl795TiXmWppTwmSJ92RC1qvD6e4XOF+slgmo3e6Ah+kEu+6AN8s7NAOEwqGmir58MgSQSWmBSA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm64-gnu": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.142.0.tgz", - "integrity": "sha512-gc0EXsKtXgerujmU2Bql3u1L1HsSQ2774R83idq/FoNMPVV/RY/1ErFsvnit7KoiP/sLvzQixeUo4Ut0ic0wmw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm64-musl": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.142.0.tgz", - "integrity": "sha512-F2XvmWSE0uWpie+jHKKIFgdVOe9ypGhkEZxKx5DuW215K6cbAC274yYaPkcM7EqY4Df3Weyhpcz3lsURyH2LVg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.142.0.tgz", - "integrity": "sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.142.0.tgz", - "integrity": "sha512-+G8F/4ckwT7FCJV4H2bt09xEzJbjNCfuL4Sp1AYNaFtFMVtgIGMuJlteT82U+K0UIZ/DzAR/LDlMFnEuajG7Kw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-riscv64-musl": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.142.0.tgz", - "integrity": "sha512-hTsHtTLxMAfCo+rpF5K3qZJKW2NpPN/CHd4mYB3y7XlSdspHkd2gehDIofP64AacA9nWQw2tY3O7wR6UY8IVOA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-s390x-gnu": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.142.0.tgz", - "integrity": "sha512-6y7qYY3TCUDYjqswImdTGl92y+KA/80twALegQPN27kfY+bG7Ib1+L3jbmrCZQx6wrVnai9IPsEZp07I0hx7JQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-x64-gnu": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.142.0.tgz", - "integrity": "sha512-i69kAWU+2LgoH5bR+zWiiu+UzAw7Oxkwv7COeJTeY19pn4e70nKQcr9Pm6cL2Z0Z54d+gl9qADlK/0yyuCPiBA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-x64-musl": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.142.0.tgz", - "integrity": "sha512-4SQs678MmjYVrmhAgCWD4o0vpaFszXw9xLX5p2Z9MMFcltxiLkA88wQjh80YHjPrXtpyZ2CWI5m+1yNKM0m2Pw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-openharmony-arm64": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.142.0.tgz", - "integrity": "sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-wasm32-wasi": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.142.0.tgz", - "integrity": "sha512-3pLDyY3+oogW73RM5uehNgAiR/Xfb7fvO2Q1Z1gIqZ2+50XDVQmBVlRkHXZTU4gKnQHpwETNsYQVsJ3joVB2iA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.2", - "@emnapi/runtime": "1.11.2", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-win32-arm64-msvc": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.142.0.tgz", - "integrity": "sha512-Had/VeVY28Oyb0K+Q4FV8KCzoBycIh93oDK6pCbya9lkzdq+ikMHMgBubsdqqlybjJmQRawCQRrnBRHyQwYvcQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-win32-ia32-msvc": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.142.0.tgz", - "integrity": "sha512-GGi3+YphVHavvgs6gum2UXoNCqzHAmPt/nXkn8ZQZstV2Q1qZD1Mn8fz/nWrDkefHQtrG/+1/XrbMxsBTo6Svw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-win32-x64-msvc": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.142.0.tgz", - "integrity": "sha512-Ny/Wv4Us1LGC/ljwNTp+Hx3r/pH15EFfeDF0p+n898gt+TtRd6C9SccHcuUhDiNTb8s5tt7jdeAMDRQZ4Vq6hg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", - "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@parcel/watcher": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", - "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.3", - "is-glob": "^4.0.3", - "node-addon-api": "^7.0.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.6.0", - "@parcel/watcher-darwin-arm64": "2.6.0", - "@parcel/watcher-darwin-x64": "2.6.0", - "@parcel/watcher-freebsd-x64": "2.6.0", - "@parcel/watcher-linux-arm-glibc": "2.6.0", - "@parcel/watcher-linux-arm-musl": "2.6.0", - "@parcel/watcher-linux-arm64-glibc": "2.6.0", - "@parcel/watcher-linux-arm64-musl": "2.6.0", - "@parcel/watcher-linux-x64-glibc": "2.6.0", - "@parcel/watcher-linux-x64-musl": "2.6.0", - "@parcel/watcher-win32-arm64": "2.6.0", - "@parcel/watcher-win32-x64": "2.6.0" - } - }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", - "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", - "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", - "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", - "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", - "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", - "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", - "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", - "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", - "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", - "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", - "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-x64": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", - "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher/node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.0.tgz", - "integrity": "sha512-9yB1l95IrJuNGDFdOYe79vdApdz6WWBCObE+rQ2LUliYUlcyFwSYIb2xb5/Ifw7dAtMy2ZqNyd8QTSOc7duAKw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.0.tgz", - "integrity": "sha512-pexNaW9ACLUOaBITOpU6qVu4VrsOFIjTv6bzgu0YUATo4eUJx0V605PxwZfndpPOn0ilqGqvGQ0M8UW0IE24jg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.0.tgz", - "integrity": "sha512-NqKYaq0355ZmNMG4QGpxtEDxsc7tGDhjhCm4PpE0cwnBW+5Il95LJyq414niEiaKLVjnVHBEjSo1wngKxJNiFw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.0.tgz", - "integrity": "sha512-3vPoHzh6eBTz9IbB0/qZdSr0Qeks2echn+I4cHu2joV74VriPDdldswksEDzrl1mBB+oPRi+67+3Ib59paxIPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.0.tgz", - "integrity": "sha512-E6NNefZ1bUVmKJq2tJkf45J4Zyczj7qm9rUT7NY+Xo2474Y13qWAwc2tvBt0BAVbmtXR1llkxXg0Ou1jbDf2SQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.0.tgz", - "integrity": "sha512-D+TgkdgM1vu+7/Fpf8+v0ARW+RXEP9Ccazgm8zQ4JFFd9Q7SrYQ2TakU5S5ihazQDgpKyAgZDOcIFsvoHmTZ8w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.0.tgz", - "integrity": "sha512-wUqdwJBbAv0APN87GecstdMUtLjjNTs0hBALpxETD73mccFxdmt/XeizXDtN5RAlBwNKmI+Tg+blect2G+8IeQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.0.tgz", - "integrity": "sha512-9DtF35qR9/NrfhM4oxLplCzVVjE+KKm8Pjemi0i/sdhAWkUasjmSo8WTTubNJClhSHCfyk2yeyoXDQEDPtDAAw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.0.tgz", - "integrity": "sha512-RzuHrBh8X8Hntd2N4VR02QGEciq/9JhcZoTpR/Cee6otRrlILGCf3cg2ygHuih+ZebUnWmMrDX6ITI85btO6rQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.0.tgz", - "integrity": "sha512-MK7L0018jjh1jR3mh21G2j1zAVcpscJBlPo2z19pRjv2XOYGRhaV4LyiD8HO6nCDdZln9IFgCMIV5yt4E3klGQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.0.tgz", - "integrity": "sha512-gyrxLQ9NfGb/9LoVnC4kb9miUghw1mghnkfYvNHSnVIXriabnfgGPUP4RLcJm87q3KgYz4FYUG8IDiWUT+CpSw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.0.tgz", - "integrity": "sha512-/6VFMQGRmrhP77KXDC+StIxGzcNp5JOIyYtw0CQ8gPlzhpiIRucYfoM5FaFamHd5BJYIdH86yfP46l1p3WdrFA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.0.tgz", - "integrity": "sha512-rwdbUL465kisF24WEJLvP3JrEG6E5GRuIHt5wpMwHGERtHe4Wm2CIvtf5gTBgr2tGOHKh5NdKEAFS2VkOPE91g==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.2", - "@emnapi/runtime": "1.11.2", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.0.tgz", - "integrity": "sha512-+5suHwRiKGmhwyUaNT8a5QbrBvLFh2DbO910TEmGRH1aSxwrCezodvGQnulv4uiWEIv1Kq4ypRsJ5+O+ry1DiA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.0.tgz", - "integrity": "sha512-WfFv6/qGufotqBSBzBYwgpCkJBk8Nj7697LL9vTz/XWc67e0r3oewu8iMRwQj3AUL45GVD7wVsPjCsAAtW66Wg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@schematics/angular": { - "version": "22.1.6", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-22.1.6.tgz", - "integrity": "sha512-RiD4OZJ4yuaM1aXb3pt1gjDSWwkP0jrgiCuoaBls1eabcrDmlsNuQv0ekxbcGgCRPdSJvmHRZIHHmFPzei8w3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "22.1.6", - "@angular-devkit/schematics": "22.1.6", - "jsonc-parser": "3.3.1", - "typescript": "6.0.3" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@socket.io/component-emitter": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", - "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/cors": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", - "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/gensync": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.5.tgz", - "integrity": "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/jasmine": { - "version": "5.1.15", - "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-5.1.15.tgz", - "integrity": "sha512-ZAC8KjmV2MJxbNTrwXFN+HKeajpXQZp6KpPiR6Aa4XvaEnjP6qh23lL/Rqb7AYzlp3h/rcwDrQ7Gg7q28cQTQg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/jsesc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", - "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", - "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~8.3.0" - } - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@vitejs/plugin-basic-ssl": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz", - "integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "peerDependencies": { - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/agent-base": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", - "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - } - }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/base64id": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", - "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^4.5.0 || >= 5.9" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.14", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz", - "integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/beasties": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.4.3.tgz", - "integrity": "sha512-fIIeLOcbAB/K1kb1HBVJoiq1alHL4RCYBSo5e7HzrNkkgMggXR1Vqt/Z9JWnkfe/qdCo66Ux3QRwZioAIBdWRA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "css-select": "^6.0.0", - "css-what": "^7.0.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.3", - "htmlparser2": "^10.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.49", - "postcss-media-query-parser": "^0.2.3", - "postcss-safe-parser": "^7.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/body-parser": { - "version": "1.20.6", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", - "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true, - "license": "ISC" - }, - "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.8", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", - "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.11.12", - "caniuse-lite": "^1.0.30001809", - "electron-to-chromium": "^1.5.402", - "node-releases": "^2.0.53", - "update-browserslist-db": "^1.3.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001809", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", - "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chardet": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", - "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", - "dev": true, - "license": "MIT" - }, - "node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-spinners": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", - "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.1.1.tgz", - "integrity": "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^9.0.0", - "string-width": "^8.2.0" - }, - "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/string-width": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", - "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/connect": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", - "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "finalhandler": "1.1.2", - "parseurl": "~1.3.3", - "utils-merge": "1.0.1" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/connect/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/connect/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cross-spawn/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css-select": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", - "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^7.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "nth-check": "^2.1.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-what": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", - "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/custom-event": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", - "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", - "dev": true, - "license": "MIT" - }, - "node_modules/date-format": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", - "integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/di": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/di/-/di-0.0.1.tgz", - "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", - "dev": true, - "license": "MIT" - }, - "node_modules/dom-serialize": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", - "integrity": "sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "custom-event": "~1.0.0", - "ent": "~2.2.0", - "extend": "^3.0.0", - "void-elements": "^2.0.0" - } - }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.407", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.407.tgz", - "integrity": "sha512-4R8XgQOdfxexCd/u63lRm6wCHjECwI45MV9wxAs2ggtfWe2hwlo1ql97jKsju2IcJ+jFSTwBssyYoiWhh7mauQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/empathic": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz", - "integrity": "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/engine.io": { - "version": "6.6.9", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", - "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/cors": "^2.8.12", - "@types/node": ">=10.0.0", - "@types/ws": "^8.5.12", - "accepts": "~1.3.4", - "base64id": "2.0.0", - "cookie": "~0.7.2", - "cors": "~2.8.5", - "debug": "~4.4.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.21.0" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/engine.io-parser": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", - "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/ent": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz", - "integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "punycode": "^1.4.1", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", - "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", - "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/express/node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express/node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/body-parser/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/express/node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/media-typer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", - "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/express/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/express/node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/type-is/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-string-truncated-width": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", - "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-string-width": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", - "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-string-truncated-width": "^3.0.2" - } - }, - "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fast-wrap-ansi": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", - "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-string-width": "^3.0.2" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", - "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "~2.3.0", - "parseurl": "~1.3.3", - "statuses": "~1.5.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/finalhandler/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/flatted": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", - "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", - "dev": true, - "license": "ISC" - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hono": { - "version": "4.13.5", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz", - "integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/hosted-git-info": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-10.1.1.tgz", - "integrity": "sha512-DeOnSPAvOndYKfw075gt8yZzQ7S2hNztw34zBTfhIzLhmBTswIBg5/y+pqu/VD5cYWm5goAFTusDmUEmKZ0PEQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^11.1.0" - }, - "engines": { - "node": "^22.22.2 || ^24.15.0 || >=26.0.0" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/htmlparser2": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", - "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", - "dev": true, - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "entities": "^7.0.1" - } - }, - "node_modules/htmlparser2/node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/http-errors/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/https-proxy-agent": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", - "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4", - "proxy-agent-negotiate": "1.1.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/immutable": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", - "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", - "dev": true, - "license": "MIT" - }, - "node_modules/import-meta-resolve": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", - "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", - "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isbinaryfile": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", - "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/gjtorikian/" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/generator": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", - "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.8", - "@babel/types": "^7.29.8", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/traverse": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", - "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.8", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.8", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/istanbul-lib-instrument/node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jasmine-core": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.6.0.tgz", - "integrity": "sha512-niVlkeYVRwKFpmfWg6suo6H9CrNnydfBLEqefM5UjibYS+UoTjZdmvPJSiuyrRLGnFj1eYRhFd/ch+5hSlsFVA==", - "dev": true, - "license": "MIT" - }, - "node_modules/jose": { - "version": "6.2.10", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", - "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jsonc-parser": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", - "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/karma": { - "version": "6.4.4", - "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", - "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@colors/colors": "1.5.0", - "body-parser": "^1.19.0", - "braces": "^3.0.2", - "chokidar": "^3.5.1", - "connect": "^3.7.0", - "di": "^0.0.1", - "dom-serialize": "^2.2.1", - "glob": "^7.1.7", - "graceful-fs": "^4.2.6", - "http-proxy": "^1.18.1", - "isbinaryfile": "^4.0.8", - "lodash": "^4.17.21", - "log4js": "^6.4.1", - "mime": "^2.5.2", - "minimatch": "^3.0.4", - "mkdirp": "^0.5.5", - "qjobs": "^1.2.0", - "range-parser": "^1.2.1", - "rimraf": "^3.0.2", - "socket.io": "^4.7.2", - "source-map": "^0.6.1", - "tmp": "^0.2.1", - "ua-parser-js": "^0.7.30", - "yargs": "^16.1.1" - }, - "bin": { - "karma": "bin/karma" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/karma-chrome-launcher": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/karma-chrome-launcher/-/karma-chrome-launcher-3.2.0.tgz", - "integrity": "sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "which": "^1.2.1" - } - }, - "node_modules/karma-coverage": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/karma-coverage/-/karma-coverage-2.2.1.tgz", - "integrity": "sha512-yj7hbequkQP2qOSb20GuNSIyE//PgJWHwC2IydLE6XRtsnaflv+/OSGNssPjobYUlhVVagy99TQpqUt3vAUG7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "istanbul-lib-coverage": "^3.2.0", - "istanbul-lib-instrument": "^5.1.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.1", - "istanbul-reports": "^3.0.5", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/karma-coverage/node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/karma-coverage/node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/karma-coverage/node_modules/@babel/generator": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", - "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.8", - "@babel/types": "^7.29.8", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/karma-coverage/node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/karma-coverage/node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/karma-coverage/node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/karma-coverage/node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/karma-coverage/node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/karma-coverage/node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/karma-coverage/node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/karma-coverage/node_modules/@babel/traverse": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", - "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.8", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.8", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/karma-coverage/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/karma-coverage/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/karma-coverage/node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/karma-coverage/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/karma-jasmine": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz", - "integrity": "sha512-i/zQLFrfEpRyQoJF9fsCdTMOF5c2dK7C7OmsuKg2D0YSsuZSfQDiLuaiktbuio6F2wiCsZSnSnieIQ0ant/uzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "jasmine-core": "^4.1.0" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "karma": "^6.0.0" - } - }, - "node_modules/karma-jasmine-html-reporter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/karma-jasmine-html-reporter/-/karma-jasmine-html-reporter-2.1.0.tgz", - "integrity": "sha512-sPQE1+nlsn6Hwb5t+HHwyy0A1FNCVKuL1192b+XNauMYWThz2kweiBVW1DqloRpVvZIJkIoHVB7XRpK78n1xbQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "jasmine-core": "^4.0.0 || ^5.0.0", - "karma": "^6.0.0", - "karma-jasmine": "^5.0.0" - } - }, - "node_modules/karma-jasmine/node_modules/jasmine-core": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-4.6.1.tgz", - "integrity": "sha512-VYz/BjjmC3klLJlLwA4Kw8ytk0zDSmbbDLNs794VnWmkcCB7I9aAL/D48VNQtmITyPvea2C3jdUMfc3kAoy0PQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/karma/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/karma/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/karma/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/karma/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/karma/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/karma/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/karma/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/karma/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/karma/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/karma/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/karma/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/karma/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/karma/node_modules/yargs": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", - "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/karma/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/listr2": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-11.0.0.tgz", - "integrity": "sha512-8K88S0aSrcSXdJfiZtEy5BQMnR+TyjrCGLcgAvQs6ta0NEnIm0RJ72/Pv67Jvg07cfBhDbuN74V81lSSVYEFEw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^6.1.1", - "log-update": "^8.0.0", - "wrap-ansi": "^10.0.0" - }, - "engines": { - "node": ">=22.13.0" - } - }, - "node_modules/lmdb": { - "version": "3.5.6", - "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.5.6.tgz", - "integrity": "sha512-j3uE8ReKNyUWDjhfEFSJqE/1DLtfTR5Z8yFzVHvBjAk37wNg7HdScjcv8ttPHRvrdgPQMPWxFFI0SsdBzI5lBw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@harperfast/extended-iterable": "^1.0.3", - "msgpackr": "^1.11.2", - "node-addon-api": "^6.1.0", - "node-gyp-build-optional-packages": "5.2.2", - "ordered-binary": "^1.5.3", - "weak-lru-cache": "^1.2.2" - }, - "bin": { - "download-lmdb-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@lmdb/lmdb-darwin-arm64": "3.5.6", - "@lmdb/lmdb-darwin-x64": "3.5.6", - "@lmdb/lmdb-linux-arm": "3.5.6", - "@lmdb/lmdb-linux-arm64": "3.5.6", - "@lmdb/lmdb-linux-x64": "3.5.6", - "@lmdb/lmdb-win32-arm64": "3.5.6", - "@lmdb/lmdb-win32-x64": "3.5.6" - } - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-symbols": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", - "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-unicode-supported": "^2.0.0", - "yoctocolors": "^2.1.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-8.0.0.tgz", - "integrity": "sha512-lddSgOt3bPASrylL54ZSpy8nBHns+vBVSoILlVOx+dei300pnLRN958rj/EdlVLKuWlSESU3qdnDZdAI7FXYGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.3.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^9.0.0", - "string-width": "^8.2.0", - "strip-ansi": "^7.2.0", - "wrap-ansi": "^10.0.0" - }, - "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/string-width": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", - "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log4js": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", - "integrity": "sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "date-format": "^4.0.14", - "debug": "^4.3.4", - "flatted": "^3.2.7", - "rfdc": "^1.3.0", - "streamroller": "^3.1.5" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/magic-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.0.0.tgz", - "integrity": "sha512-CGvjzMN08iv6w1mm4/x3Gh1hLb4VnyRUA15FFpl6CsCIGGoe36k7kY5KNz9QDbSBN5I/fWHM6ZlIkUTa5xdUEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/msgpackr": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.12.1.tgz", - "integrity": "sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==", - "dev": true, - "license": "MIT", - "optional": true, - "optionalDependencies": { - "msgpackr-extract": "^3.0.2" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", - "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" - } - }, - "node_modules/mute-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", - "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/negotiator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", - "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/negotiator/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" - } - }, - "node_modules/node-releases": { - "version": "2.0.53", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", - "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm-package-arg": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-14.0.0.tgz", - "integrity": "sha512-69XQh3k+dtGa1p+7RaR57IuG3rCko96xr/nUfN4yDYBXbTYICiWcOpsFKLN2GtGE9cyIljE+f1exnaYt9MvM+Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "hosted-git-info": "^10.1.0", - "proc-log": "^7.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^8.0.0" - }, - "engines": { - "node": "^22.22.2 || ^24.15.0 || >=26.0.0" - } - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/obug": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", - "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.1.tgz", - "integrity": "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^5.6.2", - "cli-cursor": "^5.0.0", - "cli-spinners": "^3.2.0", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.1.0", - "log-symbols": "^7.0.1", - "stdin-discarder": "^0.3.2", - "string-width": "^8.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/string-width": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", - "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ordered-binary": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.1.tgz", - "integrity": "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/oxc-parser": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.142.0.tgz", - "integrity": "sha512-kKR+jPiRJYJDexVoziIg/FVGvr1fT1FZSSJOk6tVoMKKSlsf1Cso+cgGCJkOEDWOP174vRntCPFKg+AS7InWvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "^0.142.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/sponsors/Boshen" - }, - "optionalDependencies": { - "@oxc-parser/binding-android-arm-eabi": "0.142.0", - "@oxc-parser/binding-android-arm64": "0.142.0", - "@oxc-parser/binding-darwin-arm64": "0.142.0", - "@oxc-parser/binding-darwin-x64": "0.142.0", - "@oxc-parser/binding-freebsd-x64": "0.142.0", - "@oxc-parser/binding-linux-arm-gnueabihf": "0.142.0", - "@oxc-parser/binding-linux-arm-musleabihf": "0.142.0", - "@oxc-parser/binding-linux-arm64-gnu": "0.142.0", - "@oxc-parser/binding-linux-arm64-musl": "0.142.0", - "@oxc-parser/binding-linux-ppc64-gnu": "0.142.0", - "@oxc-parser/binding-linux-riscv64-gnu": "0.142.0", - "@oxc-parser/binding-linux-riscv64-musl": "0.142.0", - "@oxc-parser/binding-linux-s390x-gnu": "0.142.0", - "@oxc-parser/binding-linux-x64-gnu": "0.142.0", - "@oxc-parser/binding-linux-x64-musl": "0.142.0", - "@oxc-parser/binding-openharmony-arm64": "0.142.0", - "@oxc-parser/binding-wasm32-wasi": "0.142.0", - "@oxc-parser/binding-win32-arm64-msvc": "0.142.0", - "@oxc-parser/binding-win32-ia32-msvc": "0.142.0", - "@oxc-parser/binding-win32-x64-msvc": "0.142.0" - } - }, - "node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "license": "MIT", - "dependencies": { - "entities": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-html-rewriting-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-8.0.1.tgz", - "integrity": "sha512-NaRku2aMpUN1Sh1Gyk1KWUh2A7EJx2c6qYzvwsPtqhoHoaURshdrceYK3LunVCm3WHhm6FS7Vcczbvdh3/UIVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^8.0.0", - "parse5": "^8.0.0", - "parse5-sax-parser": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-html-rewriting-stream/node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/parse5-sax-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-8.0.0.tgz", - "integrity": "sha512-/dQ8UzHZwnrzs3EvDj6IkKrD/jIZyTlB+8XrHJvcjNgRdmWruNdN9i9RK/JtxakmlUdPwKubKPTCqvbTgzGhrw==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse5": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5/node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/piscina": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.2.0.tgz", - "integrity": "sha512-DszUCKeVN/5G5QKo6jAVHL8fmKnkJvQ0ACiVgY7YGCq3TUB2oznAOayvZPIAdEThvhczkXR+qm3IHsNXpFCYfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.x" - }, - "optionalDependencies": { - "@napi-rs/nice": "^1.0.4" - } - }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.16", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-media-query-parser": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", - "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", - "dev": true, - "license": "MIT" - }, - "node_modules/postcss-safe-parser": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", - "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/proc-log": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-7.0.0.tgz", - "integrity": "sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^22.22.2 || ^24.15.0 || >=26.0.0" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-agent-negotiate": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", - "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "kerberos": "^2.0.0" - }, - "peerDependenciesMeta": { - "kerberos": { - "optional": true - } - } - }, - "node_modules/punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/qjobs": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz", - "integrity": "sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.9" - } - }, - "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", - "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readdirp": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", - "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/reflect-metadata": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rolldown": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.0.tgz", - "integrity": "sha512-u7tgm5l4Yw1iTqUL4EcYOAt7fFvCgQMLeidrnD4GALlC6aOznCjezYajgxeyKw27u0Q5N7fwgCzjVyPIWzwuBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.140.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.2.0", - "@rolldown/binding-darwin-arm64": "1.2.0", - "@rolldown/binding-darwin-x64": "1.2.0", - "@rolldown/binding-freebsd-x64": "1.2.0", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.0", - "@rolldown/binding-linux-arm64-gnu": "1.2.0", - "@rolldown/binding-linux-arm64-musl": "1.2.0", - "@rolldown/binding-linux-ppc64-gnu": "1.2.0", - "@rolldown/binding-linux-s390x-gnu": "1.2.0", - "@rolldown/binding-linux-x64-gnu": "1.2.0", - "@rolldown/binding-linux-x64-musl": "1.2.0", - "@rolldown/binding-openharmony-arm64": "1.2.0", - "@rolldown/binding-wasm32-wasi": "1.2.0", - "@rolldown/binding-win32-arm64-msvc": "1.2.0", - "@rolldown/binding-win32-x64-msvc": "1.2.0" - } - }, - "node_modules/rolldown/node_modules/@oxc-project/types": { - "version": "0.140.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.140.0.tgz", - "integrity": "sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/sass": { - "version": "1.101.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.101.0.tgz", - "integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^5.0.0", - "immutable": "^5.1.5", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=20.19.0" - }, - "optionalDependencies": { - "@parcel/watcher": "^2.4.1" - } - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/send/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/send/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/send/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/send/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static/node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/slice-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", - "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" - }, - "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/socket.io": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", - "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "~1.3.4", - "base64id": "~2.0.0", - "cors": "~2.8.5", - "debug": "~4.4.1", - "engine.io": "~6.6.0", - "socket.io-adapter": "~2.5.2", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/socket.io-adapter": { - "version": "2.5.8", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", - "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "~4.4.1", - "ws": "~8.21.0" - } - }, - "node_modules/socket.io-parser": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", - "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 12" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/stdin-discarder": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", - "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/streamroller": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz", - "integrity": "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "date-format": "^4.0.14", - "debug": "^4.3.4", - "fs-extra": "^8.1.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tmp": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", - "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/ua-parser-js": { - "version": "0.7.41", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", - "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/ua-parser-js" - }, - { - "type": "paypal", - "url": "https://paypal.me/faisalman" - }, - { - "type": "github", - "url": "https://github.com/sponsors/faisalman" - } - ], - "license": "MIT", - "bin": { - "ua-parser-js": "script/cli.js" - }, - "engines": { - "node": "*" - } - }, - "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", - "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/validate-npm-package-name": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-8.0.0.tgz", - "integrity": "sha512-SCv6OOV6Xj2/3cXy3dGmADluJTNcL3o7hZAglNPTe+WYuEuvxgJzxPrSDLZhF+CwyQOubqgecjMmTJGMVLWjYQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^22.22.2 || ^24.15.0 || >=26.0.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vite": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", - "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.17", - "rolldown": "~1.1.5", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/vite/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/vite/node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.139.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" - } - }, - "node_modules/void-elements": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", - "integrity": "sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/watchpack": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", - "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/weak-lru-cache": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", - "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, - "node_modules/wrap-ansi": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.1.tgz", - "integrity": "sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "string-width": "^8.2.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", - "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ws": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yargs": { - "version": "18.1.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", - "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^8.2.1", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/yargs/node_modules/string-width": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", - "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yoctocolors": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", - "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "dev": true, - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } - }, - "node_modules/zone.js": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", - "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", - "license": "MIT" - } - } -} diff --git a/apps/showcase_ui/package.json b/apps/showcase_ui/package.json deleted file mode 100644 index 5969a2da0..000000000 --- a/apps/showcase_ui/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "artemis-showcase-ui", - "version": "1.0.0", - "scripts": { - "ng": "ng", - "start": "ng serve --proxy-config proxy.conf.json", - "build": "ng build", - "watch": "ng build --watch --configuration development", - "test": "ng test" - }, - "private": true, - "dependencies": { - "@angular/cdk": "^22.1.5", - "@angular/common": "^22.1.4", - "@angular/compiler": "^22.1.4", - "@angular/core": "^22.1.4", - "@angular/forms": "^22.1.4", - "@angular/platform-browser": "^22.1.4", - "@angular/platform-browser-dynamic": "^22.1.4", - "@angular/router": "^22.1.4", - "rxjs": "~7.8.0", - "tslib": "^2.3.0", - "zone.js": "~0.15.0" - }, - "devDependencies": { - "@angular/build": "^22.1.6", - "@angular/cli": "^22.1.6", - "@angular/compiler-cli": "^22.1.4", - "@types/jasmine": "~5.1.0", - "istanbul-lib-instrument": "^6.0.3", - "jasmine-core": "~5.6.0", - "karma": "~6.4.0", - "karma-chrome-launcher": "~3.2.0", - "karma-coverage": "~2.2.0", - "karma-jasmine": "~5.1.0", - "karma-jasmine-html-reporter": "~2.1.0", - "typescript": "~6.0.3" - } -} diff --git a/apps/showcase_ui/proxy.conf.json b/apps/showcase_ui/proxy.conf.json deleted file mode 100644 index fd09eea92..000000000 --- a/apps/showcase_ui/proxy.conf.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "/api": { - "target": "http://localhost:8000", - "secure": false, - "logLevel": "debug" - }, - "/images": { - "target": "http://localhost:8000", - "secure": false, - "logLevel": "debug" - }, - "/videos": { - "target": "http://localhost:8000", - "secure": false, - "logLevel": "debug" - }, - "/local_file": { - "target": "http://localhost:8000", - "secure": false, - "logLevel": "debug" - } -} - diff --git a/apps/showcase_ui/public/favicon.ico b/apps/showcase_ui/public/favicon.ico deleted file mode 100644 index df9d973c4..000000000 Binary files a/apps/showcase_ui/public/favicon.ico and /dev/null differ diff --git a/apps/showcase_ui/public/logo.png b/apps/showcase_ui/public/logo.png deleted file mode 100644 index 2d96e1400..000000000 Binary files a/apps/showcase_ui/public/logo.png and /dev/null differ diff --git a/apps/showcase_ui/public/logo.svg b/apps/showcase_ui/public/logo.svg deleted file mode 100644 index 53a9a18bf..000000000 --- a/apps/showcase_ui/public/logo.svg +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/apps/showcase_ui/src/app/app.component.html b/apps/showcase_ui/src/app/app.component.html deleted file mode 100644 index c4d9d0434..000000000 --- a/apps/showcase_ui/src/app/app.component.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - diff --git a/apps/showcase_ui/src/app/app.component.scss b/apps/showcase_ui/src/app/app.component.scss deleted file mode 100644 index 0f3fad224..000000000 --- a/apps/showcase_ui/src/app/app.component.scss +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - diff --git a/apps/showcase_ui/src/app/app.component.spec.ts b/apps/showcase_ui/src/app/app.component.spec.ts deleted file mode 100644 index dcae6f074..000000000 --- a/apps/showcase_ui/src/app/app.component.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { TestBed } from '@angular/core/testing'; -import { provideRouter } from '@angular/router'; -import { provideHttpClient, withXhr } from '@angular/common/http'; -import { AppComponent } from './app.component'; - -describe('AppComponent', () => { - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [AppComponent], - providers: [provideRouter([]), provideHttpClient(withXhr())] - }).compileComponents(); - }); - - it('should create the app', () => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.componentInstance; - expect(app).toBeTruthy(); - }); - - it(`should have the 'frontend' title`, () => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.componentInstance; - expect(app.title).toEqual('frontend'); - }); - -}); diff --git a/apps/showcase_ui/src/app/app.component.ts b/apps/showcase_ui/src/app/app.component.ts deleted file mode 100644 index c9376cf21..000000000 --- a/apps/showcase_ui/src/app/app.component.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, ChangeDetectionStrategy } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; -import { NavSwitcherComponent } from './components/nav-switcher/nav-switcher.component'; - -@Component({ - selector: 'app-root', - imports: [RouterOutlet, NavSwitcherComponent], - templateUrl: './app.component.html', - changeDetection: ChangeDetectionStrategy.Eager, - styleUrl: './app.component.scss' -}) -export class AppComponent { - title = 'frontend'; -} diff --git a/apps/showcase_ui/src/app/app.config.ts b/apps/showcase_ui/src/app/app.config.ts deleted file mode 100644 index c5a0f8775..000000000 --- a/apps/showcase_ui/src/app/app.config.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; -import { provideRouter } from '@angular/router'; -import { provideHttpClient, withXhr } from '@angular/common/http'; - -import { routes } from './app.routes'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideZoneChangeDetection({ eventCoalescing: true }), - provideRouter(routes), - provideHttpClient(withXhr()) - ] -}; diff --git a/apps/showcase_ui/src/app/app.routes.ts b/apps/showcase_ui/src/app/app.routes.ts deleted file mode 100644 index 3c163ece9..000000000 --- a/apps/showcase_ui/src/app/app.routes.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Routes } from '@angular/router'; -import { HomeComponent } from './pages/home/home.component'; -import { WorkspaceComponent } from './pages/workspace/workspace.component'; -import { LegacyWorkspaceComponent } from './pages/legacy-workspace/legacy-workspace.component'; - -export const routes: Routes = [ - { path: '', component: HomeComponent }, - { path: 'workspace', component: WorkspaceComponent }, - { path: 'check', component: LegacyWorkspaceComponent }, - { path: '**', redirectTo: '' } -]; diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts b/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts deleted file mode 100644 index 5c915fd12..000000000 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { StartupProgressEvent } from '../../services/agent.service'; -import { buildStartupWorkItems } from './agent-stream.component'; - -describe('startup Work block', () => { - it('shows only the three device preparation operations', () => { - const events: StartupProgressEvent[] = [ - { stage: 'submitting', message: 'Submitting the task', timestamp: 100 }, - { stage: 'queued', message: 'Task received and queued', timestamp: 100.9 }, - { stage: 'launching', message: 'Starting the execution process', timestamp: 101 }, - { stage: 'configuration', message: 'Loading the run configuration', timestamp: 103.1 }, - { stage: 'device_check', message: 'Checking the Android device', timestamp: 103.2 }, - { stage: 'device_ready', message: 'Android device connected', timestamp: 123 }, - { stage: 'model_warmup', message: 'Warming the model connection', timestamp: 123 }, - { stage: 'uiautomator', message: 'Connecting to UI Automator', timestamp: 124 }, - { stage: 'uiautomator_ready', message: 'UI Automator is ready', timestamp: 128 }, - { stage: 'environment', message: 'Preparing the device environment', timestamp: 128 }, - { stage: 'environment_ready', message: 'Device environment is ready', timestamp: 131 } - ]; - - const items = buildStartupWorkItems(events, 131, false, true); - - expect(items.map((item) => item.stage)).toEqual([ - 'device_ready', - 'uiautomator_ready', - 'environment_ready' - ]); - expect(items.map((item) => item.elapsed)).toEqual(['20s', '4.0s', '3.0s']); - expect(items.every((item) => !item.isActive)).toBeTrue(); - }); - - it('keeps the current preparation operation live without exposing first-action text', () => { - const events: StartupProgressEvent[] = [ - { stage: 'device_check', message: 'Checking the Android device', timestamp: 10 } - ]; - - const [item] = buildStartupWorkItems(events, 11.4, false, true); - - expect(item.message).toBe('Checking the Android device'); - expect(item.elapsed).toBe('1.4s'); - expect(item.isActive).toBeTrue(); - }); - - it('shows the helper install sub-step while the hierarchy service is connecting', () => { - const events: StartupProgressEvent[] = [ - { stage: 'uiautomator', message: 'Connecting to the UI hierarchy service', timestamp: 10 }, - { - stage: 'helper_install', - message: 'Installing the Artemis accessibility helper on this device for the first time (v1.1.3, about 3 seconds)', - timestamp: 10.2 - } - ]; - - const [item] = buildStartupWorkItems(events, 12, false, true); - - expect(item.isActive).toBeTrue(); - expect(item.message).toContain('Installing the Artemis accessibility helper'); - expect(item.elapsed).toBe('2.0s'); - }); - - it('uses the hierarchy source line as the completion message of the second step', () => { - const events: StartupProgressEvent[] = [ - { stage: 'uiautomator', message: 'Connecting to the UI hierarchy service', timestamp: 10 }, - { stage: 'helper_install', message: 'Installing ...', timestamp: 10.2 }, - { stage: 'uiautomator_ready', message: 'UI hierarchy service is ready (helper)', timestamp: 13 }, - { - stage: 'hierarchy_backend', - message: 'UI hierarchy source: Artemis accessibility helper v1.1.3', - timestamp: 13.1 - }, - // A later mid-run switch must not rewrite the startup line. - { - stage: 'hierarchy_backend_changed', - message: 'UI hierarchy source switched from Artemis accessibility helper to UIAutomator2 because ...', - timestamp: 90 - } - ]; - - const [item] = buildStartupWorkItems(events, 100, true, true); - - expect(item.isActive).toBeFalse(); - expect(item.message).toBe('UI hierarchy source: Artemis accessibility helper v1.1.3'); - expect(item.elapsed).toBe('3.0s'); - }); - - it('falls back to the generic completion line for runs without a source event', () => { - const events: StartupProgressEvent[] = [ - { stage: 'uiautomator', message: 'Connecting to the UI hierarchy service', timestamp: 10 }, - { stage: 'environment', message: 'Preparing the device environment', timestamp: 14 } - ]; - - const [item] = buildStartupWorkItems(events, 20, false, true); - - expect(item.message).toBe('UI hierarchy service is ready'); - }); - - it('uses old first_response events only as a hidden completion boundary', () => { - const events: StartupProgressEvent[] = [ - { stage: 'environment', message: 'Preparing the device environment', timestamp: 20 }, - { stage: 'first_response', message: 'Generating the first action', timestamp: 23 } - ]; - - const [item] = buildStartupWorkItems(events, 24, false, true); - - expect(item.message).toBe('Device environment is ready'); - expect(item.elapsed).toBe('3.0s'); - expect(item.isActive).toBeFalse(); - }); -}); diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html deleted file mode 100644 index 93172d380..000000000 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html +++ /dev/null @@ -1,1436 +0,0 @@ - - -
- -
-
- - -
- - - -
-
- list_alt - Task Queue -
- - @if (activeQueue().length === 0) { - - } - @for (session of activeQueue(); track trackSession($index, session)) { - - } - - @if (historyTasks().length === 0) { - - } - -
-
- - - -
-
- description - Notes & Plans -
- - @if (noteKeys().length > 1) { -
-
- @for (key of noteKeys(); track trackNoteKey($index, key)) { - - } -
-
- } - @if (noteKeys().length === 0) { - - } - - @if (noteKeys().length > 0) { - - } -
-
-
- - - @if (consolidatedBlocks().length > 0 || showPlanningLoader()) { -
- - @if (agentService.viewedModel(); as model) { - - - - - - } - - -
- } - - -
- @if (agentService.isSessionContentLoading()) { -
- progress_activity - Loading run history... -
- } - - @if (!agentService.isSessionContentLoading() && consolidatedBlocks().length === 0 && !showPlanningLoader() && startupWorkItems().length === 0) { -
-
sensors
-

No Stream Activity Selected

-

Please select a session from the Task List on the right to view its real-time log payloads.

-
- } - - - @if (!agentService.isSessionContentLoading() && (phases().length > 0 || showPlanningLoader() || startupWorkItems().length > 0)) { -
- @if (startupWorkItems().length > 0) { -
-
- Worked for {{ startupPreparationDuration() }}s -
-
-
-
-
-
- @for (item of startupWorkItems(); track item) { -
- -
- - {{ item.message }} · {{ item.isActive ? 'Waiting ' : 'Waited ' }}{{ item.elapsed }} - -
-
- } -
-
-
-
-
-
- } - @for (phase of phases(); track trackPhase($index, phase); let first = $first) { -
- @if (hasVisibleBlocks(phase)) { -
- {{ isCheckerPhase(phase) ? 'Checked' : 'Worked' }} for {{ phase.durationSeconds }}s - @if (phase.tokens) { - - · - {{ formatTokenCount(phase.tokens) }} - - } -
- } - @if (hasVisibleBlocks(phase)) { -
- @for (block of phase.blocks; track trackBlock($index, block)) { - @if (hasVisibleContent(block)) { -
- -
- - @if (isCheckerBlock(block)) { -
-
- - {{ getCheckerPhaseLabel(block) }} - @if (block.data.phase !== 'outcome' && block.data.subgoal_text) { - {{ block.data.subgoal_text }} - } - {{ getCheckerStatusLabel(block) }} -
- @if (!isCheckerMuted(block) && getCheckerItems(block).length > 0) { -
    - @for (item of getCheckerItems(block); track $index) { -
  1. - {{ $index + 1 }} - {{ item.text }} - {{ getCheckMethodLabel(item, block) }} -
  2. - } -
- } -
- } - - @if (getSortedStepEvents(block.data).length > 0) { -
- @for (item of getSortedStepEvents(block.data); track trackStepEvent(itemIdx, item); let itemIdx = $index) { - - @if (isCheckerBlock(block) && item.type === 'thinking') { -
-
- - {{ isCheckerSegmentResetNotice(item) ? getCheckerSegmentResetMessage(item) : 'Thought' }} -
- @if (item.data.text && item.data.text.trim()) { -
-
-
-
-
- } -
- } - @if (isCheckerBlock(block) && item.type === 'text' && isCheckerTextVisible(item)) { - @if (item.data.isReset) { -
-
- -
- {{ formatResetMessage(getCheckerSegmentResetMessage(item), isCheckerSegmentRunning(block, itemIdx)) }} -
-
-
- } - @if (item.data.text && item.data.text.trim()) { -
-
- - Work -
-
-
-
-
- } - } - - @if (!isCheckerBlock(block) && item.type === 'thinking') { - @if (getBlockResets(block, true).length > 0) { -
- @for (reset of getBlockResets(block, true); track reset.id || $index) { -
- -
- {{ formatResetMessage(reset.message, reset.isWaiting) }} -
-
- } -
- } - @if (getStreamDisplayText(block.id + '-native', item.data.text) || (isBlockRunning(block) && !isStreamRewinding(block.id + '-native') && !hasWaitingReset(block, true))) { -
-
- - Thought -
- @if (getStreamDisplayText(block.id + '-native', item.data.text)) { -
-
-
-
-
- } -
- } - } - - @if (!isCheckerBlock(block) && item.type === 'text') { - @if (getBlockResets(block, false).length > 0) { -
- @for (reset of getBlockResets(block, false); track reset.id || $index) { -
- -
- {{ formatResetMessage(reset.message, reset.isWaiting) }} -
-
- } -
- } - @if (getStreamDisplayText(block.id, item.data.text) || (isBlockRunning(block) && !isStreamRewinding(block.id) && !hasWaitingReset(block, false))) { -
-
- - Work -
- @if (getStreamDisplayText(block.id, item.data.text)) { -
-
-
- } -
- } - } - - @if (isReportStatusAction(item.data)) { -
-
- - {{ getReportStatusValue(item.data) === 'failed' ? 'cancel' : 'task_alt' }} - - - {{ getReportStatusValue(item.data) === 'failed' ? 'Task Execution Failed' : 'Task Completed' }} - - - - {{ getReportStatusValue(item.data) === 'failed' ? 'error' : 'check_circle' }} - - {{ getReportStatusValue(item.data) === 'failed' ? 'Failed' : 'Completed' }} - - - {{ isActionExpanded('action-' + block.id + '-' + itemIdx) ? 'expand_less' : 'expand_more' }} - -
- - @if (!isActionExpanded('action-' + block.id + '-' + itemIdx) && getReportStatusExplanation(item.data)) { -
-
- Summary: - "{{ getReportStatusExplanation(item.data) }}" -
-
- } - - @if (isActionExpanded('action-' + block.id + '-' + itemIdx)) { -
-
- @if (getReportStatusExplanation(item.data); as expText) { -
- Report & Findings - -
- } -
- - @if (getStepPreImageUrl(block.data, item.data) || getStepPostImageUrl(block.data, item.data)) { -
- @if (getStepPreImageUrl(block.data, item.data)) { -
- Final Verified State -
-
- Final Screen Screenshot -
-
-
- } - @if (getStepPostImageUrl(block.data, item.data)) { -
- Post Verification -
-
- Post Screenshot -
-
-
- } -
- } -
- } -
- } - - @if (item.type === 'action' && !isReportStatusAction(item.data) && (isAndroidAction(item.data) || item.data?.type === 'action')) { -
-
- {{ getActionIcon(item.data) }} - {{ getActionTitle(item.data) }} - @if (isActionFailed(item.data, block.data)) { - - error - Failed - - } - - {{ isActionExpanded('action-' + block.id + '-' + itemIdx) ? 'expand_less' : 'expand_more' }} - -
- - @if (!isActionExpanded('action-' + block.id + '-' + itemIdx) && (getActionTargetText(item.data) || getActionInputText(item.data) || getActionCoords(item.data))) { -
- @if (getActionTargetText(item.data)) { -
- Target: - "{{ getActionTargetText(item.data) }}" -
- } - @if (getActionInputText(item.data)) { -
- {{ getActionInputLabel(item.data) }}: - "{{ getActionInputText(item.data) }}" -
- } - @if (getActionCoords(item.data)) { -
- Coordinates: - {{ getActionCoords(item.data) }} -
- } -
- } - - @if (isActionExpanded('action-' + block.id + '-' + itemIdx)) { -
-
- @if (getActionTargetText(item.data)) { -
- Target - "{{ getActionTargetText(item.data) }}" -
- } - @if (getActionInputText(item.data)) { -
- {{ getActionInputLabel(item.data) }} - "{{ getActionInputText(item.data) }}" -
- } - @if (getActionCoords(item.data)) { -
- Coordinates - {{ getActionCoords(item.data) }} -
- } - @if (getActionBounds(item.data)) { -
- Target Bounds - [{{ getActionBounds(item.data) }}] -
- } - @if (getActionClass(item.data)) { -
- Target Class - {{ getActionClass(item.data) }} -
- } - @for (param of getActionExtraParams(item.data); track trackParam($index, param)) { -
- {{ param.key }} - {{ param.value }} -
- } - - @if (!getActionTargetText(item.data) && !getActionInputText(item.data) && !getActionCoords(item.data) && !getActionBounds(item.data) && !getActionClass(item.data) && getActionExtraParams(item.data).length === 0) { -
- Action - {{ getActionTitle(item.data) }} -
- } -
- - @if (getStepPreImageUrl(block.data, item.data) || getStepPostImageUrl(block.data, item.data)) { -
- @if (getStepPreImageUrl(block.data, item.data)) { -
- Pre-Action (Before) -
-
- Pre-Action Screenshot -
-
-
-
- } - @if (getStepPostImageUrl(block.data, item.data)) { -
- Post-Action (After) -
-
- Post-Action Screenshot -
-
-
- } -
- } - @if (isActionFailed(item.data, block.data)) { -
- error - {{ getActionErrorMessage(item.data, block.data) }} -
- } -
- } -
- } - - @if (item.type === 'tool') { - - @if (isBackendSwitchNote(item.data)) { -
-
- -
- {{ getBackendSwitchMessage(item.data) }} -
-
-
- } - - @if (isLLMRetry(item.data)) { -
- @for (retry of getLLMRetryEntries(item.data); track retry; let retryIndex = $index) { -
- -
- @if (isLLMRetryEntryWaiting(retry)) { - - LLM temporarily unavailable - @if (getLLMRetryEntryDelay(retry); as retryDelay) { - · Retrying in {{ retryDelay }}… - } - - - Frequent retries may indicate a low API rate limit. Consider increasing the limit or reducing request concurrency. - - } - @if (!isLLMRetryEntryWaiting(retry)) { - - Retrying LLM request · Waited {{ getLLMRetryEntryWaited(retry) }} - - } -
-
- } -
- } - - @if (isDisplayableLLMFailure(item.data)) { -
-
-
- error - LLM Request Failed - @if (isTerminalLLMFailure(item.data)) { - - pause_circle - Paused - - } @else { - - warning - Failed - - } - @if (hasLLMFailureRetryEntries(item.data)) { - - {{ isActionExpanded('llm-failure-' + block.id + '-' + itemIdx) ? 'expand_less' : 'expand_more' }} - - } -
-
-
- What happened: - The AI request could not be completed after automatic retries. -
-
- Failure reason: - {{ getLLMErrorText(item.data) }} -
- @if (getLLMFailureWaited(item.data); as waited) { -
- Waited: - {{ waited }} -
- } - @if (isActionExpanded('llm-failure-' + block.id + '-' + itemIdx)) { -
-
Retry details
- @for (retry of getLLMFailureRetryEntries(item.data); track retry; let retryIndex = $index) { -
- Retry {{ retryIndex + 1 }} - - Planned {{ getLLMRetryEntryDelay(retry) || '0s' }} - · Waited {{ getLLMRetryEntryWaited(retry) }} - -
- } -
- } -
-
- @if (isTerminalLLMFailure(item.data)) { -
- - Task paused · no steps are running -
- } -
- } - - @if (shouldShowTool(item.data, block.data) && !isDeviceActionTool(item.data)) { -
-
- -
- {{ isVideoTool(item.data) ? getVideoAnalysisLabel(item.data) : getToolDisplayLabel(item.data) }}{{ ((isNoteTool(item.data) && getToolKey(item.data)) || isVideoTool(item.data)) ? ':' : '' }} - - @if (isNoteTool(item.data) && getToolKey(item.data); as key) { - - draft - {{ key }} - - } - - @if (isVideoTool(item.data)) { - - videocam - {{ getVideoAnalysisRangeLabel(item.data) }} - - @if (getVideoAnalysisDetail(item.data); as detail) { - · {{ detail }} - } - } -
-
-
- } - - @if (shouldShowTool(item.data, block.data) && isDeviceActionTool(item.data)) { -
-
- {{ getToolIcon(item.data) }} - {{ getToolTitle(item.data) }} - @if (isToolFailed(item.data)) { - - error - Failed - - } - - {{ isActionExpanded('tool-' + block.id + '-' + itemIdx) ? 'expand_less' : 'expand_more' }} - -
- - @if (!isActionExpanded('tool-' + block.id + '-' + itemIdx) && (isAdbCommandTool(item.data) ? getAdbCommandLine(item.data) : (getToolTargetText(item.data) || getToolInputText(item.data) || getToolCoords(item.data) || getToolAnalysisText(item.data) || getToolGenericDetails(item.data)))) { -
- - @if (isAdbCommandTool(item.data)) { - @if (getAdbCommandLine(item.data)) { -
- Command: - {{ getAdbCommandLine(item.data) }} -
- } - @if (getAdbCwd(item.data)) { -
- Cwd: - "{{ getAdbCwd(item.data) }}" -
- } - @if (getAdbTerminalId(item.data)) { -
- Terminal ID: - "{{ getAdbTerminalId(item.data) }}" -
- } - } - - @if (!isAdbCommandTool(item.data)) { - @if (getToolTargetText(item.data)) { -
- Target: - "{{ getToolTargetText(item.data) }}" -
- } - @if (getToolInputText(item.data)) { -
- {{ getToolInputLabel(item.data) }}: - "{{ getToolInputText(item.data) }}" -
- } - @if (getToolCoords(item.data)) { -
- Coordinates: - {{ getToolCoords(item.data) }} -
- } - @if (getToolAnalysisText(item.data)) { -
- Analysis: - "{{ getToolAnalysisText(item.data) }}" -
- } - @if (getToolGenericDetails(item.data)) { -
- Details: - "{{ getToolGenericDetails(item.data) }}" -
- } - } -
- } - - @if (isActionExpanded('tool-' + block.id + '-' + itemIdx)) { -
-
- @if (isAdbCommandTool(item.data)) { - @if (getAdbCommandLine(item.data)) { -
- Command - {{ getAdbCommandLine(item.data) }} -
- } - @if (getAdbCwd(item.data)) { -
- Working Directory - "{{ getAdbCwd(item.data) }}" -
- } - @if (getAdbTerminalId(item.data)) { -
- Terminal ID - {{ getAdbTerminalId(item.data) }} -
- } - } - @if (!isAdbCommandTool(item.data)) { - @if (getToolTargetText(item.data)) { -
- Target - "{{ getToolTargetText(item.data) }}" -
- } - @if (getToolInputText(item.data)) { -
- {{ getToolInputLabel(item.data) }} - "{{ getToolInputText(item.data) }}" -
- } - @if (getToolCoords(item.data)) { -
- Coordinates - {{ getToolCoords(item.data) }} -
- } - @if (getToolAnalysisText(item.data)) { -
- Analysis - "{{ getToolAnalysisText(item.data) }}" -
- } - @if (getToolGenericDetails(item.data)) { -
- Details - "{{ getToolGenericDetails(item.data) }}" -
- } - @for (param of getToolExtraParams(item.data); track trackParam($index, param)) { -
- {{ param.key }} - {{ param.value }} -
- } - } -
- - @if (getStepPreImageUrl(block.data, item.data) || getStepPostImageUrl(block.data, item.data)) { -
- @if (getStepPreImageUrl(block.data, item.data)) { -
- Pre-Action (Before) -
-
- Pre-Action Screenshot -
-
-
-
- } - @if (getStepPostImageUrl(block.data, item.data)) { -
- Post-Action (After) -
-
- Post-Action Screenshot -
-
-
- } -
- } - @if (isToolFailed(item.data)) { -
- error - {{ getToolErrorMessage(item.data) }} -
- } -
- } -
- } - } - } -
- } - - @if (hasCheckerResult(block)) { -
- @for (verdict of getCheckerVerdicts(block); track $index) { -
- {{ getVerdictItemNumber(block, verdict, $index) }} - {{ getVerdictIcon(verdict.status) }} - - {{ verdict.evidence || getVerdictStatusText(verdict.status) }} - @if (verdict.suggestion) { - {{ verdict.suggestion }} - } - -
- } - @for (note of getCheckerNotes(block); track note) { -
{{ note }}
- } -
- } -
-
- } - } -
- } -
- } - - @if (outputterReport(); as outReport) { -
-
-
- assignment_turned_in - Task Report -
-
- -
-
-
- @if (parsedOutputReport(); as parsedOut) { -
- @if (parsedOut.title) { -

{{ parsedOut.title }}

- } - @if (parsedOut.milestones.length > 0) { -
- @for (m of parsedOut.milestones; track trackMilestone($index, m)) { -
- - {{ m.type === 'checked' ? 'check_circle' : (m.type === 'progress' ? 'pending' : 'radio_button_unchecked') }} - - - @for (seg of m.segments; track seg) { - @if (seg.bold) { - {{ seg.text }} - } - @if (seg.code) { - {{ seg.text }} - } - @if (!seg.bold && !seg.code) { - {{ seg.text }} - } - } - -
- } -
- } - @if (parsedOut.otherLines.length > 0) { -
- @for (line of parsedOut.otherLines; track trackMarkdownLine($index, line)) { -

- @if (line.type === 'verify') { - {{ line.checkKind || 'verify' }}{{ line.atEnd ? '@end' : '' }} - } - @if (line.type === 'finding') { - finding - } - @for (seg of line.segments; track seg) { - @if (seg.bold) { - {{ seg.text }} - } - @if (seg.code) { - {{ seg.text }} - } - @if (!seg.bold && !seg.code) { - {{ seg.text }} - } - } -

- } -
- } -
- } -
-
- } - - @if (showPlanningLoader() && (startupWorkItems().length === 0 || startupPreparationIsComplete() || consolidatedBlocks().length > 0)) { -
-
- progress_activity - {{ currentPlanningText() }} -
-
- } -
- } -
- - - @if (selectedZoomImage()) { -
-
- -
- Zoomed Screenshot -
-
-
-
- } -
diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss deleted file mode 100644 index cb1c34f71..000000000 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss +++ /dev/null @@ -1,3973 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -$border-color: rgba(0, 0, 0, 0.08); -$text-primary: #1f1f1f; -$text-secondary: #1f1f1f; -$text-muted: #1f1f1f; -$bg-main: #f9fafb; -$uniform-gap: 8px; -$stream-row-height: 25px; - -:host { - display: flex; - flex-direction: column; - height: 100%; - width: 100%; - overflow: hidden; -} - -.agent-stream-container { - display: flex; - flex-direction: column; - height: 100%; - background: #f8f9fa; - box-sizing: border-box; - position: relative; - container-type: inline-size; - container-name: stream-panel; - - .vscode-nav-header { - position: relative; - height: 60px; - flex-shrink: 0; - background: #ffffff; - border-bottom: 1px solid rgba(0, 0, 0, 0.08); - display: flex; - align-items: center; - justify-content: flex-end; - padding: 0 16px; - box-sizing: border-box; - user-select: none; - box-shadow: none; - z-index: 10; - - @media (min-width: 1151px) { - display: none !important; - } - - .vscode-tab-container { - display: flex; - height: 36px; - box-sizing: border-box; - background: rgba(241, 245, 249, 0.72); - backdrop-filter: blur(16px) saturate(180%); - -webkit-backdrop-filter: blur(16px) saturate(180%); - border: 1px solid rgba(226, 232, 240, 0.85); - border-radius: 18px; - padding: 3px; - gap: 3px; - align-items: center; - flex-shrink: 0; - box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.8); - } - - .vscode-tab-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - height: 30px; - padding: 0 14px; - box-sizing: border-box; - border: none; - background: transparent; - border-radius: 15px; - font-size: 13px; - font-weight: 600; - color: #64748b; - font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - text-decoration: none; - cursor: pointer; - position: relative; - line-height: 1; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - outline: none; - white-space: nowrap; - - .tab-icon { - font-size: 16px; - color: inherit; - transition: color 0.15s ease; - } - - .badge-count { - background: #2563eb; - color: #ffffff; - font-size: 10px; - font-weight: 700; - padding: 1px 6px; - border-radius: 10px; - line-height: 1; - } - - .dropdown-chevron { - font-size: 16px; - color: #94a3b8; - margin-left: 2px; - transition: transform 0.2s ease, color 0.15s ease; - - &.open { - transform: rotate(180deg); - color: #1e293b; - } - } - - &:hover:not(.active) { - color: #0f172a; - background: rgba(255, 255, 255, 0.6); - } - - &.active { - background: #ffffff; - color: #2563eb; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.02); - - .tab-icon { - color: #2563eb; - } - } - - &.video-replay-tab-btn { - .video-indicator-dot { - width: 7px; - height: 7px; - border-radius: 50%; - background: #10b981; - box-shadow: 0 0 6px #10b981; - margin-left: -2px; - flex-shrink: 0; - } - - &.has-video { - .tab-icon { - color: #0284c7; - } - } - } - } - - .header-right-actions { - margin-left: auto; - display: flex; - align-items: center; - - .btn-resume-task, - .btn-stop-task { - padding: 5px 10px; - background: #2563eb; - border: 1px solid #2563eb; - border-radius: 16px; - font-size: 13px; - font-weight: 600; - color: #ffffff; - cursor: pointer; - display: flex; - align-items: center; - gap: 4px; - transition: all 0.2s; - box-shadow: 0 2px 4px rgba(37, 99, 235, 0.2); - - .material-symbols-outlined { - font-size: 15px; - color: inherit; - } - - &:hover { - background: #1d4ed8; - border-color: #1d4ed8; - box-shadow: 0 4px 8px rgba(37, 99, 235, 0.3); - } - } - - .btn-stop-task { - background: #ef4444; - border-color: #ef4444; - box-shadow: 0 2px 4px rgba(239, 68, 68, 0.2); - - &:hover { - background: #dc2626; - border-color: #dc2626; - box-shadow: 0 4px 8px rgba(239, 68, 68, 0.3); - } - } - } - - - } - - .stream-floating-toolbar { - position: absolute; - top: 12px; - right: 20px; - height: 36px; - display: inline-flex; - align-items: center; - gap: 8px; - z-index: 40; - pointer-events: auto; - - @media (max-width: 1150px) { - top: 72px; - } - - /* Model Info Chip: click opens the run info popover */ - .model-info-chip { - display: inline-flex; - align-items: center; - gap: 5px; - height: 30px; - padding: 0 11px; - margin: 0; - box-sizing: border-box; - background: rgba(255, 255, 255, 0.72); - backdrop-filter: blur(16px) saturate(180%); - -webkit-backdrop-filter: blur(16px) saturate(180%); - border: 1px solid rgba(226, 232, 240, 0.85); - border-radius: 15px; - cursor: pointer; - user-select: none; - appearance: none; - -webkit-appearance: none; - - &:hover { - background: rgba(255, 255, 255, 0.92); - border-color: rgba(203, 213, 225, 0.95); - } - - &.active { - background: #eff6ff; - border-color: #bfdbfe; - - .model-icon, - .model-name { - color: #1d4ed8; - } - } - - &:focus-visible { - outline: 2px solid #93c5fd; - outline-offset: 1px; - } - box-shadow: 0 2px 6px -1px rgba(15, 23, 42, 0.05), inset 0 1px 1px rgba(255, 255, 255, 0.9); - font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - - .model-icon { - font-size: 15px; - width: 15px; - height: 15px; - display: inline-flex; - align-items: center; - justify-content: center; - color: #64748b; - } - - .model-label { - font-size: 12px; - font-weight: 500; - color: #64748b; - line-height: 1; - } - - .model-name { - font-size: 12px; - font-weight: 700; - color: #1e293b; - letter-spacing: 0.1px; - line-height: 1; - } - } - - /* Screen Recording Action Button: Interactive Button */ - .btn-screen-recording { - display: inline-flex; - align-items: center; - gap: 5px; - height: 30px; - padding: 0 12px; - box-sizing: border-box; - background: rgba(255, 255, 255, 0.72); - backdrop-filter: blur(16px) saturate(180%); - -webkit-backdrop-filter: blur(16px) saturate(180%); - border: 1px solid rgba(226, 232, 240, 0.85); - border-radius: 15px; - box-shadow: 0 2px 6px -1px rgba(15, 23, 42, 0.05), inset 0 1px 1px rgba(255, 255, 255, 0.9); - cursor: pointer; - transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); - user-select: none; - outline: none; - font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - - .video-icon { - font-size: 15px; - width: 15px; - height: 15px; - display: inline-flex; - align-items: center; - justify-content: center; - color: #64748b; - transition: transform 0.2s ease, color 0.2s ease; - } - - .btn-text { - font-size: 12px; - font-weight: 600; - color: #475569; - letter-spacing: 0.1px; - line-height: 1; - transition: color 0.15s ease; - } - - .video-pulse-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: #ef4444; - box-shadow: 0 0 6px #ef4444; - animation: pulseRed 1.5s infinite; - } - - &.recording { - background: rgba(254, 242, 242, 0.9); - border-color: rgba(248, 113, 113, 0.6); - box-shadow: 0 2px 6px -1px rgba(220, 38, 38, 0.15), inset 0 1px 1px rgba(255, 255, 255, 0.9); - - .video-icon { - color: #dc2626; - } - - .btn-text { - color: #b91c1c; - } - } - - &.processing { - .video-icon { - color: #2563eb; - animation: spin 1s linear infinite; - } - } - - &:hover:not(.active) { - background: rgba(255, 255, 255, 0.88); - border-color: rgba(203, 213, 225, 0.95); - box-shadow: 0 4px 12px -2px rgba(15, 23, 42, 0.08), inset 0 1px 1px rgba(255, 255, 255, 1); - color: #0f172a; - - .video-icon { - color: #1a73e8; - transform: scale(1.08); - } - - .btn-text { - color: #0f172a; - } - } - - &:active { - transform: translateY(0); - box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); - } - - &.has-steps { - background: rgba(240, 253, 250, 0.9); - border-color: rgba(94, 234, 212, 0.7); - box-shadow: 0 2px 6px -1px rgba(13, 148, 136, 0.12), inset 0 1px 1px rgba(255, 255, 255, 0.9); - - .steps-icon { - color: #0d9488; - } - - .btn-text { - color: #0f766e; - } - - &:hover:not(.active) { - background: rgba(204, 251, 241, 0.95); - border-color: rgba(45, 212, 191, 0.85); - - .steps-icon { - color: #0f766e; - } - } - } - - &.failed { - background: rgba(254, 242, 242, 0.8); - border-color: rgba(254, 202, 202, 0.9); - - .error-icon { - color: #ef4444; - } - - .btn-text { - color: #dc2626; - } - } - - .steps-count-badge { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 1px 5px; - border-radius: 10px; - background: #0d9488; - color: #ffffff; - font-size: 10px; - font-weight: 700; - line-height: 1; - margin-left: 2px; - } - - &.active { - background: rgba(26, 115, 232, 0.95); - border-color: rgba(26, 115, 232, 0.95); - box-shadow: 0 2px 8px rgba(26, 115, 232, 0.35); - - .video-icon, .btn-text { - color: #ffffff; - } - - .video-pulse-dot { - background: #ffffff; - box-shadow: 0 0 6px rgba(255, 255, 255, 0.9); - } - - .steps-count-badge { - background: #ffffff; - color: #1a73e8; - } - } - } - } - - .stream-logs-content { - flex: 1; - overflow-y: auto; - padding: 72px 24px 100px 24px; - position: relative; - - .session-loading-state { - min-height: 180px; - display: flex; - align-items: center; - justify-content: center; - gap: 9px; - color: #64748b; - font-size: 13px; - - .material-symbols-outlined { - font-size: 18px; - } - } - display: flex; - flex-direction: column; - - .floating-model-badge { - position: sticky; - top: 0px; - float: right; - margin-top: -2px; - margin-right: 4px; - display: inline-flex; - align-items: center; - gap: 4px; - padding: 3px 9px; - background: rgba(255, 255, 255, 0.92); - backdrop-filter: blur(12px); - border: 1px solid rgba(99, 102, 241, 0.2); - border-radius: 12px; - box-shadow: 0 1px 4px rgba(99, 102, 241, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04); - z-index: 35; - cursor: default; - transition: all 0.2s ease; - user-select: none; - - &:hover { - background: rgba(255, 255, 255, 1); - border-color: rgba(99, 102, 241, 0.4); - box-shadow: 0 2px 8px rgba(99, 102, 241, 0.15); - transform: translateY(-1px); - - .model-icon { - color: #4f46e5; - transform: rotate(15deg) scale(1.05); - } - } - - .model-icon { - font-size: 13px; - color: #6366f1; - transition: all 0.3s ease; - } - - .model-name { - font-size: 11px; - font-weight: 600; - color: #334155; - letter-spacing: normal; - font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; - } - } - - &::-webkit-scrollbar { - width: 6px; - } - &::-webkit-scrollbar-track { - background: transparent; - } - &::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.15); - border-radius: 10px; - } - &::-webkit-scrollbar-thumb:hover { - background: rgba(0, 0, 0, 0.25); - } - - .empty-state { - flex: 1 1 auto; - min-height: 100%; - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - text-align: center; - box-sizing: border-box; - padding: 30px; - margin: auto 0; - - .empty-icon { - margin-bottom: 12px; - opacity: 0.8; - - .material-symbols-outlined { - font-size: 40px; - color: #94a3b8; - } - } - - h3 { - margin: 0 0 6px 0; - font-size: 12px; - font-weight: 600; - color: $text-primary; - } - - p { - margin: 0; - font-size: 12px; - color: $text-secondary; - max-width: 320px; - line-height: 1.5; - } - } - - .logs-list-container { - display: flex; - flex-direction: column; - - .planning-loader-container { - display: flex; - align-items: center; - margin-top: 10px; - margin-bottom: 10px; - padding-left: 2px; - - .planning-loader { - display: inline-flex; - align-items: center; - gap: 8px; - color: #475569; - font-size: 13px; - font-weight: 500; - user-select: none; - - .spin-animation { - font-size: 16px; - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - animation: spin-loader 1s linear infinite; - color: #2563eb; - flex-shrink: 0; - } - - .loader-text { - color: #475569; - font-size: 13px; - font-weight: 500; - line-height: 1.4; - } - } - } - } - - .phase-container { - display: flex; - flex-direction: column; - } - - .phase-header { - font-size: 12px; - font-weight: normal; - line-height: 1.5; - min-height: 20px; - box-sizing: border-box; - color: #71717a; - margin-top: $uniform-gap * 2; - margin-bottom: 8px; - user-select: none; - display: flex; - align-items: center; - gap: 4px; - - &.first-phase { - margin-top: 0; - } - - .phase-worked-time { - color: #71717a; - font-weight: normal; - } - - .phase-token-usage { - display: inline-flex; - align-items: center; - gap: 4px; - color: #8e8e93; - cursor: default; - - .token-dot { - color: #a1a1aa; - font-weight: bold; - } - - .token-text { - color: #71717a; - font-variant-numeric: tabular-nums; - } - } - } - - .phase-body { - display: flex; - flex-direction: column; - gap: $uniform-gap; - padding-left: 12px; - border-left: 1px solid rgba(0, 0, 0, 0.04); - margin-left: 6px; - position: relative; - z-index: 1; - - .block-item { - display: flex; - flex-direction: column; - gap: $uniform-gap; - - .llm-stream-body, - .step-body { - .thinking-stream-details { - border-radius: 8px; - padding: 0; - overflow: visible; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02); - border: 1px solid transparent; - background: transparent; - display: flex; - flex-direction: column; - - .details-content-wrapper { - display: grid; - grid-template-rows: 1fr; - transition: grid-template-rows 0.3s cubic-bezier(0.4, 0, 0.2, 1); - overflow: hidden; - - .details-content { - min-height: 0; - } - } - - - &.native-thinking-details { - border-color: transparent; - background: transparent; - box-shadow: none; - - &:not(.collapsed) { - background: transparent; - } - - .details-summary { - color: #71717a; - - &::after { - color: #d4d4d8; - } - - &:hover { - color: #1f1f1f; - } - } - - &.is-running { - .details-summary { - background: linear-gradient( - 90deg, - #71717a 0%, - #2563eb 25%, - #7c3aed 50%, - #0284c7 75%, - #71717a 100% - ); - background-size: 200% 100%; - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - animation: subtle-color-flow 2.8s linear infinite; - font-weight: 500; - - .stream-status-dot { - background: #3b82f6; - box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.45); - animation: pulse-stream-dot 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; - } - } - } - - &.is-resetting { - .details-summary { - background: none !important; - -webkit-background-clip: border-box !important; - background-clip: border-box !important; - -webkit-text-fill-color: #71717a !important; - color: #71717a !important; - animation: none !important; - font-weight: normal !important; - - .stream-status-dot { - background: #f59e0b; - box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.45); - animation: pulse-stream-amber 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; - } - } - } - - .stream-status-dot { - background: #d4d4d8; - - &.streaming { - background: #3b82f6; - box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.45); - animation: pulse-stream-dot 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; - } - - &.resetting { - background: #f59e0b; - box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.45); - animation: pulse-stream-amber 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; - } - } - - .stream-text { - color: #1f1f1f; - } - } - - &.raw-thinking-details { - border-color: #dbeafe; - background: #f8fafc; - - &:not(.collapsed) { - background: #f8fafc; - } - - .details-summary { - color: #1e40af; - - &::after { - color: #93c5fd; - } - - &:hover { - color: #1e3a8a; - } - } - - .stream-status-dot { - background: #93c5fd; - - &.streaming { - background: #2563eb; - animation: pulse-stream-blue 1.5s infinite; - } - } - } - - &:not(.collapsed) { - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02); - - .details-summary { - margin-bottom: $uniform-gap; - } - - .details-summary::after { - transform: rotate(90deg); - } - } - - .details-summary { - padding: 0; - margin-bottom: 0; - font-family: 'Google Sans', 'Google Sans Text', 'Inter', Arial, sans-serif; - font-weight: normal; - font-size: 12px; - line-height: 1.5; - min-height: 20px; - box-sizing: border-box; - letter-spacing: 0.5px; - cursor: pointer; - user-select: none; - display: flex; - align-items: center; - gap: 8px; - outline: none; - list-style: none; - - &::-webkit-details-marker { - display: none; - } - - &::after { - content: '❯'; - display: inline-block; - font-size: 9px; - margin-left: 6px; - transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), color 0.2s ease; - } - - &:hover { - .stream-status-dot { - transform: scale(1.25); - background: #71717a; - } - - &::after { - transform: translateX(3px); - color: #71717a; - } - } - } - - .stream-status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - display: inline-block; - transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.2s ease; - } - - .stream-box { - max-height: 250px; - overflow-y: auto; - background: transparent; - padding: 4px 0; - box-sizing: border-box; - - &::-webkit-scrollbar { - width: 6px; - } - &::-webkit-scrollbar-track { - background: transparent; - } - &::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.08); - border-radius: 999px; - &:hover { - background: rgba(0, 0, 0, 0.16); - } - } - } - - .stream-text { - font-family: inherit; - font-size: 12.5px; - line-height: 1.65; - color: #1f1f1f; - white-space: normal; - word-break: break-word; - - strong { - font-weight: 600; - color: #111827; - } - - div, p { - font-size: 12.5px; - line-height: 1.65; - color: #1f1f1f; - margin: 4px 0; - } - - h1, h2, h3, h4, h5, h6 { - font-size: 12.5px; - font-weight: 600; - color: #1f1f1f; - margin-top: 8px; - margin-bottom: 4px; - } - - .inline-code { - font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace; - font-size: 11px; - background: #f4f4f5; - color: #b91c1c; - padding: 2px 6px; - border-radius: 4px; - } - - ul, ol { - margin: 4px 0; - padding-left: 16px; - li { - font-size: 12.5px; - line-height: 1.65; - color: #1f1f1f; - margin: 4px 0; - padding-left: 2px; - } - } - - .md-para-gap { - height: 6px; - } - } - - &.collapsed { - .details-content-wrapper { - grid-template-rows: 0fr; - display: none; - } - - .details-summary { - margin-bottom: 0; - } - } - } - - .thinking-stream-flat-card { - background: transparent; - border: 1px solid transparent; - border-radius: 8px; - padding: 0; - margin-bottom: 0; - box-shadow: none; - - .report-header { - font-family: 'Google Sans', 'Google Sans Text', 'Inter', Arial, sans-serif; - font-weight: normal; - font-size: 12px; - line-height: 1.5; - min-height: 20px; - box-sizing: border-box; - letter-spacing: 0.5px; - color: #71717a; - margin-bottom: $uniform-gap; - user-select: none; - display: flex; - align-items: center; - gap: 8px; - - .stream-status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - display: inline-block; - background: #d4d4d8; - - &.streaming { - background: #71717a; - animation: pulse-stream-grey 1.5s infinite; - } - } - } - - .report-content { - font-size: 12.5px; - line-height: 1.65; - color: #1f1f1f; - } - - .stream-box { - max-height: 250px; - overflow-y: auto; - background: transparent; - padding: 4px 0; - box-sizing: border-box; - - &::-webkit-scrollbar { - width: 6px; - } - &::-webkit-scrollbar-track { - background: transparent; - } - &::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.08); - border-radius: 999px; - &:hover { - background: rgba(0, 0, 0, 0.16); - } - } - } - - .stream-text { - font-family: inherit; - font-size: 12.5px; - line-height: 1.65; - color: #1f1f1f; - white-space: normal; - word-break: break-word; - - strong { - font-weight: 600; - color: #111827; - } - - div, p { - font-size: 12.5px; - line-height: 1.65; - color: #1f1f1f; - margin: 4px 0; - } - - h1 { - font-size: 14px; - font-weight: 700; - color: #0f172a; - margin-top: 12px; - margin-bottom: 6px; - } - - h2 { - font-size: 13.5px; - font-weight: 700; - color: #0f172a; - margin-top: 10px; - margin-bottom: 5px; - } - - h3 { - font-size: 13px; - font-weight: 600; - color: #1e293b; - margin-top: 8px; - margin-bottom: 4px; - } - - h4, h5, h6 { - font-size: 12.5px; - font-weight: 600; - color: #334155; - margin-top: 6px; - margin-bottom: 3px; - } - - em { - font-style: italic; - color: inherit; - } - - blockquote { - margin: 6px 0; - padding: 4px 10px; - border-left: 3px solid #cbd5e1; - color: #475569; - background: rgba(241, 245, 249, 0.5); - border-radius: 0 4px 4px 0; - } - - .code-block { - margin: 6px 0; - padding: 8px 12px; - background: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 6px; - overflow-x: auto; - font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace; - font-size: 11.5px; - line-height: 1.5; - color: #1e293b; - } - - .md-hr { - border: none; - border-top: 1px solid #e2e8f0; - margin: 10px 0; - } - - .inline-code { - font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace; - font-size: 11px; - background: #f4f4f5; - color: #b91c1c; - padding: 2px 6px; - border-radius: 4px; - } - - ul, ol { - margin: 4px 0; - padding-left: 16px; - li { - font-size: 12.5px; - line-height: 1.65; - color: #1f1f1f; - margin: 4px 0; - padding-left: 2px; - - &.md-verify-item { - color: #334155; - } - - &.md-finding-item { - color: #92400e; - } - } - } - - .md-para-gap { - height: 6px; - } - } - } - - .checker-stream-flat-card { - background: #f5f3ff; - border: 1px solid #e9d5ff; - border-radius: 8px; - padding: 12px 16px; - margin-bottom: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.01); - - .report-header { - font-family: 'Google Sans', 'Google Sans Text', 'Inter', Arial, "Liberation Sans", sans-serif; - font-weight: 700; - font-size: 12px; - line-height: 1.5; - letter-spacing: 0.5px; - color: #6b21a8; - margin-bottom: 8px; - user-select: none; - display: flex; - align-items: center; - gap: 8px; - - .stream-status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - display: inline-block; - background: #c084fc; - - &.streaming { - background: #a855f7; - animation: pulse-stream-purple 1.5s infinite; - } - } - } - - .report-content { - font-size: 12px; - line-height: 1.6; - color: #27272a; - - .stream-text { - font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace; - font-size: 11.5px; - white-space: pre-wrap; - word-break: break-all; - color: #5b21b6; - background: rgba(255, 255, 255, 0.5); - padding: 8px; - border-radius: 6px; - border: 1px dashed rgba(168, 85, 247, 0.2); - } - } - - .checker-result-badge { - display: inline-flex; - align-items: center; - gap: 6px; - font-weight: 700; - font-size: 11.5px; - padding: 4px 10px; - border-radius: 6px; - margin-bottom: 10px; - border: 1px solid transparent; - - &.success { - background: #ecfdf5; - color: #065f46; - border-color: #a7f3d0; - } - - &.failed { - background: #fef2f2; - color: #991b1b; - border-color: #fca5a5; - } - } - - .checker-reason { - font-size: 12px; - line-height: 1.6; - color: #3f3f46; - font-weight: 500; - } - } - } - - .step-body { - display: flex; - flex-direction: column; - gap: $uniform-gap; - - .step-raw-thinking-message { - background: transparent; - border: 1px solid transparent; - border-radius: 8px; - padding: 0; - margin-bottom: 0; - box-shadow: none; - - &.is-running { - .report-header { - background: linear-gradient( - 90deg, - #71717a 0%, - #2563eb 25%, - #7c3aed 50%, - #0284c7 75%, - #71717a 100% - ); - background-size: 200% 100%; - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - animation: subtle-color-flow 2.8s linear infinite; - font-weight: 500; - - .stream-status-dot { - background: #3b82f6; - box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.45); - animation: pulse-stream-dot 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; - } - } - } - - &.is-resetting { - .report-header { - background: none !important; - -webkit-background-clip: border-box !important; - background-clip: border-box !important; - -webkit-text-fill-color: #71717a !important; - color: #71717a !important; - animation: none !important; - font-weight: normal !important; - margin-bottom: 0; - - .stream-status-dot { - background: #f59e0b; - box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.45); - animation: pulse-stream-amber 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; - } - } - } - - .report-header { - font-family: 'Google Sans', 'Google Sans Text', 'Inter', Arial, sans-serif; - font-weight: normal; - font-size: 12px; - line-height: 1.5; - min-height: 20px; - box-sizing: border-box; - letter-spacing: 0.5px; - color: #71717a; - margin-bottom: $uniform-gap; - user-select: none; - display: flex; - align-items: center; - gap: 8px; - - .header-label { - line-height: 1.5; - word-break: break-word; - } - - .stream-status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - display: inline-block; - background: #d4d4d8; - - &.streaming { - background: #3b82f6; - box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.45); - animation: pulse-stream-dot 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; - } - - &.resetting { - background: #f59e0b; - box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.45); - animation: pulse-stream-amber 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; - } - } - } - - .report-content { - font-size: 12.5px; - line-height: 1.65; - color: #1f1f1f; - - strong { - font-weight: 600; - color: #111827; - } - - div, p { - font-size: 12.5px; - line-height: 1.65; - color: #1f1f1f; - margin: 4px 0; - } - - h1, h2, h3, h4, h5, h6 { - font-size: 12.5px; - font-weight: 600; - color: #1f1f1f; - margin-top: 8px; - margin-bottom: 4px; - } - - .inline-code { - font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace; - font-size: 11px; - background: #f4f4f5; - color: #b91c1c; - padding: 2px 6px; - border-radius: 4px; - } - - ul, ol { - margin: 4px 0; - padding-left: 16px; - li { - font-size: 12.5px; - line-height: 1.65; - color: #1f1f1f; - margin: 4px 0; - padding-left: 2px; - } - } - - .md-para-gap { - height: 6px; - } - } - } - - .step-action-summary-card { - background: #f4fbf7; - border: 1px solid #d1fae5; - border-radius: 8px; - padding: 12px 16px; - margin-bottom: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.01); - - .report-header { - font-family: 'Google Sans', 'Google Sans Text', 'Inter', Arial, "Liberation Sans", sans-serif; - font-weight: 700; - font-size: 12px; - line-height: 1.5; - letter-spacing: 0.5px; - color: #065f46; - margin-bottom: 8px; - user-select: none; - } - - .report-content { - font-size: 12px; - line-height: 1.6; - color: #27272a; - - strong { - font-weight: 600; - color: #09090b; - } - - .inline-code { - font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace; - font-size: 11px; - background: #e6f4ea; - color: #137333; - padding: 2px 6px; - border-radius: 4px; - } - - ul, ol { - margin: 2px 0; - padding-left: 20px; - li { - margin-bottom: 2px; - } - } - - .md-para-gap { - height: 4px; - } - } - } - - .step-actions-container { - display: flex; - flex-direction: column; - gap: $uniform-gap; - margin-top: 0; - margin-bottom: 0; - - .llm-retry-notes { - display: flex; - flex-direction: column; - gap: $uniform-gap; - padding: 0; - - .llm-retry-note { - min-height: $stream-row-height; - box-sizing: border-box; - display: flex; - align-items: center; - gap: 8px; - color: #71717a; - font-family: 'Google Sans', 'Google Sans Text', 'Inter', Arial, sans-serif; - font-size: 12px; - line-height: 1.5; - letter-spacing: 0.2px; - - .stream-status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: #d4d4d8; - flex: 0 0 auto; - - &.streaming { - background: #3b82f6; - box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.45); - animation: pulse-stream-dot 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; - } - - &.resetting { - background: #f59e0b; - box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.45); - animation: pulse-stream-amber 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; - } - } - - .llm-retry-note-text { - color: #71717a; - display: flex; - flex-direction: column; - min-width: 0; - - .llm-retry-status-line, - .llm-retry-guidance { - display: block; - } - - .llm-retry-guidance { - color: #a1a1aa; - } - } - } - } - - .step-generic-tool { - border: 1px solid transparent; - - &.is-video-attention:not(.is-running) { - .tool-call-label .stream-status-dot { - background: #f59e0b; - box-shadow: none; - animation: none; - } - } - - &.is-llm-error { - .tool-call-label { - display: flex; - align-items: flex-start; - color: #71717a; - line-height: 1.5; - user-select: text; - - .error-icon { - font-size: 14px; - color: #94a3b8; - margin-top: 1px; - margin-left: -3px; - margin-right: -3px; - flex-shrink: 0; - } - - .tool-call-text { - display: block; - color: inherit; - } - - .error-title-text { - font-weight: 500; - color: #71717a; - margin-right: 4px; - flex-shrink: 0; - } - - .error-message-text { - color: #71717a; - word-break: break-word; - } - } - } - - &.is-running { - .tool-call-label { - .stream-status-dot { - background: #3b82f6; - box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.45); - animation: pulse-stream-dot 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; - } - - .tool-call-text > span:first-child { - background: linear-gradient( - 90deg, - #71717a 0%, - #2563eb 25%, - #7c3aed 50%, - #0284c7 75%, - #71717a 100% - ); - background-size: 200% 100%; - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - animation: subtle-color-flow 2.8s linear infinite; - font-weight: 500; - } - } - } - - .tool-call-label { - font-size: 12px; - font-weight: normal; - color: #71717a; - font-family: 'Google Sans', 'Google Sans Text', 'Inter', Arial, sans-serif; - letter-spacing: 0.5px; - line-height: 1.5; - min-height: 22px; - box-sizing: border-box; - display: inline-flex; - align-items: center; - gap: 8px; - user-select: none; - padding: 0; - - span:not(.tool-key-pill) { - color: inherit; - } - - .stream-status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: #d4d4d8; - display: inline-block; - flex-shrink: 0; - - &.streaming { - background: #3b82f6; - box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.45); - animation: pulse-stream-dot 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; - } - } - - .is-failed-dot { - font-size: 13px; - color: #94a3b8; - display: inline-flex; - align-items: center; - justify-content: center; - width: 8px; - height: 8px; - flex-shrink: 0; - line-height: 1; - } - - .tool-call-text { - display: inline-flex; - align-items: center; - gap: 2px; - color: inherit; - } - - .tool-key-pill { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 6px; - border: none; - background: transparent; - font-size: 12px; - font-weight: 500; - color: #1a73e8; - cursor: pointer; - border-radius: 4px; - transition: all 0.2s ease; - vertical-align: middle; - text-decoration: none !important; - - .pill-icon { - font-size: 14px; - color: inherit; - } - - &:hover { - color: #1557b0; - background: rgba(26, 115, 232, 0.08); - } - } - - .video-status-detail { - color: inherit; - margin-left: 2px; - } - } - } - - .step-llm-error-card { - background: #fef2f2; - border: 1px solid #fecaca; - border-radius: 10px; - padding: 10px 12px; - margin-top: 0; - margin-bottom: 0; - margin-left: -19px; - box-shadow: 0 4px 12px rgba(239, 68, 68, 0.03); - width: fit-content; - min-width: 260px; - max-width: 480px; - display: flex; - flex-direction: column; - gap: 8px; - transition: all 0.2s ease; - position: relative; - - &:hover { - border-color: #fca5a5; - box-shadow: 0 4px 16px rgba(239, 68, 68, 0.08); - } - - .error-card-header { - display: flex; - align-items: center; - gap: 8px; - border-bottom: 1px solid #fee2e2; - padding-bottom: 6px; - - .error-icon { - font-size: 18px; - color: #ef4444; - } - - .error-title { - font-family: 'Google Sans', 'Google Sans Text', 'Inter', Arial, sans-serif; - font-weight: 700; - font-size: 12px; - color: #991b1b; - } - } - - .error-card-body { - display: flex; - flex-direction: column; - gap: 6px; - - .error-detail-row { - display: flex; - font-size: 12px; - - .detail-value { - color: #b91c1c; - font-weight: 500; - word-break: break-word; - line-height: 1.4; - } - } - } - } - - .llm-failure-stack { - width: 380px; - max-width: 100%; - margin-left: -19px; - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 10px; - - .step-android-action-card { - width: 100%; - margin-left: 0; - box-sizing: border-box; - } - - .llm-resume-row { - min-height: 36px; - display: flex; - align-items: center; - gap: 10px; - } - - .btn-continue-task { - min-height: 36px; - padding: 7px 12px 7px 10px; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 7px; - box-sizing: border-box; - border: 1px solid #1d4ed8; - border-radius: 9px; - background: #2563eb; - color: #ffffff; - font-family: inherit; - font-size: 12px; - font-weight: 700; - line-height: 1; - cursor: pointer; - box-shadow: 0 2px 7px rgba(37, 99, 235, 0.22); - transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; - - .continue-icon { - width: 22px; - height: 22px; - display: inline-flex; - align-items: center; - justify-content: center; - margin: -2px 0 -2px -2px; - border-radius: 50%; - background: rgba(255, 255, 255, 0.16); - font-size: 16px; - } - - .continue-arrow { - font-size: 15px; - opacity: 0.72; - transition: transform 0.18s ease, opacity 0.18s ease; - } - - &:hover { - background: #1d4ed8; - border-color: #1e40af; - box-shadow: 0 4px 11px rgba(37, 99, 235, 0.28); - transform: translateY(-1px); - - .continue-arrow { - opacity: 1; - transform: translateX(2px); - } - } - - &:active { - box-shadow: 0 1px 4px rgba(37, 99, 235, 0.2); - transform: translateY(0) scale(0.98); - } - - &:focus-visible { - outline: 3px solid rgba(59, 130, 246, 0.25); - outline-offset: 2px; - } - } - - .llm-resume-hint { - color: #94a3b8; - font-size: 11px; - line-height: 1.35; - white-space: nowrap; - } - - @media (max-width: 560px) { - .llm-resume-row { - width: 100%; - } - - .btn-continue-task { - flex: 1; - } - - .llm-resume-hint { - display: none; - } - } - } - - .step-android-action-card { - background: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 10px; - padding: 10px 12px; - margin-top: 0; - margin-bottom: 0; - margin-left: -19px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02); - width: 380px; - max-width: 100%; - display: flex; - flex-direction: column; - gap: 8px; - transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); - position: relative; - cursor: pointer; - user-select: none; - - &.is-running { - border-color: rgba(59, 130, 246, 0.35); - box-shadow: 0 2px 10px rgba(59, 130, 246, 0.08); - animation: subtle-card-glow 2.5s ease-in-out infinite alternate; - - .action-icon { - color: #3b82f6; - } - - .action-title { - background: linear-gradient( - 90deg, - #0f172a 0%, - #2563eb 30%, - #7c3aed 50%, - #0284c7 70%, - #0f172a 100% - ); - background-size: 200% 100%; - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - animation: subtle-color-flow 2.8s linear infinite; - } - } - - &:hover { - border-color: #cbd5e1; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04); - transform: translateY(-1px); - - .action-expand-icon { - color: #1e293b; - background: #e2e8f0; - } - } - - &.is-expanded { - border-color: #94a3b8; - background: #ffffff; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06); - - .action-expand-icon { - color: #0f172a; - background: #e2e8f0; - } - } - - &.is-failed { - background: #f8fafc; - border-color: #fca5a5; - - &.has-details { - cursor: pointer; - } - - &:hover { - border-color: #f87171; - } - - &.is-expanded { - background: #ffffff; - border-color: #f87171; - } - } - - &.task-report-card { - background: #f0fdf4; - border: 1px solid #bbf7d0; - - &.is-completed { - background: #f0fdf4; - border-color: #86efac; - - .report-icon { - color: #16a34a; - font-variation-settings: 'FILL' 1; - } - - .action-title { - color: #14532d; - font-weight: 700; - } - - .action-status-badge.is-completed { - background: #dcfce7; - color: #15803d; - border: 1px solid #bbf7d0; - } - } - - &.is-failed { - background: #fef2f2; - border-color: #fecaca; - - .report-icon { - color: #dc2626; - font-variation-settings: 'FILL' 1; - } - - .action-title { - color: #7f1d1d; - font-weight: 700; - } - - .action-status-badge.is-failed { - background: #fee2e2; - color: #b91c1c; - border: 1px solid #fca5a5; - } - } - - .report-markdown-body { - font-size: 12px; - line-height: 1.5; - color: #1f2937; - font-weight: 400; - user-select: text; - word-break: break-word; - - p { - margin: 3px 0; - } - - strong { - font-weight: 600; - color: #0f172a; - } - - ul, ol { - margin: 3px 0; - padding-left: 18px; - } - - code { - font-family: 'Fira Code', monospace; - background: #e2e8f0; - color: #1f2937; - padding: 1px 4px; - border-radius: 4px; - font-size: 11px; - } - } - } - - &.has-no-details { - padding: 8px 12px; - gap: 0; - - .action-card-header { - border-bottom: none; - padding-bottom: 0; - } - } - - .action-card-header { - display: flex; - align-items: center; - gap: 8px; - border-bottom: 1px solid #f1f5f9; - padding-bottom: 6px; - width: 100%; - - .action-icon { - font-size: 18px; - color: #64748b; - - &.is-failed { - color: #ef4444; - } - - } - - .action-title { - font-family: 'Google Sans', 'Google Sans Text', 'Inter', Arial, "Liberation Sans", sans-serif; - font-weight: 700; - font-size: 12px; - color: #0f172a; - flex-grow: 1; - } - - .action-agent-badge { - display: inline-flex; - align-items: center; - font-size: 10px; - font-weight: 600; - padding: 1px 6px; - border-radius: 4px; - background: #f0fdf4; - color: #166534; - border: 1px solid #bbf7d0; - letter-spacing: 0.2px; - margin-right: 6px; - white-space: nowrap; - - &.is-outputter { - background: linear-gradient(135deg, rgba(245, 243, 255, 0.95) 0%, rgba(238, 242, 255, 0.95) 100%); - color: #6d28d9; - border-color: #c4b5fd; - } - } - - .action-status-badge { - display: inline-flex; - align-items: center; - gap: 3px; - font-size: 11px; - font-weight: 600; - padding: 1px 6px; - border-radius: 4px; - margin-left: auto; - white-space: nowrap; - - .status-icon { - font-size: 13px; - } - - &.is-failed { - background: #fef2f2; - color: #dc2626; - border: 1px solid #fee2e2; - - .status-icon { - color: #ef4444; - } - } - - &.is-paused { - background: #fff7ed; - color: #c2410c; - border: 1px solid #fed7aa; - - .status-icon { - color: #f97316; - } - } - - } - - .action-expand-icon { - font-size: 18px; - color: #94a3b8; - margin-left: 4px; - padding: 2px; - border-radius: 50%; - transition: all 0.2s ease; - display: flex; - align-items: center; - justify-content: center; - } - } - - .action-card-body { - display: flex; - flex-direction: column; - gap: 6px; - - .action-detail-row { - display: flex; - align-items: baseline; - font-size: 12px; - - .detail-label { - color: #64748b; - font-weight: 500; - width: 85px; - flex-shrink: 0; - } - - .detail-value { - color: #334155; - font-weight: 600; - word-break: break-word; - - &.coordinate-badge { - background: #e2e8f0; - color: #475569; - padding: 2px 6px; - border-radius: 4px; - font-family: 'Fira Code', monospace; - font-size: 11px; - } - - &.command-value { - font-family: 'Fira Code', Consolas, Monaco, monospace; - background: #f1f5f9; - border: 1px solid #e2e8f0; - padding: 2px 6px; - border-radius: 4px; - font-size: 11px; - font-weight: 500; - color: #0f172a; - white-space: pre-wrap; - word-break: break-all; - - code { - font-family: inherit; - font-size: inherit; - color: inherit; - background: none; - padding: 0; - border: none; - } - } - } - } - - .failure-retry-details { - margin-top: 4px; - padding-top: 9px; - border-top: 1px solid #fecaca; - display: flex; - flex-direction: column; - gap: 7px; - animation: fadeInDetails 0.2s ease-out; - - .failure-retry-title { - color: #991b1b; - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.03em; - } - - .failure-retry-row { - display: flex; - justify-content: space-between; - gap: 12px; - color: #7f1d1d; - font-size: 10.5px; - line-height: 1.4; - - span:last-child { - color: #64748b; - text-align: right; - } - } - } - } - - .action-card-expanded-details { - display: flex; - flex-direction: column; - gap: 10px; - padding-top: 4px; - animation: fadeInDetails 0.2s ease-out; - - .expanded-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 8px; - background: #f8fafc; - padding: 10px; - border-radius: 8px; - border: 1px solid #f1f5f9; - - .grid-item { - display: flex; - flex-direction: column; - gap: 2px; - - &.full-width { - grid-column: 1 / -1; - } - - .grid-label { - font-size: 10px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.5px; - color: #64748b; - } - - .grid-value { - font-size: 12px; - font-weight: 600; - color: #1e293b; - word-break: break-all; - - &.code-pill { - font-family: 'Roboto Mono', 'Fira Code', Consolas, monospace; - font-size: 11px; - background: #e2e8f0; - color: #334155; - padding: 2px 6px; - border-radius: 4px; - display: inline-block; - width: fit-content; - } - - &.command-value { - code { - font-family: 'Roboto Mono', 'Fira Code', Consolas, monospace; - font-size: 11px; - background: #0f172a; - color: #38bdf8; - padding: 4px 8px; - border-radius: 4px; - display: block; - word-break: break-all; - white-space: pre-wrap; - } - } - } - } - } - - .expanded-screenshots-container { - display: flex; - flex-direction: row; - flex-wrap: nowrap; - gap: 12px; - margin-top: 4px; - justify-content: flex-start; - width: 100%; - - .screenshot-column { - flex: 1 1 0; - min-width: 0; - max-width: 320px; - display: flex; - flex-direction: column; - gap: 6px; - align-items: center; - - .screenshot-label { - font-family: 'Google Sans', 'Google Sans Text', 'Inter', Arial, "Liberation Sans", sans-serif; - font-size: 11.5px; - font-weight: 500; - color: #64748b; - letter-spacing: normal; - text-transform: none; - - } - - .image-wrapper { - display: inline-flex; - justify-content: center; - align-items: center; - max-width: 100%; - border: 1px solid #e2e8f0; - border-radius: 8px; - background-color: #f8fafc; - cursor: zoom-in; - overflow: hidden; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); - transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; - - &:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); - border-color: #cbd5e1; - } - - .img-relative-box { - position: relative; - display: inline-block; - max-width: 100%; - - img { - display: block; - max-width: 100%; - max-height: 280px; - width: auto; - height: auto; - margin: 0 auto; - border-radius: 6px; - } - - .overlay-layer { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - pointer-events: none; - overflow: visible; - } - } - } - } - } - - .expanded-error-banner { - display: flex; - align-items: center; - gap: 8px; - background: #fff1f2; - border: 1px solid #fecdd3; - color: #be123c; - padding: 8px 12px; - border-radius: 6px; - font-size: 12px; - font-weight: 500; - - .icon { - font-size: 16px; - color: #e11d48; - } - } - } - } - - .final-summary-card { - padding: 20px 24px; - display: flex; - flex-direction: column; - gap: 12px; - background: #ffffff; - border: 1px solid rgba(0, 0, 0, 0.06); - border-radius: 12px; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.01), 0 2px 4px -1px rgba(0, 0, 0, 0.006); - - .final-summary-header { - font-weight: 500; - font-size: 12px; - color: #71717a; - display: flex; - align-items: center; - gap: 6px; - user-select: none; - } - - .final-summary-body { - font-size: 12px; - line-height: 1.6; - color: #27272a; - - strong { - font-weight: 600; - color: #09090b; - } - - ul, ol { - margin: 6px 0; - padding-left: 20px; - li { - margin-bottom: 6px; - } - } - - div { - margin-bottom: 4px; - } - - .md-para-gap { - height: 8px; - } - } - } - } - } - } - } - } -} - -@keyframes spin-loader { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@keyframes pulse-green { - 0% { - box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); - } - 70% { - box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); - } - 100% { - box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); - } -} - -@keyframes pulse-stream-blue { - 0% { - box-shadow: 0 0 0 0 rgba(14, 165, 233, 0.4); - } - 70% { - box-shadow: 0 0 0 6px rgba(14, 165, 233, 0); - } - 100% { - box-shadow: 0 0 0 0 rgba(14, 165, 233, 0); - } -} - -@keyframes pulse-stream-green { - 0% { - box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); - } - 70% { - box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); - } - 100% { - box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); - } -} - -@keyframes pulse-stream-purple { - 0% { - box-shadow: 0 0 0 0 rgba(168, 85, 247, 0.4); - } - 70% { - box-shadow: 0 0 0 6px rgba(168, 85, 247, 0); - } - 100% { - box-shadow: 0 0 0 0 rgba(168, 85, 247, 0); - } -} - -@keyframes pulse-stream-grey { - 0% { - box-shadow: 0 0 0 0 rgba(113, 113, 122, 0.4); - } - 70% { - box-shadow: 0 0 0 6px rgba(113, 113, 122, 0); - } - 100% { - box-shadow: 0 0 0 0 rgba(113, 113, 122, 0); - } -} - -@keyframes pulse-black-dot { - 0% { - box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.4); - } - 70% { - box-shadow: 0 0 0 5px rgba(0, 0, 0, 0); - } - 100% { - box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); - } -} - -@keyframes pulse-black-dot-small { - 0% { - opacity: 1; - transform: scale(1); - } - 50% { - opacity: 0.4; - transform: scale(0.8); - } - 100% { - opacity: 1; - transform: scale(1); - } -} - -@keyframes fadeInDetails { - from { - opacity: 0; - transform: translateY(-4px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes dropdownFadeIn { - from { - opacity: 0; - transform: translateY(-4px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -.image-modal-backdrop { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - width: 100%; - height: 100%; - background-color: rgba(15, 23, 42, 0.75); - backdrop-filter: blur(4px); - z-index: 1000; - display: flex; - align-items: center; - justify-content: center; - padding: 24px; - box-sizing: border-box; - animation: fadeInModal 0.2s ease-out; - - .image-modal-content { - position: relative; - max-width: calc(100% - 48px); - max-height: calc(100% - 48px); - background: #0f172a; - border-radius: 12px; - padding: 12px; - box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 8px 10px -6px rgba(0, 0, 0, 0.5); - display: flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - - .image-modal-close { - position: absolute; - top: -12px; - right: -12px; - width: 32px; - height: 32px; - border-radius: 50%; - background: #ef4444; - color: white; - border: 2px solid white; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); - transition: transform 0.15s ease, background-color 0.15s ease; - z-index: 20; - - &:hover { - transform: scale(1.1); - background: #dc2626; - } - - .material-symbols-outlined { - font-size: 18px; - } - } - - .img-relative-box { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - max-width: 100%; - max-height: 100%; - - img { - display: block; - max-width: 100%; - max-height: calc(100vh - 140px); - width: auto; - height: auto; - object-fit: contain; - border-radius: 6px; - } - - .overlay-layer { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - pointer-events: none; - overflow: visible; - border-radius: 6px; - } - } - } -} - -@keyframes pulse-animation { - 0% { - transform: scale(0.5); - opacity: 1; - } - 100% { - transform: scale(1.8); - opacity: 0; - } -} - -@keyframes fadeInModal { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes subtle-color-flow { - 0% { - background-position: 0% 50%; - } - 100% { - background-position: 200% 50%; - } -} - -@keyframes pulse-stream-dot { - 0%, 100% { - transform: scale(1); - box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.45); - } - 50% { - transform: scale(1.15); - box-shadow: 0 0 0 4px rgba(59, 130, 246, 0); - } -} - -@keyframes pulse-stream-amber { - 0%, 100% { - transform: scale(1); - box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.45); - } - 50% { - transform: scale(1.15); - box-shadow: 0 0 0 4px rgba(245, 158, 11, 0); - } -} - -.spinning { - animation: spin 1.2s linear infinite; -} - - -@keyframes subtle-card-glow { - 0% { - border-color: rgba(59, 130, 246, 0.25); - box-shadow: 0 2px 8px rgba(59, 130, 246, 0.05); - } - 100% { - border-color: rgba(99, 102, 241, 0.45); - box-shadow: 0 4px 14px rgba(99, 102, 241, 0.12); - } -} - -/* Outputter Post-Execution Synthesis Card */ -.outputter-synthesis-stream-card { - margin: 16px 0 12px 0; - border-radius: 12px; - border: 1px solid rgba(196, 181, 253, 0.85); - background: linear-gradient(135deg, rgba(245, 243, 255, 0.95) 0%, rgba(250, 245, 255, 0.98) 100%); - box-shadow: 0 4px 16px rgba(124, 58, 237, 0.08), 0 1px 3px rgba(0, 0, 0, 0.03); - overflow: hidden; - animation: fadeInModal 0.25s ease-out; - - .synthesis-card-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - background: rgba(237, 233, 254, 0.6); - border-bottom: 1px solid rgba(196, 181, 253, 0.7); - gap: 12px; - flex-wrap: wrap; - - .header-left { - display: flex; - align-items: center; - gap: 10px; - - .icon-sparkle { - font-size: 22px; - color: #7c3aed; - } - - .main-title { - font-size: 14px; - font-weight: 700; - color: #4338ca; - } - } - - .header-right { - .btn-open-note { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 5px 12px; - background: linear-gradient(135deg, #6366f1 0%, #7c3aed 100%); - color: #ffffff; - border: none; - border-radius: 8px; - font-size: 12px; - font-weight: 600; - cursor: pointer; - transition: all 0.15s ease; - box-shadow: 0 2px 6px rgba(99, 102, 241, 0.25); - - .material-symbols-outlined { - font-size: 15px; - } - - &:hover { - background: linear-gradient(135deg, #4f46e5 0%, #6d28d9 100%); - box-shadow: 0 4px 10px rgba(99, 102, 241, 0.35); - transform: translateY(-1px); - } - } - } - } - - .synthesis-card-preview { - padding: 14px 16px; - - .parsed-note-container { - display: flex; - flex-direction: column; - gap: 10px; - - .parsed-note-title { - font-size: 14px; - font-weight: 700; - color: #1e1b4b; - margin: 0; - } - - .parsed-milestones { - display: flex; - flex-direction: column; - gap: 6px; - - .milestone-item { - display: flex; - align-items: flex-start; - gap: 8px; - font-size: 12.5px; - line-height: 1.45; - color: #374151; - - .status-icon { - font-size: 16px; - color: #7c3aed; - margin-top: 1px; - flex-shrink: 0; - } - - &.status-checked .status-icon { - color: #10b981; - } - - &.status-progress .status-icon { - color: #f59e0b; - } - } - } - - .parsed-other-lines { - display: flex; - flex-direction: column; - gap: 4px; - - .other-line { - margin: 0; - font-size: 12.5px; - line-height: 1.5; - color: #4b5563; - - strong { - color: #1e1b4b; - } - - code { - font-family: 'JetBrains Mono', monospace; - background: rgba(255, 255, 255, 0.9); - border: 1px solid #e5e7eb; - padding: 1px 4px; - border-radius: 4px; - color: #6d28d9; - font-size: 11.5px; - } - } - } - } - } -} - -/* Checker blocks share the step timeline, marked by a violet left border. */ -.agent-stream-container .stream-logs-content .phase-body .block-item.type-checker { - border-left: 2px solid #ddd6fe; - padding-left: 12px; - margin-left: -14px; - font-family: 'Google Sans', 'Google Sans Text', 'Inter', Arial, sans-serif; - - .check-index { - flex: 0 0 14px; - text-align: right; - color: #a1a1aa; - font-variant-numeric: tabular-nums; - user-select: none; - } - - .check-lane { - display: flex; - flex-direction: column; - gap: $uniform-gap; - margin-bottom: $uniform-gap; - - .check-header { - display: flex; - align-items: center; - gap: 8px; - min-height: 20px; - font-size: 12px; - line-height: 1.5; - letter-spacing: 0.2px; - color: #71717a; - user-select: none; - - .stream-status-dot { - flex: 0 0 auto; - width: 8px; - height: 8px; - border-radius: 50%; - display: inline-block; - background: #d4d4d8; - - &.streaming { - background: #7c3aed; - box-shadow: 0 0 0 0 rgba(124, 58, 237, 0.45); - animation: pulse-stream-dot 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; - } - } - - .check-title { - flex: 0 0 auto; - font-weight: 500; - color: #52525b; - } - - .check-subject { - flex: 1 1 auto; - min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .check-status { - flex: 0 0 auto; - margin-left: auto; - font-weight: 500; - color: #52525b; - white-space: nowrap; - } - } - - &.tone-passed .check-status { color: #15803d; } - &.tone-failed .check-status { color: #b91c1c; } - &.tone-inconclusive .check-status { color: #b45309; } - &.tone-running .check-status { color: #6d28d9; } - &.tone-muted { opacity: 0.6; } - - .check-items { - list-style: none; - margin: 0; - padding: 0 0 0 16px; - display: flex; - flex-direction: column; - gap: 2px; - - .check-item { - display: flex; - align-items: baseline; - gap: 8px; - font-size: 12px; - line-height: 1.5; - color: #3f3f46; - - .check-text { - flex: 1 1 auto; - min-width: 0; - word-break: break-word; - } - - .check-method { - flex: 0 0 auto; - color: #a1a1aa; - white-space: nowrap; - } - } - } - } - - .check-result { - display: flex; - flex-direction: column; - gap: 4px; - margin-top: $uniform-gap; - padding-left: 16px; - - .check-verdict { - display: flex; - align-items: flex-start; - gap: 8px; - font-size: 12px; - line-height: 1.5; - color: #52525b; - - .check-index { - line-height: 18px; - } - - .verdict-icon { - flex: 0 0 auto; - font-size: 15px; - line-height: 18px; - color: #a1a1aa; - } - - &.passed .verdict-icon { color: #16a34a; } - &.failed .verdict-icon { color: #dc2626; } - &.inconclusive .verdict-icon { color: #d97706; } - &.superseded, &.unchecked { color: #a1a1aa; } - - .verdict-body { - flex: 1 1 auto; - min-width: 0; - word-break: break-word; - } - - .verdict-suggestion { - display: block; - color: #1d4ed8; - } - } - - .check-note { - font-size: 12px; - line-height: 1.5; - color: #71717a; - padding-left: 22px; - word-break: break-word; - } - } -} - -@media (min-width: 1151px) { - @container stream-panel (max-width: 660px) { - .agent-stream-container .stream-floating-toolbar { - .model-info-chip { - padding: 0 9px; - - .model-label { - display: none; - } - } - - .btn-screen-recording { - padding: 0 9px; - gap: 0; - - .btn-text { - display: none; - } - } - } - } -} - -@media (max-width: 1150px) { - @container stream-panel (max-width: 900px) { - .agent-stream-container .vscode-nav-header .vscode-tab-btn { - padding: 0 9px; - gap: 0; - - .tab-label { - display: none; - } - } - } -} - -/* ========================================================================= */ -/* Floating Dropdown Popover Menu (Angular CDK Connected Overlay Top Layer) */ -/* ========================================================================= */ -/* Floating Dropdown Popover Menu */ -.vscode-dropdown-menu { - position: relative; - width: 360px; - box-sizing: border-box; - max-width: calc(100vw - 24px); - max-height: 480px; - background: #ffffff; - border: 1px solid #cbd5e1; - border-radius: 12px; - box-shadow: 0 16px 40px -4px rgba(15, 23, 42, 0.16), 0 4px 12px rgba(0, 0, 0, 0.06); - z-index: 100; - overflow-y: auto; - padding: 14px; - display: flex; - flex-direction: column; - gap: 10px; - animation: dropdownFadeIn 0.15s cubic-bezier(0.16, 1, 0.3, 1); - - .popover-title-header { - display: flex; - align-items: center; - gap: 8px; - padding-bottom: 8px; - border-bottom: 1px solid #e2e8f0; - font-size: 14px; - font-weight: 700; - color: #0f172a; - - .material-symbols-outlined { - font-size: 18px; - color: #2563eb; - } - } - - /* Run info popover: elapsed, tokens, context share, Pro tuning */ - &.run-info-dropdown { - width: 288px; - gap: 0; - padding: 12px 14px 10px; - - .run-info-header { - display: flex; - align-items: center; - gap: 7px; - padding-bottom: 9px; - border-bottom: 1px solid #e2e8f0; - - .material-symbols-outlined { - font-size: 18px; - color: #64748b; - - &.is-pro { - color: #2563eb; - } - } - - .run-info-title { - font-size: 13px; - font-weight: 700; - color: #0f172a; - } - - .run-info-model { - margin-left: auto; - max-width: 150px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace; - font-size: 10.5px; - color: #94a3b8; - } - } - - .run-info-row { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; - padding: 9px 0; - border-bottom: 1px solid #f1f5f9; - - &:last-child { - border-bottom: none; - } - - &.is-stacked { - flex-direction: column; - align-items: stretch; - gap: 0; - } - } - - .run-info-line { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; - } - - .run-info-label { - font-size: 12px; - font-weight: 500; - color: #64748b; - white-space: nowrap; - } - - .run-info-value { - font-size: 12.5px; - font-weight: 600; - color: #0f172a; - text-align: right; - font-variant-numeric: tabular-nums; - - &.is-muted { - color: #cbd5e1; - font-weight: 500; - } - } - - .run-info-sub { - display: block; - margin-top: 2px; - font-size: 11px; - font-weight: 400; - color: #94a3b8; - } - - .run-info-sub-inline { - margin-left: 3px; - font-size: 11px; - font-weight: 400; - color: #94a3b8; - } - - .run-info-meter { - height: 4px; - margin-top: 7px; - border-radius: 2px; - background: #e2e8f0; - overflow: hidden; - - .run-info-meter-fill { - height: 100%; - border-radius: 2px; - background: #2563eb; - transition: width 0.3s ease; - } - - &.is-high .run-info-meter-fill { - background: #f59e0b; - } - - &.is-critical .run-info-meter-fill { - background: #ef4444; - } - } - - .run-info-note { - padding-top: 9px; - font-size: 11.5px; - color: #94a3b8; - } - } - - &.notes-dropdown { - width: 380px; - max-height: 520px; - - .notes-selector-container { - display: flex; - flex-direction: column; - gap: 6px; - margin-bottom: 4px; - - .notes-selector-list { - display: flex; - flex-wrap: wrap; - gap: 6px; - - .note-selector-pill { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 4px 10px; - border: 1px solid #e2e8f0; - background: #ffffff; - border-radius: 16px; - font-size: 11.5px; - font-weight: 500; - color: #475569; - cursor: pointer; - transition: all 0.15s ease; - - .pill-icon { - font-size: 14px; - } - - &:hover { - background: #f8fafc; - border-color: #cbd5e1; - color: #1e293b; - } - - &.active { - background: #eff6ff; - border-color: #bfdbfe; - color: #1d4ed8; - font-weight: 600; - } - } - } - } - - .dropdown-notes-content { - display: flex; - flex-direction: column; - gap: 10px; - padding: 4px 2px; - - .note-title { - font-size: 13.5px; - font-weight: 700; - color: #1f1f1f; - margin: 0 0 4px 0; - font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif; - } - - .code-segment { - font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace; - background-color: #f1f3f4; - color: #1967d2; - padding: 1px 5px; - border-radius: 4px; - font-size: 12px; - margin: 0 2px; - word-break: break-all; - } - - .milestones-container { - display: flex; - flex-direction: column; - gap: 8px; - } - - .milestone-block { - display: flex; - flex-direction: column; - - &.checked { - .milestone-title-row { - .milestone-status-icon.checked { - opacity: 0.7; - } - .milestone-text { - color: #a1a1aa; - } - } - } - - .milestone-title-row { - display: flex; - align-items: flex-start; - gap: 8px; - - .milestone-status-icon { - font-size: 18px; - user-select: none; - margin-top: 2px; - font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24; - - &.checked, &.progress { - color: #1a73e8; - } - &.unchecked { - color: #cbd5e1; - } - } - - .milestone-text { - font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif; - color: #1f1f1f; - font-size: 13px; - line-height: 1.5; - word-break: break-word; - flex: 1; - } - } - - .milestone-substeps { - margin-top: 4px; - display: flex; - flex-direction: column; - gap: 5px; - padding-left: 26px; - - .substep-row { - display: flex; - align-items: flex-start; - gap: 8px; - font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif; - color: #1f1f1f; - font-size: 12.5px; - line-height: 1.5; - - &.checked { - color: #a1a1aa; - .check-icon.checked { - opacity: 0.7; - } - } - - &.verify { - color: #334155; - gap: 8px; - align-items: flex-start; - - .verify-badge { - flex-shrink: 0; - display: inline-flex; - align-items: center; - justify-content: center; - font-family: 'Google Sans Text', 'Inter', -apple-system, sans-serif; - font-size: 10.5px; - font-weight: 550; - line-height: 1.2; - letter-spacing: 0.2px; - text-transform: lowercase; - color: #475569; - background: #f1f5f9; - border: 1px solid #e2e8f0; - border-radius: 4px; - padding: 1.5px 6px; - margin-top: 1.5px; - user-select: none; - - &.assert { - color: #0369a1; - background: #f0f9ff; - border-color: #e0f2fe; - } - } - - .line-text { - color: #334155; - font-size: 12.5px; - line-height: 1.5; - } - } - - &.finding { - color: #92400e; - gap: 8px; - align-items: flex-start; - - .finding-badge { - flex-shrink: 0; - display: inline-flex; - align-items: center; - justify-content: center; - font-family: 'Google Sans Text', 'Inter', -apple-system, sans-serif; - font-size: 10.5px; - font-weight: 550; - line-height: 1.2; - letter-spacing: 0.2px; - text-transform: lowercase; - color: #b45309; - background: #fef3c7; - border: 1px solid #fde68a; - border-radius: 4px; - padding: 1.5px 6px; - margin-top: 1.5px; - user-select: none; - } - - .line-text { - color: #92400e; - font-size: 12.5px; - line-height: 1.5; - } - } - - .check-icon { - font-size: 16px; - user-select: none; - margin-top: 2px; - font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24; - - &.checked, &.progress { - color: #1a73e8; - } - &.unchecked { - color: #cbd5e1; - } - } - - .bullet { - color: #9ca3af; - font-weight: bold; - user-select: none; - } - - .line-text { - word-break: break-word; - color: inherit; - } - } - } - - .milestone-checks-container { - margin-top: 6px; - padding-left: 26px; - display: flex; - flex-direction: column; - gap: 6px; - } - } - - .verification-card { - background: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 6px; - padding: 7px 10px; - display: flex; - flex-direction: column; - gap: 3px; - - &.standalone { - margin-top: 6px; - margin-bottom: 6px; - } - - .card-header { - display: flex; - align-items: center; - - .card-tag { - font-family: 'Google Sans Text', 'Inter', -apple-system, sans-serif; - font-size: 9.5px; - font-weight: 600; - line-height: 1; - letter-spacing: 0.6px; - text-transform: uppercase; - color: #64748b; - - &.assert { - color: #0284c7; - } - } - } - - .card-body { - font-family: inherit; - font-size: 12px; - line-height: 1.5; - color: #334155; - word-break: break-word; - - .bold-segment { - font-weight: 600; - color: #1e293b; - } - - .code-segment { - font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace; - font-size: 11px; - background: #f1f5f9; - color: #0f172a; - padding: 1px 5px; - border-radius: 4px; - border: 1px solid #e2e8f0; - } - } - - &.finding { - background: #fffbeb; - border-color: #fef3c7; - - .card-header .card-tag { - color: #b45309; - } - - .card-body { - color: #92400e; - } - } - } - - .other-lines-container { - display: flex; - flex-direction: column; - gap: 6px; - margin-top: 6px; - padding-top: 6px; - border-top: 1px dashed #e2e8f0; - - .md-line { - display: flex; - align-items: flex-start; - gap: 6px; - font-size: 12.5px; - color: #334155; - line-height: 1.5; - - .line-text { - word-break: break-word; - color: inherit; - } - } - } - } - } - - &::-webkit-scrollbar { - width: 6px; - } - &::-webkit-scrollbar-thumb { - background: #cbd5e1; - border-radius: 4px; - } - - .dropdown-header { - display: flex; - align-items: center; - justify-content: space-between; - padding-bottom: 8px; - border-bottom: 1px solid #e2e8f0; - - .dropdown-title { - display: flex; - align-items: center; - gap: 8px; - font-size: 13px; - font-weight: 700; - color: #0f172a; - - .icon-main { - font-size: 18px; - color: #2563eb; - } - } - } - - .dropdown-section-label { - display: flex; - align-items: center; - gap: 6px; - font-size: 11px; - font-weight: 700; - color: #475569; - text-transform: uppercase; - letter-spacing: 0.5px; - padding: 4px 2px 2px 2px; - - &.history-label { - margin-top: 6px; - } - - .icon-bolt { - font-size: 15px; - color: #d97706; - } - .icon-history { - font-size: 15px; - color: #64748b; - } - } - - .dropdown-empty-state { - padding: 20px 14px; - text-align: center; - font-size: 12px; - color: #94a3b8; - background: #f8fafc; - border: 1px dashed #e2e8f0; - border-radius: 6px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 6px; - - .empty-icon { - font-size: 22px; - color: #94a3b8; - } - } - - .dropdown-history-list { - display: flex; - flex-direction: column; - gap: 8px; - } - - .dropdown-item-card { - padding: 10px 12px; - background: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 6px; - cursor: pointer; - transition: all 0.15s ease; - display: flex; - flex-direction: column; - gap: 6px; - - &:hover { - background: #f1f5f9; - border-color: #cbd5e1; - transform: translateY(-1px); - - .item-header .item-header-actions { - &:has(.btn-delete-card) .item-time, - &:has(.btn-delete-card) .item-time-divider, - &:has(.btn-stop-card) .item-time, - &:has(.btn-stop-card) .item-time-divider { - opacity: 0; - } - - .btn-delete-card, - .btn-stop-card { - opacity: 1; - pointer-events: auto; - } - } - } - - &.selected { - background: #eff6ff; - border-color: #93c5fd; - box-shadow: 0 0 0 1px #2563eb; - - .item-goal { - color: #1e3a8a; - font-weight: 600; - } - } - - &.running { - border-left: 3.5px solid #2563eb; - } - - .item-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - min-width: 0; - - .task-badge { - flex-shrink: 0; - display: inline-flex; - align-items: center; - font-size: 10.5px; - font-weight: 700; - padding: 2px 5px; - border-radius: 4px; - letter-spacing: 0.05em; - border: 1px solid #e2e8f0; - background: #fafafa; - color: #64748b; - - &::before { - content: ''; - width: 4px; - height: 4px; - border-radius: 50%; - display: inline-block; - margin-right: 4px; - } - - &.running { - background: #fafafa; - color: #1f1f1f; - border-color: #1f1f1f; - &::before { - background-color: #1f1f1f; - animation: pulse-black-dot-small 1.5s infinite ease-in-out; - } - } - &.paused { - background: #fff7ed; - color: #c2410c; - border-color: #fdba74; - &::before { background-color: #f97316; } - } - &.completed { - background: #fafafa; - color: #1f1f1f; - border-color: #e2e8f0; - &::before { - background-color: #1f1f1f; - } - } - &.pending { - background: #fafafa; - color: #64748b; - border-color: #e2e8f0; - &::before { - background-color: #94a3b8; - } - } - &.failed { - background: #fafafa; - color: #ef4444; - border-color: #fee2e2; - &::before { - background-color: #ef4444; - } - } - &.cancelled { - background: #fafafa; - color: #d97706; - border-color: #fef3c7; - &::before { - background-color: #f59e0b; - } - } - - .status-dot { - display: none; - } - } - - .item-header-actions { - display: flex; - align-items: center; - gap: 4px; - position: relative; - min-width: 0; - flex-shrink: 1; - justify-content: flex-end; - - .item-device { - display: inline-flex; - align-items: center; - gap: 2.5px; - min-width: 0; - flex-shrink: 1; - font-size: 10.5px; - color: #64748b; - line-height: 1; - cursor: default; - transition: color 0.15s ease, opacity 0.15s ease; - - .device-icon { - font-size: 12px; - width: 12px; - height: 12px; - line-height: 12px; - color: #64748b; - opacity: 0.75; - flex-shrink: 0; - } - - .device-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; - font-family: inherit; - letter-spacing: -0.15px; - } - - &:hover { - color: #334155; - .device-icon { - color: #334155; - opacity: 1; - } - } - } - - .item-time-divider { - font-size: 10px; - color: #94a3b8; - line-height: 1; - user-select: none; - flex-shrink: 0; - transition: opacity 0.15s ease; - } - - .item-time { - font-size: 11px; - color: #64748b; - white-space: nowrap; - flex-shrink: 0; - line-height: 1; - transition: opacity 0.15s ease; - } - - .btn-delete-card { - position: absolute; - right: 0; - top: 50%; - transform: translateY(-50%); - width: 18px; - height: 18px; - padding: 0; - border: none; - background: transparent; - border-radius: 3px; - color: #94a3b8; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - opacity: 0; - pointer-events: none; - transition: all 0.15s ease; - - .material-symbols-outlined { - font-size: 13px; - line-height: 1; - } - - &:hover { - color: #ef4444; - background: #fee2e2; - } - } - - .btn-stop-card { - position: absolute; - right: 0; - top: 50%; - transform: translateY(-50%); - width: 18px; - height: 18px; - padding: 0; - border: none; - background: transparent; - border-radius: 3px; - color: #ef4444; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - opacity: 0; - pointer-events: none; - transition: all 0.15s ease; - - .material-symbols-outlined { - font-size: 14px; - line-height: 1; - } - - &:hover { - color: #dc2626; - background: #fee2e2; - transform: translateY(-50%) scale(1.1); - } - } - } - } - - .item-goal { - font-size: 12.5px; - color: #334155; - line-height: 1.4; - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - } - - .item-footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - font-size: 10.5px; - color: #94a3b8; - - .item-id { - font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace; - font-size: 10.5px; - color: #94a3b8; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; - flex: 1; - transition: color 0.15s ease; - - &:hover { - color: #475569; - } - } - - .item-date { - flex-shrink: 0; - } - } - } - - .dropdown-note-item { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 12px; - border-radius: 6px; - cursor: pointer; - font-size: 13px; - color: #334155; - transition: background 0.15s ease; - - .note-icon { - font-size: 18px; - color: #64748b; - } - - .note-key-text { - flex: 1; - font-weight: 500; - } - - .check-icon { - font-size: 18px; - color: #2563eb; - } - - &:hover { - background: #f1f5f9; - color: #0f172a; - } - - &.selected { - background: #eff6ff; - color: #2563eb; - font-weight: 600; - - .note-icon { - color: #2563eb; - } - } - } -} diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts deleted file mode 100644 index 437237215..000000000 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts +++ /dev/null @@ -1,2234 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, ChangeDetectionStrategy, NgZone, signal, computed, effect, inject, untracked, DestroyRef, AfterViewInit } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { RouterLink } from '@angular/router'; -import { HttpClient } from '@angular/common/http'; -import { AgentService, StartupProgressEvent } from '../../services/agent.service'; -import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model'; -import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model'; -import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay'; -import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE } from '../../core/models/stream.model'; -import { - contextPercent, - formatCompactTokens, - formatElapsed, - parseRunTuning, - sessionElapsedSeconds, - tuningLabel -} from '../../utils/run-info.util'; - -export const PLANNING_LOADER_PHRASES: string[] = [ - 'Planning next step...', - 'Analyzing screen coordinates...', - 'Consulting neural network...', - 'Formulating tactical action plan...', - 'Deciphering UI state & elements...', - 'Synthesizing decision pathways...', - 'Calibrating next move...', - 'Aligning logical vectors...', - 'Evaluating optimal sub-goals...', - 'Gathering sensory inputs...', - 'Computing next interaction...', - 'Optimizing execution strategy...', - 'Simulating probable outcomes...', - 'Summoning AI intuition...', - 'Brewing the next command...', - 'Strategizing tactical moves...' -]; - -export interface StartupWorkItem extends StartupProgressEvent { - isActive: boolean; - elapsed: string; -} - -interface StartupWorkStage { - started: string; - completed: string; - completedMessage: string; - /** Sub-steps that replace the live message while the stage is still running. */ - liveDetailStages?: string[]; - /** A later event whose message is a better completion line than the stage's own. */ - completedDetailStage?: string; -} - -const STARTUP_WORK_STAGES: StartupWorkStage[] = [ - { - started: 'device_check', - completed: 'device_ready', - completedMessage: 'Android device connected' - }, - { - started: 'uiautomator', - completed: 'uiautomator_ready', - completedMessage: 'UI hierarchy service is ready', - // First task on a device installs / upgrades the accessibility helper - // (a few seconds): say so instead of a generic "connecting". - liveDetailStages: ['helper_install', 'helper_upgrade'], - // "UI hierarchy source: Artemis accessibility helper v1.1.3" (or UIAutomator2). - completedDetailStage: 'hierarchy_backend' - }, - { - started: 'environment', - completed: 'environment_ready', - completedMessage: 'Device environment is ready' - } -]; - -function formatStartupElapsed(seconds: number): string { - const safeSeconds = Math.max(0, seconds); - return safeSeconds < 10 - ? `${safeSeconds.toFixed(1)}s` - : `${Math.round(safeSeconds)}s`; -} - -/** - * Collapse noisy process-level startup events into the three device preparation - * operations that are useful to someone watching a run. - */ -export function buildStartupWorkItems( - events: StartupProgressEvent[], - nowSeconds: number, - executionHasOutput: boolean, - agentIsActive: boolean -): StartupWorkItem[] { - const byStage = new Map(events.map((event) => [event.stage, event])); - const firstResponse = byStage.get('first_response'); - - return STARTUP_WORK_STAGES.flatMap((stage, stageIndex) => { - const started = byStage.get(stage.started); - const explicitlyCompleted = byStage.get(stage.completed); - if (!started && !explicitlyCompleted) return []; - - const nextStageStarted = STARTUP_WORK_STAGES - .slice(stageIndex + 1) - .map((nextStage) => byStage.get(nextStage.started) || byStage.get(nextStage.completed)) - .find((event): event is StartupProgressEvent => Boolean(event)); - const inferredCompletion = stage.started === 'environment' - ? firstResponse - : nextStageStarted; - const completed = explicitlyCompleted || inferredCompletion; - const isActive = !completed && !executionHasOutput && agentIsActive; - const startTimestamp = started?.timestamp || explicitlyCompleted?.timestamp || nowSeconds; - const endTimestamp = completed?.timestamp - || (isActive ? nowSeconds : events[events.length - 1]?.timestamp || startTimestamp); - - const liveDetail = (stage.liveDetailStages || []) - .map((detailStage) => byStage.get(detailStage)) - .filter((event): event is StartupProgressEvent => Boolean(event)) - .filter((event) => event.timestamp >= startTimestamp) - .sort((a, b) => b.timestamp - a.timestamp)[0]; - const completedDetail = stage.completedDetailStage - ? byStage.get(stage.completedDetailStage) - : undefined; - const message = completed - ? (completedDetail?.message || explicitlyCompleted?.message || stage.completedMessage) - : (liveDetail?.message || started!.message); - - return [{ - ...(explicitlyCompleted || started!), - message, - isActive, - elapsed: formatStartupElapsed(endTimestamp - startTimestamp) - }]; - }); -} - -import { - parseNote, - parseNoteLines, - extractCheckerResult, - renderMarkdownToHtml -} from '../../utils/markdown-parser.util'; - -import { - getActionObject, - isAndroidAction, - getActionIcon, - getActionTitle, - getActionTargetText, - getActionInputLabel, - getActionInputText, - getActionCoords, - getActionBounds, - getActionResourceId, - getActionClass, - isActionFailed, - getActionErrorMessage, - extractActionExtraParams, - getStepPreImageUrl, - getStepPostImageUrl, - isReportStatusAction, - getReportStatusValue, - getReportStatusExplanation -} from '../../utils/action-formatter.util'; - -import { - getToolArgs, - isNoteTool, - isVideoTool, - getVideoAnalysisView, - formatVideoTime, - getVideoToolTarget, - isDeviceActionTool, - isFailureAnalyzerActionTool, - getToolAgentName, - shouldShowTool, - getUniqueGenericTools, - getToolKey, - getToolDisplayLabel, - isCompressionTool, - isCompressionWaiting, - getCompressionPhaseLabel, - getToolIcon, - getToolTitle, - getToolTargetText, - getToolInputLabel, - getToolInputText, - getToolCoords, - getToolAnalysisText, - getToolGenericDetails, - isAdbCommandTool, - getAdbCommandLine, - getAdbCwd, - getAdbTerminalId, - isToolFailed, - getToolErrorMessage, - extractToolExtraParams, - isHumanThinking, - cleanErrorMessage -} from '../../utils/tool-formatter.util'; - -import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util'; - -import { - consolidateLogsToBlocks, - isBackendSwitchNote, - groupBlocksToPhases, - extractBlockTokens, - formatTokenCount, - getSortedStepEvents, - compileSessionSummary, - computeSessionDuration, - checkPlanningLoader -} from '../../utils/stream-aggregator.util'; - -export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamResetNotice }; - -@Component({ - selector: 'app-agent-stream', - standalone: true, - imports: [CommonModule, FormsModule, OverlayModule], - templateUrl: './agent-stream.component.html', - styleUrl: './agent-stream.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class AgentStreamComponent implements AfterViewInit { - public agentService = inject(AgentService); - private http = inject(HttpClient); - private destroyRef = inject(DestroyRef); - private zone = inject(NgZone); - - // Auto-scroll state tracking - public isUserAtBottom = true; - private resizeObserver: ResizeObserver | null = null; - private streamLogsContainer: HTMLElement | null = null; - private autoScrollTimer: ReturnType | null = null; - private autoScrollStreamBoxes = false; - private onContainerScroll = () => { - const container = this.streamLogsContainer; - if (!container) return; - const threshold = 150; - const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight; - this.isUserAtBottom = distanceToBottom <= threshold; - }; - - // Dynamic Planning Loader Game-style Phrases - public currentPlanningText = signal(PLANNING_LOADER_PHRASES[0]); - private planningIntervalId: any = null; - private currentPhraseIndex = 0; - - // Top Nav Dropdown States - public isTaskDropdownOpen = signal(false); - public isNotesDropdownOpen = signal(false); - - // Run info popover (opened from the Flash / Pro chip): elapsed time, token - // usage, executor context share and the Pro tuning the run started with. - public isRunInfoOpen = signal(false); - public runUsage = signal(null); - private runInfoClock = signal(Date.now()); - - public runInfoIsPro = computed(() => this.getModelClass(this.agentService.viewedModel()?.name) === 'is-pro'); - - public runInfoElapsed = computed(() => { - const session = this.agentService.currentSession(); - const running = this.agentService.isCurrentSessionRunning(); - return formatElapsed(sessionElapsedSeconds(session, running, this.runInfoClock())); - }); - - public runInfoTuning = computed(() => { - const usage = this.runUsage(); - if (usage?.run_tuning && (usage.run_tuning.verification_level || usage.run_tuning.explorer_mode)) { - return usage.run_tuning; - } - return parseRunTuning(this.agentService.currentSession()?.device_info); - }); - - public runInfoContextPercent = computed(() => { - const usage = this.runUsage(); - if (!usage) return null; - return contextPercent(usage.operator_context_tokens, usage.operator_context_window_tokens); - }); - - // CDK Connected Overlay Positioning Strategy - public readonly dropdownPositions: ConnectedPosition[] = [ - { - originX: 'end', - originY: 'bottom', - overlayX: 'end', - overlayY: 'top', - offsetY: 6, - }, - { - originX: 'end', - originY: 'top', - overlayX: 'end', - overlayY: 'bottom', - offsetY: -6, - }, - { - originX: 'start', - originY: 'bottom', - overlayX: 'start', - overlayY: 'top', - offsetY: 6, - } - ]; - - // Zoom Modal State - public selectedZoomImage = signal(null); - public selectedZoomAction = signal(null); - - // Signals and properties for managing delayed collapsing of thought streams - public collapsedStreams = signal>(new Set()); - private scheduledCollapses = new Set(); - - // Signals and maps for typewriter effect simulation. All active typewriters - // are driven by ONE requestAnimationFrame loop (run outside the Angular - // zone) that batches every text advance into a single signal update. - public typedTextsSignal = signal>({}); - private typingTargets = new Map(); - private typingRafId: number | null = null; - private lastTypingTimestamp = 0; - // ≈ legacy pace of 8 characters every 12ms interval tick. - private static readonly TYPING_CHARS_PER_SECOND = 667; - private static readonly TYPING_MIN_FRAME_MS = 28; - - // Active reset notices per block (displayed after typewriter rewind finishes) - public activeBlockResets = signal>(new Map()); - public activeResetNotices = signal>(new Map()); - private rewindTargets = new Map(); - // Fast rewind pace (backspace speed ≈ 4x normal typing speed for snappy deletion) - private static readonly REWIND_CHARS_PER_SECOND = 2600; - - // Rendered-markdown memo per template slot: returning the identical string - // instance for unchanged text lets the [innerHTML] binding skip re-sanitizing - // and re-parsing the whole fragment on every change-detection pass. - private markdownHtmlCache = new Map(); - // Thinking-text extraction memo (includes the JSON-detection heuristics). - private thinkingTextCache = new WeakMap(); - private deviceSerialCache = new WeakMap(); - - // Set to track expanded and collapsed state of action cards - public expandedActionCards = signal>(new Set()); - public collapsedActionCards = signal>(new Set()); - public retryClock = signal(Date.now()); - - // Performance caches for parameter and event extractions - private actionParamsCache = new WeakMap(); - private toolParamsCache = new WeakMap(); - private sortedEventsCache = new WeakMap(); - - // Top Nav Task Queue Computed Properties - public activeQueue = computed(() => { - const list = this.agentService.sessions().filter((s) => { - const status = this.getTaskStatus(s); - return status === 'running' || status === 'paused' || status === 'pending'; - }); - return list.sort((a, b) => { - const statusA = this.getTaskStatus(a); - const statusB = this.getTaskStatus(b); - const isRunA = statusA === 'running' || statusA === 'paused'; - const isRunB = statusB === 'running' || statusB === 'paused'; - if (isRunA && !isRunB) return -1; - if (!isRunA && isRunB) return 1; - return (a.start_time || 0) - (b.start_time || 0); - }); - }); - - public historyTasks = computed(() => { - return this.agentService.sessions().filter((s) => { - const status = this.getTaskStatus(s); - return status === 'completed' || status === 'failed' || status === 'cancelled'; - }); - }); - - // Top Nav Notes Computed Properties - public currentNoteContent = computed(() => { - const notes = this.agentService.currentNotes(); - const key = this.agentService.selectedNoteKey(); - return notes[key] || ''; - }); - - public noteKeys = computed(() => { - return Object.keys(this.agentService.currentNotes()).filter(key => key.toLowerCase().endsWith('.md')); - }); - - - public outputterReport = computed(() => { - const notes = this.agentService.currentNotes(); - return notes['output.md'] || null; - }); - - // Memoized so the Task Report card is not re-parsed (and its DOM rebuilt) - // on every change-detection pass while streams are typing. - public parsedOutputReport = computed(() => { - const report = this.outputterReport(); - return report ? parseNote(report) : null; - }); - - public parsedNote = computed(() => { - return this.getParsedNote(this.currentNoteContent()); - }); - - // Stream Log Filtering & Aggregation Computed Properties - public filteredLogs = computed(() => { - const logs = this.agentService.sessionLogs(); - const currentSessionId = this.agentService.currentSessionId(); - if (!currentSessionId) return logs; - return logs.filter((log: any) => { - const logSessionId = log.session_id || log.data?.session_id; - return !logSessionId || logSessionId === currentSessionId; - }); - }); - - public sessionSummary = computed(() => { - return compileSessionSummary(this.filteredLogs()); - }); - - public showPlanningLoader = computed(() => { - const isRunning = this.agentService.agentStatus() === 'running'; - const currentSessionId = this.agentService.currentSessionId(); - const runningSessionId = this.agentService.runningSessionId(); - const isCurrentRunningSession = !currentSessionId || currentSessionId === runningSessionId; - return checkPlanningLoader(this.filteredLogs(), isRunning, isCurrentRunningSession); - }); - - public isViewingPausedTask = computed(() => { - // A live task_paused event and the polled runner status must agree before - // recovery controls are shown. This prevents a stale failure trace from - // offering Resume while the active task is still reported as running. - if (!this.agentService.isPaused() || this.agentService.agentStatus() !== 'paused') return false; - - const currentSessionId = this.agentService.currentSessionId(); - const pausedSessionId = this.agentService.runningSessionId(); - return !currentSessionId || currentSessionId === pausedSessionId; - }); - - public sessionDuration = computed(() => { - const currentSession = this.agentService.sessions().find(s => s.session_id === this.agentService.currentSessionId()); - return computeSessionDuration(currentSession?.start_time || 0, this.filteredLogs()); - }); - - public consolidatedBlocks = computed(() => { - return consolidateLogsToBlocks(this.filteredLogs()); - }); - - public phases = computed(() => { - const currentSession = this.agentService.sessions().find(s => s.session_id === this.agentService.currentSessionId()); - return groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0); - }); - - public startupWorkItems = computed(() => { - this.retryClock(); - const events = this.agentService.currentStartupProgress(); - const agentIsActive = ['running', 'paused'].includes(this.agentService.agentStatus()); - return buildStartupWorkItems( - events, - Date.now() / 1000, - this.consolidatedBlocks().length > 0, - agentIsActive - ); - }); - - public startupPreparationIsRunning = computed(() => { - return this.startupWorkItems().some((item) => item.isActive); - }); - - public startupPreparationIsComplete = computed(() => { - const items = this.startupWorkItems(); - return items.length > 0 && !items.some((item) => item.isActive); - }); - - public startupPreparationDuration = computed(() => { - const items = this.startupWorkItems(); - if (items.length === 0) return 0; - - const events = this.agentService.currentStartupProgress(); - const startedAt = events[0]?.timestamp || items[0].timestamp; - const completedAt = events.find((event) => event.stage === 'environment_ready')?.timestamp - || events.find((event) => event.stage === 'first_response')?.timestamp; - const endedAt = this.startupPreparationIsRunning() - ? Date.now() / 1000 - : completedAt || Math.max(...items.map((item) => item.timestamp)); - - return Math.max(1, Math.round(endedAt - startedAt)); - }); - - private retryablePauseTrace = computed(() => { - if (!this.isViewingPausedTask()) return null; - - const blocks = this.consolidatedBlocks(); - for (let blockIndex = blocks.length - 1; blockIndex >= 0; blockIndex--) { - const tools = blocks[blockIndex]?.data?.generic_tools; - if (!Array.isArray(tools)) continue; - - for (let toolIndex = tools.length - 1; toolIndex >= 0; toolIndex--) { - const tool = tools[toolIndex]; - // Older persisted traces used the llm_pause name without adding the - // newer payload.pause marker. Treat both contracts consistently. - if (this.isDisplayableLLMFailure(tool)) { - return tool; - } - } - } - return null; - }); - - public hasVisibleBlocks(phase: any): boolean { - if (!phase || !phase.blocks) return false; - return phase.blocks.some((b: any) => this.hasVisibleContent(b)); - } - - constructor() { - // Run info popover: fetch usage when opened or when the viewed session - // changes, and keep refreshing while that session is still running. - effect((onCleanup) => { - const open = this.isRunInfoOpen(); - const sessionId = this.agentService.currentSessionId(); - const running = this.agentService.isCurrentSessionRunning(); - if (!open || !sessionId) return; - if (untracked(() => this.runUsage())?.session_id !== sessionId) { - this.runUsage.set(null); - } - this.loadRunUsage(sessionId); - if (!running) return; - const usageInterval = setInterval(() => this.loadRunUsage(sessionId), 3000); - onCleanup(() => clearInterval(usageInterval)); - }); - effect((onCleanup) => { - if (!this.isRunInfoOpen() || !this.agentService.isCurrentSessionRunning()) return; - this.runInfoClock.set(Date.now()); - const clockInterval = setInterval(() => this.runInfoClock.set(Date.now()), 1000); - onCleanup(() => clearInterval(clockInterval)); - }); - - // The retry clock only needs to tick while something on screen is timing - // (startup progress, LLM retry countdowns). An idle page stays quiet. - this.zone.runOutsideAngular(() => { - const retryClockInterval = setInterval(() => { - const status = this.agentService.agentStatus(); - if (status === 'running' || status === 'paused' || this.agentService.isRetrying()) { - this.retryClock.set(Date.now()); - } - }, 1000); - this.destroyRef.onDestroy(() => clearInterval(retryClockInterval)); - }); - - // Auto scroll stream box during active text streaming - effect(() => { - const logs = this.filteredLogs(); - const activeStream = logs.find(log => log.type === 'llm_stream' && !log.data?.isCompleted); - if (activeStream) { - this.scheduleAutoScroll(true); - } - }); - - // Auto scroll main log list when new logs are added or typewriter updates - effect(() => { - this.consolidatedBlocks(); - this.typedTextsSignal(); - this.scheduleAutoScroll(false); - }); - - // Listen for stream reset events to trigger fast typewriter rewind - effect(() => { - const resetEvent = this.agentService.streamResetEvent(); - if (!resetEvent) return; - untracked(() => { - this.triggerStreamRewind( - resetEvent.stream_execution_id || resetEvent.stream_exec_id, - resetEvent.message || DEFAULT_STREAM_RESET_MESSAGE - ); - }); - }); - - // Typewriter drive logic: triggers typing when block appears or expands - effect(() => { - const blocks = this.consolidatedBlocks(); - blocks.forEach(block => { - // Check blocks render their per-turn segments directly (no typewriter). - if (block.type === 'checker') return; - const blockId = block.id; - - // If the block is flagged as reset, ensure typewriter rewind is active or reset notice is set - if (block.data?.isReset) { - untracked(() => { - const resetMsg = block.data.resetMessage || DEFAULT_STREAM_RESET_MESSAGE; - const currentRecord = this.typedTextsSignal(); - const hasText = (currentRecord[blockId]?.length || 0) > 0 || (currentRecord[blockId + '-native']?.length || 0) > 0; - if (hasText) { - if (!this.rewindTargets.has(blockId) && !this.rewindTargets.has(blockId + '-native')) { - this.triggerStreamRewind(block.data.execution_id, resetMsg); - } - } else { - const activeResets = this.activeBlockResets().get(blockId) || []; - if (activeResets.length === 0) { - this.upsertBlockResetNotice(blockId, { - id: block.data.execution_id || `${blockId}-text`, - message: resetMsg, - isWaiting: !block.data?.isCompleted, - streamType: 'text' - }); - } - if (!this.activeResetNotices().has(blockId)) { - this.activeResetNotices.update(m => { - const next = new Map(m); - next.set(blockId, resetMsg); - return next; - }); - } - } - }); - return; - } - - const rawText = this.getRawThinking(block) || ''; - const nativeText = this.getNativeThinking(block) || ''; - - untracked(() => { - // Handle Raw Thinking (Work) - const currentRecord = this.typedTextsSignal(); - if (rawText) { - if (!(blockId in currentRecord)) { - if (block.data?.isCompleted) { - this.typedTextsSignal.update(r => ({ ...r, [blockId]: rawText })); - } else { - this.typedTextsSignal.update(r => ({ ...r, [blockId]: '' })); - this.startTyping(blockId, rawText, false); - } - } else { - const currentVal = currentRecord[blockId] || ''; - if (currentVal.length < rawText.length && !this.typingTargets.has(blockId)) { - if (block.data?.isCompleted) { - this.typedTextsSignal.update(r => ({ ...r, [blockId]: rawText })); - } else { - this.startTyping(blockId, rawText, false); - } - } - } - } - - // Handle Native Thinking (Thought) - const nativeKey = blockId + '-native'; - if (nativeText) { - const execId = block.data?.execution_id || block.data?.step_id || blockId; - if (!(nativeKey in currentRecord)) { - if (block.data?.isCompleted) { - this.typedTextsSignal.update(r => ({ ...r, [nativeKey]: nativeText })); - this.collapsedStreams.update(set => { - const newSet = new Set(set); - newSet.add(execId); - return newSet; - }); - } else { - this.typedTextsSignal.update(r => ({ ...r, [nativeKey]: '' })); - this.startTyping(nativeKey, nativeText, true, execId); - } - } else { - const currentVal = currentRecord[nativeKey] || ''; - if (currentVal.length < nativeText.length && !this.typingTargets.has(nativeKey)) { - if (block.data?.isCompleted) { - this.typedTextsSignal.update(r => ({ ...r, [nativeKey]: nativeText })); - this.collapsedStreams.update(set => { - const newSet = new Set(set); - newSet.add(execId); - return newSet; - }); - } else { - this.startTyping(nativeKey, nativeText, true, execId); - } - } - } - } - }); - }); - }); - - // Reset typewriter and collapse states when session changes - effect(() => { - this.agentService.currentSessionId(); - untracked(() => { - this.stopTypingLoop(); - this.typedTextsSignal.set({}); - this.activeResetNotices.set(new Map()); - this.activeBlockResets.set(new Map()); - this.markdownHtmlCache.clear(); - this.scheduledCollapses.clear(); - this.collapsedStreams.set(new Set()); - this.isUserAtBottom = true; - - setTimeout(() => { - const container = document.querySelector('.stream-logs-content'); - if (container) { - container.scrollTop = container.scrollHeight; - } - }, 100); - }); - }); - - // Periodically rotate loading text like game loading screen hints - effect(() => { - const isLoading = this.showPlanningLoader(); - if (isLoading) { - if (!this.planningIntervalId) { - this.currentPhraseIndex = Math.floor(Math.random() * PLANNING_LOADER_PHRASES.length); - this.currentPlanningText.set(PLANNING_LOADER_PHRASES[this.currentPhraseIndex]); - - this.planningIntervalId = setInterval(() => { - this.currentPhraseIndex = (this.currentPhraseIndex + 1) % PLANNING_LOADER_PHRASES.length; - this.currentPlanningText.set(PLANNING_LOADER_PHRASES[this.currentPhraseIndex]); - }, 2800); - } - } else { - if (this.planningIntervalId) { - clearInterval(this.planningIntervalId); - this.planningIntervalId = null; - } - } - }); - - this.destroyRef.onDestroy(() => { - if (this.planningIntervalId) { - clearInterval(this.planningIntervalId); - this.planningIntervalId = null; - } - if (this.resizeObserver) { - this.resizeObserver.disconnect(); - this.resizeObserver = null; - } - this.stopTypingLoop(); - if (this.autoScrollTimer) { - clearTimeout(this.autoScrollTimer); - this.autoScrollTimer = null; - } - if (this.streamLogsContainer) { - this.streamLogsContainer.removeEventListener('scroll', this.onContainerScroll); - this.streamLogsContainer = null; - } - }); - } - - public ngAfterViewInit(): void { - const container = document.querySelector('.stream-logs-content') as HTMLElement | null; - this.streamLogsContainer = container; - if (!container) return; - - // The scroll listener lives outside the Angular zone: tracking the - // user's scroll position must not trigger change detection per frame. - this.zone.runOutsideAngular(() => { - container.addEventListener('scroll', this.onContainerScroll, { passive: true }); - }); - - if (typeof ResizeObserver !== 'undefined') { - this.resizeObserver = new ResizeObserver(() => { - if (this.isUserAtBottom) { - this.scheduleAutoScroll(false); - } - }); - this.resizeObserver.observe(container); - } - } - - /** - * Coalesce every auto-scroll request into one deferred pass that batches all - * DOM reads before all writes, instead of thrashing layout per update. - */ - private scheduleAutoScroll(includeStreamBoxes: boolean): void { - this.autoScrollStreamBoxes = this.autoScrollStreamBoxes || includeStreamBoxes; - if (this.autoScrollTimer) return; - this.zone.runOutsideAngular(() => { - this.autoScrollTimer = setTimeout(() => { - this.autoScrollTimer = null; - const includeBoxes = this.autoScrollStreamBoxes; - this.autoScrollStreamBoxes = false; - - const boxes = includeBoxes - ? (Array.from(document.getElementsByClassName('stream-box')) as HTMLElement[]) - : []; - const boxTargets = boxes.map((el) => el.scrollHeight); - const container = this.streamLogsContainer; - const containerTarget = container && this.isUserAtBottom ? container.scrollHeight : null; - - boxes.forEach((el, i) => { el.scrollTop = boxTargets[i]; }); - if (container && containerTarget !== null) { - container.scrollTop = containerTarget; - } - }, 50); - }); - } - - // Top Nav Action Methods - public toggleTaskDropdown(event: Event): void { - event.stopPropagation(); - this.isTaskDropdownOpen.update(v => !v); - if (this.isTaskDropdownOpen()) { - this.isNotesDropdownOpen.set(false); - } - } - - public toggleNotesDropdown(event: Event): void { - event.stopPropagation(); - this.isNotesDropdownOpen.update((v) => !v); - if (this.isNotesDropdownOpen()) { - this.isTaskDropdownOpen.set(false); - } - } - - public openOutputterNote(event?: Event): void { - if (event) event.stopPropagation(); - this.agentService.selectedNoteKey.set('output.md'); - this.isNotesDropdownOpen.set(true); - this.isTaskDropdownOpen.set(false); - } - - public closeAllDropdowns(): void { - this.isTaskDropdownOpen.set(false); - this.isNotesDropdownOpen.set(false); - this.isRunInfoOpen.set(false); - } - - public toggleRunInfo(event: Event): void { - event.stopPropagation(); - this.isRunInfoOpen.update((v) => !v); - if (this.isRunInfoOpen()) { - this.isTaskDropdownOpen.set(false); - this.isNotesDropdownOpen.set(false); - } - } - - private loadRunUsage(sessionId: string): void { - this.agentService.getSessionUsage(sessionId).subscribe({ - next: (usage) => { - if (this.agentService.currentSessionId() !== sessionId) return; - this.runUsage.set(usage); - }, - error: (err) => console.error('Failed to load session usage:', err) - }); - } - - public formatRunElapsed(seconds: number): string { - return formatElapsed(seconds); - } - - public formatRunTokens(tokens: number | null | undefined): string { - return formatCompactTokens(tokens); - } - - public runTuningLabel(kind: 'verify' | 'explore', id: string | null | undefined): string { - return tuningLabel(kind, id); - } - - public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' { - if (session.status) { - const s = session.status.toLowerCase(); - if (s === 'completed' || s === 'success' || s === 'failed' || s === 'cancelled') { - return (s === 'success' ? 'completed' : s) as any; - } - if (s === 'running' || s === 'paused' || s === 'pending') { - return s as any; - } - } - if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) { - return this.agentService.agentStatus() as 'running' | 'paused'; - } - return 'completed'; - } - - /** - * Determine the device serial number for the session - */ - public getDeviceSerial(session: Session): string | null { - if (this.deviceSerialCache.has(session)) { - return this.deviceSerialCache.get(session) ?? null; - } - let resolved: string | null = null; - const serial = session.device_serial || session.device_id; - if (serial && serial !== 'pending' && serial !== 'null' && serial !== 'undefined') { - resolved = serial; - } else if (session.device_info) { - try { - const info = typeof session.device_info === 'string' ? JSON.parse(session.device_info) : session.device_info; - const s = info?.device_id || info?.device_serial; - if (s && s !== 'pending' && s !== 'null' && s !== 'undefined') { - resolved = s; - } - } catch { - // ignore - } - } - this.deviceSerialCache.set(session, resolved); - return resolved; - } - - public selectTask(sessionId: string, event?: Event): void { - if (event) event.stopPropagation(); - this.agentService.selectSession(sessionId, true); - this.isTaskDropdownOpen.set(false); - } - - public stopTask(sessionId: string, event?: Event): void { - if (event) event.stopPropagation(); - this.agentService.stopTask(sessionId, false); - } - - public deleteTask(sessionId: string, event?: Event): void { - if (event) event.stopPropagation(); - if (!confirm(`Are you sure you want to delete this task? This cannot be undone.`)) { - return; - } - this.agentService.deleteSession(sessionId).subscribe({ - error: (err) => { - console.error(`Failed to delete session ${sessionId}:`, err); - } - }); - } - - public selectNote(key: string, event?: Event): void { - if (event) event.stopPropagation(); - this.agentService.selectedNoteKey.set(key); - } - - public getParsedNote(content: string): ParsedNote { - return parseNote(content); - } - - public getParsedNoteLines(content: string): MarkdownLine[] { - return parseNoteLines(content); - } - - // Typewriter & Delayed Collapse - private upsertBlockResetNotice(blockId: string, notice: StreamResetNotice): void { - this.activeBlockResets.update(m => { - const next = new Map(m); - const list = [...(next.get(blockId) || [])]; - const existingIdx = list.findIndex(r => r.id === notice.id); - if (existingIdx > -1) { - list[existingIdx] = notice; - } else { - list.push(notice); - } - next.set(blockId, list); - return next; - }); - } - - private startTyping(key: string, targetText: string, isThinking: boolean = false, execId?: string) { - const blockId = key.endsWith('-native') ? key.replace('-native', '') : key; - // If there was an active rewind, cancel it, reset text to empty, and record its reset notice as completed waiting - const activeRewind = this.rewindTargets.get(key); - if (activeRewind) { - this.rewindTargets.delete(key); - this.typedTextsSignal.update(r => ({ ...r, [key]: '' })); - this.upsertBlockResetNotice(blockId, { - id: activeRewind.execId || `${blockId}-${isThinking ? 'thinking' : 'text'}`, - message: activeRewind.message, - isWaiting: false, - streamType: isThinking ? 'thinking' : 'text' - }); - } - - // Cancel any active rewind on this key - this.rewindTargets.delete(key); - - // When retry stream starts outputting content, change waiting dot to normal dot (isWaiting: false) - // The previous prompt sentence DOES NOT disappear. - this.activeBlockResets.update(m => { - const list = m.get(blockId); - if (!list || list.length === 0) return m; - const targetType = isThinking ? 'thinking' : 'text'; - const updated = list.map(r => r.streamType === targetType ? { ...r, isWaiting: false } : r); - const next = new Map(m); - next.set(blockId, updated); - return next; - }); - - this.typingTargets.set(key, { blockId, isThinking, execId, fallbackText: targetText }); - this.ensureTypingLoop(); - } - - private ensureTypingLoop(): void { - if (this.typingRafId !== null) return; - this.lastTypingTimestamp = performance.now(); - this.zone.runOutsideAngular(() => { - const step = (now: number) => { - this.typingRafId = null; - const elapsed = now - this.lastTypingTimestamp; - if (elapsed >= AgentStreamComponent.TYPING_MIN_FRAME_MS) { - this.lastTypingTimestamp = now; - this.advanceTyping(elapsed); - } - if (this.typingTargets.size > 0 || this.rewindTargets.size > 0) { - this.typingRafId = requestAnimationFrame(step); - } - }; - this.typingRafId = requestAnimationFrame(step); - }); - } - - private stopTypingLoop(): void { - this.typingTargets.clear(); - this.rewindTargets.clear(); - if (this.typingRafId !== null) { - cancelAnimationFrame(this.typingRafId); - this.typingRafId = null; - } - } - - /** - * Advance every active typewriter by the elapsed wall time and publish all - * changes as one signal update (one render pass per animation frame at most). - */ - private advanceTyping(elapsedMs: number): void { - const blocks = untracked(() => this.consolidatedBlocks()); - const currentRecord = untracked(() => this.typedTextsSignal()); - const chars = Math.max( - 1, - Math.round(AgentStreamComponent.TYPING_CHARS_PER_SECOND * elapsedMs / 1000) - ); - const rewindChars = Math.max( - 2, - Math.round(AgentStreamComponent.REWIND_CHARS_PER_SECOND * elapsedMs / 1000) - ); - const updates: Record = {}; - let hasUpdates = false; - - // 1. Advance forward typing targets - for (const [key, typing] of this.typingTargets) { - const block = blocks.find((b) => b.id === typing.blockId); - let target = typing.fallbackText; - if (block) { - target = (typing.isThinking ? this.getNativeThinking(block) : this.getRawThinking(block)) - || typing.fallbackText; - } - - const current = currentRecord[key] || ''; - if (current.length < target.length) { - updates[key] = target.slice(0, Math.min(current.length + chars, target.length)); - hasUpdates = true; - } else if (block?.data?.isCompleted) { - this.typingTargets.delete(key); - if (typing.isThinking && typing.execId) { - this.triggerDelayedCollapse(typing.execId); - } - } - } - - // 2. Advance fast rewind targets (backspacing for mid-stream resets) - const completedRewinds: Array<{ blockId: string; execId?: string; isThinking: boolean; message: string }> = []; - for (const [key, rewind] of this.rewindTargets) { - const current = updates[key] !== undefined ? updates[key] : (currentRecord[key] || ''); - if (current.length > 0) { - const nextLen = Math.max(0, current.length - rewindChars); - updates[key] = current.slice(0, nextLen); - hasUpdates = true; - if (nextLen === 0) { - this.rewindTargets.delete(key); - completedRewinds.push({ - blockId: rewind.blockId, - execId: rewind.execId, - isThinking: rewind.isThinking, - message: rewind.message - }); - } - } else { - this.rewindTargets.delete(key); - completedRewinds.push({ - blockId: rewind.blockId, - execId: rewind.execId, - isThinking: rewind.isThinking, - message: rewind.message - }); - } - } - - if (completedRewinds.length > 0) { - for (const item of completedRewinds) { - this.upsertBlockResetNotice(item.blockId, { - id: item.execId || `${item.blockId}-${item.isThinking ? 'thinking' : 'text'}`, - message: item.message, - isWaiting: true, - streamType: item.isThinking ? 'thinking' : 'text' - }); - } - this.activeResetNotices.update((notices) => { - const next = new Map(notices); - for (const item of completedRewinds) { - next.set(item.blockId, item.message); - } - return next; - }); - } - - if (hasUpdates) { - this.typedTextsSignal.update((r) => ({ ...r, ...updates })); - } - } - - /** - * Triggers fast typewriter rewind on a stream execution when a mid-stream reset occurs. - * Once characters rewind to zero, the block displays the graceful retry notification. - */ - public triggerStreamRewind(execId: string | undefined, message: string): void { - const blocks = untracked(() => this.consolidatedBlocks()); - const currentTexts = untracked(() => this.typedTextsSignal()); - - const targetBlock = blocks.find(b => - b.data?.execution_id === execId - || b.id === `stream-${execId}` - || b.data?.step_id === execId - || (Array.isArray(b.data?.stream_segments) && b.data.stream_segments.some((s: any) => s.execution_id === execId)) - ) || blocks.find(b => b.data?.isReset); - - if (!targetBlock) return; - const blockId = targetBlock.id; - - const keysToCheck: Array<{ key: string; isThinking: boolean }> = [ - { key: blockId, isThinking: false }, - { key: blockId + '-native', isThinking: true } - ]; - - let startedRewind = false; - for (const { key, isThinking } of keysToCheck) { - this.typingTargets.delete(key); - - const currentText = currentTexts[key] || ''; - if (currentText.length > 0) { - this.rewindTargets.set(key, { - blockId, - isThinking, - execId, - message - }); - startedRewind = true; - } - } - - if (startedRewind) { - this.ensureTypingLoop(); - } else { - this.upsertBlockResetNotice(blockId, { - id: execId || `${blockId}-text`, - message, - isWaiting: true, - streamType: 'text' - }); - this.activeResetNotices.update(notices => { - const next = new Map(notices); - next.set(blockId, message); - return next; - }); - } - } - - public getBlockResets(block: any, isThinking: boolean = false): StreamResetNotice[] { - if (!block) return []; - const blockId = block.id; - const targetStreamType = isThinking ? 'thinking' : 'text'; - - // 1. From activeBlockResets signal - const activeList = (this.activeBlockResets().get(blockId) || []) - .filter(r => r.streamType === targetStreamType); - - // 2. From block.data.stream_resets (aggregated from logs) - const blockDataResets = (Array.isArray(block.data?.stream_resets) ? block.data.stream_resets : []) - .filter((r: any) => (r.streamType || 'text') === targetStreamType); - - // Fast path: if both lists are empty, check fallback or return empty array immediately - if (activeList.length === 0 && blockDataResets.length === 0) { - if (!isThinking && block.data?.isReset && block.data?.resetMessage) { - return [{ - id: block.data.execution_id || blockId, - message: block.data.resetMessage, - isWaiting: !block.data?.isCompleted, - streamType: 'text' - }]; - } - return []; - } - - // Merge by id, giving precedence to activeList - const mergedMap = new Map(); - for (const r of blockDataResets) { - mergedMap.set(r.id, { - id: r.id, - message: r.message, - isWaiting: block.data?.isCompleted ? false : (r.isWaiting ?? false), - streamType: r.streamType || targetStreamType - }); - } - for (const r of activeList) { - mergedMap.set(r.id, { - ...r, - isWaiting: block.data?.isCompleted ? false : r.isWaiting - }); - } - - return Array.from(mergedMap.values()); - } - - public hasWaitingReset(block: any, isThinking: boolean = false): boolean { - const resets = this.getBlockResets(block, isThinking); - return resets.some(r => r.isWaiting); - } - - public hasBlockResets(block: any, isThinking: boolean = false): boolean { - return this.getBlockResets(block, isThinking).length > 0; - } - - public formatResetMessage(message: string, isWaiting: boolean): string { - if (!message) return ''; - if (isWaiting) return message; - return message - .replace(/[,.\s]*(?:Retrying automatically(?:\.{3}|\.\.\.)?)\s*$/i, '') - .trim(); - } - - public getCheckerSegmentResetMessage(item: any): string { - return item?.data?.resetMessage || DEFAULT_STREAM_RESET_MESSAGE; - } - - public getResetNotice(block: any): string | null { - if (!block) return null; - const resets = this.getBlockResets(block, false); - if (resets.length > 0) { - const last = resets[resets.length - 1]; - return this.formatResetMessage(last.message, last.isWaiting); - } - const blockId = block.id; - const active = this.activeResetNotices().get(blockId); - if (active) return this.formatResetMessage(active, !block.data?.isCompleted); - if (block.data?.isReset && block.data?.resetMessage) { - return this.formatResetMessage(block.data.resetMessage, !block.data?.isCompleted); - } - return null; - } - - public isStreamRewinding(key: string): boolean { - return this.rewindTargets.has(key); - } - - public isCheckerSegmentResetNotice(item: any): boolean { - if (!item?.data?.isReset) return false; - const text = item.data.text; - return !text || text.trim().length === 0; - } - - public getStreamDisplayText(key: string, fallback: string = ''): string { - const typed = this.typedTextsSignal()[key]; - return typed !== undefined ? typed : fallback; - } - - private triggerDelayedCollapse(execId: string) { - if (!this.scheduledCollapses.has(execId)) { - this.scheduledCollapses.add(execId); - setTimeout(() => { - this.collapsedStreams.update((set: Set) => { - const newSet = new Set(set); - newSet.add(execId); - return newSet; - }); - }, 1000); - } - } - - public onDetailsToggle(event: Event, execId: string): void { - const details = event.target as HTMLDetailsElement; - const isOpen = details.open; - this.collapsedStreams.update((set: Set) => { - const newSet = new Set(set); - if (isOpen) { - newSet.delete(execId); - } else { - newSet.add(execId); - } - return newSet; - }); - } - - public toggleCollapse(execId: string): void { - this.collapsedStreams.update((set: Set) => { - const newSet = new Set(set); - if (newSet.has(execId)) { - newSet.delete(execId); - } else { - newSet.add(execId); - } - return newSet; - }); - } - - // Android Action Delegation Methods - public getActionObject(action: any): any { - return getActionObject(action); - } - - public isAndroidAction(action: any): boolean { - return isAndroidAction(action); - } - - public getActionIcon(action: any): string { - return getActionIcon(action); - } - - public getActionTitle(action: any): string { - return getActionTitle(action); - } - - public getActionTargetText(action: any): string { - return getActionTargetText(action); - } - - public getActionInputLabel(action: any): string { - return getActionInputLabel(action); - } - - public getActionInputText(action: any): string { - return getActionInputText(action); - } - - public getActionCoords(action: any): string { - return getActionCoords(action); - } - - public isActionFailed(action: any, stepData?: any): boolean { - return isActionFailed(action, stepData); - } - - public getActionErrorMessage(action: any, stepData?: any): string { - return getActionErrorMessage(action, stepData); - } - - public isActionExpanded(cardId: string, defaultExpanded: boolean = false): boolean { - if (this.collapsedActionCards().has(cardId)) return false; - if (this.expandedActionCards().has(cardId)) return true; - return defaultExpanded; - } - - public toggleActionExpanded(cardId: string, event?: Event, defaultExpanded: boolean = false): void { - if (event) { - event.stopPropagation(); - } - const isCurrentlyExpanded = this.isActionExpanded(cardId, defaultExpanded); - if (isCurrentlyExpanded) { - this.expandedActionCards.update(set => { - const newSet = new Set(set); - newSet.delete(cardId); - return newSet; - }); - this.collapsedActionCards.update(set => { - const newSet = new Set(set); - newSet.add(cardId); - return newSet; - }); - } else { - this.collapsedActionCards.update(set => { - const newSet = new Set(set); - newSet.delete(cardId); - return newSet; - }); - this.expandedActionCards.update(set => { - const newSet = new Set(set); - newSet.add(cardId); - return newSet; - }); - } - } - - public getActionBounds(action: any): string { - return getActionBounds(action); - } - - public getActionResourceId(action: any): string { - return getActionResourceId(action); - } - - public getActionClass(action: any): string { - return getActionClass(action); - } - - public getActionExtraParams(action: any): ActionParam[] { - return extractActionExtraParams(action, this.actionParamsCache); - } - - public getToolExtraParams(toolData: any): ActionParam[] { - return extractToolExtraParams(toolData, this.toolParamsCache); - } - - public getStepPreImageUrl(stepData: any, actionData?: any): string | null { - return getStepPreImageUrl(stepData, actionData); - } - - public getStepPostImageUrl(stepData: any, actionData?: any): string | null { - return getStepPostImageUrl(stepData, actionData); - } - - // Zoom Modal & Overlay Methods - public openImageModal(url: string, actionObj?: any, event?: Event): void { - if (event) { - event.stopPropagation(); - } - this.selectedZoomImage.set(url); - this.selectedZoomAction.set(actionObj || null); - } - - public closeImageModal(event?: Event): void { - if (event) { - event.stopPropagation(); - } - this.selectedZoomImage.set(null); - this.selectedZoomAction.set(null); - } - - public onImageLoad(img: HTMLImageElement, overlay: HTMLElement, actionObj: any): void { - if (!img || !overlay) return; - try { - this.drawActionCoordinatesOnOverlay(img, overlay, actionObj); - } catch (e) { - console.warn('Failed to draw action overlay:', e); - } - } - - public drawActionCoordinatesOnOverlay(img: HTMLImageElement, overlay: HTMLElement, actionObj: any): void { - drawActionCoordinatesOnOverlay(img, overlay, actionObj); - } - - // Generic Tool Delegation Methods - - /** - * First save_note call per note key, derived once per blocks change instead - * of re-scanning every block for every rendered tool row. - */ - private firstSaveNoteByKey = computed>(() => { - const firstByKey = new Map(); - for (const block of this.consolidatedBlocks()) { - if (block.type === 'step' && block.data.generic_tools) { - for (const t of this.getUniqueGenericTools(block.data.generic_tools)) { - if (t.name && t.name.toLowerCase() === 'save_note') { - const key = this.getToolKey(t); - if (key && !firstByKey.has(key)) { - firstByKey.set(key, t); - } - } - } - } - } - return firstByKey; - }); - - public isFirstSaveNoteForKey(tool: any): boolean { - if (!tool || !tool.name || tool.name.toLowerCase() !== 'save_note') { - return false; - } - const key = this.getToolKey(tool); - if (!key) return true; - - const firstCall = this.firstSaveNoteByKey().get(key); - if (!firstCall) return true; - return (tool.trace_id && firstCall.trace_id) - ? tool.trace_id === firstCall.trace_id - : tool === firstCall; - } - - public getToolDisplayLabel(tool: any): string { - return getToolDisplayLabel(tool, this.isFirstSaveNoteForKey(tool)); - } - - /** Short plain-language phase for a compress_history line (hover title). */ - public getCompressionPhaseTitle(tool: any): string | null { - return isCompressionTool(tool) ? getCompressionPhaseLabel(tool) : null; - } - - /** - * A generic tool line pulses only while work is in flight. A compression - * whose summary is ready but held back by the context gate is waiting, not - * running, even though its trace status is still `running`. - */ - public isToolLineRunning(item: any, block: any): boolean { - return this.isItemRunning(item, block) && !isCompressionWaiting(item?.data); - } - - public isNoteTool(tool: any): boolean { - return isNoteTool(tool); - } - - public isVideoTool(tool: any): boolean { - return isVideoTool(tool); - } - - public getVideoToolTarget(tool: any): string | null { - return getVideoToolTarget(tool); - } - - public getVideoAnalysisLabel(tool: any): string { - return getVideoAnalysisView(tool)?.title || 'Analyzing screen recording'; - } - - public getVideoAnalysisRangeLabel(tool: any): string { - const range = getVideoAnalysisView(tool)?.requestedRange; - if (!range) return 'Screen recording'; - return `${formatVideoTime(range.start)}–${formatVideoTime(range.end)}`; - } - - public getVideoAnalysisDetail(tool: any): string { - const view = getVideoAnalysisView(tool); - if (!view || view.totalCount <= 1) return ''; - if (view.outcome === 'running' || view.outcome === 'recovering') { - return view.completedCount > 0 - ? `${view.completedCount}/${view.totalCount} segments saved` - : ''; - } - if (view.outcome === 'partial') { - return `${view.completedCount}/${view.totalCount} segments saved`; - } - return ''; - } - - public isVideoAnalysisAttention(tool: any): boolean { - const outcome = getVideoAnalysisView(tool)?.outcome; - return outcome === 'partial' || outcome === 'failed'; - } - - public onVideoToolClick(toolData: any): void { - const curSessionId = this.agentService.currentSessionId(); - if (curSessionId) { - const start = getVideoAnalysisView(toolData)?.requestedRange?.start; - this.agentService.openVideoPlayer(curSessionId, undefined, undefined, start); - } - } - - public getScreenRecordingButtonTitle(): string { - if (this.agentService.isCurrentSessionRunning()) { - return 'Task is currently running (Recording screen)'; - } - if (this.agentService.currentSessionRecordingStatus() === 'processing') { - return 'Preparing screen recording...'; - } - if (this.agentService.currentSessionVideoUrl()) { - return 'Play Screen Recording Video'; - } - if (this.agentService.hasCurrentSessionStepFrames()) { - return this.agentService.currentSessionRecordingStatus() === 'failed' - ? 'Video recording failed — Click to replay step-by-step screenshots' - : 'Play step-by-step screenshots replay'; - } - if (this.agentService.currentSessionRecordingStatus() === 'failed') { - return 'Screen recording generation failed'; - } - return 'Screen Recording'; - } - - public isDeviceActionTool(tool: any): boolean { - return isDeviceActionTool(tool); - } - - public isFailureAnalyzerActionTool(tool: any): boolean { - return isDeviceActionTool(tool); - } - - public getToolAgentName(tool: any): string | null { - return getToolAgentName(tool); - } - - public shouldShowTool(tool: any, stepData?: any): boolean { - return shouldShowTool(tool, stepData); - } - - public getUniqueGenericTools(tools: any[] | undefined): any[] { - return getUniqueGenericTools(tools); - } - - public getSortedStepEvents(stepData: any): StepEvent[] { - return getSortedStepEvents(stepData, this.sortedEventsCache); - } - - /** - * Memoized per block-data object: templates call these many times per - * change-detection pass, and the raw-thinking path runs JSON-detection - * heuristics over the full text. - */ - private getThinkingTexts(data: any): { native: string | null; raw: string | null } { - let entry = this.thinkingTextCache.get(data); - if (!entry) { - const nativeText = data.operator_native_thinking || (data.stream_type === 'thinking' ? data.text : null); - const rawText = data.operator_raw_thinking || (data.stream_type === 'text' ? data.text : null); - entry = { - native: nativeText && nativeText.trim() ? nativeText : null, - raw: rawText && rawText.trim() && this.isHumanThinking(rawText) ? rawText : null - }; - this.thinkingTextCache.set(data, entry); - } - return entry; - } - - public getNativeThinking(block: any): string | null { - if (!block || !block.data) return null; - return this.getThinkingTexts(block.data).native; - } - - public getRawThinking(block: any): string | null { - if (!block || !block.data) return null; - return this.getThinkingTexts(block.data).raw; - } - - // --- Check blocks (verifier lane) ---------------------------------------------- - public isCheckerBlock(block: any): boolean { - return block?.type === 'checker'; - } - - public isCheckerPhase(phase: any): boolean { - const blocks: any[] = Array.isArray(phase?.blocks) ? phase.blocks : []; - return blocks.length > 0 && blocks.every((b) => this.isCheckerBlock(b)); - } - - public getCheckerPhaseLabel(block: any): string { - switch (block?.data?.phase) { - case 'final': - return 'Final check'; - case 'outcome': - return 'Result'; - default: - return 'Check'; - } - } - - public getCheckerVerdicts(block: any): any[] { - return Array.isArray(block?.data?.verdicts) ? block.data.verdicts : []; - } - - /** Declared check items; persisted attempts only carry verdicts, so derive them. */ - public getCheckerItems(block: any): any[] { - const d = block?.data || {}; - if (Array.isArray(d.items) && d.items.length > 0) return d.items; - return this.getCheckerVerdicts(block).map((v) => ({ kind: v.kind, text: v.item_text, when: v.when })); - } - - /** How the item is judged, in plain words (verify = must pass, assert = recorded test result). */ - public getCheckMethodLabel(item: any, block: any): string { - const parts: string[] = [item?.kind === 'assert' ? 'Test assertion' : 'Must pass']; - if (item?.when === 'at_end' && block?.data?.phase !== 'final') parts.push('at the end'); - return parts.join(' · '); - } - - /** Superseded / unchecked attempts collapse to their header line. */ - public isCheckerMuted(block: any): boolean { - const status = block?.data?.status; - return status === 'superseded' || status === 'unchecked'; - } - - /** Number of the check item a verdict belongs to (1-based, matches the list above the stream). */ - public getVerdictItemNumber(block: any, verdict: any, fallbackIndex: number): number { - const idx = this.getCheckerItems(block).findIndex((i) => i.text === verdict?.item_text); - return (idx > -1 ? idx : fallbackIndex) + 1; - } - - public getVerdictStatusText(status: string): string { - switch (status) { - case 'passed': - return 'Passed'; - case 'failed': - return 'Failed'; - case 'inconclusive': - return 'Could not be confirmed'; - case 'superseded': - return 'Superseded by a newer check'; - case 'unchecked': - return 'Not checked'; - default: - return status || ''; - } - } - - public hasCheckerResult(block: any): boolean { - if (!this.isCheckerBlock(block) || this.isBlockRunning(block) || this.isCheckerMuted(block)) return false; - if (block.data?.phase === 'outcome') return this.getCheckerNotes(block).length > 0; - return this.getCheckerVerdicts(block).length > 0 || this.getCheckerNotes(block).length > 0; - } - - /** Follow-ups not already visible in the verdict rows (the findings text repeats them). */ - public getCheckerNotes(block: any): string[] { - const d = block?.data || {}; - if (d.phase === 'outcome') return Array.isArray(d.last_findings) ? d.last_findings : []; - const notes: string[] = []; - if (Array.isArray(d.unmet_subgoals)) { - for (const text of d.unmet_subgoals) notes.push(`Not finished yet: ${text}`); - } - if (d.reverted) notes.push('The step was sent back to be redone.'); - if (d.error) notes.push(String(d.error)); - return notes; - } - - // Streamed turns of a check: the last one is live while the block runs; - // finished Thought turns fold unless the reader opens them. - public openCheckSegments = signal>(new Set()); - - public isCheckerSegmentRunning(block: any, itemIdx: number): boolean { - if (!this.isBlockRunning(block)) return false; - return itemIdx === this.getSortedStepEvents(block.data).length - 1; - } - - public isCheckerSegmentCollapsed(block: any, itemIdx: number): boolean { - if (this.isCheckerSegmentRunning(block, itemIdx)) return false; - return !this.openCheckSegments().has(`${block.id}-${itemIdx}`); - } - - public toggleCheckerSegment(block: any, itemIdx: number): void { - const key = `${block.id}-${itemIdx}`; - this.openCheckSegments.update((set) => { - const next = new Set(set); - if (next.has(key)) next.delete(key); else next.add(key); - return next; - }); - } - - public isCheckerTextVisible(item: any): boolean { - if (item?.data?.isReset) return true; - const text = item?.data?.text; - return typeof text === 'string' && text.trim().length > 0 && this.isHumanThinking(text); - } - - public getCheckerTone(block: any): string { - const d = block?.data || {}; - if (d.phase === 'outcome') { - return d.task_status === 'completed' ? 'passed' : (d.task_status === 'blocked' ? 'failed' : 'inconclusive'); - } - if (d.isCompleted === false) return 'running'; - if (d.status === 'superseded' || d.status === 'unchecked') return 'muted'; - if (d.status === 'error') return 'inconclusive'; - const verdicts = this.getCheckerVerdicts(block); - if (verdicts.some((v) => v.status === 'failed')) return 'failed'; - if (verdicts.some((v) => v.status === 'inconclusive')) return 'inconclusive'; - return 'passed'; - } - - public getCheckerStatusLabel(block: any): string { - const d = block?.data || {}; - if (d.phase === 'outcome') { - const t = d.tests || {}; - const label = d.task_status === 'completed' ? 'Goal completed' - : (d.task_status === 'blocked' ? 'Blocked' : 'Partially completed'); - const counts = [ - [t.passed, 'passed'], - [t.failed, 'failed'], - [t.inconclusive, 'inconclusive'], - [t.unchecked, 'not checked'] - ] - .filter(([n]) => Number(n) > 0) - .map(([n, word]) => `${n} ${word}`); - return counts.length > 0 ? `${label} · ${counts.join(' · ')}` : label; - } - if (d.isCompleted === false) return 'Checking…'; - switch (d.status) { - case 'superseded': - return 'Superseded'; - case 'unchecked': - return 'Not checked'; - case 'error': - return 'No verdict'; - } - const verdicts = this.getCheckerVerdicts(block); - const failed = verdicts.filter((v) => v.status === 'failed').length; - if (failed > 0) return `${failed} failed`; - if (verdicts.some((v) => v.status === 'inconclusive')) return 'Inconclusive'; - return verdicts.length > 0 ? 'Passed' : 'Done'; - } - - public getVerdictIcon(status: string): string { - switch (status) { - case 'passed': - return 'check_circle'; - case 'failed': - return 'cancel'; - case 'inconclusive': - return 'help'; - case 'superseded': - return 'history'; - case 'unchecked': - return 'radio_button_unchecked'; - default: - return 'pending'; - } - } - - public hasVisibleContent(block: any): boolean { - if (!block) return false; - if (block.type === 'checker') return true; - const hasNative = Boolean(this.getNativeThinking(block)); - const hasRaw = Boolean(this.getRawThinking(block)); - const hasReset = this.hasBlockResets(block, false) || this.hasBlockResets(block, true); - const hasVisibleTools = Boolean(block.data?.generic_tools) && block.data.generic_tools.some((t: any) => - this.shouldShowTool(t, block.data) - || this.isDisplayableLLMFailure(t) - || this.isLLMRetry(t) - || this.isReportStatusAction(t) - || this.isBackendSwitchNote(t) - ); - const hasAndroidActions = Boolean(block.data?.action_taken) && (this.isAndroidAction(block.data.action_taken) || this.isReportStatusAction(block.data.action_taken)); - return hasNative || hasRaw || hasReset || hasVisibleTools || hasAndroidActions; - } - - public isReportStatusAction(action: any): boolean { - return isReportStatusAction(action); - } - - public getReportStatusValue(action: any): string { - return getReportStatusValue(action); - } - - public getReportStatusExplanation(action: any): string { - return getReportStatusExplanation(action); - } - - public isHumanThinking(text: string | null): boolean { - return isHumanThinking(text); - } - - public isTerminalLLMFailure(tool: any): boolean { - return !!tool && this.retryablePauseTrace() === tool; - } - - public isDisplayableLLMFailure(tool: any): boolean { - return tool?.type === 'llm_call' - && tool?.status === 'failed' - && (tool?.name === 'llm_pause' || tool?.payload?.pause === true); - } - - /** Mid-run change of the UI-hierarchy source (helper <-> UIAutomator2). */ - public isBackendSwitchNote(tool: any): boolean { - return isBackendSwitchNote(tool); - } - - public getBackendSwitchMessage(tool: any): string { - return String(tool?.payload?.message || 'UI hierarchy source changed'); - } - - public resumePausedTask(event: Event): void { - event.stopPropagation(); - this.agentService.resumeTask(); - } - - public getLLMErrorText(tool: any): string { - const noDetails = 'The AI provider did not return error details after the request failed.'; - if (!tool) return noDetails; - const rawError = tool.payload?.error || tool.error; - if (!rawError) return noDetails; - const cleaned = this.cleanErrorMessage(rawError); - return cleaned === 'Unknown error' ? noDetails : cleaned; - } - - public isLLMRetry(tool: any): boolean { - return tool?.type === 'llm_call' - && tool?.status === 'retrying' - && tool?.payload?.source === 'provider_sdk' - && ['google', 'gemini'].includes(String(tool?.payload?.provider || '').toLowerCase()); - } - - public getLLMRetryEntries(tool: any): any[] { - const retries = tool?.payload?.retries; - return Array.isArray(retries) && retries.length > 0 ? retries : [tool?.payload || tool]; - } - - public getLLMFailureRetryEntries(tool: any): any[] { - const retries = tool?.payload?.retries; - if (!Array.isArray(retries)) return []; - return retries.filter((retry: any) => - retry?.source === 'provider_sdk' - && ['google', 'gemini'].includes(String(retry?.provider || '').toLowerCase()) - ); - } - - public hasLLMFailureRetryEntries(tool: any): boolean { - return this.getLLMFailureRetryEntries(tool).length > 0; - } - - public getLLMRetryEntryDelay(entry: any): string | null { - const delay = Number(entry?.delay); - if (!Number.isFinite(delay) || delay <= 0) return null; - return `${delay.toFixed(2).replace(/\.00$/, '')}s`; - } - - public getLLMRetryEntryWaited(entry: any): string { - const delay = Number(entry?.delay); - if (!Number.isFinite(delay) || delay <= 0) return '0s'; - - const rawStartedAt = Number(entry?.scheduled_at ?? entry?.timestamp); - if (!Number.isFinite(rawStartedAt) || rawStartedAt <= 0) { - return this.formatLLMDuration(delay); - } - const startedAtMs = rawStartedAt < 1e11 ? rawStartedAt * 1000 : rawStartedAt; - const elapsed = Math.min(delay, Math.max(0, (this.retryClock() - startedAtMs) / 1000)); - return this.formatLLMDuration(elapsed); - } - - public isLLMRetryEntryWaiting(entry: any): boolean { - const delay = Number(entry?.delay); - const rawStartedAt = Number(entry?.scheduled_at ?? entry?.timestamp); - if (!Number.isFinite(delay) || delay <= 0 || !Number.isFinite(rawStartedAt)) return false; - const startedAtMs = rawStartedAt < 1e11 ? rawStartedAt * 1000 : rawStartedAt; - return this.retryClock() < startedAtMs + delay * 1000; - } - - public getLLMFailureWaited(tool: any): string | null { - const waited = Number(tool?.payload?.waited_seconds); - if (!Number.isFinite(waited) || waited < 0) return null; - return this.formatLLMDuration(waited); - } - - public formatLLMDuration(seconds: number): string { - if (seconds < 0.05) return '0s'; - if (seconds < 10) return `${seconds.toFixed(1).replace(/\.0$/, '')}s`; - return `${Math.round(seconds)}s`; - } - - public cleanErrorMessage(rawError: any): string { - return cleanErrorMessage(rawError); - } - - public getToolKey(tool: any): string | null { - return getToolKey(tool); - } - - public getToolCallName(action: any): string { - const act = this.getActionObject(action); - if (!act) return 'Tool'; - const label = this.getToolDisplayLabel(act); - const key = this.getToolKey(act); - return key ? `${label}: ${key}` : label; - } - - public getToolCallLabel(action: any): string { - return this.getToolCallName(action); - } - - public isToolFailed(tool: any): boolean { - return isToolFailed(tool); - } - - public getToolErrorMessage(tool: any): string { - return getToolErrorMessage(tool); - } - - public getToolArgs(tool: any): any { - return getToolArgs(tool); - } - - public getToolIcon(tool: any): string { - return getToolIcon(tool); - } - - public getToolTitle(tool: any): string { - return getToolTitle(tool); - } - - public getToolTargetText(tool: any): string { - return getToolTargetText(tool); - } - - public getToolInputLabel(tool: any): string { - return getToolInputLabel(tool); - } - - public getToolInputText(tool: any): string { - return getToolInputText(tool); - } - - public getToolCoords(tool: any): string { - return getToolCoords(tool); - } - - public getToolAnalysisText(tool: any): string { - return getToolAnalysisText(tool); - } - - public getToolGenericDetails(tool: any): string { - return getToolGenericDetails(tool); - } - - public isAdbCommandTool(tool: any): boolean { - return isAdbCommandTool(tool); - } - - public getAdbCommandLine(tool: any): string { - return getAdbCommandLine(tool); - } - - public getAdbCwd(tool: any): string { - return getAdbCwd(tool); - } - - public getAdbTerminalId(tool: any): string { - return getAdbTerminalId(tool); - } - - public onSessionChange(event: Event): void { - const selectElement = event.target as HTMLSelectElement; - const sessionId = selectElement.value; - if (sessionId) { - this.agentService.selectSession(sessionId, true); - } - } - - public isStepEvent(eventType: string): boolean { - return eventType === 'step_recorded' || eventType === 'step_updated'; - } - - public getCheckerResult(text: string): CheckerResult | null { - return extractCheckerResult(text); - } - - public isShortContent(data: any): boolean { - if (!data) return true; - const str = typeof data === 'string' ? data : JSON.stringify(data); - return str.length < 200; - } - - public formatJson(data: any): string { - if (typeof data === 'string') { - try { - return JSON.stringify(JSON.parse(data), null, 2); - } catch { - return data; - } - } - return JSON.stringify(data, null, 2); - } - - public getStatusLabel(status: string): string { - switch (status) { - case 'idle': - return 'Idle'; - case 'running': - return 'Running'; - case 'completed': - return 'Completed'; - case 'offline': - return 'Offline'; - default: - return status.toUpperCase(); - } - } - - /** - * Render markdown with a per-slot memo. Returning the identical cached - * string instance for unchanged text means the [innerHTML] binding sees the - * same reference and skips DOM/sanitizer work entirely. - */ - public renderMarkdown(text: string, cacheKey?: string): string { - if (!text) return ''; - if (!cacheKey) { - return renderMarkdownToHtml(text); - } - const cached = this.markdownHtmlCache.get(cacheKey); - if (cached && cached.source === text) { - return cached.html; - } - const html = renderMarkdownToHtml(text); - this.markdownHtmlCache.set(cacheKey, { source: text, html }); - return html; - } - - public onToolKeyClick(key: string): void { - this.agentService.selectedNoteKey.set(key); - this.agentService.activeTab.set('notes'); - - const sessionId = this.agentService.currentSessionId(); - if (sessionId) { - this.agentService.fetchNotes(sessionId); - } - } - - public trackSession(index: number, session: Session): string { - return session.session_id; - } - - public trackNoteKey(index: number, key: string): string { - return key; - } - - public trackMilestone(index: number, milestone: NoteMilestone): number { - return milestone.index; - } - - public trackMarkdownLine(index: number, line: MarkdownLine): number { - return index; - } - - public trackPhase(index: number, phase: any): string { - return phase.id; - } - - public trackBlock(index: number, block: any): string { - return block.id; - } - - public trackTool(index: number, tool: any): string { - return tool.trace_id || index.toString(); - } - - public trackStepEvent(index: number, item: { type: string; data: any; timestamp?: number }): string { - if (!item || !item.data) return index.toString(); - // A step has at most one thinking and one text event; keying them by type - // alone keeps their DOM stable while the streamed text grows (keying by - // text length would tear down and rebuild the node on every chunk). - if (item.type === 'thinking' || item.type === 'text') { - return item.type; - } - return item.type + '-' + (item.data.trace_id - || item.data.timestamp || item.data.start_time || item.data.created_at || item.data.id - || ((item.data.action || item.data.name || '') + '-' + index)); - } - - public trackParam(index: number, param: { key: string; value: string }): string { - return param.key; - } - - public getModelDisplayName(name: string | undefined | null): string { - if (!name) return 'Flash'; - const lower = name.toLowerCase(); - if (lower.includes('pro')) return 'Pro'; - if (lower.includes('flash')) return 'Flash'; - return name.replace(/^artemis\s+/i, ''); - } - - public getModelIcon(name: string | undefined | null): string { - if (!name) return 'bolt'; - const lower = name.toLowerCase(); - if (lower.includes('pro')) return 'diamond'; - if (lower.includes('flash')) return 'bolt'; - return 'smart_toy'; - } - - public getModelClass(name: string | undefined | null): string { - if (!name) return 'is-flash'; - const lower = name.toLowerCase(); - if (lower.includes('pro')) return 'is-pro'; - if (lower.includes('flash')) return 'is-flash'; - return ''; - } - - public getArchitectureTooltip(model?: ModelInfo | null): string { - if (!model) return 'Agent Architecture: ARTEMIS Flash (Reactive Fast Loop)'; - const name = this.getModelDisplayName(model.name); - const isPro = name.toLowerCase().includes('pro'); - const archDesc = isPro - ? 'ARTEMIS Pro (Multi-Agent Cognitive State Graph)' - : 'ARTEMIS Flash (Reactive Fast Loop)'; - if (model.id) { - return `Agent Architecture: ${archDesc} · LLM: ${model.id} (${model.provider || 'google'})`; - } - return `Agent Architecture: ${archDesc}`; - } - - public formatTokenCount(tokens?: number): string { - return formatTokenCount(tokens); - } - - public getTokenTooltip(phase: PhaseBlock): string { - if (!phase.tokens) return ''; - if (phase.promptTokens && phase.completionTokens) { - return `Consumed: ${phase.tokens.toLocaleString()} tokens (${phase.promptTokens.toLocaleString()} in / ${phase.completionTokens.toLocaleString()} out)`; - } - return `Consumed: ${phase.tokens.toLocaleString()} tokens`; - } - - public isCurrentSessionActive(): boolean { - const status = this.agentService.agentStatus(); - if (status !== 'running') return false; - const currentId = this.agentService.currentSessionId(); - const runningId = this.agentService.runningSessionId(); - if (currentId && runningId && currentId !== runningId) { - return false; - } - const currentSession = this.agentService.sessions().find(s => s.session_id === currentId); - if (currentSession && (currentSession.status === 'completed' || currentSession.status === 'failed' || currentSession.status === 'cancelled')) { - return false; - } - return true; - } - - public isItemRunning(item: any, block: any): boolean { - if (!item || !item.data) return false; - if (!this.isCurrentSessionActive()) return false; - if (item.data.status === 'success' || item.data.status === 'failed' || item.data.status === 'cancelled') return false; - if (this.isReportStatusAction(item.data) || this.isReportStatusAction(item.data?.action_taken)) return false; - if (item.data.status === 'running') return true; - - // Check if session is currently running and this is the active trailing item - const blocks = this.consolidatedBlocks(); - const lastBlock = blocks.length > 0 ? blocks[blocks.length - 1] : null; - if (lastBlock && (lastBlock.id === block?.id || !block?.data?.isCompleted)) { - const events = this.getSortedStepEvents(block.data); - const lastEvent = events.length > 0 ? events[events.length - 1] : null; - if (lastEvent && (lastEvent.data?.trace_id === item.data?.trace_id || lastEvent === item)) { - return true; - } - } - return false; - } - - public isBlockRunning(block: any): boolean { - if (!block || !block.data) return false; - if (!this.isCurrentSessionActive()) return false; - if (block.type === 'checker') return block.data.isCompleted === false; - if (this.isReportStatusAction(block.data.action_taken) || block.data?.status === 'completed' || block.data?.status === 'failed' || block.data?.status === 'cancelled') return false; - if (block.data.isCompleted === false) return true; - const blocks = this.consolidatedBlocks(); - const lastBlock = blocks.length > 0 ? blocks[blocks.length - 1] : null; - return lastBlock?.id === block.id; - } -} diff --git a/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.html b/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.html deleted file mode 100644 index efd8882db..000000000 --- a/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.html +++ /dev/null @@ -1,380 +0,0 @@ - - -
- -
- -
- - -
- -
- @if (agentService.sessions().length > 0 && agentService.activeTab() === 'tasks') { - - } -
-
- - -
- - @if (agentService.activeTab() === 'tasks') { - -
- bolt Active Queue ({{ activeQueue().length }}) -
- @if (activeQueue().length === 0) { -
- No active or queued tasks. -
- } - @for (session of activeQueue(); track trackSession($index, session)) { -
-
- - @if (getTaskStatus(session) === 'running') { - - } - {{ getTaskStatus(session) | uppercase }} - -
- @if (getDeviceSerial(session)) { - - phone_android - {{ getDeviceSerial(session) }} - - } - @if (getDeviceSerial(session)) { - · - } - {{ session.start_time * 1000 | date:'shortTime' }} - @if (getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused') { - - } - @if (getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused') { - - } -
-
-
- {{ session.initial_goal || 'Unnamed Task' }} -
- -
- } - -
- - history Past Runs & History ({{ historyTasks().length }}) - -
- @if (historyTasks().length === 0) { -
- No past tasks. -
- } - @for (session of historyTasks(); track trackSession($index, session)) { -
-
- - @if (getTaskStatus(session) === 'running') { - - } - {{ getTaskStatus(session) | uppercase }} - -
- @if (getDeviceSerial(session)) { - - phone_android - {{ getDeviceSerial(session) }} - - } - @if (getDeviceSerial(session)) { - · - } - {{ session.start_time * 1000 | date:'shortTime' }} - @if (getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused') { - - } -
-
-
- {{ session.initial_goal || 'Unnamed Task' }} -
- -
- } -
-
- } - - - @if (agentService.activeTab() === 'notes') { -
- - @if (noteKeys().length > 1) { -
-
- @for (key of noteKeys(); track trackNoteKey($index, key)) { - - } -
-
- } - @if (noteKeys().length === 0) { -
-
- description -
-

No Notes & Plans

-

No notes or structured plans were recorded for this session.

-
- } - - @if (noteKeys().length > 0) { -
- - @if (parsedNote().title) { -

{{ parsedNote().title }}

- } - - @if (parsedNote().milestones.length > 0) { -
- @for (milestone of parsedNote().milestones; track trackMilestone($index, milestone)) { -
- -
- @switch (milestone.type) { - @case ('checked') { - check_box - } - @case ('progress') { - indeterminate_check_box - } - @case ('unchecked') { - check_box_outline_blank - } - } -
- @for (seg of milestone.segments; track seg) { - {{ seg.text }} - } -
-
- - @if (milestone.subSteps.length > 0) { -
- @for (step of milestone.subSteps; track trackMarkdownLine($index, step)) { -
- @switch (step.type) { - @case ('checked') { - check_box - } - @case ('progress') { - indeterminate_check_box - } - @case ('unchecked') { - check_box_outline_blank - } - @case ('list-item') { - • - } - } - @if (step.type !== 'empty') { - - @for (seg of step.segments; track seg) { - {{ seg.text }} - } - - } -
- } -
- } - - @if (milestone.checks.length > 0) { -
- @for (check of milestone.checks; track trackMarkdownLine($index, check)) { -
-
- {{ check.checkKind || 'verify' }}{{ check.atEnd ? ' @end' : '' }} -
-
- @for (seg of check.segments; track seg) { - {{ seg.text }} - } -
-
- } -
- } -
- } -
- } - - @if (parsedNote().otherLines.length > 0) { -
- @for (line of parsedNote().otherLines; track trackMarkdownLine($index, line)) { - @if (line.type === 'verify' || line.type === 'finding') { -
-
- {{ line.checkKind || 'verify' }}{{ line.atEnd ? ' @end' : '' }} -
-
- @for (seg of line.segments; track seg) { - {{ seg.text }} - } -
-
- } @else { -
- @switch (line.type) { - @case ('checked') { - check_box - } - @case ('progress') { - indeterminate_check_box - } - @case ('unchecked') { - check_box_outline_blank - } - @case ('list-item') { - • - } - } - @if (line.type !== 'empty') { - - @for (seg of line.segments; track seg) { - {{ seg.text }} - } - - } -
- } - } -
- } -
- } -
- } -
-
- diff --git a/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.scss b/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.scss deleted file mode 100644 index 2de9b953a..000000000 --- a/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.scss +++ /dev/null @@ -1,1171 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -$border-color: rgba(0, 0, 0, 0.08); -$text-primary: #1f1f1f; -$text-secondary: #71717a; -$text-muted: #a1a1aa; - -:host { - display: flex; - flex-direction: column; - height: 100%; - width: 100%; - overflow: hidden; -} - -.chat-interface-container { - container-type: inline-size; - display: flex; - flex-direction: column; - height: 100%; - background: #ffffff; - box-sizing: border-box; - - .chat-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - height: 60px; - padding: 0 20px; - border-bottom: 1px solid $border-color; - background: #ffffff; - box-sizing: border-box; - - .tab-selector-bar { - display: flex; - height: 36px; - box-sizing: border-box; - background: rgba(241, 245, 249, 0.72); - backdrop-filter: blur(16px) saturate(180%); - -webkit-backdrop-filter: blur(16px) saturate(180%); - border: 1px solid rgba(226, 232, 240, 0.85); - border-radius: 18px; - padding: 3px; - gap: 3px; - align-items: center; - flex-shrink: 0; - box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.8); - - .tab-selector-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - height: 30px; - padding: 0 14px; - box-sizing: border-box; - border: none; - background: transparent; - border-radius: 15px; - font-size: 13px; - font-weight: 600; - color: #64748b; - cursor: pointer; - line-height: 1; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - white-space: nowrap; - - .tab-icon { - font-size: 16px; - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - } - - &:hover:not(.active) { - color: #0f172a; - background: rgba(255, 255, 255, 0.7); - } - - &.active { - background: #ffffff; - color: #2563eb; - font-weight: 700; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.02); - } - } - } - - .header-right { - display: flex; - align-items: center; - justify-content: flex-end; - flex: 1 1 0; - min-width: max-content; - flex-shrink: 0; - - .btn-resume-task { - padding: 6px 14px; - background: linear-gradient(135deg, rgba(37, 99, 235, 0.92), rgba(26, 115, 232, 0.85)); - backdrop-filter: blur(14px) saturate(180%); - -webkit-backdrop-filter: blur(14px) saturate(180%); - border: 1px solid rgba(255, 255, 255, 0.35); - border-radius: 16px; - font-size: 13.5px; - font-weight: 600; - color: #ffffff; - cursor: pointer; - display: flex; - align-items: center; - gap: 5px; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - margin-right: 8px; - box-shadow: 0 4px 14px rgba(37, 99, 235, 0.3), inset 0 1px 1px rgba(255, 255, 255, 0.45); - - .material-symbols-outlined { - font-size: 16px; - color: inherit; - } - - &:hover { - background: linear-gradient(135deg, rgba(37, 99, 235, 1), rgba(26, 115, 232, 0.95)); - border-color: rgba(255, 255, 255, 0.6); - box-shadow: 0 6px 18px rgba(37, 99, 235, 0.42), inset 0 1px 1px rgba(255, 255, 255, 0.6); - transform: translateY(-0.5px); - } - - &:active { - transform: scale(0.97); - } - } - - .btn-clear-history { - width: 32px; - height: 32px; - padding: 0; - box-sizing: border-box; - background: rgba(255, 255, 255, 0.72); - backdrop-filter: blur(16px) saturate(180%); - -webkit-backdrop-filter: blur(16px) saturate(180%); - border: 1px solid rgba(203, 213, 225, 0.85); - border-radius: 50%; - color: #64748b; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 2px 6px -1px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.9); - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - - .material-symbols-outlined { - font-size: 16px; - color: inherit; - } - - &:hover:not(:disabled) { - background: #fef2f2; - color: #ef4444; - border-color: #fca5a5; - box-shadow: 0 3px 10px rgba(239, 68, 68, 0.15); - transform: scale(1.05); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - } - } - } - - .chat-messages { - flex: 1; - overflow-y: auto; - padding: 12px 12px 100px 12px; - display: flex; - flex-direction: column; - gap: 8px; - background: #ffffff; - - &::-webkit-scrollbar { - width: 5px; - } - &::-webkit-scrollbar-thumb { - background: rgba(148, 163, 184, 0.3); - border-radius: 4px; - } - - .queue-section-header { - font-size: 11.5px; - font-weight: 700; - color: #475569; - margin: 6px 0 2px; - display: flex; - align-items: center; - gap: 6px; - - .section-icon { - font-size: 14px; - color: inherit; - } - } - - .empty-section-placeholder { - display: flex; - align-items: center; - justify-content: center; - background: #f1f5f9; - border: 1px dashed #cbd5e1; - border-radius: 8px; - padding: 12px; - color: #64748b; - font-size: 12px; - text-align: center; - } - - .history-section { - margin-top: 12px; - border-top: 1px solid rgba(226, 232, 240, 0.8); - padding-top: 12px; - - &[open] .history-summary { - margin-bottom: 8px; - } - - .history-summary { - font-size: 11.5px; - font-weight: 700; - color: #475569; - cursor: pointer; - outline: none; - user-select: none; - list-style: none; - display: flex; - align-items: center; - gap: 6px; - padding: 4px 6px; - margin-left: -6px; - border-radius: 6px; - transition: background 0.2s; - - .section-icon { - font-size: 14px; - color: inherit; - } - - &::-webkit-details-marker { - display: none; - } - - &:hover { - background: #f1f5f9; - color: #1e293b; - } - } - - .history-list-wrapper { - display: flex; - flex-direction: column; - gap: 8px; - } - } - - .task-card { - background: #ffffff; - border: 1px solid rgba(226, 232, 240, 0.8); - border-radius: 10px; - padding: 10px 12px; - display: flex; - flex-direction: column; - gap: 6px; - cursor: pointer; - position: relative; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.01); - - &:hover { - transform: translateY(-1px); - border-color: rgba(203, 213, 225, 1); - box-shadow: 0 3px 8px rgba(148, 163, 184, 0.08); - - .task-card-header .task-card-header-actions { - &:has(.btn-delete-card) .task-time, - &:has(.btn-delete-card) .task-time-divider, - &:has(.btn-stop-card) .task-time, - &:has(.btn-stop-card) .task-time-divider { - opacity: 0; - } - - .btn-delete-card, - .btn-stop-card { - opacity: 1; - pointer-events: auto; - } - } - } - - &.selected { - border-color: #3b82f6; - box-shadow: 0 0 0 1px #3b82f6, 0 4px 12px rgba(59, 130, 246, 0.08); - background: #ffffff; - } - - &.running { - border-color: #10b981; - box-shadow: 0 0 0 1px #10b981, 0 4px 12px rgba(16, 185, 129, 0.08); - - &.selected { - border-color: #3b82f6; - box-shadow: 0 0 0 1px #3b82f6, 0 4px 12px rgba(59, 130, 246, 0.08); - } - } - - .task-card-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 8px; - min-width: 0; - - .task-badge { - flex-shrink: 0; - display: inline-flex; - align-items: center; - font-size: 10.5px; - font-weight: 700; - padding: 2px 5px; - border-radius: 4px; - letter-spacing: 0.05em; - border: 1px solid #e2e8f0; - background: #fafafa; - color: #64748b; - - &::before { - content: ''; - width: 4px; - height: 4px; - border-radius: 50%; - display: inline-block; - margin-right: 4px; - } - - &.running { - background: #fafafa; - color: #1f1f1f; - border-color: #1f1f1f; - &::before { - background-color: #1f1f1f; - animation: pulse-black-dot-small 1.5s infinite ease-in-out; - } - } - &.paused { - background: #fff7ed; - color: #c2410c; - border-color: #fdba74; - &::before { background-color: #f97316; } - } - &.completed { - background: #fafafa; - color: #1f1f1f; - border-color: #e2e8f0; - &::before { - background-color: #1f1f1f; - } - } - &.pending { - background: #fafafa; - color: #64748b; - border-color: #e2e8f0; - &::before { - background-color: #94a3b8; - } - } - &.failed { - background: #fafafa; - color: #ef4444; - border-color: #fee2e2; - &::before { - background-color: #ef4444; - } - } - &.cancelled { - background: #fafafa; - color: #d97706; - border-color: #fef3c7; - &::before { - background-color: #f59e0b; - } - } - - .status-dot { - display: none; - } - } - - .task-card-header-actions { - display: flex; - align-items: center; - gap: 4px; - position: relative; - min-width: 0; - flex-shrink: 1; - justify-content: flex-end; - - .task-device { - display: inline-flex; - align-items: center; - gap: 2.5px; - min-width: 0; - flex-shrink: 1; - font-size: 11px; - color: #94a3b8; - line-height: 1; - cursor: default; - transition: color 0.15s ease, opacity 0.15s ease; - - .device-icon { - font-size: 12.5px; - width: 12.5px; - height: 12.5px; - line-height: 12.5px; - color: #94a3b8; - opacity: 0.75; - flex-shrink: 0; - } - - .device-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - min-width: 0; - font-family: inherit; - letter-spacing: -0.15px; - } - - &:hover { - color: #64748b; - .device-icon { - color: #64748b; - opacity: 1; - } - } - } - - .task-time-divider { - font-size: 10px; - color: #cbd5e1; - line-height: 1; - user-select: none; - flex-shrink: 0; - transition: opacity 0.15s ease; - } - - .task-time { - font-size: 11.5px; - color: #94a3b8; - white-space: nowrap; - flex-shrink: 0; - line-height: 1; - transition: opacity 0.15s ease; - } - - .btn-delete-card { - position: absolute; - right: 0; - top: 50%; - transform: translateY(-50%); - width: 20px; - height: 20px; - padding: 0; - border: none; - background: transparent; - border-radius: 4px; - color: #94a3b8; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - opacity: 0; - pointer-events: none; - transition: all 0.15s ease; - - .material-symbols-outlined { - font-size: 14px; - line-height: 1; - } - - &:hover:not(:disabled) { - color: #ef4444; - background: #fee2e2; - } - - &:disabled { - opacity: 0.3; - cursor: not-allowed; - } - } - - .btn-stop-card { - position: absolute; - right: 0; - top: 50%; - transform: translateY(-50%); - width: 20px; - height: 20px; - padding: 0; - border: none; - background: transparent; - border-radius: 4px; - color: #ef4444; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - opacity: 0; - pointer-events: none; - transition: all 0.15s ease; - - .material-symbols-outlined { - font-size: 15px; - line-height: 1; - } - - &:hover:not(:disabled) { - color: #dc2626; - background: #fee2e2; - transform: translateY(-50%) scale(1.1); - } - - &:disabled { - opacity: 0.3; - cursor: not-allowed; - } - } - } - } - - .task-goal { - font-size: 12.5px; - font-weight: 500; - color: #1e293b; - line-height: 1.4; - word-break: break-word; - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - } - - .task-card-footer { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - border-top: 1px solid rgba(241, 245, 249, 0.8); - padding-top: 6px; - font-size: 11.5px; - color: #94a3b8; - - .task-id { - font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace; - font-size: 11px; - color: #94a3b8; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; - flex: 1; - transition: color 0.15s ease; - - &:hover { - color: #475569; - } - } - - .task-date { - flex-shrink: 0; - font-size: 11px; - color: #94a3b8; - } - } - } - } - - - - - // Notes tab content - .notes-tab-content { - display: flex; - flex-direction: column; - gap: 16px; - height: 100%; - - .notes-selector-container { - display: flex; - flex-direction: column; - gap: 8px; - - .selector-title { - font-size: 11px; - font-weight: 700; - color: #94a3b8; - letter-spacing: 0.05em; - } - - .notes-selector-list { - display: flex; - flex-wrap: wrap; - gap: 8px; - - .note-selector-pill { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 12px; - border: 1px solid #e2e8f0; - background: #ffffff; - border-radius: 20px; - font-size: 13px; - font-weight: 500; - color: #475569; - cursor: pointer; - transition: all 0.15s ease; - - .pill-icon { - font-size: 15px; - } - - &:hover { - background: #f8fafc; - border-color: #cbd5e1; - color: #1e293b; - } - - &.active { - background: #eff6ff; - border-color: #bfdbfe; - color: #1d4ed8; - font-weight: 600; - } - } - } - } - - .empty-notes-container { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 48px 24px; - margin: auto 0; - text-align: center; - user-select: none; - animation: fadeIn 0.25s cubic-bezier(0.16, 1, 0.3, 1); - - .empty-notes-icon { - width: 52px; - height: 52px; - border-radius: 50%; - background: #f1f5f9; - border: 1px solid #e2e8f0; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 14px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02); - - .material-symbols-outlined { - font-size: 26px; - color: #94a3b8; - } - } - - .empty-notes-title { - font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - font-size: 14px; - font-weight: 600; - color: #1e293b; - margin: 0 0 6px 0; - } - - .empty-notes-subtitle { - font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - font-size: 12.5px; - color: #64748b; - line-height: 1.5; - max-width: 240px; - margin: 0; - } - } - - .note-viewer-cards { - display: flex; - flex-direction: column; - flex: 1; - padding: 16px 8px; - overflow-y: auto; - - .note-title { - font-size: 14px; - font-weight: 700; - color: #1f1f1f; - margin-top: 0; - margin-bottom: 20px; - font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif; - } - - .code-segment { - font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace; - background-color: #f1f3f4; - color: #1967d2; - padding: 2px 6px; - border-radius: 4px; - font-size: 14px; - margin: 0 2px; - word-break: break-all; - font-weight: normal; - } - - .checked { - .code-segment { - color: #a1a1aa; - background-color: #f8f9fa; - } - } - - .milestones-container { - display: flex; - flex-direction: column; - gap: 6px; - margin-bottom: 24px; - } - - .milestone-block { - display: flex; - flex-direction: column; - - &.checked { - .milestone-title-row { - .milestone-status-icon.checked { - opacity: 0.7; - } - .milestone-text { - color: #a1a1aa; - } - } - } - - .milestone-title-row { - display: flex; - align-items: flex-start; - gap: 10px; - - .milestone-status-icon { - font-size: 20px; - user-select: none; - margin-top: 3px; - font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24; - - &.checked { - color: #1a73e8; - } - - &.progress { - color: #1a73e8; - } - - &.unchecked { - color: #cbd5e1; - } - } - - .milestone-text { - font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif; - color: #1f1f1f; - font-size: 14px; - line-height: 1.8; - font-weight: 400; - word-break: break-word; - flex: 1; - - span { - color: inherit; - } - - .bold-segment { - font-weight: inherit; - color: inherit; - } - } - } - - .milestone-substeps { - margin-top: 8px; - display: flex; - flex-direction: column; - gap: 10px; - padding-left: 28px; - - .substep-row { - display: flex; - align-items: flex-start; - gap: 10px; - font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif; - color: #1f1f1f; - font-size: 14px; - line-height: 1.8; - - span { - color: inherit; - } - - &.checked { - color: #a1a1aa; - - .check-icon.checked { - opacity: 0.7; - } - } - - .check-icon { - font-size: 20px; - user-select: none; - margin-top: 3px; - font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24; - - &.checked { - color: #1a73e8; - } - - &.progress { - color: #1a73e8; - } - - &.unchecked { - color: #cbd5e1; - } - } - - .bullet { - color: #9ca3af; - font-weight: bold; - user-select: none; - padding-left: 2px; - } - - .line-text { - word-break: break-word; - color: inherit; - - span { - color: inherit; - } - - .bold-segment { - font-weight: inherit; - color: inherit; - } - } - } - } - - .milestone-checks-container { - margin-top: 8px; - padding-left: 28px; - display: flex; - flex-direction: column; - gap: 8px; - } - } - - .verification-card { - background: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 6px; - padding: 8px 12px; - display: flex; - flex-direction: column; - gap: 4px; - - &.standalone { - margin-top: 8px; - margin-bottom: 8px; - } - - .card-header { - display: flex; - align-items: center; - - .card-tag { - font-family: 'Google Sans Text', 'Inter', -apple-system, sans-serif; - font-size: 10.5px; - font-weight: 600; - line-height: 1; - letter-spacing: 0.6px; - text-transform: uppercase; - color: #64748b; - - &.assert { - color: #0284c7; - } - } - } - - .card-body { - font-family: inherit; - font-size: 13.5px; - line-height: 1.6; - color: #334155; - word-break: break-word; - - .bold-segment { - font-weight: 600; - color: #1e293b; - } - - .code-segment { - font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace; - font-size: 12px; - background: #f1f5f9; - color: #0f172a; - padding: 1.5px 6px; - border-radius: 4px; - border: 1px solid #e2e8f0; - } - } - - &.finding { - background: #fffbeb; - border-color: #fef3c7; - - .card-header .card-tag { - color: #b45309; - } - - .card-body { - color: #92400e; - } - } - } - - .other-lines-container { - display: flex; - flex-direction: column; - padding: 8px 4px; - - .md-line { - display: flex; - align-items: flex-start; - gap: 10px; - margin-bottom: 14px; - min-height: 22px; - font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif; - color: #1f1f1f; - font-size: 14px; - line-height: 1.6; - - &.empty { - height: 12px; - } - - &.h1 { - font-size: 14px; - font-weight: 700; - margin-top: 24px; - margin-bottom: 12px; - border-bottom: 1px solid #e5e7eb; - padding-bottom: 6px; - } - - &.h2 { - font-size: 14px; - font-weight: 700; - margin-top: 20px; - margin-bottom: 10px; - } - - &.h3 { - font-size: 14px; - font-weight: 700; - margin-top: 14px; - margin-bottom: 8px; - } - - &.checked { - color: #a1a1aa; - - .check-icon { - color: #1a73e8; - opacity: 0.7; - } - } - - &.progress { - .check-icon { - color: #1a73e8; - } - } - - &.unchecked { - .check-icon { - color: #cbd5e1; - } - } - - &.list-item { - .bullet { - color: #9ca3af; - font-weight: bold; - user-select: none; - } - } - - span { - color: inherit; - } - - .check-icon { - font-size: 20px; - user-select: none; - margin-top: 1px; - font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24; - } - - .line-text { - word-break: break-word; - color: inherit; - - span { - color: inherit; - } - - .bold-segment { - font-weight: inherit; - color: inherit; - } - } - } - } - } - } -} - -@keyframes pulse { - 0% { transform: scale(0.85); opacity: 0.6; } - 50% { transform: scale(1.15); opacity: 1; } - 100% { transform: scale(0.85); opacity: 0.6; } -} - -@keyframes bannerSlideIn { - from { transform: translateY(5px); opacity: 0; } - to { transform: translateY(0); opacity: 1; } -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(4px); } - to { opacity: 1; transform: translateY(0); } -} - -@keyframes pulse-black-dot-small { - 0% { - box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.4); - } - 70% { - box-shadow: 0 0 0 4px rgba(0, 0, 0, 0); - } - 100% { - box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); - } -} - -@keyframes pulse-blue-dot { - 0% { - box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.4); - } - 70% { - box-shadow: 0 0 0 5px rgba(37, 99, 235, 0); - } - 100% { - box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); - } -} - -@container (max-width: 380px) { - .chat-header { - padding: 0 8px; - gap: 6px; - height: 36px; - - .header-left { - display: none; - } - - .tab-selector-bar { - padding: 2px; - gap: 1px; - - .tab-selector-btn { - padding: 5px 8px; - font-size: 12px; - gap: 4px; - - .tab-icon { - font-size: 14px; - } - } - } - - .header-right { - .btn-clear-history { - height: 24px; - width: 30px; - padding: 0; - font-size: 12px; - gap: 0; - border-radius: 16px; - box-sizing: border-box; - display: inline-flex; - align-items: center; - justify-content: center; - - .material-symbols-outlined { - font-size: 14px; - line-height: 1; - } - - .btn-text { - display: none; - } - } - } - } -} - -@container (max-width: 340px) { - .chat-header { - .tab-selector-bar { - .tab-selector-btn { - padding: 5px 6px; - .tab-text { - display: none; - } - } - } - - .header-right { - .btn-clear-history { - width: 26px; - } - } - } -} diff --git a/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts b/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts deleted file mode 100644 index 45b36a1ea..000000000 --- a/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts +++ /dev/null @@ -1,269 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, ChangeDetectionStrategy, inject, computed, signal } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { AgentService } from '../../services/agent.service'; -import { Session } from '../../core/models/session.model'; -import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model'; -import { parseNote, parseNoteLines } from '../../utils/markdown-parser.util'; - -export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote }; - -@Component({ - selector: 'app-chat-interface', - standalone: true, - imports: [CommonModule, FormsModule], - templateUrl: './chat-interface.component.html', - styleUrl: './chat-interface.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class ChatInterfaceComponent { - public agentService = inject(AgentService); - - public taskInput: string = ''; - // Signals so async completion handlers refresh this OnPush view. - public isSubmitting = signal(false); - public errorMessage = signal(null); - - // Device-serial resolution can require a JSON.parse of device_info; memoize - // it per session object so template re-evaluation stays cheap. - private deviceSerialCache = new WeakMap(); - - /** - * Filtered computed list of active tasks (running or pending) sorted by status and submission order - */ - public activeQueue = computed(() => { - const list = this.agentService.sessions().filter((s) => { - const status = this.getTaskStatus(s); - return status === 'running' || status === 'paused' || status === 'pending'; - }); - return list.sort((a, b) => { - const statusA = this.getTaskStatus(a); - const statusB = this.getTaskStatus(b); - const isRunA = statusA === 'running' || statusA === 'paused'; - const isRunB = statusB === 'running' || statusB === 'paused'; - if (isRunA && !isRunB) return -1; - if (!isRunA && isRunB) return 1; - return (a.start_time || 0) - (b.start_time || 0); // stable FIFO order - }); - }); - - /** - * Filtered computed list of historical/completed tasks (completed, failed, or cancelled) - */ - public historyTasks = computed(() => { - return this.agentService.sessions().filter((s) => { - const status = this.getTaskStatus(s); - return status === 'completed' || status === 'failed' || status === 'cancelled'; - }); - }); - - /** - * Submit a new task goal to the backend - */ - public submitTask(): void { - const goal = this.taskInput.trim(); - if (!goal) { - return; - } - - this.isSubmitting.set(true); - this.errorMessage.set(null); - - this.agentService.runTask(goal).subscribe({ - next: (res) => { - this.taskInput = ''; - this.isSubmitting.set(false); - // Fetch status to refresh sessions list and select new session - this.agentService.fetchStatus(); - }, - error: (err) => { - console.error('Failed to submit task:', err); - this.isSubmitting.set(false); - this.errorMessage.set(err.error?.detail || 'The runner is busy. Please wait for the current task to finish.'); - // Auto-dismiss error banner after 5 seconds - setTimeout(() => { - this.errorMessage.set(null); - }, 5000); - } - }); - } - - /** - * Stop an individual task session - */ - public stopTask(sessionId: string, event: MouseEvent): void { - event.stopPropagation(); - this.isSubmitting.set(true); - this.errorMessage.set(null); - this.agentService.stopTask(sessionId, false); - setTimeout(() => { - this.isSubmitting.set(false); - }, 400); - } - - /** - * Stop the currently running task and optionally clear the backend queue - */ - public stopTasks(stopAll: boolean = false): void { - this.isSubmitting.set(true); - this.errorMessage.set(null); - this.agentService.stopTask(stopAll); - setTimeout(() => { - this.isSubmitting.set(false); - }, 400); - } - - /** - * Clear all database data/history to start fresh - */ - public clearHistory(): void { - if (!confirm('Are you sure you want to clear all tasks and history? This cannot be undone.')) { - return; - } - this.isSubmitting.set(true); - this.errorMessage.set(null); - this.agentService.clearAllHistory().subscribe({ - next: () => { - this.isSubmitting.set(false); - }, - error: (err: any) => { - console.error('Failed to clear history:', err); - this.isSubmitting.set(false); - this.errorMessage.set(err.error?.detail || 'Failed to clear history.'); - } - }); - } - - /** - * Delete an individual task / session - */ - public deleteTask(sessionId: string, event: MouseEvent): void { - event.stopPropagation(); - if (!confirm(`Are you sure you want to delete this task? This cannot be undone.`)) { - return; - } - this.isSubmitting.set(true); - this.errorMessage.set(null); - this.agentService.deleteSession(sessionId).subscribe({ - next: () => { - this.isSubmitting.set(false); - }, - error: (err: any) => { - console.error(`Failed to delete task ${sessionId}:`, err); - this.isSubmitting.set(false); - this.errorMessage.set(err.error?.detail || 'Failed to delete task.'); - } - }); - } - - /** - * Determine the current task execution status - */ - public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' { - if (session.status) { - const s = session.status.toLowerCase(); - if (s === 'completed' || s === 'success' || s === 'failed' || s === 'cancelled') { - return (s === 'success' ? 'completed' : s) as any; - } - if (s === 'running' || s === 'paused' || s === 'pending') { - return s as any; - } - } - if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) { - return this.agentService.agentStatus() as 'running' | 'paused'; - } - return 'completed'; - } - - /** - * Determine the device serial number for the session - */ - public getDeviceSerial(session: Session): string | null { - if (this.deviceSerialCache.has(session)) { - return this.deviceSerialCache.get(session) ?? null; - } - let resolved: string | null = null; - const serial = session.device_serial || session.device_id; - if (serial && serial !== 'pending' && serial !== 'null' && serial !== 'undefined') { - resolved = serial; - } else if (session.device_info) { - try { - const info = typeof session.device_info === 'string' ? JSON.parse(session.device_info) : session.device_info; - const s = info?.device_id || info?.device_serial; - if (s && s !== 'pending' && s !== 'null' && s !== 'undefined') { - resolved = s; - } - } catch { - // ignore - } - } - this.deviceSerialCache.set(session, resolved); - return resolved; - } - - /** - * Select a session in the UI to monitor its steps - */ - public selectTask(sessionId: string): void { - this.agentService.selectSession(sessionId, true); - } - - // Notes Computed Properties - public currentNoteContent = computed(() => { - const notes = this.agentService.currentNotes(); - const key = this.agentService.selectedNoteKey(); - return notes[key] || ''; - }); - - public noteKeys = computed(() => { - return Object.keys(this.agentService.currentNotes()).filter(key => key.toLowerCase().endsWith('.md')); - }); - - public parsedNote = computed(() => { - return this.getParsedNote(this.currentNoteContent()); - }); - - public getParsedNote(content: string): ParsedNote { - return parseNote(content); - } - - public getParsedNoteLines(content: string): MarkdownLine[] { - return parseNoteLines(content); - } - - public selectNote(key: string): void { - this.agentService.selectedNoteKey.set(key); - } - - public trackSession(index: number, session: Session): string { - return session.session_id; - } - - public trackNoteKey(index: number, key: string): string { - return key; - } - - public trackMilestone(index: number, milestone: NoteMilestone): number { - return milestone.index; - } - - public trackMarkdownLine(index: number, line: MarkdownLine): number { - return index; - } -} diff --git a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html deleted file mode 100644 index 1c468f660..000000000 --- a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html +++ /dev/null @@ -1,433 +0,0 @@ - - - -@if (agentService.isVideoWindowOpen() && isTheaterMode()) { -
-} - -@if (agentService.isVideoWindowOpen()) { -
- -
-
- - {{ agentService.recordingPlaybackStatus() === 'live' ? 'sensors' : (agentService.playerMode() === 'steps' ? 'slideshow' : (agentService.recordingPlaybackStatus() === 'processing' ? 'progress_activity' : 'smart_display')) }} - - - {{ agentService.recordingPlaybackStatus() === 'live' ? 'Live Device Screen' : (agentService.playerMode() === 'steps' ? ('Step Replay (' + (totalStepFrames() > 0 ? (activeStepIndex() + 1) + '/' + totalStepFrames() : '0') + ')') : (agentService.recordingPlaybackStatus() === 'processing' ? 'Preparing Recording' : (agentService.activeVideoTitle() || 'Screen Recording'))) }} - -
-
- - @if (agentService.recordingPlaybackStatus() === 'ready' && agentService.hasCurrentSessionStepFrames() && !agentService.isVideoMinimized()) { -
- - -
- } - - - - @if (!agentService.isVideoMinimized()) { - - } - - -
-
- - @if (!agentService.isVideoMinimized()) { -
- - @if (agentService.recordingPlaybackStatus() === 'live') { - @if (!liveStreamError()) { -
- Live Device Screen -
-
- - LIVE -
- -
-
- } - @if (liveStreamError()) { -
-
- phonelink_off -
-

Device Stream Connecting

-

Waiting for ADB device screen stream...

- -
- } - } - - @if (agentService.recordingPlaybackStatus() !== 'live') { - - @if (agentService.playerMode() === 'video' && (agentService.recordingPlaybackStatus() === 'processing' || agentService.isVideoLoading())) { -
- progress_activity - {{ agentService.recordingPlaybackMessage() || 'Preparing screen recording...' }} -
- } - - @if (agentService.playerMode() === 'steps' && totalStepFrames() > 0) { -
- - @if (agentService.recordingPlaybackStatus() === 'failed' || videoLoadError()) { -
- info - Screen recording unavailable. Replaying step-by-step screenshots. -
- } - -
- - @if (isStepImageLoading()) { -
- progress_activity - Loading screenshot... -
- } - - @if (stepImageError() && !isStepImageLoading()) { -
- broken_image - Screenshot preview unavailable for this step -
- } - - @if (!stepImageError()) { -
- - -
-
- } - - @if (currentStepFrame(); as frame) { -
-
- Step {{ frame.stepNumber }} - - @if (frame.action) { - - {{ getStepActionIcon(frame.action) }} - - } - {{ frame.actionText || frame.title }} - -
-
- - @if (frame.postImageUrl) { -
- - -
- } - - -
-
- } -
- - -
- } - - @if (agentService.playerMode() === 'video' && agentService.recordingPlaybackStatus() === 'ready' && !agentService.isVideoLoading() && agentService.activeVideoUrl(); as videoUrl) { - @if (!videoLoadError()) { -
- -
- } - - @if (!videoLoadError()) { - - } - } - - @if (!agentService.isVideoLoading() && (agentService.playerMode() === 'video' || totalStepFrames() === 0) && (agentService.recordingPlaybackStatus() === 'failed' || agentService.recordingPlaybackStatus() === 'unavailable' || videoLoadError()) && totalStepFrames() === 0) { -
-
- videocam_off -
-

- {{ agentService.recordingPlaybackStatus() === 'failed' || videoLoadError() ? 'Recording Playback Failed' : 'No Video Recording Available' }} -

-

- {{ videoLoadError() ? 'The finalized recording could not be played.' : (agentService.recordingPlaybackMessage() || 'No screen recording was found for this task.') }} -

- @if (agentService.recordingPlaybackStatus() === 'failed' || videoLoadError()) { - - } -
- } - } -
- } -
- } diff --git a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.scss b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.scss deleted file mode 100644 index ff7f82c87..000000000 --- a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.scss +++ /dev/null @@ -1,987 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -.theater-backdrop { - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background: rgba(15, 23, 42, 0.6); - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); - z-index: 1040; - animation: fadeIn 0.15s ease-out; -} - -.floating-video-wrapper { - position: fixed; - width: 400px; - max-width: calc(100vw - 32px); - background: rgba(255, 255, 255, 0.84); - backdrop-filter: blur(24px) saturate(180%); - -webkit-backdrop-filter: blur(24px) saturate(180%); - border: 1px solid rgba(255, 255, 255, 0.85); - border-radius: 16px; - box-shadow: 0 20px 50px -10px rgba(15, 23, 42, 0.22), 0 0 0 1px rgba(226, 232, 240, 0.6), inset 0 1px 1px rgba(255, 255, 255, 0.9); - z-index: 1050; - overflow: hidden; - display: flex; - flex-direction: column; - box-sizing: border-box; - animation: popIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); - user-select: none; - font-family: 'Google Sans', 'Google Sans Text', 'Inter', system-ui, -apple-system, sans-serif; - - &.minimized { - width: 280px; - box-shadow: 0 10px 25px -5px rgba(15, 23, 42, 0.18); - - .window-header { - border-bottom: none; - } - } - - &.theater { - left: 50% !important; - top: 50% !important; - transform: translate(-50%, -50%); - width: 85vw; - max-width: 960px; - height: auto; - max-height: 88vh; - box-shadow: 0 24px 60px -12px rgba(15, 23, 42, 0.35); - - .video-body { - max-height: calc(88vh - 46px); - - .video-container { - video { - max-height: calc(88vh - 120px); - } - } - } - } - - /* --- Window Header (Draggable Handle) --- */ - .window-header { - height: 44px; - background: rgba(255, 255, 255, 0.75); - backdrop-filter: blur(16px); - border-bottom: 1px solid rgba(226, 232, 240, 0.75); - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 8px 0 12px; - cursor: grab; - gap: 8px; - flex-shrink: 0; - - &:active { - cursor: grabbing; - } - - .header-left { - display: flex; - align-items: center; - gap: 8px; - overflow: hidden; - flex: 1; - - .header-icon { - font-size: 18px; - color: #2563eb; - flex-shrink: 0; - } - - .header-title { - font-size: 13px; - font-weight: 600; - color: #0f172a; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - letter-spacing: -0.2px; - } - - .mode-switcher-group { - display: flex; - align-items: center; - background: rgba(241, 245, 249, 0.65); - backdrop-filter: blur(12px); - border: 1px solid rgba(226, 232, 240, 0.8); - border-radius: 8px; - padding: 2px; - margin-left: 4px; - flex-shrink: 0; - - .mode-btn { - border: none; - background: transparent; - color: #475569; - font-size: 10px; - font-weight: 700; - padding: 2px 7px; - border-radius: 6px; - cursor: pointer; - transition: all 0.15s ease; - display: flex; - align-items: center; - gap: 4px; - - .pulse-dot-mini { - width: 5px; - height: 5px; - border-radius: 50%; - background: #ef4444; - animation: pulseRed 1.2s infinite; - } - - &:hover:not(:disabled) { - color: #0f172a; - background: rgba(255, 255, 255, 0.6); - } - - &.active { - background: #0284c7; - color: #ffffff; - font-weight: 700; - box-shadow: 0 1px 3px rgba(2, 132, 199, 0.3); - } - - &:disabled { - opacity: 0.4; - cursor: not-allowed; - } - } - } - } - - .header-actions { - display: flex; - align-items: center; - gap: 4px; - flex-shrink: 0; - - .mode-toggle-group { - display: flex; - align-items: center; - background: rgba(241, 245, 249, 0.7); - backdrop-filter: blur(12px); - border: 1px solid rgba(226, 232, 240, 0.85); - border-radius: 8px; - padding: 2px; - margin-right: 4px; - gap: 2px; - - .mode-btn { - display: inline-flex; - align-items: center; - gap: 3px; - border: none; - background: transparent; - color: #475569; - font-size: 11px; - font-weight: 600; - padding: 2px 7px; - border-radius: 6px; - cursor: pointer; - transition: all 0.15s ease; - - .mode-icon { - font-size: 13px; - } - - &:hover:not(.active) { - color: #0f172a; - background: rgba(255, 255, 255, 0.8); - } - - &.active { - background: #0284c7; - color: #ffffff; - box-shadow: 0 1px 3px rgba(2, 132, 199, 0.3); - } - } - } - - .speed-pill-group { - display: flex; - align-items: center; - background: rgba(241, 245, 249, 0.65); - backdrop-filter: blur(12px); - border: 1px solid rgba(226, 232, 240, 0.8); - border-radius: 8px; - padding: 2px; - margin-right: 4px; - - .speed-btn { - border: none; - background: transparent; - color: #334155; - font-size: 11px; - font-weight: 700; - padding: 2px 6px; - border-radius: 4px; - cursor: pointer; - transition: all 0.15s ease; - - &:hover { - color: #0f172a; - background: #cbd5e1; - } - - &.active { - background: #0284c7; - color: #ffffff; - font-weight: 700; - box-shadow: 0 1px 3px rgba(2, 132, 199, 0.3); - } - } - } - - .header-btn { - background: rgba(241, 245, 249, 0.75); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border: 1px solid rgba(226, 232, 240, 0.85); - color: #1e293b; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: 7px; - box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.8); - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - - &:hover { - background: rgba(255, 255, 255, 0.95); - color: #0f172a; - border-color: rgba(203, 213, 225, 0.95); - transform: translateY(-0.5px); - box-shadow: 0 2px 6px rgba(15, 23, 42, 0.08); - } - - &.close-btn:hover { - background: rgba(254, 226, 226, 0.95); - color: #dc2626; - border-color: rgba(252, 165, 165, 0.9); - box-shadow: 0 2px 6px rgba(220, 38, 38, 0.15); - } - - &:active { - transform: scale(0.96); - } - - .material-symbols-outlined { - font-size: 17px; - font-weight: 600; - } - } - } - } - - /* --- Video Body Area --- */ - .video-body { - position: relative; - background: #ffffff; - display: flex; - flex-direction: column; - align-items: stretch; - justify-content: flex-start; - overflow: hidden; - - .video-loading-state { - padding: 36px 20px; - display: flex; - flex-direction: column; - align-items: center; - gap: 10px; - background: #ffffff; - width: 100%; - box-sizing: border-box; - color: #475569; - font-size: 13px; - font-weight: 500; - - .spinning-icon { - font-size: 28px; - color: #2563eb; - animation: spin 1s linear infinite; - } - } - - .live-stream-container { - position: relative; - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - background: #ffffff; - min-height: 260px; - overflow: hidden; - - .live-screen-img { - width: 100%; - max-height: 520px; - display: block; - object-fit: contain; - user-select: none; - background: #ffffff; - } - - .live-status-overlay { - position: absolute; - top: 10px; - left: 12px; - right: 12px; - display: flex; - align-items: center; - justify-content: space-between; - pointer-events: none; - - .live-badge { - display: flex; - align-items: center; - gap: 6px; - background: #fef2f2; - border: 1px solid #fca5a5; - color: #dc2626; - font-size: 10.5px; - font-weight: 700; - padding: 3px 9px; - border-radius: 12px; - letter-spacing: 0.5px; - box-shadow: 0 1px 4px rgba(220, 38, 38, 0.15); - - .pulse-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: #ef4444; - animation: pulseRed 1.2s infinite; - } - } - - .live-refresh-btn { - pointer-events: auto; - background: rgba(255, 255, 255, 0.95); - backdrop-filter: blur(6px); - border: 1px solid #cbd5e1; - color: #1e293b; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: 50%; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); - transition: all 0.15s ease; - - &:hover { - background: #f1f5f9; - color: #0f172a; - border-color: #94a3b8; - transform: rotate(90deg); - } - - .material-symbols-outlined { - font-size: 16px; - } - } - } - } - - .step-replay-container { - display: flex; - flex-direction: column; - background: #ffffff; - position: relative; - width: 100%; - overflow: hidden; - - .step-replay-notice { - display: flex; - align-items: center; - gap: 6px; - background: #fffbeb; - color: #b45309; - padding: 6px 12px; - font-size: 11.5px; - font-weight: 500; - border-bottom: 1px solid #fde68a; - - .notice-icon { - font-size: 14px; - color: #d97706; - flex-shrink: 0; - } - } - - .step-image-viewport { - display: flex; - align-items: center; - justify-content: center; - position: relative; - background: #f8fafc; - min-height: 280px; - max-height: 520px; - overflow: hidden; - padding: 10px 0; - - .step-image-loading-state, - .step-image-error-state { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 8px; - background: #f8fafc; - color: #64748b; - font-size: 12px; - font-weight: 500; - z-index: 5; - - .spinning-icon { - font-size: 24px; - color: #0284c7; - animation: spin 1s linear infinite; - } - - .broken-icon { - font-size: 28px; - color: #94a3b8; - } - } - - .step-image-frame-wrap { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - max-width: 92%; - max-height: 500px; - - .step-frame-img { - max-width: 100%; - max-height: 500px; - width: auto; - height: auto; - object-fit: contain; - display: block; - user-select: none; - cursor: pointer; - border-radius: 8px; - box-shadow: 0 4px 20px -2px rgba(15, 23, 42, 0.14), 0 0 0 1px rgba(226, 232, 240, 0.9); - background: #ffffff; - transition: opacity 0.15s ease; - - &:hover { - opacity: 0.98; - } - } - - .overlay-layer { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - pointer-events: none; - z-index: 8; - } - } - - &:hover .step-frame-badge { - opacity: 1; - transform: translateY(0); - pointer-events: auto; - } - - .step-frame-badge { - position: absolute; - bottom: 12px; - left: 10px; - right: 10px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - background: rgba(15, 23, 42, 0.88); - backdrop-filter: blur(14px) saturate(180%); - -webkit-backdrop-filter: blur(14px) saturate(180%); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 10px; - padding: 6px 10px; - color: #ffffff; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); - opacity: 0; - transform: translateY(8px); - pointer-events: none; - transition: opacity 0.22s cubic-bezier(0.16, 1, 0.3, 1), transform 0.22s cubic-bezier(0.16, 1, 0.3, 1); - z-index: 15; - - &.visible { - opacity: 1; - transform: translateY(0); - pointer-events: auto; - } - - .badge-content-left { - display: flex; - align-items: center; - gap: 7px; - overflow: hidden; - flex: 1; - - .badge-step-no { - font-size: 10.5px; - font-weight: 700; - background: #0284c7; - padding: 2px 7px; - border-radius: 5px; - letter-spacing: 0.3px; - flex-shrink: 0; - box-shadow: 0 1px 3px rgba(2, 132, 199, 0.4); - } - - .badge-action-desc { - font-size: 11.5px; - font-weight: 500; - color: #f8fafc; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: flex; - align-items: center; - gap: 4px; - - .action-type-icon { - font-size: 15px; - color: #38bdf8; - flex-shrink: 0; - } - } - } - - .badge-actions-right { - display: flex; - align-items: center; - gap: 6px; - flex-shrink: 0; - - .pre-post-toggle { - display: inline-flex; - align-items: center; - background: rgba(255, 255, 255, 0.12); - border: 1px solid rgba(255, 255, 255, 0.15); - border-radius: 6px; - padding: 2px; - gap: 2px; - - .toggle-btn { - border: none; - background: transparent; - color: rgba(255, 255, 255, 0.7); - font-size: 10.5px; - font-weight: 700; - padding: 2px 7px; - border-radius: 4px; - cursor: pointer; - transition: all 0.15s ease; - - &:hover { - color: #ffffff; - background: rgba(255, 255, 255, 0.2); - } - - &.active { - background: #0284c7; - color: #ffffff; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); - } - } - } - - .pin-card-btn { - background: transparent; - border: none; - color: rgba(255, 255, 255, 0.6); - cursor: pointer; - padding: 3px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; - transition: all 0.15s ease; - - &:hover { - color: #ffffff; - background: rgba(255, 255, 255, 0.15); - } - - &.pinned { - color: #38bdf8; - background: rgba(56, 189, 248, 0.2); - } - - .material-symbols-outlined { - font-size: 14px; - } - } - } - } - } - } - - .video-container { - position: relative; - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - background: #ffffff; - - video { - width: 100%; - height: auto; - max-height: 520px; - display: block; - object-fit: contain; - cursor: pointer; - background: #ffffff; - } - } - - .video-controls-footer { - background: rgba(255, 255, 255, 0.84); - backdrop-filter: blur(20px) saturate(180%); - -webkit-backdrop-filter: blur(20px) saturate(180%); - border-top: 1px solid rgba(226, 232, 240, 0.75); - padding: 12px 14px; - display: flex; - flex-direction: column; - gap: 10px; - flex-shrink: 0; - box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.9); - - .scrubber-bar-container { - position: relative; - width: 100%; - height: 8px; - display: flex; - align-items: center; - cursor: pointer; - - .scrubber-range { - position: absolute; - width: 100%; - height: 100%; - opacity: 0; - cursor: pointer; - z-index: 5; - margin: 0; - } - - &::before { - content: ''; - position: absolute; - left: 0; - right: 0; - height: 5px; - background: rgba(226, 232, 240, 0.85); - border-radius: 3px; - } - - .scrubber-progress-fill { - position: absolute; - left: 0; - height: 5px; - background: linear-gradient(90deg, #0284c7, #2563eb); - border-radius: 3px; - pointer-events: none; - box-shadow: 0 0 8px rgba(2, 132, 199, 0.4); - } - - .step-ticks-wrapper { - position: absolute; - left: 0; - right: 0; - height: 100%; - pointer-events: none; - display: flex; - align-items: center; - z-index: 6; - - .step-tick { - position: absolute; - width: 7px; - height: 7px; - border-radius: 50%; - background: rgba(255, 255, 255, 0.9); - border: 1.5px solid #94a3b8; - transform: translate(-50%, 0); - pointer-events: auto; - cursor: pointer; - transition: all 0.15s ease; - - &:hover { - transform: translate(-50%, 0) scale(1.4); - border-color: #0284c7; - background: #ffffff; - } - - &.passed { - background: #0284c7; - border-color: #ffffff; - } - - &.active { - background: #38bdf8; - border-color: #ffffff; - transform: translate(-50%, 0) scale(1.3); - box-shadow: 0 0 6px rgba(56, 189, 248, 0.8); - } - } - } - } - - .controls-row { - display: flex; - align-items: center; - justify-content: space-between; - - .controls-left, .controls-right { - display: flex; - align-items: center; - gap: 8px; - } - - .ctrl-btn { - background: rgba(241, 245, 249, 0.75); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border: 1px solid rgba(226, 232, 240, 0.85); - box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.85); - color: #0f172a; - width: 32px; - height: 32px; - border-radius: 8px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - - &:hover { - background: rgba(255, 255, 255, 0.95); - border-color: rgba(191, 219, 254, 0.9); - color: #0284c7; - transform: translateY(-0.5px); - box-shadow: 0 3px 8px rgba(15, 23, 42, 0.08); - } - - &:active { - transform: scale(0.96); - } - - &.active { - color: #0284c7; - background: rgba(224, 242, 254, 0.85); - border-color: rgba(56, 189, 248, 0.8); - box-shadow: 0 2px 6px rgba(2, 132, 199, 0.15); - } - - &.play-btn { - background: linear-gradient(135deg, rgba(2, 132, 199, 0.92), rgba(37, 99, 235, 0.88)); - backdrop-filter: blur(14px) saturate(180%); - -webkit-backdrop-filter: blur(14px) saturate(180%); - border: 1px solid rgba(255, 255, 255, 0.35); - color: #ffffff; - width: 36px; - height: 36px; - border-radius: 50%; - box-shadow: 0 4px 14px rgba(2, 132, 199, 0.35), inset 0 1px 1px rgba(255, 255, 255, 0.5); - - &:hover { - background: linear-gradient(135deg, rgba(2, 132, 199, 1), rgba(37, 99, 235, 0.95)); - box-shadow: 0 6px 18px rgba(2, 132, 199, 0.45), inset 0 1px 1px rgba(255, 255, 255, 0.6); - transform: translateY(-1px) scale(1.04); - } - - &:active { - transform: scale(0.96); - } - - .material-symbols-outlined { - font-size: 22px; - color: #ffffff; - } - } - - &.speed-pill-btn { - width: auto; - min-width: 34px; - padding: 0 7px; - font-size: 11px; - font-weight: 700; - font-family: 'JetBrains Mono', 'Roboto Mono', monospace; - color: #334155; - letter-spacing: -0.2px; - - &:hover { - color: #0284c7; - border-color: rgba(191, 219, 254, 0.9); - } - } - - .material-symbols-outlined { - font-size: 18px; - color: #1e293b; - } - } - - .time-display { - font-size: 12px; - font-weight: 700; - font-family: 'JetBrains Mono', 'Roboto Mono', monospace; - color: #1e293b; - margin-left: 4px; - letter-spacing: 0.5px; - } - } - } - - /* --- Clean, Minimal High-Contrast Empty State --- */ - .empty-video-fallback { - background: #ffffff; - padding: 36px 20px; - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - box-sizing: border-box; - width: 100%; - - .fallback-icon-wrap { - width: 48px; - height: 48px; - border-radius: 50%; - background: #fef2f2; - border: 1px solid #fee2e2; - display: flex; - align-items: center; - justify-content: center; - margin-bottom: 12px; - - .fallback-icon { - font-size: 24px; - color: #ef4444; - } - } - - .fallback-title { - margin: 0 0 6px 0; - font-size: 13.5px; - font-weight: 600; - color: #0f172a; - } - - .fallback-desc { - margin: 0 0 12px 0; - font-size: 12px; - color: #64748b; - line-height: 1.4; - max-width: 240px; - } - - .retry-btn { - display: inline-flex; - align-items: center; - gap: 6px; - background: #0284c7; - color: #ffffff; - border: none; - border-radius: 6px; - padding: 5px 12px; - font-size: 11.5px; - font-weight: 600; - cursor: pointer; - transition: all 0.15s ease; - - &:hover { - background: #0369a1; - transform: translateY(-1px); - } - - .material-symbols-outlined { - font-size: 15px; - } - } - } - } -} - -@keyframes popIn { - from { - opacity: 0; - transform: scale(0.96) translateY(6px); - } - to { - opacity: 1; - transform: scale(1) translateY(0); - } -} - -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -@keyframes pulseRed { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } -} - -::ng-deep { - .action-point-marker { - z-index: 10; - pointer-events: none; - } - @keyframes pulse-animation { - 0% { - transform: scale(0.6); - opacity: 1; - } - 100% { - transform: scale(2.2); - opacity: 0; - } - } -} diff --git a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts b/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts deleted file mode 100644 index 6b140cec3..000000000 --- a/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts +++ /dev/null @@ -1,631 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - Component, - ChangeDetectionStrategy, - NgZone, - inject, - signal, - computed, - ElementRef, - ViewChild, - HostListener, - effect, - OnDestroy -} from '@angular/core'; - -import { FormsModule } from '@angular/forms'; -import { AgentService } from '../../services/agent.service'; -import { StepReplayFrame } from '../../core/models/stream.model'; -import { drawActionCoordinatesOnOverlay } from '../../utils/image-overlay.util'; -import { getActionIcon } from '../../utils/action-formatter.util'; -import { locateTimelineTime, sessionTimeToTimelineTime } from '../../utils/recording-timeline.util'; - -@Component({ - selector: 'app-floating-video-player', - standalone: true, - imports: [FormsModule], - templateUrl: './floating-video-player.component.html', - styleUrl: './floating-video-player.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class FloatingVideoPlayerComponent implements OnDestroy { - public agentService = inject(AgentService); - private zone = inject(NgZone); - - @ViewChild('videoRef') videoRef?: ElementRef; - @ViewChild('stepImgRef') stepImgRef?: ElementRef; - @ViewChild('stepOverlayRef') stepOverlayRef?: ElementRef; - - // Playback state signals - public isPlaying = signal(false); - public playbackRate = signal(1.0); - public isLooping = signal(false); - public currentTime = signal(0); - public duration = signal(0); - public isMuted = signal(false); - public isTheaterMode = signal(false); - public videoLoadError = signal(false); - public liveStreamUrl = signal('/api/stream/device-live'); - public liveStreamError = signal(false); - public activeSegmentIndex = signal(0); - public currentVideoUrl = computed(() => { - const segments = this.agentService.activeVideoSegments(); - return segments[this.activeSegmentIndex()]?.url || this.agentService.activeVideoUrl(); - }); - private pendingLocalTime: number | null = null; - private pendingAutoplay = false; - private pendingAbsoluteSeek: number | null = null; - - // Step Replay state signals - public activeStepIndex = signal(0); - public isStepPlaying = signal(false); - public viewMode = signal<'pre' | 'post'>('pre'); // 'pre' = action preview with coords, 'post' = action result - public isCardHovered = signal(false); - public isCardPinned = signal(false); - public isCardVisible = computed(() => this.isCardHovered() || this.isCardPinned()); - public isStepImageLoading = signal(false); - public stepImageError = signal(false); - private stepTimer: any = null; - - public stepFrames = computed(() => this.agentService.currentSessionStepFrames()); - public totalStepFrames = computed(() => this.stepFrames().length); - public currentStepFrame = computed(() => { - const frames = this.stepFrames(); - const idx = this.activeStepIndex(); - return frames[idx] || null; - }); - public currentStepImageUrl = computed(() => { - const frame = this.currentStepFrame(); - if (!frame) return ''; - if (this.viewMode() === 'post' && frame.postImageUrl) { - return frame.postImageUrl; - } - return frame.preImageUrl || frame.imageUrl || ''; - }); - public stepProgressPercent = computed(() => { - const total = this.totalStepFrames(); - if (total <= 1) return 0; - return (this.activeStepIndex() / (total - 1)) * 100; - }); - - // Available speed options - public speedOptions = [0.5, 1.0, 1.5, 2.0, 4.0]; - - // Dragging and window positioning state - public posX = signal( - typeof window !== 'undefined' ? Math.max(20, window.innerWidth - 420) : 100 - ); - public posY = signal( - typeof window !== 'undefined' ? Math.max(60, window.innerHeight - 700) : 100 - ); - - private isDragging = false; - private dragStartX = 0; - private dragStartY = 0; - private initialPosX = 0; - private initialPosY = 0; - - constructor() { - // Reset video load error whenever active video URL changes - effect( - () => { - const url = this.agentService.activeVideoUrl(); - const segments = this.agentService.activeVideoSegments(); - this.videoLoadError.set(false); - this.activeSegmentIndex.set(0); - this.currentTime.set(0); - this.duration.set(segments.reduce((sum, segment) => sum + segment.duration, 0)); - this.isPlaying.set(false); - }, - { allowSignalWrites: true } - ); - - // Reset step playback state only when session actually changes - let lastTrackedSessionId: string | null = null; - effect( - () => { - const curSessionId = this.agentService.currentSessionId(); - const total = this.totalStepFrames(); - if (curSessionId !== lastTrackedSessionId) { - lastTrackedSessionId = curSessionId; - this.activeStepIndex.set(0); - this.viewMode.set('pre'); - this.pauseStepPlay(); - } else if (total > 0 && this.activeStepIndex() >= total) { - this.activeStepIndex.set(total - 1); - } - }, - { allowSignalWrites: true } - ); - - // Track step screenshot URL changes and reset loading/error state - effect(() => { - const url = this.currentStepImageUrl(); - if (url) { - this.isStepImageLoading.set(true); - this.stepImageError.set(false); - } else { - this.isStepImageLoading.set(false); - } - }, { allowSignalWrites: true }); - - // Re-draw overlay whenever activeStepIndex or viewMode changes - effect(() => { - this.activeStepIndex(); - this.viewMode(); - setTimeout(() => this.drawStepOverlay(), 40); - }); - - effect(() => { - const request = this.agentService.videoSeekRequest(); - if (!request) return; - this.pendingAbsoluteSeek = request.seconds; - if (this.agentService.recordingPlaybackStatus() !== 'ready') return; - const video = this.videoRef?.nativeElement; - if (video && video.readyState >= 1) { - const target = this.pendingAbsoluteSeek; - this.pendingAbsoluteSeek = null; - this.seekToSessionTime(target, true); - } - }); - - effect(() => { - const request = this.agentService.stepSeekRequest(); - if (!request) return; - this.seekStepFrame(request.index); - }); - } - - public onLiveStreamError(): void { - this.liveStreamError.set(true); - } - - public retryLiveStream(): void { - this.liveStreamError.set(false); - this.liveStreamUrl.set(`/api/stream/device-live?t=${Date.now()}`); - } - - /** - * Format seconds into mm:ss format - */ - public formatTime(seconds: number): string { - if (isNaN(seconds) || seconds < 0) return '00:00'; - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - const mStr = mins < 10 ? '0' + mins : String(mins); - const sStr = secs < 10 ? '0' + secs : String(secs); - return `${mStr}:${sStr}`; - } - - /** - * Progress percentage for timeline scrubber (0 - 100) - */ - public progressPercent = computed(() => { - const dur = this.duration(); - if (dur <= 0) return 0; - return Math.min(100, Math.max(0, (this.currentTime() / dur) * 100)); - }); - - /** - * Start dragging the window from header. The move/up listeners are attached - * only for the duration of the drag and live outside the Angular zone, so - * ordinary mouse movement over the page never touches change detection. - */ - public startDrag(event: MouseEvent): void { - if (this.isTheaterMode()) return; - this.isDragging = true; - this.dragStartX = event.clientX; - this.dragStartY = event.clientY; - this.initialPosX = this.posX(); - this.initialPosY = this.posY(); - event.preventDefault(); - - this.zone.runOutsideAngular(() => { - document.addEventListener('mousemove', this.onDrag); - document.addEventListener('mouseup', this.stopDrag); - }); - } - - private onDrag = (event: MouseEvent): void => { - if (!this.isDragging) return; - const deltaX = event.clientX - this.dragStartX; - const deltaY = event.clientY - this.dragStartY; - - const minX = 10; - const minY = 10; - const maxX = Math.max(10, window.innerWidth - 240); - const maxY = Math.max(10, window.innerHeight - 80); - - const targetX = Math.max(minX, Math.min(maxX, this.initialPosX + deltaX)); - const targetY = Math.max(minY, Math.min(maxY, this.initialPosY + deltaY)); - - this.posX.set(targetX); - this.posY.set(targetY); - }; - - private stopDrag = (): void => { - this.isDragging = false; - document.removeEventListener('mousemove', this.onDrag); - document.removeEventListener('mouseup', this.stopDrag); - }; - - @HostListener('window:resize') - public onResize(): void { - // Keep window within viewport bounds if window resized - const maxX = Math.max(10, window.innerWidth - 240); - const maxY = Math.max(10, window.innerHeight - 80); - if (this.posX() > maxX) this.posX.set(maxX); - if (this.posY() > maxY) this.posY.set(maxY); - } - - @HostListener('window:keydown', ['$event']) - public onKeyDown(event: KeyboardEvent): void { - if (!this.agentService.isVideoWindowOpen()) return; - - if (event.key === 'Escape') { - if (this.isTheaterMode()) { - this.isTheaterMode.set(false); - } else { - this.agentService.closeVideoPlayer(); - } - } - } - - /** - * Video element event handlers - */ - public onLoadedMetadata(): void { - const v = this.videoRef?.nativeElement; - if (v) { - const segments = this.agentService.activeVideoSegments(); - if (!segments.length) { - this.duration.set(v.duration || 0); - } - this.videoLoadError.set(false); - v.playbackRate = this.playbackRate(); - v.loop = this.isLooping() && !segments.length; - v.muted = this.isMuted(); - if (this.pendingAbsoluteSeek !== null) { - const target = this.pendingAbsoluteSeek; - this.pendingAbsoluteSeek = null; - this.seekToSessionTime(target, true); - // A seek into another finalized segment changes the video source. Let - // that segment's metadata event apply pendingLocalTime. - if (this.pendingLocalTime !== null) return; - } - if (this.pendingLocalTime !== null) { - v.currentTime = Math.max(0, Math.min(v.duration || this.pendingLocalTime, this.pendingLocalTime)); - this.pendingLocalTime = null; - } - if (this.pendingAutoplay) { - this.pendingAutoplay = false; - v.play().catch(() => {}); - } - if (this.agentService.consumeVideoAutoplay()) { - this.isMuted.set(true); - v.muted = true; - v.play().catch(() => { - this.isPlaying.set(false); - }); - } - } - } - - public onTimeUpdate(): void { - const v = this.videoRef?.nativeElement; - if (v) { - const segments = this.agentService.activeVideoSegments(); - const segment = segments[this.activeSegmentIndex()]; - this.currentTime.set((segment?.start || 0) + v.currentTime); - if (!segments.length && v.duration && v.duration !== this.duration()) { - this.duration.set(v.duration); - } - } - } - - public onPlay(): void { - this.isPlaying.set(true); - } - - public onPause(): void { - this.isPlaying.set(false); - } - - public onEnded(): void { - const segments = this.agentService.activeVideoSegments(); - if (segments.length && this.activeSegmentIndex() < segments.length - 1) { - this.pendingLocalTime = 0; - this.pendingAutoplay = true; - this.activeSegmentIndex.update(index => index + 1); - return; - } - if (segments.length && this.isLooping()) { - this.seek(0, true); - return; - } - this.isPlaying.set(false); - } - - public onError(): void { - this.videoLoadError.set(true); - this.isPlaying.set(false); - } - - /** - * Playback actions - */ - public togglePlay(): void { - const v = this.videoRef?.nativeElement; - if (!v) return; - if (v.paused) { - v.play().catch(() => {}); - } else { - v.pause(); - } - } - - public restart(): void { - this.seek(0, true); - } - - /** - * Seek to a session-relative time (seconds since the task session started, - * the axis step timestamps and analyzer ranges use). Manifest v2 segments - * carry real session offsets, so the time lands in the right segment even - * across scrcpy restart gaps; legacy playlists fall back to the timeline axis. - */ - public seekToSessionTime(sessionSeconds: number, autoplay = false): void { - const segments = this.agentService.activeVideoSegments(); - this.seek(sessionTimeToTimelineTime(segments, sessionSeconds), autoplay); - } - - /** Seek on the back-to-back playlist axis used by the scrubber. */ - public seek(seconds: number, autoplay = false): void { - const v = this.videoRef?.nativeElement; - if (!v) return; - const target = Math.max(0, Math.min(this.duration(), seconds)); - const segments = this.agentService.activeVideoSegments(); - const location = locateTimelineTime(segments, target); - if (!location) { - v.currentTime = target; - if (autoplay) v.play().catch(() => {}); - return; - } - const { index, localTime } = location; - if (index === this.activeSegmentIndex()) { - v.currentTime = localTime; - if (autoplay) v.play().catch(() => {}); - } else { - this.pendingLocalTime = localTime; - this.pendingAutoplay = autoplay || !v.paused; - this.activeSegmentIndex.set(index); - } - this.currentTime.set(target); - } - - public onScrub(event: Event): void { - const input = event.target as HTMLInputElement; - const targetTime = parseFloat(input.value); - this.seek(targetTime); - } - - public setSpeed(rate: number): void { - this.playbackRate.set(rate); - const v = this.videoRef?.nativeElement; - if (v) { - v.playbackRate = rate; - } - if (this.isStepPlaying()) { - this.scheduleNextStepFrame(); - } - } - - public toggleLoop(): void { - const next = !this.isLooping(); - this.isLooping.set(next); - const v = this.videoRef?.nativeElement; - if (v) { - v.loop = next && !this.agentService.activeVideoSegments().length; - } - } - - public toggleMute(): void { - const next = !this.isMuted(); - this.isMuted.set(next); - const v = this.videoRef?.nativeElement; - if (v) { - v.muted = next; - } - } - - public toggleTheater(): void { - this.isTheaterMode.set(!this.isTheaterMode()); - } - - public toggleMinimize(): void { - this.agentService.isVideoMinimized.set(!this.agentService.isVideoMinimized()); - } - - public openInNewTab(): void { - const url = this.agentService.activeVideoUrl(); - if (url) { - window.open(url, '_blank'); - } - } - - /** - * Step Replay Controls - */ - public toggleStepPlay(): void { - if (this.isStepPlaying()) { - this.pauseStepPlay(); - } else { - this.startStepPlay(); - } - } - - public startStepPlay(): void { - if (this.totalStepFrames() <= 0) return; - if (this.activeStepIndex() >= this.totalStepFrames() - 1) { - this.activeStepIndex.set(0); - } - this.isStepPlaying.set(true); - this.scheduleNextStepFrame(); - } - - public pauseStepPlay(): void { - this.isStepPlaying.set(false); - if (this.stepTimer) { - clearTimeout(this.stepTimer); - this.stepTimer = null; - } - } - - private scheduleNextStepFrame(): void { - if (this.stepTimer) clearTimeout(this.stepTimer); - const delay = Math.max(300, 1500 / this.playbackRate()); - this.stepTimer = setTimeout(() => { - if (!this.isStepPlaying()) return; - if (this.activeStepIndex() < this.totalStepFrames() - 1) { - this.activeStepIndex.update((i) => i + 1); - this.scheduleNextStepFrame(); - } else { - if (this.isLooping()) { - this.activeStepIndex.set(0); - this.scheduleNextStepFrame(); - } else { - this.pauseStepPlay(); - } - } - }, delay); - } - - public prevStepFrame(): void { - this.pauseStepPlay(); - this.viewMode.set('pre'); - this.activeStepIndex.update((i) => Math.max(0, i - 1)); - } - - public nextStepFrame(): void { - this.pauseStepPlay(); - this.viewMode.set('pre'); - this.activeStepIndex.update((i) => Math.min(this.totalStepFrames() - 1, i + 1)); - } - - public seekStepFrame(index: number): void { - this.viewMode.set('pre'); - this.activeStepIndex.set(Math.max(0, Math.min(this.totalStepFrames() - 1, index))); - } - - public onStepScrub(event: Event): void { - const input = event.target as HTMLInputElement; - this.seekStepFrame(parseInt(input.value, 10)); - } - - public restartStep(): void { - this.seekStepFrame(0); - this.startStepPlay(); - } - - public setStepViewMode(mode: 'pre' | 'post', event?: Event): void { - if (event) event.stopPropagation(); - this.viewMode.set(mode); - setTimeout(() => this.drawStepOverlay(), 40); - } - - public toggleStepViewMode(event?: Event): void { - if (event) event.stopPropagation(); - this.setStepViewMode(this.viewMode() === 'pre' ? 'post' : 'pre'); - } - - public onViewportMouseEnter(): void { - this.isCardHovered.set(true); - } - - public onViewportMouseLeave(): void { - this.isCardHovered.set(false); - } - - public toggleCardPin(event?: Event): void { - if (event) event.stopPropagation(); - this.isCardPinned.update((v) => !v); - } - - public cycleSpeed(): void { - const current = this.playbackRate(); - const idx = this.speedOptions.indexOf(current); - const nextIdx = (idx + 1) % this.speedOptions.length; - this.setSpeed(this.speedOptions[nextIdx]); - } - - public onStepImageLoad(): void { - this.isStepImageLoading.set(false); - this.stepImageError.set(false); - this.drawStepOverlay(); - } - - public onStepImageError(): void { - this.isStepImageLoading.set(false); - this.stepImageError.set(true); - } - - public drawStepOverlay(): void { - const img = this.stepImgRef?.nativeElement; - const overlay = this.stepOverlayRef?.nativeElement; - if (!img || !overlay) return; - overlay.innerHTML = ''; - - // Only draw coordinate overlays when viewing the pre-action screenshot - if (this.viewMode() === 'post') { - return; - } - - const frame = this.currentStepFrame(); - if (!frame || !frame.action) return; - - try { - drawActionCoordinatesOnOverlay(img, overlay, frame.action); - } catch (err) { - console.warn('Failed to draw step coordinates overlay:', err); - } - } - - public getStepActionIcon(action: any): string { - return getActionIcon(action); - } - - public openImageInNewTab(): void { - const url = this.currentStepImageUrl() || this.currentStepFrame()?.imageUrl; - if (url) { - window.open(url, '_blank'); - } - } - - public trackStepFrame(index: number, frame: StepReplayFrame): string { - return frame.stepId || String(index); - } - - public ngOnDestroy(): void { - if (this.stepTimer) { - clearTimeout(this.stepTimer); - this.stepTimer = null; - } - document.removeEventListener('mousemove', this.onDrag); - document.removeEventListener('mouseup', this.stopDrag); - } -} diff --git a/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.html b/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.html deleted file mode 100644 index 44ec41208..000000000 --- a/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.html +++ /dev/null @@ -1,130 +0,0 @@ - - -
- -
-
-

👣 Real-time Agent Stream (Legacy Card View)

-

Select a session to monitor raw agent activity

-
- -
- - - -
-
- - -
- @if (agentService.sessionLogs().length === 0) { -
-
📡
-

No Stream Activity Selected

-

Please select a running session from the dropdown above to view real-time log payloads.

-
- } - - @if (agentService.sessionLogs().length > 0) { -
- @for (log of agentService.sessionLogs(); track log; let i = $index) { -
-
-
- #{{ i + 1 }} - {{ log.type }} -
- {{ log.timestamp | date:'HH:mm:ss.SSS' }} -
- - @if (isStepEvent(log.type)) { -
- - @if (log.data.summary) { -
-
💬 Message to User / Summary:
-
{{ log.data.summary }}
-
- } - - @if (log.data.operator_native_thinking) { -
- 🧠 Model Thinking Process (Click to expand) -
{{ log.data.operator_native_thinking }}
-
- } - - @if (log.data.operator_raw_thinking) { -
- 📝 Raw Plan / Thoughts (Click to expand) -
{{ log.data.operator_raw_thinking }}
-
- } - - @if (log.data.action_taken) { -
- 🛠️ Tool Call Details (Click to expand) -
-
{{ formatJson(log.data.action_taken) }}
-
-
- } - - @if (log.data.last_execution_result) { -
- ✓ Execution Result (Click to expand) -
-
{{ formatJson(log.data.last_execution_result) }}
-
-
- } -
- } - - @if (!isStepEvent(log.type)) { -
-
- 📄 Raw Payload Data (Click to expand) -
-
{{ formatJson(log.data) }}
-
-
-
- } -
- } -
- } -
-
diff --git a/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.scss b/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.scss deleted file mode 100644 index e1d13c873..000000000 --- a/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.scss +++ /dev/null @@ -1,406 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -$border-color: rgba(0, 0, 0, 0.08); -$text-primary: #1f2937; -$text-secondary: #4b5563; -$text-muted: #9ca3af; -$bg-main: #f9fafb; - -:host { - display: flex; - flex-direction: column; - height: 100%; - width: 100%; - overflow: hidden; -} - -.agent-stream-container { - display: flex; - flex-direction: column; - height: 100%; - background: rgba(255, 255, 255, 0.4); - backdrop-filter: blur(16px); - box-sizing: border-box; - - .stream-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 20px 24px; - border-bottom: 1px solid $border-color; - background: rgba(255, 255, 255, 0.6); - flex-wrap: wrap; - gap: 16px; - - .header-title { - h2 { - margin: 0 0 4px 0; - font-size: 20px; - font-weight: 700; - color: $text-primary; - letter-spacing: -0.5px; - } - .subtitle { - margin: 0; - font-size: 13px; - color: $text-secondary; - } - } - - .session-selector-wrapper { - display: flex; - align-items: center; - gap: 12px; - - label { - font-size: 13.5px; - font-weight: 600; - color: $text-primary; - } - - .session-dropdown { - padding: 8px 12px; - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 8px; - font-size: 13.5px; - background-color: #ffffff; - outline: none; - color: $text-primary; - min-width: 250px; - max-width: 400px; - cursor: pointer; - transition: border-color 0.2s; - - &:focus { - border-color: #3b82f6; - } - } - - .btn-refresh { - padding: 8px 14px; - background: #ffffff; - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 8px; - font-size: 13px; - font-weight: 600; - color: $text-primary; - cursor: pointer; - display: flex; - align-items: center; - gap: 6px; - transition: background 0.2s, transform 0.1s; - - &:hover { - background: #f3f4f6; - } - - &:active { - transform: scale(0.98); - } - } - } - } - - .stream-logs-content { - flex: 1; - overflow-y: auto; - padding: 24px; - background: rgba(249, 250, 251, 0.3); - - &::-webkit-scrollbar { - width: 6px; - } - &::-webkit-scrollbar-track { - background: transparent; - } - &::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.15); - border-radius: 10px; - } - &::-webkit-scrollbar-thumb:hover { - background: rgba(0, 0, 0, 0.25); - } - - .empty-state { - height: 100%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - text-align: center; - padding: 40px; - - .empty-icon { - font-size: 48px; - margin-bottom: 16px; - opacity: 0.8; - } - - h3 { - margin: 0 0 8px 0; - font-size: 18px; - font-weight: 600; - color: $text-primary; - } - - p { - margin: 0; - font-size: 14px; - color: $text-secondary; - max-width: 320px; - line-height: 1.5; - } - } - - .logs-list { - display: flex; - flex-direction: column; - gap: 16px; - - .log-card { - background: #ffffff; - border: 1px solid rgba(0, 0, 0, 0.06); - border-radius: 12px; - padding: 16px 20px; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.01), 0 2px 4px -1px rgba(0, 0, 0, 0.006); - display: flex; - flex-direction: column; - gap: 12px; - transition: transform 0.2s, box-shadow 0.2s; - - &:hover { - transform: translateY(-1px); - box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.02), 0 4px 6px -2px rgba(0, 0, 0, 0.01); - } - - .log-card-header { - display: flex; - justify-content: space-between; - align-items: center; - - .badge-group { - display: flex; - align-items: center; - gap: 8px; - - .index-badge { - font-size: 11px; - font-weight: 700; - color: $text-muted; - font-family: monospace; - } - - .type-badge { - font-size: 10px; - font-weight: 800; - text-transform: uppercase; - padding: 2.5px 7px; - border-radius: 9999px; - letter-spacing: 0.5px; - font-family: monospace; - - &.llm_stream { - background: #e0f2fe; - color: #0369a1; - } - &.trace_recorded { - background: #f3e8ff; - color: #6b21a8; - } - &.step_recorded { - background: #ecfdf5; - color: #047857; - } - &.step_updated { - background: #fef3c7; - color: #b45309; - } - &.background_tasks_updated { - background: #fff1f2; - color: #be123c; - } - } - } - - .timestamp { - font-size: 11.5px; - color: $text-muted; - font-family: monospace; - } - } - - .log-card-body { - &.step-body { - display: flex; - flex-direction: column; - gap: 16px; - - .step-summary-section { - .section-title { - font-size: 11px; - font-weight: 700; - color: $text-secondary; - margin-bottom: 6px; - text-transform: uppercase; - letter-spacing: 0.5px; - } - - .summary-bubble { - background: #f1f5f9; - border: 1px solid rgba(0, 0, 0, 0.05); - border-left: 4px solid #475569; - border-radius: 8px; - padding: 12px 16px; - font-size: 13.5px; - line-height: 1.5; - color: #1e293b; - } - } - - details { - border: 1px solid rgba(0, 0, 0, 0.06); - border-radius: 8px; - background: #fafafa; - overflow: hidden; - transition: border-color 0.2s; - - &[open] { - border-color: rgba(0, 0, 0, 0.12); - .details-summary { - border-bottom: 1px solid rgba(0, 0, 0, 0.06); - background: rgba(0, 0, 0, 0.02); - } - } - - .details-summary { - padding: 10px 14px; - font-size: 12.5px; - font-weight: 700; - color: $text-secondary; - cursor: pointer; - user-select: none; - outline: none; - background: #ffffff; - transition: background 0.2s; - - &:hover { - background: rgba(0, 0, 0, 0.01); - } - } - - .details-content { - padding: 14px; - font-size: 13px; - line-height: 1.6; - color: #334155; - - &.thinking-text { - background: #f0fdf4; - color: #14532d; - white-space: pre-wrap; - font-family: inherit; - } - - &.planning-text { - background: #f8fafc; - color: #334155; - white-space: pre-wrap; - font-family: inherit; - } - } - - &.thinking-details { - border-left: 4px solid #16a34a; - .details-summary { - color: #16a34a; - } - } - - &.planning-details { - border-left: 4px solid #4f46e5; - .details-summary { - color: #4f46e5; - } - } - - &.tool-details { - border-left: 4px solid #2563eb; - .details-summary { - color: #2563eb; - } - } - - &.result-details { - border-left: 4px solid #0891b2; - .details-summary { - color: #0891b2; - } - } - } - } - - &.raw-body { - details { - border: 1px solid rgba(0, 0, 0, 0.06); - border-radius: 8px; - overflow: hidden; - - .details-summary { - padding: 10px 14px; - font-size: 12px; - font-weight: 600; - color: $text-secondary; - cursor: pointer; - outline: none; - background: #ffffff; - } - - .details-content { - padding: 12px; - } - } - } - - .json-code { - margin: 0; - background: #1e293b; - color: #f8fafc; - padding: 14px 18px; - border-radius: 8px; - font-family: 'Fira Code', 'Roboto Mono', monospace; - font-size: 12.5px; - line-height: 1.5; - overflow-x: auto; - white-space: pre-wrap; - word-break: break-all; - - &::-webkit-scrollbar { - height: 4px; - } - &::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.2); - border-radius: 4px; - } - &::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.3); - } - } - } - } - } - } -} diff --git a/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.ts b/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.ts deleted file mode 100644 index 025eadc83..000000000 --- a/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, inject, ChangeDetectionStrategy } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { AgentService } from '../../services/agent.service'; - -@Component({ - selector: 'app-legacy-agent-stream', - standalone: true, - imports: [CommonModule, FormsModule], - templateUrl: './legacy-agent-stream.component.html', - changeDetection: ChangeDetectionStrategy.Eager, - styleUrl: './legacy-agent-stream.component.scss' -}) -export class LegacyAgentStreamComponent { - public agentService = inject(AgentService); - - /** - * Handle dropdown session selection change - */ - public onSessionChange(event: Event): void { - const selectElement = event.target as HTMLSelectElement; - const sessionId = selectElement.value; - if (sessionId) { - this.agentService.selectSession(sessionId, true); - } - } - - /** - * Check if the event type represents a structured step record - */ - public isStepEvent(eventType: string): boolean { - return eventType === 'step_recorded' || eventType === 'step_updated'; - } - - /** - * Determine if the content is short enough to remain open by default - */ - public isShortContent(data: any): boolean { - if (!data) return true; - const str = typeof data === 'string' ? data : JSON.stringify(data); - return str.length < 200; - } - - /** - * Format JSON data for display in HTML pre tag - */ - public formatJson(data: any): string { - if (typeof data === 'string') { - try { - return JSON.stringify(JSON.parse(data), null, 2); - } catch (e) { - return data; - } - } - return JSON.stringify(data, null, 2); - } -} diff --git a/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.scss b/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.scss deleted file mode 100644 index 8a7541bf7..000000000 --- a/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.scss +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -.floating-nav-switcher { - position: fixed; - top: 12px; - left: 24px; - z-index: 1100; - height: 36px; - box-sizing: border-box; - display: inline-flex; - align-items: center; - background: rgba(255, 255, 255, 0.72); - backdrop-filter: blur(16px) saturate(180%); - -webkit-backdrop-filter: blur(16px) saturate(180%); - border: 1px solid rgba(226, 232, 240, 0.85); - border-radius: 18px; - padding: 3px; - gap: 3px; - box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(0, 0, 0, 0.02), inset 0 1px 1px rgba(255, 255, 255, 0.9); - user-select: none; - font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - transition: background 0.25s cubic-bezier(0.4, 0, 0.2, 1), - box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1), - border-color 0.25s cubic-bezier(0.4, 0, 0.2, 1); - - &:hover { - background: rgba(255, 255, 255, 0.85); - box-shadow: 0 4px 14px -2px rgba(15, 23, 42, 0.1), 0 2px 4px rgba(0, 0, 0, 0.03), inset 0 1px 1px rgba(255, 255, 255, 1); - border-color: rgba(203, 213, 225, 0.9); - } - - .nav-tab-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - height: 30px; - padding: 0 14px; - box-sizing: border-box; - border-radius: 15px; - font-size: 13px; - font-weight: 600; - color: #475569; - text-decoration: none; - cursor: pointer; - line-height: 1; - transition: background 0.2s cubic-bezier(0.4, 0, 0.2, 1), - color 0.2s cubic-bezier(0.4, 0, 0.2, 1), - box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1), - transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); - outline: none; - white-space: nowrap; - - .tab-icon { - font-size: 16px; - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - color: #64748b; - transition: color 0.15s ease, transform 0.15s ease; - } - - .tab-label { - color: #475569; - transition: color 0.15s ease; - } - - &:hover:not(.active) { - background: rgba(255, 255, 255, 0.7); - color: #0f172a; - - .tab-icon { - color: #1a73e8; - } - .tab-label { - color: #0f172a; - } - } - - &.active { - background: rgba(26, 115, 232, 0.95) !important; - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - color: #ffffff !important; - font-weight: 700; - box-shadow: 0 2px 8px rgba(26, 115, 232, 0.35), inset 0 1px 1px rgba(255, 255, 255, 0.35); - - .tab-icon, - .tab-label, - span { - color: #ffffff !important; - } - } - - &:active { - transform: scale(0.97); - } - } -} diff --git a/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.ts b/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.ts deleted file mode 100644 index 866f0ed12..000000000 --- a/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, ChangeDetectionStrategy } from '@angular/core'; - -import { RouterLink, RouterLinkActive } from '@angular/router'; - -@Component({ - selector: 'app-nav-switcher', - standalone: true, - imports: [RouterLink, RouterLinkActive], - template: ` - - `, - changeDetection: ChangeDetectionStrategy.Eager, - styleUrls: ['./nav-switcher.component.scss'] -}) -export class NavSwitcherComponent {} diff --git a/apps/showcase_ui/src/app/core/data/smart-tasks.data.ts b/apps/showcase_ui/src/app/core/data/smart-tasks.data.ts deleted file mode 100644 index 015a1fbf8..000000000 --- a/apps/showcase_ui/src/app/core/data/smart-tasks.data.ts +++ /dev/null @@ -1,334 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export interface AppReference { - name: string; - icon: string; - pkg?: string; - category?: string; -} - -export type SuggestionCategory = - | 'all' - | 'flash' - | 'pro' - | 'cross_app' - | 'monitor'; - -export interface SmartSuggestion { - id: string; - title: string; - description: string; - goal: string; - profile: 'flash' | 'pro'; - category: 'flash' | 'pro' | 'cross_app' | 'monitor'; - tag: string; - apps: AppReference[]; - requiredPackages?: string[]; - matchMode?: 'any' | 'all'; - priority?: number; -} - -/** - * Recognized Android App Package Registry - */ -export const APP_REGISTRY: Record = { - // Google Suite & System - 'com.google.android.apps.maps': { name: 'Maps', icon: 'explore', pkg: 'com.google.android.apps.maps', category: 'navigation' }, - 'com.google.android.gm': { name: 'Gmail', icon: 'mail', pkg: 'com.google.android.gm', category: 'productivity' }, - 'com.android.chrome': { name: 'Chrome', icon: 'public', pkg: 'com.android.chrome', category: 'browser' }, - 'com.google.android.youtube': { name: 'YouTube', icon: 'smart_display', pkg: 'com.google.android.youtube', category: 'entertainment' }, - 'com.android.settings': { name: 'Settings', icon: 'settings', pkg: 'com.android.settings', category: 'system' }, - 'com.google.android.deskclock': { name: 'Clock', icon: 'timer', pkg: 'com.google.android.deskclock', category: 'utility' }, - 'com.android.deskclock': { name: 'Clock', icon: 'timer', pkg: 'com.android.deskclock', category: 'utility' }, - 'com.google.android.calculator': { name: 'Calculator', icon: 'calculate', pkg: 'com.google.android.calculator', category: 'utility' }, - 'com.android.calculator2': { name: 'Calculator', icon: 'calculate', pkg: 'com.android.calculator2', category: 'utility' }, - 'com.google.android.apps.photos': { name: 'Photos', icon: 'photo_library', pkg: 'com.google.android.apps.photos', category: 'media' }, - 'com.google.android.calendar': { name: 'Calendar', icon: 'calendar_month', pkg: 'com.google.android.calendar', category: 'productivity' }, - 'com.google.android.keep': { name: 'Keep Notes', icon: 'note_alt', pkg: 'com.google.android.keep', category: 'productivity' }, - 'com.android.vending': { name: 'Play Store', icon: 'storefront', pkg: 'com.android.vending', category: 'tools' }, - 'com.google.android.apps.messaging': { name: 'Messages', icon: 'chat', pkg: 'com.google.android.apps.messaging', category: 'communication' }, - - // Popular Ecosystem Apps - 'com.tencent.mm': { name: 'WeChat', icon: 'forum', pkg: 'com.tencent.mm', category: 'social' }, - 'com.xingin.xhs': { name: 'Xiaohongshu', icon: 'auto_stories', pkg: 'com.xingin.xhs', category: 'social' }, - 'com.sankuai.meituan': { name: 'Meituan', icon: 'restaurant', pkg: 'com.sankuai.meituan', category: 'lifestyle' }, - 'com.dianping.v1': { name: 'Dianping', icon: 'star', pkg: 'com.dianping.v1', category: 'lifestyle' }, - 'tv.danmaku.bili': { name: 'Bilibili', icon: 'video_library', pkg: 'tv.danmaku.bili', category: 'entertainment' }, - 'com.eg.android.AlipayGphone': { name: 'Alipay', icon: 'account_balance_wallet', pkg: 'com.eg.android.AlipayGphone', category: 'finance' }, - 'com.netease.cloudmusic': { name: 'NetEase Music', icon: 'headphones', pkg: 'com.netease.cloudmusic', category: 'entertainment' }, - 'com.spotify.music': { name: 'Spotify', icon: 'music_note', pkg: 'com.spotify.music', category: 'entertainment' } -}; - -/** - * Curated, clean task preset library (1~2 representative tasks per common app) - */ -export const SMART_TASK_LIBRARY: SmartSuggestion[] = [ - // 1. Google Maps - { - id: 'maps_coffee', - title: 'Find Specialty Coffee', - description: 'Search nearby top-rated cafes in Google Maps', - goal: 'Open Google Maps, search for top-rated specialty coffee shops nearby, and view the top result details.', - profile: 'flash', - category: 'flash', - tag: 'Maps', - apps: [{ name: 'Maps', icon: 'explore', pkg: 'com.google.android.apps.maps' }], - requiredPackages: ['com.google.android.apps.maps'], - priority: 95 - }, - { - id: 'pro_commute_share', - title: 'Commute ETA & Message Draft', - description: 'Check transit time on Maps and draft arrival ETA in Messages', - goal: 'Open Google Maps to check commute time to the International Airport, calculate arrival time, then open Messages and draft an ETA text message.', - profile: 'pro', - category: 'cross_app', - tag: 'Maps + Messages', - apps: [ - { name: 'Maps', icon: 'explore', pkg: 'com.google.android.apps.maps' }, - { name: 'Messages', icon: 'chat', pkg: 'com.google.android.apps.messaging' } - ], - requiredPackages: ['com.google.android.apps.maps', 'com.google.android.apps.messaging'], - matchMode: 'all', - priority: 92 - }, - - // 2. Gmail - { - id: 'gmail_receipts', - title: 'Search Order Receipts', - description: 'Find recent flight or delivery confirmation emails in Gmail', - goal: 'Open Gmail and search for recent flight or package delivery confirmation emails.', - profile: 'flash', - category: 'flash', - tag: 'Gmail', - apps: [{ name: 'Gmail', icon: 'mail', pkg: 'com.google.android.gm' }], - requiredPackages: ['com.google.android.gm'], - priority: 90 - }, - { - id: 'pro_email_to_calendar', - title: 'Email Itinerary to Calendar', - description: 'Extract flight or event dates from Gmail and schedule in Calendar', - goal: 'Open Gmail to find the latest event invitation or itinerary, extract dates and location, then open Google Calendar and create a corresponding calendar event.', - profile: 'pro', - category: 'cross_app', - tag: 'Gmail + Calendar', - apps: [ - { name: 'Gmail', icon: 'mail', pkg: 'com.google.android.gm' }, - { name: 'Calendar', icon: 'calendar_month', pkg: 'com.google.android.calendar' } - ], - requiredPackages: ['com.google.android.gm'], - priority: 94 - }, - - // 3. Chrome - { - id: 'chrome_research', - title: 'Search AI News Breakthroughs', - description: 'Search latest multimodal AI developments in Chrome browser', - goal: 'Open Chrome browser and search for latest breakthroughs in multimodal mobile AI agents.', - profile: 'flash', - category: 'flash', - tag: 'Chrome', - apps: [{ name: 'Chrome', icon: 'public', pkg: 'com.android.chrome' }], - requiredPackages: ['com.android.chrome'], - priority: 88 - }, - { - id: 'pro_research_keep', - title: 'Product Research & Notes Note', - description: 'Compare top 3 headphones on Chrome and record comparison in Keep', - goal: 'Open Chrome, research top 3 noise-cancelling headphones comparing price and battery life, then write a structured comparison summary note in Keep Notes.', - profile: 'pro', - category: 'pro', - tag: 'Chrome + Keep', - apps: [ - { name: 'Chrome', icon: 'public', pkg: 'com.android.chrome' }, - { name: 'Keep Notes', icon: 'note_alt', pkg: 'com.google.android.keep' } - ], - requiredPackages: ['com.android.chrome'], - priority: 91 - }, - - // 4. YouTube - { - id: 'youtube_lofi', - title: 'Play Lo-Fi Music Radio', - description: 'Search and play a Lo-Fi hip hop live stream on YouTube', - goal: 'Open YouTube, search for "Lofi hip hop beats relaxing radio" and tap on the live stream.', - profile: 'flash', - category: 'flash', - tag: 'YouTube', - apps: [{ name: 'YouTube', icon: 'smart_display', pkg: 'com.google.android.youtube' }], - requiredPackages: ['com.google.android.youtube'], - priority: 85 - }, - - // 5. Settings - { - id: 'settings_display_wifi', - title: 'Dark Mode & Wi-Fi Check', - description: 'Toggle dark theme and verify network connection in Settings', - goal: 'Open Settings app, navigate to Display settings, ensure Dark theme is enabled, and check Wi-Fi connection status.', - profile: 'flash', - category: 'flash', - tag: 'Settings', - apps: [{ name: 'Settings', icon: 'settings', pkg: 'com.android.settings' }], - requiredPackages: ['com.android.settings'], - priority: 87 - }, - { - id: 'pro_settings_qa', - title: 'Subsystem Health & Crash Probe', - description: 'Traverse Settings submenus to verify screens and check for crash dialogs', - goal: 'Explore Settings submenus (Network, Connected devices, Apps, Battery, Storage), verify each screen loads properly without ANR or crash dialogs, and summarize results.', - profile: 'pro', - category: 'monitor', - tag: 'Settings QA', - apps: [{ name: 'Settings', icon: 'settings', pkg: 'com.android.settings' }], - requiredPackages: ['com.android.settings'], - priority: 93 - }, - - // 6. Clock - { - id: 'clock_timer', - title: '25-Min Pomodoro Timer', - description: 'Start a 25-minute focus countdown timer in Clock app', - goal: 'Open Clock app, switch to Timer tab, set 25 minutes and start the countdown timer.', - profile: 'flash', - category: 'flash', - tag: 'Clock', - apps: [{ name: 'Clock', icon: 'timer', pkg: 'com.google.android.deskclock' }], - requiredPackages: ['com.google.android.deskclock', 'com.android.deskclock'], - priority: 86 - }, - - // 7. Calculator - { - id: 'calc_gratuity', - title: 'Split Bill & Calculate Tip', - description: 'Calculate 18% gratuity on $186.40 for 3 people in Calculator', - goal: 'Open Calculator and calculate 18% tip on a bill of $186.40, then divide by 3 people.', - profile: 'flash', - category: 'flash', - tag: 'Calculator', - apps: [{ name: 'Calculator', icon: 'calculate', pkg: 'com.google.android.calculator' }], - requiredPackages: ['com.google.android.calculator', 'com.android.calculator2'], - priority: 84 - }, - - // 8. Photos - { - id: 'photos_inspect', - title: 'Inspect Recent Screenshot', - description: 'Open Google Photos and review the latest screenshot taken', - goal: 'Open Google Photos and view the most recent screenshot in the screenshots album.', - profile: 'flash', - category: 'flash', - tag: 'Photos', - apps: [{ name: 'Photos', icon: 'photo_library', pkg: 'com.google.android.apps.photos' }], - requiredPackages: ['com.google.android.apps.photos'], - priority: 82 - }, - - // 9. WeChat - { - id: 'wechat_browse', - title: 'Check WeChat Messages', - description: 'Open WeChat and view top recent chat conversations', - goal: 'Open WeChat and view the top recent chat messages.', - profile: 'flash', - category: 'flash', - tag: 'WeChat', - apps: [{ name: 'WeChat', icon: 'forum', pkg: 'com.tencent.mm' }], - requiredPackages: ['com.tencent.mm'], - priority: 89 - }, - { - id: 'pro_wechat_to_calendar', - title: 'WeChat Notice to Calendar', - description: 'Extract meeting notice from WeChat chat and add to Calendar', - goal: 'Open WeChat, locate the latest meeting announcement or event message in the top chat, extract the time and topic, then open Calendar and schedule an event.', - profile: 'pro', - category: 'cross_app', - tag: 'WeChat + Calendar', - apps: [ - { name: 'WeChat', icon: 'forum', pkg: 'com.tencent.mm' }, - { name: 'Calendar', icon: 'calendar_month', pkg: 'com.google.android.calendar' } - ], - requiredPackages: ['com.tencent.mm'], - priority: 93 - }, - - // 10. Xiaohongshu - { - id: 'xhs_coffee_guide', - title: 'RED Cafe Guide Search', - description: 'Search trending specialty cafe reviews on Xiaohongshu', - goal: 'Open Xiaohongshu, search for top-rated specialty coffee shops, and view the top post.', - profile: 'flash', - category: 'flash', - tag: 'Xiaohongshu', - apps: [{ name: 'Xiaohongshu', icon: 'auto_stories', pkg: 'com.xingin.xhs' }], - requiredPackages: ['com.xingin.xhs'], - priority: 87 - }, - - // 11. Meituan / Dianping - { - id: 'meituan_ramen_search', - title: 'Meituan Food Search', - description: 'Search top-rated Ramen nearby on Meituan or Dianping', - goal: 'Open Meituan or Dianping, search for top-rated Ramen nearby, and view top restaurant rating.', - profile: 'flash', - category: 'flash', - tag: 'Meituan', - apps: [{ name: 'Meituan', icon: 'restaurant', pkg: 'com.sankuai.meituan' }], - requiredPackages: ['com.sankuai.meituan', 'com.dianping.v1'], - priority: 86 - }, - - // 12. Bilibili - { - id: 'bilibili_stream', - title: 'Bilibili Tech Video', - description: 'Search and play an AI Agent tutorial video on Bilibili', - goal: 'Open Bilibili, search for "AI Agent Architecture", and play the top matching video.', - profile: 'flash', - category: 'flash', - tag: 'Bilibili', - apps: [{ name: 'Bilibili', icon: 'video_library', pkg: 'tv.danmaku.bili' }], - requiredPackages: ['tv.danmaku.bili'], - priority: 85 - }, - - // 13. Play Store - { - id: 'pro_playstore_review', - title: 'Play Store App Review Study', - description: 'Compare top task management apps and ratings on Google Play', - goal: 'Open Google Play Store, search for top rated task management apps, compare ratings and latest user reviews of the top 2 candidates, and record recommendations.', - profile: 'pro', - category: 'pro', - tag: 'Play Store', - apps: [{ name: 'Play Store', icon: 'storefront', pkg: 'com.android.vending' }], - requiredPackages: ['com.android.vending'], - priority: 88 - } -]; diff --git a/apps/showcase_ui/src/app/core/models/markdown.model.ts b/apps/showcase_ui/src/app/core/models/markdown.model.ts deleted file mode 100644 index 10b9adddc..000000000 --- a/apps/showcase_ui/src/app/core/models/markdown.model.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export interface MarkdownSegment { - text: string; - bold: boolean; - code?: boolean; -} - -export interface MarkdownLine { - type: 'h1' | 'h2' | 'h3' | 'checked' | 'progress' | 'unchecked' | 'list-item' | 'verify' | 'assert' | 'finding' | 'text' | 'empty'; - segments: MarkdownSegment[]; - indent: number; - checkKind?: 'verify' | 'assert' | 'finding'; - atEnd?: boolean; -} - -export interface NoteMilestone { - index: number; - type: 'checked' | 'progress' | 'unchecked'; - segments: MarkdownSegment[]; - subSteps: MarkdownLine[]; - checks: MarkdownLine[]; -} - -export interface ParsedNote { - title: string | null; - milestones: NoteMilestone[]; - otherLines: MarkdownLine[]; -} diff --git a/apps/showcase_ui/src/app/core/models/pro-tuning.model.spec.ts b/apps/showcase_ui/src/app/core/models/pro-tuning.model.spec.ts deleted file mode 100644 index bb70e239b..000000000 --- a/apps/showcase_ui/src/app/core/models/pro-tuning.model.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - DEFAULT_EXPLORER_MODE, - DEFAULT_VERIFICATION_LEVEL, - EXPLORER_MODES, - VERIFICATION_LEVELS, - levelIndex, - notchPercent -} from './pro-tuning.model'; - -describe('pro tuning ladders', () => { - it('keeps every notch id and label unique within its ladder', () => { - for (const ladder of [VERIFICATION_LEVELS, EXPLORER_MODES]) { - expect(new Set(ladder.map((l) => l.id)).size).toBe(ladder.length); - expect(new Set(ladder.map((l) => l.label)).size).toBe(ladder.length); - } - }); - - it('mirrors the backend wire values', () => { - expect(VERIFICATION_LEVELS.map((l) => l.id)).toEqual(['off', 'final', 'checkpoints', 'strict']); - expect(EXPLORER_MODES.map((l) => l.id)).toEqual(['flash', 'pro', 'ultra']); - expect(VERIFICATION_LEVELS.map((l) => l.id)).toContain(DEFAULT_VERIFICATION_LEVEL); - expect(EXPLORER_MODES.map((l) => l.id)).toContain(DEFAULT_EXPLORER_MODE); - }); - - it('every level explains itself', () => { - for (const level of [...VERIFICATION_LEVELS, ...EXPLORER_MODES]) { - expect(level.tagline.length).toBeGreaterThan(10); - expect(level.latency.length).toBeGreaterThan(0); - expect(level.runs.length).toBeGreaterThan(0); - expect(level.bestFor.length).toBeGreaterThan(10); - } - }); - - it('resolves ids case-insensitively and falls back to the default', () => { - expect(levelIndex(VERIFICATION_LEVELS, ' STRICT ', DEFAULT_VERIFICATION_LEVEL)).toBe(3); - expect(levelIndex(VERIFICATION_LEVELS, 'nope', DEFAULT_VERIFICATION_LEVEL)).toBe(1); - expect(levelIndex(EXPLORER_MODES, null, DEFAULT_EXPLORER_MODE)).toBe(0); - }); - - it('maps notches onto the track', () => { - expect(notchPercent(0, 4)).toBe(0); - expect(notchPercent(3, 4)).toBe(100); - expect(notchPercent(1, 3)).toBe(50); - expect(notchPercent(9, 3)).toBe(100); - expect(notchPercent(0, 1)).toBe(0); - }); -}); diff --git a/apps/showcase_ui/src/app/core/models/pro-tuning.model.ts b/apps/showcase_ui/src/app/core/models/pro-tuning.model.ts deleted file mode 100644 index 9e57c3eef..000000000 --- a/apps/showcase_ui/src/app/core/models/pro-tuning.model.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Launcher options for `/api/run` (`verification_level`, `explorer_mode`). - * Keep ids in sync with `VERIFICATION_LEVEL_PRESETS` in `artemis/config/agent.py` - * and `EXPLORER_TIERS` in `artemis/agents/explorer/tiers.py`. - */ - -export type VerificationLevelId = 'off' | 'final' | 'checkpoints' | 'strict'; -export type ExplorerModeId = 'flash' | 'pro' | 'ultra'; - -/** One notch on a tuning slider. */ -export interface TuningLevel { - /** Wire value sent to the backend. */ - id: TId; - /** Short name shown next to the slider title and as the hover card heading. */ - label: string; - /** One-sentence summary shown in the hover card. */ - tagline: string; - /** Plain-language time cost, e.g. "no extra time". */ - latency: string; - /** Checks or searches performed at this level. */ - runs: string[]; - /** Checks or searches omitted at this level. */ - skips?: string[]; - /** When to pick this level. */ - bestFor: string; -} - -export const VERIFICATION_LEVELS: readonly TuningLevel[] = [ - { - id: 'off', - label: 'Off', - tagline: 'No checking. The run ends as soon as the task looks done.', - latency: 'no extra time', - runs: [ - 'Each step is treated as finished the moment it is carried out.', - 'You get the full action trace, but no pass / fail verdict.' - ], - skips: ['Nothing is double-checked and nothing is retried.'], - bestFor: 'Quick tries and demos, when you only want to watch what happens.' - }, - { - id: 'final', - label: 'At the end', - tagline: 'One check of the finished result against your goal. This is the default.', - latency: 'adds about 20–60 s at the end', - runs: [ - 'When the task finishes, the final screen, the step history and the device state are compared with what you asked for.', - 'If the result does not match, the task goes back and tries to fix it, up to 3 times.' - ], - skips: ['Nothing is checked while the task is still running.'], - bestFor: 'Everyday tasks: an honest pass / fail without slowing the run down.' - }, - { - id: 'checkpoints', - label: 'Every step', - tagline: 'Each step is checked as soon as it is done, plus the final check.', - latency: 'a short check after each step, done in the background', - runs: [ - 'Every step is checked right after it completes, using the screenshots from that moment.', - 'If a step went wrong, it gets fixed before moving on (up to 2 tries per step).', - 'A failed test condition is written down and the task keeps going.', - 'The final check still runs at the end.' - ], - bestFor: 'Long tasks where one early mistake would spoil everything after it.' - }, - { - id: 'strict', - label: 'Strict', - tagline: 'Every step is checked, with more retries. The first failed test stops the run.', - latency: 'slowest: more checks and more retries', - runs: [ - 'Each check takes longer and gets more attempts: 4 fixes per step and 5 at the end.', - 'The first failed test condition stops the run immediately, with the evidence attached.' - ], - bestFor: 'Release checks and regression runs, where a wrong pass is never acceptable.' - } -]; - -export const EXPLORER_MODES: readonly TuningLevel[] = [ - { - id: 'flash', - label: 'Quick glance', - tagline: 'Finds buttons and text on the screen in a single look.', - latency: '1 look per search', - runs: [ - 'Something on screen is asked for by name, icon or colour and its position comes back straight away.', - 'Several things can be looked up at once.' - ], - skips: ['No zooming in and no second try.'], - bestFor: 'Ordinary apps with clearly labelled buttons, icons and text.' - }, - { - id: 'pro', - label: 'Second look', - tagline: 'Takes up to 3 looks, thinking in between, before answering.', - latency: 'up to 3 looks per search', - runs: [ - 'The screen layout is read first, then the picture is searched.', - 'If the first try misses, a different approach is tried within the 3 looks.' - ], - skips: ['Still no zooming into small areas, to keep searches short.'], - bestFor: 'Things described by where they are ("the switch next to Wi-Fi") or with unclear labels.' - }, - { - id: 'ultra', - label: 'Close-up', - tagline: 'Zooms into parts of the screen and takes up to 8 looks.', - latency: 'up to 8 looks per search (slowest)', - runs: [ - 'Parts of the screen can be cropped and magnified to read tiny text and crowded layouts piece by piece.', - 'Later looks reuse the earlier ones, so they cost less time than they sound.' - ], - bestFor: 'Crowded screens, tiny targets, charts and drawings, and checks where exact placement matters.' - } -]; - -/** Per-run tuning sent with `/api/run` for the Pro profile. */ -export interface ProTuningOptions { - verificationLevel?: VerificationLevelId | string; - explorerMode?: ExplorerModeId | string; -} - -/** Effective defaults reported by `GET /api/run/defaults`. */ -export interface ProTuningDefaults { - verification_level?: string | null; - explorer_mode?: string | null; -} - -export const DEFAULT_VERIFICATION_LEVEL: VerificationLevelId = 'final'; -export const DEFAULT_EXPLORER_MODE: ExplorerModeId = 'flash'; - -/** Index of a level id within its ladder; falls back to the default when unknown. */ -export function levelIndex( - ladder: readonly TuningLevel[], - id: string | null | undefined, - fallback: TId -): number { - const wanted = String(id ?? '').trim().toLowerCase(); - const idx = ladder.findIndex((l) => l.id === wanted); - if (idx >= 0) return idx; - return Math.max(0, ladder.findIndex((l) => l.id === fallback)); -} - -/** Slider fill percentage for a notch index on a ladder of `count` notches. */ -export function notchPercent(index: number, count: number): number { - if (count <= 1) return 0; - const clamped = Math.min(Math.max(index, 0), count - 1); - return (clamped / (count - 1)) * 100; -} diff --git a/apps/showcase_ui/src/app/core/models/session.model.ts b/apps/showcase_ui/src/app/core/models/session.model.ts deleted file mode 100644 index ef322d1cf..000000000 --- a/apps/showcase_ui/src/app/core/models/session.model.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export interface ModelInfo { - name: string; - id: string; - provider: string; - architecture?: string; -} - -export interface TaskQueueItem { - session_id: string; - goal: string; - profile?: string; - status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled'; - created_at?: number; - start_time?: number; - device_serial?: string | null; - device_id?: string | null; -} - -export interface Session { - session_id: string; - initial_goal: string; - start_time: number; - end_time?: number; - status?: string; - video_url?: string; - recording_status?: 'recording' | 'finalizing' | 'processing' | 'ready' | 'failed' | 'unavailable'; - model_info?: ModelInfo; - device_serial?: string | null; - device_id?: string | null; - device_info?: any; -} - -export interface AgentStatusResponse { - status: 'idle' | 'running' | 'paused' | 'completed' | 'offline'; - session_id?: string | null; - goal?: string | null; - pid?: number | null; - queue?: (TaskQueueItem | string)[]; - background_tasks?: any[]; - model_info?: ModelInfo | null; - paused_error?: string | null; -} - -/** Per-run Pro tuning persisted with the session (`device_info.run_tuning`). */ -export interface SessionRunTuning { - verification_level?: string | null; - explorer_mode?: string | null; -} - -/** `GET /api/sessions/{id}/usage`: session-wide LLM usage and the live executor context. */ -export interface SessionUsage { - session_id: string; - llm_calls: number; - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; - cached_tokens: number; - /** Prompt size of the latest Operator / Flash runner call; null before the first call. */ - operator_context_tokens: number | null; - operator_context_window_tokens: number; - operator_context_updated_at?: number | null; - profile?: string | null; - run_tuning?: SessionRunTuning | null; -} - -export type AgentStatus = 'idle' | 'running' | 'completed' | 'offline' | string; - -export type TaskStatus = 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled'; - diff --git a/apps/showcase_ui/src/app/core/models/stream.model.ts b/apps/showcase_ui/src/app/core/models/stream.model.ts deleted file mode 100644 index 86645f094..000000000 --- a/apps/showcase_ui/src/app/core/models/stream.model.ts +++ /dev/null @@ -1,246 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export interface ActionParam { - key: string; - value: string; -} - -export interface ActionExecution { - id?: string; - trace_id?: string; - agent?: string; - agent_name?: string; - action: string; - name?: string; - args?: Record; - params?: Record; - payload?: any; - pre_image_name?: string | null; - post_image_name?: string | null; - status?: 'success' | 'failed' | 'error' | 'running' | string; - duration?: number; - duration_ms?: number; - error?: string | null; - timestamp?: number; -} - -export interface StepEvent { - type: 'thinking' | 'text' | 'action' | 'tool'; - data: any; - timestamp?: number; -} - -export interface LLMStreamEventData { - execution_id: string; - step_id?: string; - session_id?: string; - text?: string; - chunk?: string; - stream_type?: 'thinking' | 'text' | string; - isCompleted?: boolean; - isReset?: boolean; - resetMessage?: string; -} - -export const DEFAULT_STREAM_RESET_MESSAGE = - 'A request error occurred during output generation, typically caused by lower API priority. Retrying automatically...'; - -export interface StreamResetNotice { - id: string; - message: string; - isWaiting: boolean; - streamType: string; -} - -export interface LLMStreamResetEventData { - stream_exec_id?: string; - stream_execution_id?: string; - step_id?: string; - session_id?: string; - action?: 'discard' | string; - reason?: string; - category?: string; - error?: string; - message?: string; - retry_attempt?: number; - timestamp?: number; -} - -export interface StepItemData { - step_id: string; - step_type?: string; - step_number: number; - session_id: string; - timestamp: number; - operator_native_thinking?: string; - operator_raw_thinking?: string; - action_taken?: any; - generic_tools?: any[]; - last_execution_result?: any; - pre_image_name?: string; - post_image_name?: string; - extra_metadata?: any; - token_usage?: { - prompt_tokens?: number; - completion_tokens?: number; - total_tokens?: number; - }; - total_tokens?: number; - duration?: number; -} - -export interface StepBlock { - id: string; - type: 'llm_stream' | 'step' | 'checker'; - timestamp: string; - data: any; -} - -export interface PhaseBlock { - id: string; - durationSeconds: number; - tokens?: number; - promptTokens?: number; - completionTokens?: number; - blocks: StepBlock[]; -} - -/** One check item's verdict as booked by the Checker (ledger record shape). */ -export interface CheckerVerdict { - item_text: string; - kind: 'verify' | 'assert' | string; - status: 'passed' | 'failed' | 'inconclusive' | 'superseded' | 'unchecked' | string; - evidence: string; - suggestion?: string; -} - -export interface CheckerCheckItem { - kind: string; - text: string; - when?: string; -} - -/** - * One streamed LLM turn of a multi-turn agent (the Checker's tool loop): - * the text of one execution id with the time its first chunk arrived, so the - * timeline can interleave it with the tool calls that followed. Live sessions - * build these from `llm_stream` chunks; historical sessions rebuild them from - * the persisted transcript (`PersistedCheckerStream`). - */ -export interface StreamSegment { - execution_id: string; - stream_type: 'thinking' | 'text'; - text: string; - timestamp: string; - isCompleted?: boolean; - isReset?: boolean; - resetMessage?: string; -} - -/** One persisted segment of a Checker attempt's streamed reasoning (backend `check_streams.jsonl`). */ -export interface PersistedStreamSegment { - execution_id: string; - /** `thought` is the model's reasoning stream, `answer` its visible text. */ - role: 'thought' | 'answer' | string; - /** Unix seconds of the segment's first chunk. */ - when: number; - text: string; -} - -/** - * One Checker attempt's persisted transcript as served by - * `GET /api/sessions/{id}/checks` (`streams[]`). Text is bounded server-side; - * `truncated` says the middle was cut out. - */ -export interface PersistedCheckerStream { - attempt_id: string; - checkpoint_id?: string; - phase?: string; - trace_id?: string | null; - ts?: number; - truncated?: boolean; - dropped_chars?: number; - segments: PersistedStreamSegment[]; -} - -/** - * Data of a `checker` timeline block: one Checker attempt (a midway checkpoint - * of a completed subgoal or the exit final review), or the run outcome - * (`phase: 'outcome'`). Streamed reasoning and tool traces land in the same - * `operator_*_thinking` / `generic_tools` fields as an Operator step so the - * timeline renders them with one code path. - */ -export interface CheckerBlockData { - event?: string; - attempt_id?: string; - checkpoint_id?: string; - phase: 'checkpoint' | 'final' | 'outcome' | string; - subgoal_text?: string; - status?: 'running' | 'done' | 'superseded' | 'unchecked' | 'error' | string; - trace_id?: string | null; - anchor_step_id?: string | null; - items?: CheckerCheckItem[]; - verdicts?: CheckerVerdict[]; - findings?: string[]; - unmet_subgoals?: string[]; - reverted?: boolean; - applicable?: boolean; - repairs_used?: number; - route?: string; - error?: string; - task_status?: 'completed' | 'partial' | 'blocked' | string; - tests?: { passed: number; failed: number; inconclusive: number; unchecked: number }; - last_findings?: string[]; - started_at?: number; - finished_at?: number; - duration?: number; - isCompleted?: boolean; - generic_tools?: any[]; - /** Per-turn stream segments (live chunks or the persisted transcript); the flat fields below hold the joined text. */ - stream_segments?: StreamSegment[]; - operator_native_thinking?: string; - operator_raw_thinking?: string; - [key: string]: any; -} - -/** @deprecated legacy JSON-in-stream checker verdict (pre-ledger); kept for the parser. */ -export interface CheckerResult { - success: boolean; - reason: string; -} - -export interface StepReplayFrame { - index: number; - stepNumber: number; - rawStepNumber?: number; - stepId?: string; - title: string; - imageUrl: string; - preImageUrl?: string | null; - postImageUrl?: string | null; - action?: any; - actionType?: string; - actionText?: string; - targetText?: string; - coords?: string; - status?: 'dispatched' | 'failed' | string; - isPost?: boolean; - timestamp?: number; - phaseId?: string; - summary?: string; -} - diff --git a/apps/showcase_ui/src/app/core/models/system.model.ts b/apps/showcase_ui/src/app/core/models/system.model.ts deleted file mode 100644 index cf15406f0..000000000 --- a/apps/showcase_ui/src/app/core/models/system.model.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export type ProbeStatus = 'pass' | 'warn' | 'fail' | 'skipped'; - -export type ProbeCategory = 'device' | 'auth' | 'toolchain' | 'runtime'; - -export interface ProbeAction { - action_type: 'command' | 'hint' | 'link'; - label: string; - payload: string; -} - -export interface DeviceInfo { - serial: string; - state: string; - model: string | null; - product: string | null; - android_version: string | null; - screen_resolution: string | null; - is_locked: boolean | null; - is_emulator: boolean; - installed_packages?: string[]; -} - -export interface AdbServerEndpoint { - host: string; - port: number; - socket: string; - identity: string; - mode: 'local' | 'remote'; - is_local_default: boolean; -} - -export interface AdbServerDevice { - serial: string; - state: string; - model: string | null; - product: string | null; -} - -export interface AdbServerStatus { - endpoint: AdbServerEndpoint; -} - -export interface AdbServerConnectionResult { - success: boolean; - message: string; - endpoint: AdbServerEndpoint; - devices: AdbServerDevice[]; - persisted?: boolean; - persistence_error?: string | null; - error_code?: string; - output?: string; -} - -export interface AdbServerConnectionResponse { - connection_result: AdbServerConnectionResult; - report?: SystemReadinessReport; -} - -export interface ProbeResult { - id: string; - category: ProbeCategory; - title: string; - status: ProbeStatus; - is_blocker: boolean; - summary: string; - description: string; - metadata: Record; - actions: ProbeAction[]; -} - -export interface SystemReadinessReport { - overall_ready: boolean; - blocker_count: number; - passed_blocker_count: number; - probes: ProbeResult[]; - active_device: DeviceInfo | null; - os_type?: 'linux' | 'darwin' | 'windows' | string; - timestamp: number; -} - -export type EmulatorLaunchStage = - | 'idle' - | 'starting' - | 'waiting_for_adb' - | 'booting' - | 'ready' - | 'failed' - | 'stopped'; - -export interface EmulatorLaunchState { - avd_name: string | null; - status: EmulatorLaunchStage; - pid: number | null; - serial: string | null; - stage_message: string; - progress_percent: number; - started_at: number | null; - elapsed_seconds: number; - error: string | null; - logs: string[]; - can_retry: boolean; -} diff --git a/apps/showcase_ui/src/app/core/services/task-recommendation.service.ts b/apps/showcase_ui/src/app/core/services/task-recommendation.service.ts deleted file mode 100644 index 2d22e2bf4..000000000 --- a/apps/showcase_ui/src/app/core/services/task-recommendation.service.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Injectable } from '@angular/core'; -import { - AppReference, - SmartSuggestion, - SuggestionCategory, - APP_REGISTRY, - SMART_TASK_LIBRARY -} from '../data/smart-tasks.data'; - -@Injectable({ - providedIn: 'root' -}) -export class TaskRecommendationService { - public readonly allTasks: SmartSuggestion[] = SMART_TASK_LIBRARY; - public readonly appRegistry = APP_REGISTRY; - - /** - * Check if task matches any package installed on device - */ - public isSuggestionOnDevice( - suggestion: SmartSuggestion, - installedPackages: Set - ): boolean { - if (installedPackages.size === 0) return false; - if (!suggestion.requiredPackages || suggestion.requiredPackages.length === 0) { - return true; - } - - if (suggestion.matchMode === 'all') { - return suggestion.requiredPackages.every(pkg => installedPackages.has(pkg)); - } - return suggestion.requiredPackages.some(pkg => installedPackages.has(pkg)); - } - - /** - * Intelligently rank and filter tasks: - * Prioritizes tasks matching apps detected on user's phone without exposing clutter. - */ - public filterAndRankTasks( - installedPackages: Set, - category: SuggestionCategory, - shuffleOffset: number = 0 - ): SmartSuggestion[] { - const hasDevice = installedPackages.size > 0; - - // 1. Score tasks: prioritize tasks whose apps are on the user's phone - const scored = this.allTasks.map(task => { - const isMatched = this.isSuggestionOnDevice(task, installedPackages); - let score = task.priority ?? 50; - - if (hasDevice) { - if (isMatched) { - score += 100; - if (task.requiredPackages && task.requiredPackages.length > 1 && task.matchMode === 'all') { - score += 30; // bonus for full multi-app synergy match - } - } else { - score -= 40; - } - } - - return { task, isMatched, score }; - }); - - // 2. Filter by category - let filtered = scored.filter(item => { - if (category === 'flash') { - return item.task.profile === 'flash'; - } - if (category === 'pro') { - return item.task.profile === 'pro'; - } - if (category === 'cross_app') { - return item.task.category === 'cross_app'; - } - if (category === 'monitor') { - return item.task.category === 'monitor'; - } - return true; // 'all' - }); - - // 3. Sort by score descending (matched apps first) - filtered.sort((a, b) => b.score - a.score); - - let result = filtered.map(f => f.task); - - // 4. Apply shuffle rotation - if (shuffleOffset > 0 && result.length > 0) { - const shift = shuffleOffset % result.length; - result = [...result.slice(shift), ...result.slice(0, shift)]; - } - - return result; - } -} diff --git a/apps/showcase_ui/src/app/pages/home/home.component.html b/apps/showcase_ui/src/app/pages/home/home.component.html deleted file mode 100644 index 060a01d25..000000000 --- a/apps/showcase_ui/src/app/pages/home/home.component.html +++ /dev/null @@ -1,2103 +0,0 @@ - - -
- -
-
-
-
-
-
-
-
- - - -
- - -
-

- What would you like to do on your phone? -

-
- - -
-
- - - -
-
- - - - - @if (activeTab() === 'diagnostics') { -
- - - - -
- -
-
- @if (isEnvironmentReady()) { - check - } @else { - 1 - } -
-
-
-

System & Environment

-
-

Core tools, Android debug bridge, and video recording runtime

-
- - -
- -
- - @if (hasReadinessReport() && !isEnvironmentReady()) { -
-
-
- bolt -
-
-
-
Automated Dependency Setup
-
- - - -
-
-
Run in terminal to install ADB, FFmpeg, scrcpy, and project dependencies:
-
-
-
- {{ oneClickSetupCmd() }} - -
-
- } - - -
- - -
-
-
- terminal -
-
-
Python Runtime
-
{{ pythonProbe()?.summary || 'Checking…' }}
-
-
- -
-
- @if (pythonProbe()?.status === 'fail') { -
-
-
- $ - uv python install 3.12 - -
-
-
- } -
- - -
-
-
- construction -
-
-
Android Platform-Tools
-
{{ !adbProbe() ? 'Checking…' : (adbProbe()?.metadata?.['installed'] ? 'ADB Installed' : 'ADB Missing') }}
-
-
- -
-
- @if (adbProbe()?.status === 'fail') { -
-
-
- $ - {{ adbInstallCmd() }} - -
-
-
- } -
- - -
-
-
- tune -
-
-
System Configuration
-
{{ configProbe()?.summary || 'Config Valid' }}
-
-
- -
-
- @if (configProbe()?.status === 'fail') { -
-
-
- $ - artemis init - -
-
-
- } -
- - -
-
-
- videocam -
-
-
Video Toolchain
-
{{ toolchainProbe()?.summary || 'FFmpeg & scrcpy' }}
-
-
- -
-
- @if (toolchainProbe()?.status !== 'pass') { -
-
-
- $ - {{ toolchainInstallCmd() }} - -
-
-
- } -
- -
-
-
- - - - -
- -
-
- @if (isCredentialsReady()) { - check - } @else { - 2 - } -
-
-
-

AI Model Setup

-
-

Google Gemini or custom LLM providers (OpenAI, Claude, Ollama)

-
-
- -
- -
- -
-
-
- -
-
- - - - - - - - - - -
-
-
- Google Gemini - Recommended -
-

Fast multimodal reasoning with free API key from Google AI Studio.

-
-
-
- - -
-
-
- -
-
- tune -
-
-
- Custom Configuration -
-

Define models in config/artemis.jsonc & keys in .env - (OpenAI, Claude, Ollama).

-
-
-
-
- - - @if (modelSetupMode() === 'gemini') { - -
-
-
- vpn_key - Enter Gemini API Key: -
- - open_in_new - Get Free API Key (Google AI Studio) - -
- -
-
- key - - - - - - - @if (geminiKeyInput()) { - - } -
- - - - - - -
- - @if (geminiSaveMessage()) { - - } - @if (geminiSaveError()) { - - } -
- - -
-
-
- document_scanner -
- Optional: Google Cloud Vision OCR -
-
- -
- @if (isOcrConfigured()) { - - } - - {{ showOcrConfig() ? 'expand_less' : 'expand_more' }} - -
-
- - @if (showOcrConfig()) { -
-

- Provides screen text coordinate grounding to improve element detection and interaction accuracy on text-heavy apps. -

- - -
-
-
- key - Cloud Vision API Key: -
-
- -
-
- key - - - - - - - @if (ocrKeyInput()) { - - } -
- - - - - - -
- - @if (ocrSaveMessage()) { - - } - @if (ocrSaveError()) { - - } -
-
- } -
- } @else { - -
- - -
-
-
- tune -
- Active Model Configuration - Defined in - {{ modelConfigEnv()?.config_path || 'config/artemis.jsonc' }} -
-
-
- - -
-
- - -
-
- Default Model -
- - {{ modelConfigEnv()?.default_model?.provider || 'google' }} - / - {{ modelConfigEnv()?.default_model?.model || 'gemini-3.8-flash' }} -
-
- - @if (modelConfigEnv()?.default_model?.fallback) { -
- Fallback Model -
- {{ modelConfigEnv()?.default_model?.fallback?.provider }}/{{ - modelConfigEnv()?.default_model?.fallback?.model }} -
-
- } - -
- - - @if (showFullConfigFile()) { -
-
{{ modelConfigEnv()?.config_content }}
-
- } -
- - -
-
-
- vpn_key -
- Environment Variables & API Keys - Loaded from {{ modelConfigEnv()?.env_path || '.env' }} -
-
- - -
- - -
- @for (envItem of modelConfigEnv()?.env_vars || []; track envItem.name) { -
-
- {{ envItem.name }} -
-
- @if (envItem.is_set) { - - check_circle - Configured - @if (envItem.preview) { - ({{ envItem.preview }}) - } - - } @else { - - radio_button_unchecked - Not Set - - } -
-
- } -
-
- -
- } -
-
- - - - -
- -
-
- @if (isDeviceReady()) { - check - } @else { - 3 - } -
-
-
-

Android Device & Emulator Connection

-
-

Local emulator, attached phone, wireless device, or another ADB server

-
- - - - -
- -
- - - @if (isDeviceReady()) { -
-
-
- - phone_android - -
-
-
- {{ activeDevice()?.model || activeDevice()?.serial }} -
-
- @if (activeDevice()?.is_emulator) { - Virtual Device - · - } - {{ activeDevice()?.serial }} - @if (activeDevice()?.screen_resolution) { - · - {{ activeDevice()?.screen_resolution }} - } - @if (activeDevice()?.android_version) { - · - Android {{ activeDevice()?.android_version }} - } - @if (isRemoteAdbServer()) { - · - - Remote via {{ adbServerStatus()?.endpoint?.host }}:{{ adbServerStatus()?.endpoint?.port }} - - } -
-
-
- - Connected -
-
- - - @if (connectedDevices().length > 1) { -
- Select Active Device: -
- @for (dev of connectedDevices(); track dev.serial) { - - } -
-
- } -
- } - - - @if (isEmulatorLaunching() || (!isDeviceReady() && adbProbe()?.summary === 'Device Booting')) { -
-
-
-
-
- sync -
-
-
-

- Launching Android Emulator: {{ emulatorLaunchState()?.avd_name || activeDevice()?.serial || 'Virtual Device' }} -

- {{ emulatorLaunchState()?.status === 'booting' ? 'OS Booting' : 'Starting' }} -
-

{{ emulatorLaunchState()?.stage_message || 'Initializing Android Virtual Device...' }}

-
-
-
- - -
-
- - -
-
-
- @if (emulatorLaunchState()?.progress_percent! >= 35) { - check - } @else { - - } -
- 1. Process Start -
- -
- -
-
- @if (emulatorLaunchState()?.progress_percent! >= 65) { - check - } @else if (emulatorLaunchState()?.status === 'waiting_for_adb') { - - } @else { - 2 - } -
- 2. ADB Handshake -
- -
- -
-
- @if (emulatorLaunchState()?.status === 'ready') { - check - } @else if (emulatorLaunchState()?.status === 'booting' || adbProbe()?.summary === 'Device Booting') { - - } @else { - 3 - } -
- 3. Android OS Boot -
- -
- -
-
- check_circle -
- 4. Ready -
-
- - -
-
-
-
-
- {{ emulatorLaunchState()?.progress_percent || 25 }}% Complete - - timer - Elapsed: {{ emulatorLaunchState()?.elapsed_seconds || 0 }}s - -
-
- - -
- info - Android virtualization cold boot usually takes 20-45 seconds. Live progress is actively tracked above—please do not click launch repeatedly. -
- - - @if (showLaunchLogs()) { -
-
-
- terminal - Emulator Console Stream (stdout / stderr) -
- -
-
@if (emulatorLaunchState()?.logs?.length) {@for (log of emulatorLaunchState()?.logs; track log) {
{{ log }}
}} @else {
Waiting for process output...
}
-
- } -
-
- } - - - @if (!isDeviceReady() && !isEmulatorLaunching() && emulatorLaunchState()?.status === 'failed') { -
-
-
-
-
- error -
-
-

Emulator Launch Failed

-

{{ emulatorLaunchState()?.error || 'The Android emulator process failed to start or crashed during initialization.' }}

-
-
- -
- - @if (emulatorLaunchState()?.logs?.length) { -
-
- Process Output / Failure Logs: - -
-
@for (log of emulatorLaunchState()?.logs; track log) {
{{ log }}
}
-
- } - -
- @if (emulatorLaunchState()?.avd_name) { - - } - - -
-
-
- } - - - @if (showConnectionMethods() || (!isDeviceReady() && !isEmulatorLaunching() && adbProbe()?.summary !== 'Device Booting' && adbProbe()?.summary !== 'Device Unauthorized' && adbProbe()?.summary !== 'Device Locked' && adbProbe()?.summary !== 'Lock State Unknown' && adbProbe()?.status !== 'fail' && emulatorLaunchState()?.status !== 'failed')) { -
- -
- - - - - - - -
- - @if (activeAdbGuideTab() !== 'remote' && isRemoteAdbServer()) { -
-
- hub - - Artemis is currently using - {{ adbServerStatus()?.endpoint?.host }}:{{ adbServerStatus()?.endpoint?.port }}. - Switch to local ADB before using a local emulator, USB cable, or Wireless ADB. - -
- -
- } - - - @if (activeAdbGuideTab() === 'emulator') { -
- - @if (installedAvds().length > 0) { -
-
-
- devices - Installed Virtual Devices (AVD) - {{ installedAvds().length }} Available -
-
- -
- @for (avd of installedAvds(); track avd) { -
-
-
- smartphone -
-
-
- {{ avd }} - Virtual Device -
-
-
- -
- -
-
- } -
-
- } @else { -
-
- - -
- - -
-
- - - @if (emulatorSetupMode() === 'studio') { -
-
-
- 1 - download -
-
Install Android Studio
-
- Download for {{ effectiveOs() === 'darwin' ? 'macOS' : (effectiveOs() === 'windows' ? 'Windows' : 'Linux') }} from developer.android.com/studio and install. -
-
- -
-
- 2 - developer_mode -
-
Open Device Manager
-
- On Studio welcome screen, open More Actions > Virtual Device Manager (or Tools > Device Manager). -
-
- -
-
- 3 - add_circle -
-
Create Virtual Device
-
- Click Create Device, choose Pixel 8, select Android 14 (API 34), and click Finish. -
-
- -
-
- 4 - play_arrow -
-
Launch Emulator
-
- Click ▶ Play in Device Manager. Artemis will detect and connect to it automatically! -
-
-
- } - - - @if (emulatorSetupMode() === 'cli') { -
-
-
1
-
-
{{ emuHypervisorTitle() }}
-

{{ emuHypervisorDesc() }}

-
- {{ effectiveOs() === 'windows' ? 'PS>' : '$' }} - {{ emuHypervisorCmd() }} - -
-
-
- -
-
2
-
-
Download Emulator & System Image (API 34)
-

Install Android 14 Google APIs system image using SDK Manager:

-
- {{ effectiveOs() === 'windows' ? 'PS>' : '$' }} - {{ emuSdkInstallCmd() }} - -
-
-
- -
-
3
-
-
Create Virtual Device (Pixel 8 AVD)
-

Initialize a new Android Virtual Device named Pixel_8_API_34:

-
- {{ effectiveOs() === 'windows' ? 'PS>' : '$' }} - {{ emuCreateAvdCmd() }} - -
-
-
- -
-
4
-
-
Start the Emulator
-

Launch the virtual device (or add -no-window for headless execution):

-
- {{ effectiveOs() === 'windows' ? 'PS>' : '$' }} - emulator -avd Pixel_8_API_34 -no-boot-anim - -
-
-
-
- } -
- } - -
- } - - - @if (activeAdbGuideTab() === 'usb') { -
-
- cable - Connect your Android phone via USB cable and enable USB Debugging: -
- -
-
-
- 1 - usb -
-
Connect USB Cable
-
Plug your Android phone into this computer with a data cable.
-
- -
-
- 2 - touch_app -
-
Enable Developer Options
-
In phone Settings > About Phone, tap Build Number 7 - times.
-
- -
-
- 3 - toggle_on -
-
Enable USB Debugging
-
In Settings > Developer Options, turn USB Debugging ON. -
-
- -
-
- 4 - verified_user -
-
Authorize Computer
-
Check "Always allow" and tap Allow on the phone prompt. -
-
-
-
- } - - - @if (activeAdbGuideTab() === 'wifi') { -
-
- wifi - Connect your phone and computer on the same Wi-Fi network: -
- - -
-
-
- -
- router - -
-
- -
- -
- tag - -
-
- -
- -
-
- - @if (wifiConnectMessage()) { -
- check_circle - {{ wifiConnectMessage() }} -
- } - @if (wifiConnectError()) { -
- error - {{ wifiConnectError() }} -
- } -
-
- help_outline -
- Find IP & port in phone Settings > Developer Options > Wireless Debugging. -
-
-
- } - - - @if (activeAdbGuideTab() === 'remote') { -
-
- hub - Enter the host and port of an ADB server endpoint that this computer can access, so Artemis can discover and control a phone physically connected to another computer. -
- -
-
-
- -
- dns - -
-
- -
- -
- tag - -
-
- -
- -
-
- - - - @if (remoteAdbMessage()) { -
- check_circle - {{ remoteAdbMessage() }} -
- } - @if (remoteAdbError()) { - - } - - @if (remoteAdbDevices().length > 0) { -
- @for (device of remoteAdbDevices(); track device.serial) { -
- phone_android -
- {{ device.model || device.serial }} - {{ device.serial }} -
- - {{ device.state === 'device' ? 'Ready' : device.state }} - -
- } -
- } - - @if (remoteAdbProbeResult()?.success && !isProbedRemoteAdbActive()) { - - } -
- -

- Artemis sends all ADB commands to this server instead of the default local server. Any phone visible to that server becomes available to Artemis. -

-
- } - -
- } - - - @if (adbProbe()?.status === 'warn' && (adbProbe()?.summary === 'Device Locked' || adbProbe()?.summary === 'Lock State Unknown')) { -
-
- lock -
-
- {{ adbProbe()?.summary === 'Device Locked' ? 'Device Is Locked' : 'Unable to Verify Lock State' }} -
-
- {{ adbProbe()?.description }} -
-
- -
- - - @if (connectedDevices().length > 1) { -
- Select Active Device: -
- @for (dev of connectedDevices(); track dev.serial) { - - } -
-
- } -
- } - - - @if (adbProbe()?.status === 'warn' && adbProbe()?.summary === 'Device Unauthorized') { -
-
- screen_lock_portrait -
-
USB Debugging Authorization Pending
-
- Your Android device was detected, but it requires authorization. Please unlock your phone screen and tap "Allow USB Debugging". -
-
- -
-
- } - - - @if (adbProbe()?.status === 'fail') { -
-
- error -
- Android Platform Tools (adb) not found. Please install ADB on your operating system: -
-
- -
-
-
- Linux (Ubuntu / Debian) - @if (effectiveOs() === 'linux') { - Detected - } -
-
- $ - sudo apt-get install -y adb - -
-
- -
-
- macOS (Homebrew) - @if (effectiveOs() === 'darwin') { - Detected - } -
-
- $ - brew install android-platform-tools - -
-
- -
-
- Windows (WinGet) - @if (effectiveOs() === 'windows') { - Detected - } -
-
- > - winget install Google.PlatformTools - -
-
-
-
- } - -
-
- - - @if (isReady()) { - - } - -
- } - - - - - @if (activeTab() === 'launcher') { -
- - - @if (hasReadinessReport() && !isReady()) { -
- {{ adbProbe()?.summary === 'Device Locked' ? 'lock' : 'warning' }} - - {{ adbProbe()?.summary === 'Device Locked' - ? 'Android device is locked. Unlock it and enter the home screen before running a task.' - : 'Prerequisites incomplete: runtime, credentials, or Android device is not ready. Click here to open Setup Guide.' }} - - arrow_forward -
- } - - -
- - - @if (showIntentSuggestion()) { -
-
- auto_awesome - - Continuous monitoring or complex task detected. Recommended: Pro Mode - -
- -
- } - - -
- -
- - -
-
- {{ selectedProfile() === 'flash' ? 'bolt' : 'psychology' }} -
-
- @if (selectedProfile() === 'flash') { -

- Flash - · - Token-efficient, rapid 3–5s/step decisions straight from the live screen. Best for everyday tasks with a clear path, however long they run. - (No plan, no result check, no ADB commands) -

- } @else { -

- Pro - · - Plans first, checks its results and recovers when a tap is blocked. Handles 100+ step workflows and live phone monitoring (15–40s / step). -

- } -
-
- - - @if (errorMessage()) { -
- error - {{ errorMessage() }} -
- } - - -
-
- -
- - -
-
- -
- @if (taskGoal().length > 0) { - {{ taskGoal().length }} chars - } - - - -
-
-
- - - @if (selectedProfile() === 'pro') { -
-
- Expected Output - (Optional) -
- -
- - @if (expectedOutput().trim()) { - - } -
-
- - -
-
- Pro settings - @if (!isTuningDefault()) { - - } -
- -
- @for (slider of tuningSliders(); track slider.kind) { -
-
- {{ slider.name }} - {{ slider.level.label }} -
- - -
- - - @if (slider.index === slider.ladder.length - 1) { - - } - - - @if (activeTuningTip() === slider.kind) { - - - } -
-
- } -
-
- } - - -
- -
-
- - - -
- - - - - - - - - -
-
- -
- -
-
- - -
- @for (item of filteredSuggestions(); track item.id) { -
- -
-
- @for (app of item.apps; track app.name) { -
- {{ app.icon }} -
- } - {{ getAppNamesDisplay(item.apps) }} -
- - - {{ item.profile === 'pro' ? 'Pro' : 'Flash' }} - -
- - -
-

{{ item.title }}

-

{{ item.description }}

-
- - - -
- } -
-
- - -
- } - -
-
diff --git a/apps/showcase_ui/src/app/pages/home/home.component.scss b/apps/showcase_ui/src/app/pages/home/home.component.scss deleted file mode 100644 index c1a5e68d6..000000000 --- a/apps/showcase_ui/src/app/pages/home/home.component.scss +++ /dev/null @@ -1,5168 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// ============================================================================= -// DESIGN SYSTEM TOKENS & TYPOGRAPHY (Aligned with Workspace Minimalism) -// ============================================================================= -$font-sans: 'Google Sans Text', 'Google Sans', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -$font-mono: 'JetBrains Mono', 'Fira Code', 'Roboto Mono', 'SFMono-Regular', Consolas, monospace; - -// Surfaces & Backgrounds -$bg-base: #f8f9fa; -$card-bg: #ffffff; -$card-border: #e2e8f0; -$card-border-hover: #cbd5e1; -$card-shadow: 0 1px 3px rgba(0, 0, 0, 0.03), 0 4px 12px rgba(0, 0, 0, 0.02); - -// Brand & Semantic Colors -$primary: #1a73e8; -$primary-dark: #1557b0; -$primary-light: #eff6ff; -$primary-border: #bfdbfe; - -$emerald: #059669; -$emerald-dark: #047857; -$emerald-light: #ecfdf5; -$emerald-border: #a7f3d0; - -$amber: #d97706; -$amber-dark: #b45309; -$amber-light: #fffbeb; -$amber-border: #fde68a; - -$rose: #dc2626; -$rose-dark: #b91c1c; -$rose-light: #fef2f2; -$rose-border: #fecaca; - -// Neutral Text Scale -$text-main: #0f172a; -$text-secondary: #475569; -$text-muted: #64748b; -$text-light: #94a3b8; - -// Shared OS switcher. It is rendered in both the dependency installer and -// the empty-AVD guide, so the styling cannot be scoped to either card. -.os-selector-pills { - display: inline-flex; - align-items: center; - padding: 2px; - gap: 2px; - background: rgba(255, 255, 255, 0.65); - border: 1px solid rgba(226, 232, 240, 0.85); - border-radius: 8px; - box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.8); - backdrop-filter: blur(12px) saturate(180%); - -webkit-backdrop-filter: blur(12px) saturate(180%); - - .os-pill { - padding: 3px 9px; - border: 0; - border-radius: 6px; - background: transparent; - color: $text-secondary; - font-family: inherit; - font-size: 11px; - font-weight: 600; - line-height: 1.35; - cursor: pointer; - transition: color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease; - - &:hover { - color: $text-main; - background: rgba(255, 255, 255, 0.72); - } - - &.active { - color: #ffffff; - background: $primary; - box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12); - } - - &:focus-visible { - outline: 2px solid rgba($primary, 0.35); - outline-offset: 1px; - } - } -} - -// ============================================================================= -// FROSTED GLASS (GLASSMORPHISM) MIXINS -// ============================================================================= -@mixin glass-surface($opacity: 0.72, $blur: 16px) { - background: rgba(255, 255, 255, $opacity); - backdrop-filter: blur($blur) saturate(180%); - -webkit-backdrop-filter: blur($blur) saturate(180%); -} - -@mixin glass-primary-btn { - background: linear-gradient(135deg, rgba(26, 115, 232, 0.92) 0%, rgba(37, 99, 235, 0.85) 100%); - backdrop-filter: blur(10px) saturate(180%); - -webkit-backdrop-filter: blur(10px) saturate(180%); - border: 1px solid rgba(255, 255, 255, 0.28); - box-shadow: 0 4px 14px rgba(26, 115, 232, 0.32), inset 0 1px 1px rgba(255, 255, 255, 0.45); - color: #ffffff !important; - transition: background 0.2s cubic-bezier(0.4, 0, 0.2, 1), - border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1), - box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1), - transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), - opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1); - - span, .material-symbols-outlined, .run-icon, .action-icon { - color: #ffffff !important; - } - - &:hover:not(:disabled) { - background: linear-gradient(135deg, rgba(26, 115, 232, 1) 0%, rgba(37, 99, 235, 0.95) 100%); - box-shadow: 0 6px 20px rgba(26, 115, 232, 0.42), inset 0 1px 1px rgba(255, 255, 255, 0.6); - transform: translateY(-1px); - } - - &:active:not(:disabled) { - transform: scale(0.97) translateY(0); - box-shadow: 0 2px 8px rgba(26, 115, 232, 0.35); - } - - &:disabled { - background: rgba(148, 163, 184, 0.5); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.2); - box-shadow: none; - opacity: 0.65; - cursor: not-allowed; - transform: none; - } -} - -@mixin glass-secondary-btn { - background: rgba(255, 255, 255, 0.72); - backdrop-filter: blur(10px) saturate(180%); - -webkit-backdrop-filter: blur(10px) saturate(180%); - border: 1px solid rgba(226, 232, 240, 0.88); - box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.05), inset 0 1px 1px rgba(255, 255, 255, 0.95); - color: $text-secondary; - transition: background 0.2s cubic-bezier(0.4, 0, 0.2, 1), - border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1), - color 0.2s cubic-bezier(0.4, 0, 0.2, 1), - box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1), - transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), - opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1); - - &:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.92); - border-color: rgba(191, 219, 254, 0.95); - color: $primary; - box-shadow: 0 4px 12px -2px rgba(15, 23, 42, 0.08), inset 0 1px 1px rgba(255, 255, 255, 1); - transform: translateY(-0.5px); - - .material-symbols-outlined:not(.feedback-done) { - color: $primary; - } - } - - &:active:not(:disabled) { - transform: scale(0.97); - } - - &:disabled { - opacity: 0.55; - cursor: not-allowed; - transform: none; - } -} - -:host { - display: block; - min-height: 100vh; - width: 100%; - background-color: $bg-base; - font-family: $font-sans; - color: $text-main; - box-sizing: border-box; - -webkit-font-smoothing: antialiased; -} - -*, *::before, *::after { - box-sizing: border-box; -} - -.home-container { - position: relative; - min-height: 100vh; - display: flex; - flex-direction: column; - background-color: $bg-base; - overflow-x: hidden; - - /* Dynamic Ambient Background with Intelligent Glow */ - .aurora-background { - display: block; - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - pointer-events: none; - z-index: 1; - overflow: hidden; - opacity: 0.9; - transition: opacity 0.8s ease; - - /* The glow orbs are pre-softened radial gradients. A `filter: blur(90px+)` - on top of them looked nearly identical but forced the GPU to re-blur - half-megapixel layers on every frame of the float animations — the - gradients below bake the same softness in for free. */ - .glow { - position: absolute; - border-radius: 50%; - transform: translate3d(0, 0, 0); - will-change: transform, opacity; - pointer-events: none; - } - - .glow-1 { - top: -140px; - left: 10%; - width: 520px; - height: 520px; - background: radial-gradient(circle, rgba(59, 130, 246, 0.12) 0%, rgba(99, 102, 241, 0.04) 45%, transparent 82%); - animation: floatGlow1 22s ease-in-out infinite alternate; - } - - .glow-2 { - top: -100px; - right: 12%; - width: 560px; - height: 560px; - background: radial-gradient(circle, rgba(147, 51, 234, 0.08) 0%, rgba(219, 39, 119, 0.03) 45%, transparent 82%); - animation: floatGlow2 26s ease-in-out infinite alternate; - } - - .glow-3 { - top: 280px; - left: 30%; - width: 460px; - height: 460px; - background: radial-gradient(circle, rgba(6, 182, 212, 0.08) 0%, rgba(59, 130, 246, 0.03) 45%, transparent 82%); - animation: floatGlow3 20s ease-in-out infinite alternate; - } - - .glow-pending { - top: -120px; - left: 15%; - right: 15%; - margin: 0 auto; - width: 720px; - height: 520px; - background: radial-gradient(circle, rgba(245, 158, 11, 0.09) 0%, rgba(217, 119, 6, 0.035) 38%, transparent 82%); - opacity: 0; - transition: opacity 0.8s cubic-bezier(0.4, 0, 0.2, 1); - animation: readyPulseGlow 9s ease-in-out infinite alternate; - pointer-events: none; - } - - .glow-ready { - top: -120px; - left: 15%; - right: 15%; - margin: 0 auto; - width: 700px; - height: 520px; - background: radial-gradient(circle, rgba(16, 185, 129, 0.14) 0%, rgba(6, 182, 212, 0.08) 38%, transparent 82%); - opacity: 0; - transition: opacity 1s cubic-bezier(0.4, 0, 0.2, 1); - animation: readyPulseGlow 7s ease-in-out infinite alternate; - pointer-events: none; - } - - .glow-pro { - top: -100px; - left: 20%; - right: 20%; - margin: 0 auto; - width: 680px; - height: 520px; - background: radial-gradient(circle, rgba(99, 102, 241, 0.18) 0%, rgba(147, 51, 234, 0.08) 38%, transparent 82%); - opacity: 0; - transition: opacity 0.8s cubic-bezier(0.4, 0, 0.2, 1); - pointer-events: none; - } - } - - &.system-pending { - .aurora-background { - .glow-pending { - opacity: 1; - } - .glow-1 { - background: radial-gradient(circle, rgba(245, 158, 11, 0.07) 0%, rgba(99, 102, 241, 0.03) 55%, transparent 75%); - } - } - } - - &.system-ready { - .aurora-background { - .glow-pending { - opacity: 0; - } - .glow-ready { - opacity: 1; - } - .glow-1 { - background: radial-gradient(circle, rgba(16, 185, 129, 0.10) 0%, rgba(59, 130, 246, 0.05) 55%, transparent 75%); - } - } - } - - &.pro-theme-active { - .aurora-background { - .glow-pro { - opacity: 1; - } - .glow-ready { - opacity: 0.35; - } - } - } - - /* ========================================================================= */ - /* MAIN CONTENT LAYOUT */ - /* ========================================================================= */ - .home-content { - position: relative; - z-index: 5; - max-width: 960px; - width: 100%; - margin: 0 auto; - padding: 36px 24px 60px; - display: flex; - flex-direction: column; - gap: 18px; - - /* Hero section */ - .hero-section { - text-align: center; - padding: 16px 0 6px; - margin: 0; - - .hero-title { - font-family: 'Outfit', 'Google Sans', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - font-size: 32px; - font-weight: 600; - letter-spacing: -0.035em; - line-height: 1.2; - color: #0f172a; - margin: 0; - } - - .hero-subtitle { - font-size: 14.5px; - color: #64748b; - font-weight: 400; - margin: 8px 0 0; - letter-spacing: -0.01em; - } - } - - /* Top Mode Stepper Tabs Switcher (Frosted Glass Segmented Control) */ - /* Top Mode Stepper Tabs Switcher (Frosted Glass Segmented Control matching top-left nav) */ - .mode-tabs-container { - display: flex; - justify-content: center; - margin-bottom: 6px; - - .mode-tabs { - display: grid; - grid-template-columns: 1fr 1fr; - width: 100%; - max-width: 520px; - box-sizing: border-box; - background: rgba(255, 255, 255, 0.72); - backdrop-filter: blur(12px) saturate(180%); - -webkit-backdrop-filter: blur(12px) saturate(180%); - padding: 3px; - border-radius: 9999px; - border: 1px solid rgba(226, 232, 240, 0.85); - gap: 3px; - box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(0, 0, 0, 0.02), inset 0 1px 1px rgba(255, 255, 255, 0.9); - user-select: none; - font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - transition: background 0.25s cubic-bezier(0.4, 0, 0.2, 1), - box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1), - border-color 0.25s cubic-bezier(0.4, 0, 0.2, 1); - - &:hover { - background: rgba(255, 255, 255, 0.85); - box-shadow: 0 4px 14px -2px rgba(15, 23, 42, 0.08), 0 2px 4px rgba(0, 0, 0, 0.02), inset 0 1px 1px rgba(255, 255, 255, 1); - border-color: rgba(203, 213, 225, 0.9); - } - - .mode-tab-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 7px; - height: 32px; - padding: 0 14px; - box-sizing: border-box; - border: none; - background: transparent; - border-radius: 9999px; - cursor: pointer; - font-family: inherit; - font-size: 13px; - font-weight: 600; - color: #475569; - line-height: 1; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - outline: none; - white-space: nowrap; - min-width: 0; - - @media (max-width: 560px) { - white-space: normal; - height: auto; - min-height: 32px; - padding: 6px 10px; - gap: 5px; - font-size: 12px; - line-height: 1.2; - text-align: center; - } - - .tab-icon { - font-size: 16px; - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - color: #64748b; - flex-shrink: 0; - transition: color 0.15s ease, transform 0.15s ease; - - &.icon-ready { - color: #059669; - } - - &.icon-locked { - color: #94a3b8; - font-size: 15px; - } - } - - .tab-label { - color: #475569; - min-width: 0; - transition: color 0.15s ease; - } - - &:hover:not(.active) { - background: rgba(255, 255, 255, 0.7); - color: #0f172a; - - .tab-icon { - color: #1e293b; - - &.icon-ready { - color: #059669; - } - } - .tab-label { - color: #0f172a; - } - } - - &.active { - background: #ffffff !important; - color: #0f172a !important; - font-weight: 700; - box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04), inset 0 1px 0 #ffffff; - - .tab-icon { - color: #1a73e8; - - &.icon-ready { - color: #059669; - } - } - - .tab-label { - color: #0f172a !important; - } - } - - &:active { - transform: scale(0.98); - } - } - } - } - - /* ======================================================================= */ - /* VIEW 1: PREREQUISITES & SYSTEM SETUP GUIDE */ - /* ======================================================================= */ - .setup-guide-view { - display: flex; - flex-direction: column; - gap: 16px; - animation: fadeIn 0.2s ease-out; - - /* Unified Step Card Container */ - .guide-card { - background: $card-bg; - border: 1px solid $card-border; - border-radius: 14px; - box-shadow: $card-shadow; - overflow: hidden; - transition: border-color 0.2s ease, box-shadow 0.2s ease; - - &:hover { - border-color: $card-border-hover; - } - - .guide-card-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 14px; - padding: 16px 20px; - border-bottom: 1px solid #f1f5f9; - background: #ffffff; - - .step-badge { - width: 32px; - height: 32px; - border-radius: 8px; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 13.5px; - font-weight: 700; - background: #f1f5f9; - color: $text-secondary; - flex-shrink: 0; - transition: all 0.2s ease; - - span, .step-check-icon { - font-size: 18px; - font-weight: 700; - color: inherit; - } - - &.step-pass { - background: $emerald-light; - color: $emerald; - } - } - - .step-info { - flex: 1; - min-width: 0; - - .step-title-row { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 2px; - flex-wrap: wrap; - - .step-title { - margin: 0; - font-size: 15px; - font-weight: 600; - color: $text-main; - } - - .status-pill { - font-size: 11px; - font-weight: 600; - padding: 2px 8px; - border-radius: 12px; - line-height: 1.3; - height: 22px; - display: inline-flex; - align-items: center; - - &.pill-pass { - background: $emerald-light; - color: $emerald; - border: 1px solid $emerald-border; - } - - &.pill-warn { - background: $amber-light; - color: $amber-dark; - border: 1px solid $amber-border; - } - - &.pill-fail { - background: $rose-light; - color: $rose-dark; - border: 1px solid $rose-border; - } - } - } - - .step-desc { - margin: 0; - font-size: 12.5px; - color: $text-secondary; - line-height: 1.4; - } - } - - .btn-restart-adb, - .btn-header-action { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 0 13px; - height: 32px; - @include glass-secondary-btn; - border-radius: 8px; - font-family: inherit; - font-size: 12px; - font-weight: 600; - cursor: pointer; - flex-shrink: 0; - - .material-symbols-outlined { - font-size: 15px; - color: $text-muted; - transition: transform 0.2s ease, color 0.15s ease; - } - - .feedback-done { - color: $emerald !important; - font-size: 16px; - } - - .feedback-text { - color: $emerald; - font-weight: 600; - } - - .spinning { - animation: spin 1s linear infinite; - } - } - } - - .guide-card-body { - padding: 18px 20px; - display: flex; - flex-direction: column; - gap: 14px; - } - } - - /* --------------------------------------------------------------------- */ - /* STEP 1: ONE-CLICK INSTALLER & ENVIRONMENT MATRIX */ - /* --------------------------------------------------------------------- */ - .one-click-install-bar { - background: #f0f9ff; - border: 1px solid #bae6fd; - border-radius: 10px; - padding: 12px 16px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 14px; - - .install-bar-left { - display: flex; - align-items: center; - gap: 12px; - min-width: 0; - - .install-bar-icon-box { - width: 32px; - height: 32px; - border-radius: 8px; - background: #0284c7; - color: #ffffff; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - - .bolt-icon { - font-size: 18px; - } - } - - .install-bar-text { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; - - .install-bar-title-row { - display: flex; - align-items: center; - flex-wrap: wrap; - - .install-bar-title { - font-size: 13px; - font-weight: 600; - color: $text-main; - } - - .os-selector-pills { - display: inline-flex; - align-items: center; - background: rgba(255, 255, 255, 0.65); - backdrop-filter: blur(12px) saturate(180%); - -webkit-backdrop-filter: blur(12px) saturate(180%); - border: 1px solid rgba(226, 232, 240, 0.85); - border-radius: 8px; - padding: 2px; - gap: 2px; - box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.8); - - .os-pill { - border: none; - background: transparent; - font-size: 11px; - font-weight: 600; - color: $text-secondary; - padding: 3px 9px; - border-radius: 6px; - cursor: pointer; - transition: all 0.15s ease; - - &:hover { - color: $text-main; - background: rgba(255, 255, 255, 0.5); - } - - &.active { - background: rgba(26, 115, 232, 0.92); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); - color: #ffffff !important; - box-shadow: 0 2px 6px rgba(26, 115, 232, 0.3), inset 0 1px 1px rgba(255, 255, 255, 0.35); - } - } - } - } - - .install-bar-desc { - font-size: 12px; - color: $text-secondary; - line-height: 1.4; - margin-top: 2px; - } - } - } - - .code-box { - margin: 0; - flex-shrink: 0; - } - - @media (max-width: 860px) { - flex-direction: column; - align-items: stretch; - } - } - - /* 4-Item 2x2 Symmetric Matrix Grid */ - .env-matrix-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 12px; - - @media (max-width: 640px) { - grid-template-columns: 1fr; - } - - .env-item-card { - background: #ffffff; - border: 1px solid $card-border; - border-radius: 10px; - padding: 12px 14px; - display: flex; - flex-direction: column; - justify-content: center; - gap: 8px; - transition: border-color 0.18s ease, box-shadow 0.18s ease; - min-width: 0; - - &:hover { - border-color: $card-border-hover; - } - - .item-header { - display: flex; - align-items: center; - gap: 10px; - min-width: 0; - - .item-icon-box { - width: 32px; - height: 32px; - border-radius: 8px; - background: #f8fafc; - border: 1px solid $card-border; - color: $text-secondary; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - transition: all 0.15s ease; - - .item-icon { - font-size: 17px; - } - } - - .item-title-group { - display: flex; - flex-direction: column; - gap: 1px; - flex: 1; - min-width: 0; - - .item-name { - font-size: 13px; - font-weight: 600; - color: $text-main; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .item-subtitle { - font-size: 11.5px; - color: $text-muted; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - - .item-status { - display: flex; - align-items: center; - margin-left: auto; - flex-shrink: 0; - - .status-dot { - width: 7px; - height: 7px; - border-radius: 50%; - - &.dot-pass { - background: #10b981; - } - - &.dot-fail { - background: #ef4444; - } - - &.dot-warn { - background: #f59e0b; - } - } - } - } - - .item-body { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; - - .item-action-box { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; - } - } - - &.item-fail { - background: #fff8f8; - border-color: $rose-border; - - .item-icon-box { - background: $rose-light; - color: $rose; - border-color: $rose-border; - } - } - } - } - - /* --------------------------------------------------------------------- */ - /* STEP 2: AI MODEL & CREDENTIALS CONFIGURATION */ - /* --------------------------------------------------------------------- */ - .model-choice-cards-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - gap: 12px; - - .choice-card { - border: 1px solid $card-border; - border-radius: 10px; - padding: 14px 16px; - background: #f8fafc; - cursor: pointer; - transition: all 0.18s ease; - user-select: none; - - .choice-card-top { - display: flex; - align-items: flex-start; - gap: 12px; - - .choice-radio { - margin-top: 2px; - flex-shrink: 0; - - .radio-dot { - display: block; - width: 16px; - height: 16px; - border-radius: 50%; - border: 1.5px solid #cbd5e1; - background: #ffffff; - transition: all 0.15s ease; - - &.checked { - border-color: $primary; - background: $primary; - box-shadow: inset 0 0 0 3px #ffffff; - } - } - } - - .choice-icon-wrap { - width: 34px; - height: 34px; - border-radius: 8px; - background: #ffffff; - border: 1px solid $card-border; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - - .brand-svg { - width: 18px; - height: 18px; - } - - &.icon-gear { - color: $text-secondary; - - .material-symbols-outlined { - font-size: 18px; - } - } - } - - .choice-text { - flex: 1; - min-width: 0; - - .choice-title-row { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - - .choice-title { - font-size: 13.5px; - font-weight: 600; - color: $text-main; - } - - .tag-badge { - font-size: 10.5px; - font-weight: 600; - padding: 1px 7px; - border-radius: 10px; - line-height: 1.3; - - &.tag-rec { - background: $primary-light; - color: $primary; - border: 1px solid $primary-border; - } - - &.tag-ready { - background: $emerald-light; - color: $emerald; - border: 1px solid $emerald-border; - } - } - } - - .choice-desc { - margin: 3px 0 0 0; - font-size: 12px; - color: $text-secondary; - line-height: 1.4; - - code { - font-family: $font-mono; - background: #ffffff; - border: 1px solid $card-border; - padding: 1px 4px; - border-radius: 4px; - color: $primary-dark; - font-size: 11px; - } - } - } - } - - &:hover:not(.selected) { - border-color: $card-border-hover; - background: #ffffff; - } - - &.selected { - border-color: $primary; - background: #ffffff; - box-shadow: 0 0 0 1px $primary; - - .choice-icon-wrap { - border-color: $primary-border; - - &.icon-gear { - color: $primary; - } - } - } - } - } - - .credentials-input-box { - background: #f8fafc; - border: 1px solid $card-border; - border-radius: 10px; - padding: 14px 16px; - display: flex; - flex-direction: column; - gap: 10px; - animation: fadeIn 0.15s ease-out; - - &.ocr-box { - background: #ffffff; - border: 1px solid $card-border; - } - - .input-box-header { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 8px; - - .header-label { - display: flex; - align-items: center; - gap: 6px; - font-size: 12.5px; - font-weight: 600; - color: $text-main; - - .label-icon { - font-size: 16px; - color: $primary; - } - } - - .btn-header-link { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 11.5px; - font-weight: 600; - color: $primary; - text-decoration: none; - padding: 3px 10px; - height: 26px; - background: rgba(239, 246, 255, 0.75); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); - border: 1px solid rgba(191, 219, 254, 0.85); - border-radius: 6px; - box-shadow: 0 1px 3px rgba(37, 99, 235, 0.06), inset 0 1px 1px rgba(255, 255, 255, 0.8); - transition: all 0.15s ease; - - .material-symbols-outlined { - font-size: 13px; - color: $primary; - } - - &:hover { - background: rgba(219, 234, 254, 0.9); - color: $primary-dark; - border-color: $primary; - box-shadow: 0 2px 6px rgba(37, 99, 235, 0.15); - transform: translateY(-0.5px); - } - } - } - - .unified-key-row { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - - .unified-key-wrapper { - flex: 1 1 260px; - min-width: 0; - display: flex; - align-items: center; - background: rgba(255, 255, 255, 0.75); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border: 1px solid $card-border; - border-radius: 8px; - padding: 0 10px; - height: 36px; - transition: border-color 0.15s ease, box-shadow 0.15s ease; - - &:focus-within { - border-color: $primary; - box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.12); - } - - .key-prefix-icon { - font-size: 16px; - color: $text-light; - margin-right: 6px; - flex-shrink: 0; - } - - .unified-key-input { - flex: 1; - min-width: 0; - border: none; - outline: none; - font-family: $font-mono; - font-size: 12.5px; - color: $text-main; - background: transparent; - padding: 4px 0; - - &.is-masked { - letter-spacing: 2px; - font-weight: 700; - font-size: 13px; - } - - &::placeholder { - color: $text-light; - font-family: $font-sans; - font-size: 12px; - letter-spacing: normal; - font-weight: normal; - } - } - - .btn-icon-action { - display: flex; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; - background: transparent; - border: none; - border-radius: 4px; - color: $text-muted; - cursor: pointer; - transition: all 0.15s ease; - flex-shrink: 0; - - .material-symbols-outlined { - font-size: 15px; - } - - .text-emerald { - color: $emerald !important; - } - - &:hover { - background: rgba(241, 245, 249, 0.8); - color: $text-main; - } - } - } - - .btn-unified-save { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 5px; - padding: 0 18px; - height: 36px; - @include glass-primary-btn; - border-radius: 8px; - font-family: inherit; - font-size: 12.5px; - font-weight: 600; - cursor: pointer; - white-space: nowrap; - flex-shrink: 0; - - .material-symbols-outlined { - font-size: 16px; - } - - .spin { - animation: spin 1s linear infinite; - } - } - - .btn-unified-test { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 4px; - padding: 0 15px; - height: 36px; - background: rgba(239, 246, 255, 0.7); - backdrop-filter: blur(14px) saturate(180%); - -webkit-backdrop-filter: blur(14px) saturate(180%); - color: $primary; - border: 1px solid rgba(191, 219, 254, 0.85); - border-radius: 8px; - box-shadow: 0 2px 6px -1px rgba(37, 99, 235, 0.08), inset 0 1px 1px rgba(255, 255, 255, 0.9); - font-family: inherit; - font-size: 12.5px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - white-space: nowrap; - flex-shrink: 0; - - .material-symbols-outlined { - font-size: 15px; - color: $primary; - } - - &:hover:not(:disabled) { - background: rgba(219, 234, 254, 0.9); - border-color: $primary; - color: $primary-dark; - box-shadow: 0 4px 12px -2px rgba(37, 99, 235, 0.15), inset 0 1px 1px rgba(255, 255, 255, 1); - transform: translateY(-0.5px); - } - - &:active:not(:disabled) { - transform: scale(0.97); - } - - &:disabled { - opacity: 0.55; - cursor: not-allowed; - transform: none; - } - - .spin { - animation: spin 1s linear infinite; - } - } - } - - .feedback-msg { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - border-radius: 6px; - font-size: 12px; - font-weight: 600; - - .material-symbols-outlined { - font-size: 15px; - } - - &.success { - background: $emerald-light; - color: $emerald-dark; - border: 1px solid $emerald-border; - } - - &.error { - background: $rose-light; - color: $rose-dark; - border: 1px solid $rose-border; - } - } - } - - .ocr-enhancement-card { - background: #ffffff; - border: 1px solid $card-border; - border-radius: 10px; - overflow: hidden; - margin-top: 2px; - transition: border-color 0.18s ease; - - .ocr-card-toggle-bar { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 14px; - background: #ffffff; - cursor: pointer; - user-select: none; - transition: background 0.15s ease; - - &:hover { - background: #f8fafc; - } - - .toggle-bar-left { - display: flex; - align-items: center; - gap: 10px; - - .icon-ocr { - font-size: 18px; - color: $primary; - flex-shrink: 0; - } - - .toggle-bar-titles { - display: flex; - align-items: center; - gap: 8px; - - .ocr-main-title { - font-size: 13px; - font-weight: 600; - color: $text-main; - } - } - } - - .toggle-bar-right { - display: flex; - align-items: center; - gap: 10px; - - .status-dot { - width: 7px; - height: 7px; - border-radius: 50%; - - &.dot-pass { - background: #10b981; - } - } - - .caret-icon { - font-size: 18px; - color: $text-muted; - flex-shrink: 0; - } - } - } - - .ocr-content-body { - padding: 12px 14px; - border-top: 1px solid #f1f5f9; - display: flex; - flex-direction: column; - gap: 10px; - background: #f8fafc; - animation: fadeIn 0.15s ease-out; - - .ocr-help-desc { - margin: 0; - font-size: 12px; - color: $text-secondary; - line-height: 1.45; - } - - .credentials-input-box.ocr-box { - margin: 0; - padding: 12px; - background: #ffffff; - border: 1px solid $card-border; - border-radius: 8px; - } - } - } - - .custom-config-inspector { - display: flex; - flex-direction: column; - gap: 12px; - animation: fadeIn 0.15s ease-out; - - .inspector-card { - background: #ffffff; - border: 1px solid $card-border; - border-radius: 10px; - overflow: hidden; - - .inspector-card-header { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 8px; - padding: 10px 14px; - background: #ffffff; - border-bottom: 1px solid #f1f5f9; - - .inspector-header-left { - display: flex; - align-items: center; - gap: 8px; - - .header-icon { - font-size: 18px; - color: $primary; - } - - .inspector-header-titles { - display: flex; - flex-direction: column; - - .inspector-title { - font-size: 13px; - font-weight: 600; - color: $text-main; - } - - .inspector-subtitle { - font-size: 11.5px; - color: $text-secondary; - - code { - font-family: $font-mono; - background: #f1f5f9; - padding: 1px 4px; - border-radius: 4px; - color: $primary-dark; - } - } - } - } - - .inspector-header-actions { - display: flex; - align-items: center; - gap: 6px; - } - - .btn-file-action { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 0 10px; - height: 28px; - @include glass-secondary-btn; - border-radius: 6px; - font-family: inherit; - font-size: 11.5px; - font-weight: 600; - cursor: pointer; - - .material-symbols-outlined { - font-size: 13px; - } - - &.btn-toggle-code { - background: rgba(239, 246, 255, 0.75); - color: $primary; - border-color: rgba(191, 219, 254, 0.85); - - &:hover { - background: rgba(219, 234, 254, 0.9); - color: $primary-dark; - border-color: $primary; - } - } - } - } - - .inspector-summary-row { - display: flex; - flex-wrap: wrap; - gap: 12px; - padding: 12px 14px; - background: #ffffff; - - .summary-metric { - display: flex; - flex-direction: column; - gap: 4px; - - .metric-label { - font-size: 10.5px; - font-weight: 700; - color: $text-muted; - text-transform: uppercase; - letter-spacing: 0.5px; - } - - .metric-val-pill { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 4px 10px; - background: #f8fafc; - border: 1px solid $card-border; - border-radius: 6px; - font-family: $font-mono; - font-size: 12px; - color: $text-main; - - .provider-dot { - width: 7px; - height: 7px; - border-radius: 50%; - background: $primary; - } - - .provider-name { - font-weight: 700; - color: $primary; - } - - .model-divider { - color: $text-light; - } - - .model-name { - font-weight: 600; - color: $text-main; - } - - &.secondary { - background: #f8fafc; - color: $text-secondary; - font-size: 11.5px; - } - } - } - } - - .jsonc-viewer-box { - border-top: 1px solid $card-border; - background: #0f172a; - max-height: 260px; - overflow-y: auto; - padding: 12px 14px; - - .jsonc-code { - margin: 0; - font-family: $font-mono; - font-size: 11.5px; - line-height: 1.5; - color: #e2e8f0; - white-space: pre-wrap; - word-break: break-all; - } - } - - .env-keys-table { - display: flex; - flex-direction: column; - - .env-key-row { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 8px; - padding: 8px 14px; - border-bottom: 1px solid #f1f5f9; - transition: background 0.12s ease; - - &:last-child { - border-bottom: none; - } - - &:hover { - background: #f8fafc; - } - - .env-name-col { - display: flex; - align-items: center; - gap: 8px; - - .env-key-name { - font-family: $font-mono; - font-size: 12px; - font-weight: 600; - color: $text-main; - background: #f1f5f9; - padding: 2px 6px; - border-radius: 4px; - } - } - - .env-status-col { - display: flex; - align-items: center; - - .badge-env-pass { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 11px; - font-weight: 600; - color: $emerald; - background: $emerald-light; - border: 1px solid $emerald-border; - padding: 2px 7px; - border-radius: 6px; - - .material-symbols-outlined { - font-size: 13px; - } - - .key-preview { - font-family: $font-mono; - font-size: 10.5px; - color: $emerald-dark; - margin-left: 2px; - } - } - - .badge-env-unset { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: 11px; - font-weight: 600; - color: $text-light; - background: #f8fafc; - border: 1px solid $card-border; - padding: 2px 7px; - border-radius: 6px; - - .material-symbols-outlined { - font-size: 13px; - } - } - } - } - } - } - } - - /* --------------------------------------------------------------------- */ - /* STEP 3: ANDROID DEVICE & EMULATOR CONNECTION */ - /* --------------------------------------------------------------------- */ - .connected-device-panel { - display: flex; - flex-direction: column; - gap: 10px; - - .device-hero-card { - display: flex; - align-items: center; - gap: 16px; - padding: 16px 20px; - background: #ffffff; - border: 1px solid #e2e8f0; - border-radius: 12px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02), 0 4px 12px rgba(0, 0, 0, 0.02); - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - - &:hover { - border-color: #cbd5e1; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04), 0 8px 18px rgba(0, 0, 0, 0.03); - } - - .device-avatar { - width: 44px; - height: 44px; - border-radius: 10px; - background: #f1f5f9; - border: 1px solid #e2e8f0; - color: #334155; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - transition: all 0.2s ease; - - .device-hero-icon { - font-size: 22px; - color: #334155 !important; - } - } - - .device-hero-details { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; - flex: 1; - - .device-hero-name { - font-size: 14.5px; - font-weight: 600; - color: $text-main; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - letter-spacing: -0.01em; - } - - .device-hero-meta { - display: flex; - align-items: center; - gap: 6px; - font-size: 12px; - color: $text-muted; - flex-wrap: wrap; - - .meta-dot { - color: $text-light; - } - - .remote-endpoint-label { - color: $primary; - font-family: $font-mono; - font-size: 11px; - } - } - } - - .device-hero-status { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - font-weight: 500; - color: $emerald-dark; - margin-left: auto; - flex-shrink: 0; - - .status-dot { - width: 7px; - height: 7px; - border-radius: 50%; - background: #10b981; - box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); - animation: statusDotPulse 2s infinite cubic-bezier(0.4, 0, 0.6, 1); - } - } - - } - - .multi-device-switcher { - display: flex; - flex-direction: column; - gap: 5px; - - .switcher-label { - font-size: 11px; - font-weight: 700; - color: $text-muted; - text-transform: uppercase; - letter-spacing: 0.5px; - } - - .switcher-chips { - display: flex; - flex-wrap: wrap; - gap: 6px; - - .device-chip-btn { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 0 10px; - height: 30px; - background: #ffffff; - border: 1px solid $card-border; - border-radius: 6px; - font-family: inherit; - font-size: 11.5px; - font-weight: 600; - color: $text-secondary; - cursor: pointer; - transition: all 0.15s ease; - - .chip-icon { - font-size: 14px; - } - - .active-check { - color: $emerald; - font-size: 10.5px; - font-weight: 700; - } - - &.selected { - background: $emerald-light; - border-color: $emerald; - color: $emerald; - } - - &:hover:not(.selected) { - background: #f8fafc; - border-color: $card-border-hover; - } - } - } - } - } - - /* Live Emulator Boot Tracker & Diagnostic Card */ - .emulator-boot-tracker-container, - .emulator-failed-container { - display: flex; - flex-direction: column; - gap: 12px; - margin-bottom: 8px; - } - - .emulator-boot-tracker-card { - display: flex; - flex-direction: column; - gap: 16px; - padding: 20px; - background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); - border: 1.5px solid rgba(37, 99, 235, 0.25); - border-radius: 12px; - box-shadow: 0 4px 16px rgba(37, 99, 235, 0.06); - - .tracker-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - flex-wrap: wrap; - - .tracker-title-group { - display: flex; - align-items: flex-start; - gap: 12px; - flex: 1; - min-width: 260px; - - .tracker-icon-box { - width: 36px; - height: 36px; - border-radius: 10px; - background: rgba(37, 99, 235, 0.1); - color: $primary; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - - .spinning-icon { - font-size: 20px; - animation: spin 1.2s linear infinite; - } - } - - .tracker-titles { - display: flex; - flex-direction: column; - gap: 4px; - - .tracker-main-heading { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - - .tracker-title { - margin: 0; - font-size: 15px; - font-weight: 700; - color: $text-main; - - .avd-highlight { - color: $primary; - font-family: 'JetBrains Mono', monospace, sans-serif; - } - } - - .stage-tag-badge { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: 12px; - background: rgba(37, 99, 235, 0.12); - color: $primary; - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.5px; - } - } - - .tracker-subtitle { - margin: 0; - font-size: 13px; - color: $text-secondary; - line-height: 1.4; - } - } - } - - .tracker-actions { - display: flex; - align-items: center; - gap: 8px; - - .btn-toggle-logs { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 13px; - @include glass-secondary-btn; - border-radius: 6px; - font-size: 12px; - font-weight: 600; - cursor: pointer; - - .material-symbols-outlined { - font-size: 15px; - } - - &.active { - background: rgba(239, 246, 255, 0.9); - color: $primary; - border-color: rgba(37, 99, 235, 0.4); - box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12); - } - } - - .btn-stop-emu { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 6px 13px; - background: rgba(254, 242, 242, 0.75); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border: 1px solid rgba(254, 202, 202, 0.85); - border-radius: 6px; - color: $rose; - font-size: 12px; - font-weight: 600; - cursor: pointer; - box-shadow: 0 1px 3px rgba(220, 38, 38, 0.06), inset 0 1px 1px rgba(255, 255, 255, 0.8); - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - - .material-symbols-outlined { - font-size: 15px; - } - - &:hover { - background: rgba(254, 226, 226, 0.95); - border-color: $rose; - box-shadow: 0 4px 12px -2px rgba(220, 38, 38, 0.15); - transform: translateY(-0.5px); - } - - &:active { - transform: scale(0.97); - } - } - } - } - - /* Boot Stepper */ - .boot-stepper { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 14px; - background: #ffffff; - border: 1px solid $card-border; - border-radius: 8px; - - .step-item { - display: flex; - align-items: center; - gap: 8px; - font-size: 12px; - font-weight: 600; - color: $text-muted; - - .step-circle { - width: 22px; - height: 22px; - border-radius: 50%; - background: #f1f5f9; - border: 1.5px solid $card-border; - display: flex; - align-items: center; - justify-content: center; - font-size: 11px; - font-weight: 700; - color: $text-muted; - transition: all 0.2s ease; - - .check-icon { - font-size: 14px; - color: #ffffff; - } - - .spinner-tiny { - width: 11px; - height: 11px; - border: 2px solid rgba(37, 99, 235, 0.3); - border-top-color: $primary; - border-radius: 50%; - animation: spin 0.8s linear infinite; - } - } - - .step-label { - white-space: nowrap; - } - - &.active { - color: $primary; - - .step-circle { - border-color: $primary; - background: rgba(37, 99, 235, 0.1); - color: $primary; - } - } - - &.done { - color: $emerald; - - .step-circle { - border-color: $emerald; - background: $emerald; - color: #ffffff; - } - } - } - - .step-line { - flex: 1; - height: 2px; - background: $card-border; - margin: 0 10px; - border-radius: 2px; - transition: all 0.3s ease; - - &.done { - background: $emerald; - } - } - } - - /* Progress Bar */ - .progress-bar-container { - display: flex; - flex-direction: column; - gap: 6px; - - .progress-bar-track { - width: 100%; - height: 8px; - background: #e2e8f0; - border-radius: 6px; - overflow: hidden; - - .progress-bar-fill { - height: 100%; - background: linear-gradient(90deg, #3b82f6 0%, #10b981 100%); - border-radius: 6px; - transition: width 0.4s ease; - } - } - - .progress-bar-meta { - display: flex; - align-items: center; - justify-content: space-between; - font-size: 12px; - font-weight: 600; - - .progress-percent { - color: $primary; - } - - .progress-timer { - display: flex; - align-items: center; - gap: 4px; - color: $text-secondary; - - .timer-icon { - font-size: 14px; - } - } - } - } - - /* Tip Banner */ - .boot-tip-banner { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - background: rgba(37, 99, 235, 0.05); - border-left: 3px solid $primary; - border-radius: 0 6px 6px 0; - font-size: 12px; - color: $text-secondary; - - .tip-icon { - font-size: 16px; - color: $primary; - flex-shrink: 0; - } - } - - /* Logs Drawer */ - .boot-logs-drawer { - display: flex; - flex-direction: column; - border: 1px solid #1e293b; - border-radius: 8px; - background: #0f172a; - overflow: hidden; - - .logs-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 12px; - background: #1e293b; - color: #94a3b8; - font-size: 12px; - font-weight: 600; - - .logs-header-left { - display: flex; - align-items: center; - gap: 6px; - - .material-symbols-outlined { - font-size: 14px; - } - } - - .btn-copy-logs { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 8px; - background: #334155; - border: none; - border-radius: 4px; - color: #f8fafc; - font-size: 11px; - font-weight: 600; - cursor: pointer; - - .material-symbols-outlined { - font-size: 12px; - } - - &:hover { - background: #475569; - } - } - } - - .logs-body { - margin: 0; - padding: 12px; - max-height: 180px; - overflow-y: auto; - font-family: 'JetBrains Mono', monospace, sans-serif; - font-size: 11px; - line-height: 1.5; - color: #cbd5e1; - white-space: pre-wrap; - word-break: break-all; - - .log-line { - margin-bottom: 2px; - } - - .log-empty { - color: #64748b; - font-style: italic; - } - } - } - } - - /* Failed Diagnostic Card */ - .emulator-failed-card { - display: flex; - flex-direction: column; - gap: 14px; - padding: 18px 20px; - background: #fff8f8; - border: 1.5px solid rgba(239, 68, 68, 0.3); - border-radius: 12px; - box-shadow: 0 4px 14px rgba(239, 68, 68, 0.06); - - .failed-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - - .failed-header-left { - display: flex; - align-items: flex-start; - gap: 12px; - - .failed-icon-box { - width: 34px; - height: 34px; - border-radius: 8px; - background: rgba(239, 68, 68, 0.12); - color: $rose; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - - .failed-icon { - font-size: 20px; - } - } - - .failed-titles { - display: flex; - flex-direction: column; - gap: 2px; - - .failed-title { - margin: 0; - font-size: 15px; - font-weight: 700; - color: $rose; - } - - .failed-desc { - margin: 0; - font-size: 13px; - color: $text-main; - line-height: 1.4; - } - } - } - - .btn-dismiss-failed { - background: transparent; - border: none; - color: $text-muted; - cursor: pointer; - padding: 4px; - border-radius: 4px; - - &:hover { - background: rgba(0, 0, 0, 0.05); - color: $text-main; - } - } - } - - .failed-logs-box { - display: flex; - flex-direction: column; - background: #1e1e2e; - border-radius: 8px; - overflow: hidden; - - .logs-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 6px 12px; - background: #282a36; - color: #ff5555; - font-size: 11px; - font-weight: 600; - - .btn-copy-logs { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 6px; - background: #44475a; - border: none; - border-radius: 4px; - color: #f8fafc; - font-size: 10px; - cursor: pointer; - } - } - - .failed-logs-pre { - margin: 0; - padding: 10px 12px; - max-height: 120px; - overflow-y: auto; - font-family: 'JetBrains Mono', monospace; - font-size: 11px; - line-height: 1.4; - color: #f8f8f2; - white-space: pre-wrap; - } - } - - .failed-actions-bar { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; - - .btn-retry-launch { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 16px; - @include glass-primary-btn; - border-radius: 6px; - font-size: 12px; - font-weight: 600; - cursor: pointer; - - .material-symbols-outlined { - font-size: 15px; - } - } - - .btn-restart-adb-sub { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 14px; - @include glass-secondary-btn; - border-radius: 6px; - font-size: 12px; - font-weight: 600; - cursor: pointer; - - .material-symbols-outlined { - font-size: 15px; - } - } - - .btn-dismiss-flat { - background: transparent; - border: none; - color: $text-secondary; - font-size: 12px; - font-weight: 600; - cursor: pointer; - padding: 6px 10px; - - &:hover { - text-decoration: underline; - } - } - } - } - - /* Foolproof Connection Guide Tabs */ - .foolproof-guide-container { - display: flex; - flex-direction: column; - gap: 12px; - - .connection-methods-nav { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; - - @media (max-width: 1100px) { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - @media (max-width: 620px) { - grid-template-columns: 1fr; - } - - .method-nav-card { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 14px; - background: #f8fafc; - border: 1px solid $card-border; - border-radius: 10px; - cursor: pointer; - text-align: left; - transition: all 0.18s ease; - outline: none; - min-width: 0; - - .method-card-left { - display: flex; - align-items: center; - gap: 10px; - flex: 1; - min-width: 0; - - .method-icon-box { - width: 32px; - height: 32px; - border-radius: 8px; - background: #ffffff; - border: 1px solid $card-border; - color: $text-secondary; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - transition: all 0.15s ease; - - .material-symbols-outlined { - font-size: 17px; - } - } - - .method-info { - display: flex; - flex-direction: column; - gap: 1px; - min-width: 0; - flex: 1; - - .method-title-row { - display: flex; - align-items: center; - - .method-title { - font-size: 13px; - font-weight: 600; - color: $text-main; - } - } - - .method-desc { - font-size: 11.5px; - color: $text-muted; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; - } - } - } - - .method-card-right { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - margin-left: 8px; - - .radio-indicator { - width: 16px; - height: 16px; - border-radius: 50%; - border: 1.5px solid #cbd5e1; - background: #ffffff; - transition: all 0.15s ease; - - &.checked { - border-color: $primary; - background: $primary; - box-shadow: inset 0 0 0 3px #ffffff; - } - } - } - - &.active { - background: #ffffff; - border-color: $primary; - box-shadow: 0 0 0 1px $primary; - - .method-icon-box { - background: $primary-light; - color: $primary; - border-color: $primary-border; - } - - .method-title { - color: $primary; - } - } - - &:hover:not(.active) { - background: #ffffff; - border-color: $card-border-hover; - } - } - } - - .active-remote-notice { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 10px 12px; - background: #fffbeb; - border: 1px solid $amber-border; - border-radius: 8px; - - .active-remote-copy { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - color: #78350f; - font-size: 11.5px; - line-height: 1.45; - - .material-symbols-outlined { - color: $amber; - font-size: 17px; - flex-shrink: 0; - } - - strong { - font-family: $font-mono; - font-weight: 600; - } - } - - .btn-use-local-adb { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 5px; - min-height: 32px; - padding: 0 12px; - @include glass-secondary-btn; - border-radius: 6px; - font-family: inherit; - font-size: 11.5px; - font-weight: 600; - cursor: pointer; - white-space: nowrap; - - .material-symbols-outlined { - font-size: 15px; - } - } - - @media (max-width: 700px) { - align-items: stretch; - flex-direction: column; - - .btn-use-local-adb { - width: 100%; - } - } - - } - - .method-details-panel { - background: #ffffff; - border: 1px solid $card-border; - border-radius: 10px; - padding: 14px 16px; - display: flex; - flex-direction: column; - gap: 12px; - animation: fadeIn 0.15s ease-out; - - .avds-section { - display: flex; - flex-direction: column; - gap: 10px; - - .section-header-bar { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 6px; - padding-bottom: 6px; - border-bottom: 1px solid #f1f5f9; - - .section-title-group { - display: flex; - align-items: center; - gap: 6px; - - .header-icon { - font-size: 17px; - color: $primary; - } - - .section-title { - font-size: 13px; - font-weight: 600; - color: $text-main; - } - - .count-badge { - font-size: 10.5px; - font-weight: 600; - padding: 1.5px 6px; - border-radius: 6px; - background: #f1f5f9; - color: $text-secondary; - } - } - } - - .avds-list { - display: flex; - flex-direction: column; - gap: 8px; - - .avd-card-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 12px; - background: #f8fafc; - border: 1px solid $card-border; - border-radius: 8px; - gap: 12px; - min-width: 0; - transition: all 0.15s ease; - - &:hover { - border-color: $card-border-hover; - background: #ffffff; - } - - .avd-item-main { - display: flex; - align-items: center; - gap: 10px; - flex: 1; - min-width: 0; - - .avd-avatar { - width: 32px; - height: 32px; - border-radius: 6px; - background: #ffffff; - border: 1px solid $card-border; - color: $primary; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - - .material-symbols-outlined { - font-size: 17px; - } - } - - .avd-meta { - display: flex; - flex-direction: column; - gap: 1px; - flex: 1; - min-width: 0; - - .avd-heading { - display: flex; - align-items: center; - gap: 6px; - min-width: 0; - - .avd-name { - font-size: 13px; - font-weight: 600; - color: $text-main; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .avd-type-pill { - font-size: 9.5px; - font-weight: 600; - padding: 1px 5px; - border-radius: 4px; - background: #ffffff; - border: 1px solid $card-border; - color: $text-muted; - flex-shrink: 0; - } - } - } - } - - .avd-item-actions { - display: flex; - align-items: center; - gap: 6px; - flex-shrink: 0; - - .btn-avd-launch { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 4px; - padding: 0 15px; - height: 30px; - @include glass-primary-btn; - border-radius: 6px; - font-family: inherit; - font-size: 12px; - font-weight: 600; - cursor: pointer; - - .action-icon { - font-size: 15px; - } - - .spinner-icon { - width: 13px; - height: 13px; - border: 2px solid rgba(255, 255, 255, 0.4); - border-top-color: #ffffff; - border-radius: 50%; - animation: spin 0.8s linear infinite; - } - } - } - } - } - } - - .emulator-setup-guide-container { - display: flex; - flex-direction: column; - gap: 12px; - - .empty-avd-banner { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 12px; - padding: 12px 14px; - background: #f8fafc; - border: 1px solid $card-border; - border-radius: 8px; - - .banner-left { - display: flex; - align-items: center; - gap: 10px; - - .empty-avd-icon-box { - width: 34px; - height: 34px; - border-radius: 6px; - background: #ffffff; - border: 1px solid $card-border; - color: $text-muted; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - - .material-symbols-outlined { - font-size: 19px; - } - } - - .banner-titles { - .banner-title-row { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; - - .empty-title { - margin: 0; - font-size: 13px; - font-weight: 600; - color: $text-main; - } - } - - .empty-desc { - margin: 2px 0 0; - font-size: 12px; - color: $text-secondary; - } - } - } - - .mode-switch-pills { - display: inline-flex; - background: #ffffff; - border: 1px solid $card-border; - padding: 2px; - border-radius: 6px; - gap: 2px; - - .pill-btn { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 3px 8px; - border-radius: 4px; - border: none; - background: transparent; - color: $text-secondary; - font-family: inherit; - font-size: 11.5px; - font-weight: 600; - cursor: pointer; - transition: all 0.15s ease; - - .pill-icon { - font-size: 14px; - } - - &:hover { - color: $text-main; - } - - &.active { - background: $primary; - color: #ffffff; - } - } - } - } - - .emu-flow-grid { - .flow-step-card { - .guide-link { - color: $primary; - font-weight: 600; - text-decoration: none; - &:hover { - text-decoration: underline; - } - } - } - } - - .cli-steps-list { - display: flex; - flex-direction: column; - gap: 8px; - - .cli-step-item { - display: flex; - align-items: flex-start; - gap: 10px; - padding: 10px 12px; - background: #f8fafc; - border: 1px solid $card-border; - border-radius: 8px; - - .step-badge-num { - width: 22px; - height: 22px; - border-radius: 50%; - background: $primary-light; - color: $primary; - font-size: 11.5px; - font-weight: 700; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - margin-top: 1px; - } - - .step-content { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 3px; - - .step-title { - font-size: 12.5px; - font-weight: 600; - color: $text-main; - } - - .step-text { - margin: 0; - font-size: 11.5px; - color: $text-secondary; - } - - .small-box { - margin-top: 4px; - } - } - } - } - } - - .usb-guide-intro, - .wifi-guide-intro, - .remote-guide-intro { - display: flex; - align-items: center; - gap: 8px; - font-size: 12.5px; - font-weight: 600; - color: $text-secondary; - - .intro-icon { - font-size: 17px; - color: $primary; - } - } - - /* USB Steps Flow Grid */ - .steps-flow-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 10px; - - @media (max-width: 900px) { - grid-template-columns: repeat(2, 1fr); - } - - @media (max-width: 520px) { - grid-template-columns: 1fr; - } - - .flow-step-card { - background: #f8fafc; - border: 1px solid $card-border; - border-radius: 8px; - padding: 12px 14px; - display: flex; - flex-direction: column; - gap: 6px; - min-width: 0; - transition: all 0.15s ease; - - &:hover { - border-color: $card-border-hover; - background: #ffffff; - } - - .step-card-header { - display: flex; - align-items: center; - justify-content: space-between; - - .step-number-badge { - width: 20px; - height: 20px; - border-radius: 4px; - background: $primary; - color: #ffffff !important; - font-size: 11px; - font-weight: 700; - display: flex; - align-items: center; - justify-content: center; - } - - .step-card-icon { - font-size: 17px; - color: $text-light; - } - } - - .step-card-title { - font-size: 12.5px; - font-weight: 600; - color: $text-main; - } - - .step-card-desc { - font-size: 11.5px; - color: $text-muted; - line-height: 1.4; - } - } - } - - /* Interactive Wi-Fi Form Box */ - .wifi-interactive-box { - background: #f8fafc; - border: 1px solid $card-border; - border-radius: 8px; - padding: 12px 14px; - display: flex; - flex-direction: column; - gap: 10px; - - .wifi-form-row { - display: flex; - align-items: flex-end; - gap: 8px; - flex-wrap: wrap; - - .wifi-input-col { - display: flex; - flex-direction: column; - gap: 3px; - min-width: 0; - - &.ip-col { - flex: 1 1 180px; - } - - &.port-col { - flex: 0 1 90px; - } - - .wifi-label { - font-size: 11px; - font-weight: 600; - color: $text-muted; - } - - .wifi-input-wrap { - display: flex; - align-items: center; - background: #ffffff; - border: 1px solid $card-border; - border-radius: 6px; - padding: 0 8px; - height: 34px; - transition: border-color 0.15s ease; - - &:focus-within { - border-color: $primary; - box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.1); - } - - .wrap-icon { - font-size: 15px; - color: $text-light; - margin-right: 5px; - flex-shrink: 0; - } - - .wifi-input { - flex: 1; - min-width: 0; - border: none; - outline: none; - background: transparent; - font-family: $font-mono; - font-size: 12px; - color: $text-main; - - &::placeholder { - color: $text-light; - font-family: $font-sans; - font-size: 11.5px; - } - } - } - } - - .wifi-btn-col { - flex-shrink: 0; - - .btn-wifi-connect { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 4px; - padding: 0 16px; - height: 34px; - @include glass-primary-btn; - border-radius: 6px; - font-family: inherit; - font-size: 12px; - font-weight: 600; - cursor: pointer; - white-space: nowrap; - - .material-symbols-outlined { - font-size: 16px; - } - - .spinning { - animation: spin 1s linear infinite; - } - } - } - } - - .wifi-status-alert { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - border-radius: 6px; - font-size: 11.5px; - font-weight: 600; - - .material-symbols-outlined { - font-size: 15px; - } - - &.success { - background: $emerald-light; - color: $emerald-dark; - border: 1px solid $emerald-border; - } - - &.error { - background: $rose-light; - color: $rose-dark; - border: 1px solid $rose-border; - } - } - } - - .info-callout-box { - display: flex; - align-items: flex-start; - gap: 8px; - padding: 8px 12px; - background: $primary-light; - border: 1px solid $primary-border; - border-radius: 6px; - font-size: 11.5px; - color: $primary-dark; - line-height: 1.45; - - .callout-icon { - font-size: 16px; - color: $primary; - flex-shrink: 0; - margin-top: 1px; - } - } - - &.remote-adb-panel { - .remote-adb-interactive-box { - display: flex; - flex-direction: column; - gap: 10px; - padding: 12px 14px; - background: #f8fafc; - border: 1px solid $card-border; - border-radius: 8px; - - .remote-adb-form-row { - display: flex; - align-items: flex-end; - gap: 8px; - flex-wrap: wrap; - - .remote-adb-input-col { - display: flex; - flex-direction: column; - gap: 3px; - min-width: 0; - - &.host-col { - flex: 1 1 220px; - } - - &.port-col { - flex: 0 1 120px; - } - - .remote-adb-label { - color: $text-muted; - font-size: 11px; - font-weight: 600; - } - - .remote-adb-input-wrap { - display: flex; - align-items: center; - height: 36px; - padding: 0 9px; - background: #ffffff; - border: 1px solid $card-border; - border-radius: 6px; - transition: border-color 0.15s ease, box-shadow 0.15s ease; - - &:focus-within { - border-color: $primary; - box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.1); - } - - .wrap-icon { - margin-right: 6px; - color: $text-light; - font-size: 15px; - flex-shrink: 0; - } - - .remote-adb-input { - flex: 1; - min-width: 0; - border: 0; - outline: 0; - background: transparent; - color: $text-main; - font-family: $font-mono; - font-size: 12px; - - &::placeholder { - color: $text-light; - } - } - } - } - - .remote-adb-btn-col { - flex-shrink: 0; - - .btn-remote-adb-connect { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 5px; - height: 36px; - padding: 0 16px; - @include glass-primary-btn; - border-radius: 6px; - font-family: inherit; - font-size: 12px; - font-weight: 600; - cursor: pointer; - white-space: nowrap; - - .material-symbols-outlined { - font-size: 16px; - } - } - } - - @media (max-width: 640px) { - align-items: stretch; - flex-direction: column; - - .remote-adb-input-col, - .remote-adb-input-col.host-col, - .remote-adb-input-col.port-col, - .remote-adb-btn-col { - flex: 1 1 auto; - width: 100%; - } - - .remote-adb-btn-col .btn-remote-adb-connect { - width: 100%; - } - } - } - - .remember-remote-adb { - display: inline-flex; - align-items: center; - align-self: flex-start; - gap: 7px; - color: $text-secondary; - font-size: 11.5px; - cursor: pointer; - - input { - width: 15px; - height: 15px; - margin: 0; - accent-color: $primary; - } - } - - .btn-use-tested-endpoint { - display: inline-flex; - align-items: center; - justify-content: center; - align-self: flex-end; - gap: 5px; - min-width: 150px; - height: 36px; - padding: 0 16px; - @include glass-primary-btn; - border-radius: 6px; - font-family: inherit; - font-size: 12px; - font-weight: 600; - cursor: pointer; - - .material-symbols-outlined { - font-size: 16px; - } - } - - .remote-adb-status-alert { - display: flex; - align-items: flex-start; - gap: 6px; - padding: 7px 10px; - border-radius: 6px; - font-size: 11.5px; - font-weight: 600; - line-height: 1.4; - - .material-symbols-outlined { - font-size: 15px; - flex-shrink: 0; - } - - &.success { - color: $emerald-dark; - background: $emerald-light; - border: 1px solid $emerald-border; - } - - &.error { - color: $rose-dark; - background: $rose-light; - border: 1px solid $rose-border; - } - } - - .remote-device-results { - display: flex; - flex-direction: column; - overflow: hidden; - background: #ffffff; - border: 1px solid $card-border; - border-radius: 7px; - - .remote-device-row { - display: flex; - align-items: center; - gap: 9px; - min-width: 0; - padding: 8px 10px; - border-bottom: 1px solid #f1f5f9; - - &:last-child { - border-bottom: 0; - } - - > .material-symbols-outlined { - color: $primary; - font-size: 17px; - } - - .remote-device-copy { - display: flex; - flex: 1; - flex-direction: column; - min-width: 0; - - strong { - color: $text-main; - font-size: 12px; - font-weight: 600; - } - - span { - overflow: hidden; - color: $text-muted; - font-family: $font-mono; - font-size: 10.5px; - text-overflow: ellipsis; - white-space: nowrap; - } - } - - .remote-device-state { - color: $amber-dark; - font-size: 10.5px; - font-weight: 600; - text-transform: capitalize; - - &.ready { - color: $emerald-dark; - } - } - } - } - } - - .remote-adb-explanation { - margin: 0; - color: $text-muted; - font-size: 11.5px; - line-height: 1.5; - } - } - } - } - - /* State C: Locked / Unauthorized Device Panels */ - .unauthorized-panel, - .device-locked-panel { - .unauthorized-card, - .device-locked-card { - display: flex; - align-items: center; - gap: 12px; - padding: 12px 16px; - background: $amber-light; - border: 1px solid $amber-border; - border-radius: 10px; - - .alert-icon { - font-size: 24px; - color: $amber; - flex-shrink: 0; - } - - .unauth-details, - .locked-details { - flex: 1; - min-width: 0; - - .unauth-title, - .locked-title { - font-size: 13px; - font-weight: 600; - color: $amber-dark; - margin-bottom: 2px; - } - - .unauth-desc, - .locked-desc { - font-size: 11.5px; - color: #78350f; - line-height: 1.4; - } - } - - .btn-retry-auth, - .btn-check-unlock { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 0 12px; - height: 30px; - background: rgba(254, 243, 199, 0.75); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border: 1px solid rgba(253, 230, 138, 0.85); - border-radius: 6px; - box-shadow: 0 1px 3px rgba(217, 119, 6, 0.08), inset 0 1px 1px rgba(255, 255, 255, 0.8); - font-family: inherit; - font-size: 11.5px; - font-weight: 600; - color: $amber-dark; - cursor: pointer; - flex-shrink: 0; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - - &:hover:not(:disabled) { - background: rgba(253, 230, 138, 0.95); - border-color: $amber; - transform: translateY(-0.5px); - box-shadow: 0 3px 8px rgba(217, 119, 6, 0.15); - } - - &:active { - transform: scale(0.97); - } - - &:disabled { - cursor: not-allowed; - opacity: 0.65; - } - } - } - } - - /* State D: ADB Missing Panel */ - .adb-missing-panel { - display: flex; - flex-direction: column; - gap: 10px; - - .error-msg-box { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 12px; - background: $rose-light; - border: 1px solid $rose-border; - border-radius: 8px; - font-size: 12px; - color: $rose-dark; - - .err-icon { - font-size: 17px; - flex-shrink: 0; - } - } - - .install-commands-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 8px; - - .install-card { - background: #f8fafc; - border: 1px solid $card-border; - border-radius: 8px; - padding: 10px 12px; - display: flex; - flex-direction: column; - gap: 6px; - transition: all 0.15s ease; - - &.highlight-os { - background: $primary-light; - border-color: $primary-border; - } - - .install-os { - display: flex; - align-items: center; - justify-content: space-between; - font-size: 11.5px; - font-weight: 600; - color: $text-secondary; - - .detected-badge { - font-size: 9.5px; - font-weight: 700; - color: $primary; - background: #dbeafe; - padding: 1px 5px; - border-radius: 4px; - text-transform: uppercase; - } - } - } - } - } - - /* Ready Banner Footer: Consistent with guide-card design system */ - .ready-banner-footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 16px 20px; - background: #ffffff; - border: 1px solid $card-border; - border-radius: 12px; - box-shadow: $card-shadow; - transition: all 0.2s ease; - - &:hover { - border-color: $card-border-hover; - } - - .ready-banner-left { - display: flex; - align-items: center; - gap: 14px; - min-width: 0; - flex: 1; - - .step-badge { - width: 32px; - height: 32px; - border-radius: 8px; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 13.5px; - font-weight: 700; - background: #f1f5f9; - color: $text-secondary; - flex-shrink: 0; - - .step-check-icon { - font-size: 18px; - font-weight: 700; - color: inherit; - } - - &.step-pass { - background: $emerald-light; - color: $emerald; - } - } - - .ready-text-group { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; - - .ready-title { - margin: 0; - font-size: 15px; - font-weight: 600; - color: $text-main; - letter-spacing: -0.01em; - } - - .ready-desc { - margin: 0; - font-size: 12.5px; - color: $text-secondary; - line-height: 1.4; - } - } - } - - .btn-proceed-launch { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 0 18px; - height: 36px; - @include glass-primary-btn; - border-radius: 8px; - font-family: inherit; - font-size: 13px; - font-weight: 600; - cursor: pointer; - white-space: nowrap; - flex-shrink: 0; - - .rocket-icon { - font-size: 16px; - transition: transform 0.2s ease; - } - - &:hover:not(:disabled) { - .rocket-icon { - transform: translateX(2px) translateY(-2px); - } - } - } - - @media (max-width: 768px) { - flex-direction: column; - align-items: stretch; - - .btn-proceed-launch { - justify-content: center; - } - } - } - - } - - /* ======================================================================= */ - /* SHARED CODE BOX & TERMINAL SNIPPET STYLES */ - /* ======================================================================= */ - .code-box { - display: inline-flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 0 6px 0 10px; - height: 32px; - background: #0f172a; - color: #f8fafc; - border-radius: 6px; - font-family: $font-mono; - font-size: 11.5px; - min-width: 0; - max-width: 100%; - - .terminal-prefix { - color: #64748b; - user-select: none; - flex-shrink: 0; - font-weight: 700; - font-size: 11px; - } - - .code-text, code { - flex: 1; - min-width: 0; - color: #38bdf8 !important; - font-family: inherit; - background: transparent !important; - border: none !important; - padding: 0 !important; - font-size: inherit; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .btn-copy-code { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 3px; - padding: 0 8px; - height: 22px; - background: rgba(255, 255, 255, 0.14); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - color: #ffffff !important; - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 5px; - box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.2); - font-family: $font-sans; - font-size: 10.5px; - font-weight: 600; - cursor: pointer; - transition: all 0.18s ease; - flex-shrink: 0; - - .material-symbols-outlined, span { - color: #ffffff !important; - } - - .material-symbols-outlined { - font-size: 12px; - } - - &:hover { - background: rgba(255, 255, 255, 0.26); - border-color: rgba(255, 255, 255, 0.35); - transform: translateY(-0.5px); - } - - &:active { - transform: scale(0.96); - } - - &.copied { - background: rgba(16, 185, 129, 0.35); - backdrop-filter: blur(8px); - border-color: rgba(16, 185, 129, 0.6); - color: #34d399 !important; - box-shadow: 0 0 10px rgba(16, 185, 129, 0.3), inset 0 1px 1px rgba(255, 255, 255, 0.4); - - .material-symbols-outlined, span { - color: #34d399 !important; - } - } - } - - &.small-box { - height: 30px; - font-size: 11.5px; - padding: 0 6px 0 10px; - - .btn-copy-code { - height: 22px; - padding: 0 8px; - font-size: 10.5px; - - .material-symbols-outlined { - font-size: 12px; - } - } - } - } - - /* ======================================================================= */ - /* VIEW 2: TASK LAUNCHER (UNIFIED SCULPTURED COMPOSER SURFACE) */ - /* ======================================================================= */ - .task-launcher-view { - display: flex; - flex-direction: column; - gap: 12px; - animation: fadeIn 0.22s cubic-bezier(0.4, 0, 0.2, 1); - - .launcher-warning-bar { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 16px; - background: #fffbeb; - border: 1px solid #fde68a; - border-radius: 12px; - color: #92400e; - font-size: 13px; - cursor: pointer; - transition: all 0.15s ease; - - .warn-icon { - font-size: 18px; - color: #d97706; - flex-shrink: 0; - } - - .arrow { - font-size: 16px; - margin-left: auto; - } - - &:hover { - background: #fef3c7; - border-color: #fcd34d; - } - } - - /* Task composer */ - .composer-card { - background: rgba(255, 255, 255, 0.88); - backdrop-filter: blur(16px) saturate(190%); - -webkit-backdrop-filter: blur(16px) saturate(190%); - border: 1px solid rgba(255, 255, 255, 0.95); - box-shadow: 0 20px 50px -12px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(0, 0, 0, 0.02), inset 0 1px 1px rgba(255, 255, 255, 0.95); - border-radius: 20px; - padding: 18px 20px 14px; - transition: border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1), - box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1), - background 0.3s cubic-bezier(0.4, 0, 0.2, 1); - position: relative; - - &.is-pro-active { - border-color: rgba(221, 214, 254, 0.95); - box-shadow: 0 24px 60px -12px rgba(99, 102, 241, 0.16), 0 1px 3px rgba(0, 0, 0, 0.02), inset 0 1px 1px rgba(255, 255, 255, 0.95); - } - - .intent-suggestion-strip { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - padding: 8px 12px; - background: linear-gradient(135deg, rgba(238, 242, 255, 0.95) 0%, rgba(245, 243, 255, 0.95) 100%); - border: 1px solid rgba(199, 210, 254, 0.85); - border-radius: 10px; - margin-bottom: 12px; - animation: slideUpFade 0.22s cubic-bezier(0.4, 0, 0.2, 1); - - .suggestion-left { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - - .sparkle-icon { - font-size: 17px; - color: #6366f1; - flex-shrink: 0; - animation: sparklePulse 2.5s ease-in-out infinite alternate; - } - - .suggestion-text { - font-size: 12.5px; - color: #3730a3; - line-height: 1.4; - - strong { - color: #1e1b4b; - font-weight: 600; - } - } - } - - .btn-switch-pro { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 4px 10px; - border-radius: 6px; - border: none; - background: #6366f1; - color: #ffffff; - font-family: inherit; - font-size: 11.5px; - font-weight: 600; - cursor: pointer; - flex-shrink: 0; - transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: 0 1px 3px rgba(99, 102, 241, 0.25); - - .btn-arrow { - font-size: 14px; - transition: transform 0.15s ease; - } - - &:hover { - background: #4f46e5; - box-shadow: 0 2px 6px rgba(99, 102, 241, 0.35); - transform: translateY(-0.5px); - - .btn-arrow { - transform: translateX(2px); - } - } - - &:active { - transform: scale(0.96); - } - } - } - - .composer-input-area { - width: 100%; - - .composer-textarea { - width: 100%; - border: none; - outline: none; - background: transparent; - padding: 0; - margin: 0; - font-family: inherit; - font-size: 15.5px; - line-height: 1.6; - color: #0f172a; - resize: vertical; - min-height: 90px; - letter-spacing: -0.01em; - - &::placeholder { - color: #94a3b8; - font-weight: 400; - } - - &:disabled { - color: #94a3b8; - cursor: not-allowed; - } - } - } - - /* Contextual Model Descriptor Strip (Flash & Pro) */ - .composer-model-descriptor { - display: flex; - align-items: center; - gap: 9px; - padding: 8px 12px; - border-radius: 9px; - background: rgba(248, 250, 252, 0.75); - border: 1px solid rgba(241, 245, 249, 0.95); - margin-top: 10px; - margin-bottom: 12px; - transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); - - .descriptor-icon-wrap { - width: 22px; - height: 22px; - border-radius: 6px; - display: inline-flex; - align-items: center; - justify-content: center; - background: rgba(26, 115, 232, 0.1); - color: #1a73e8; - flex-shrink: 0; - transition: all 0.25s ease; - - .material-symbols-outlined { - font-size: 14px; - } - } - - .descriptor-body { - flex: 1; - min-width: 0; - - .desc-line { - margin: 0; - font-size: 12.5px; - line-height: 1.45; - color: #475569; - letter-spacing: -0.005em; - - .mode-name { - font-weight: 700; - color: #0f172a; - } - - .bullet { - color: #94a3b8; - margin: 0 3px; - } - - strong { - color: #0f172a; - font-weight: 600; - } - - .limitation-tag { - color: #64748b; - font-size: 11.5px; - margin-left: 3px; - } - } - } - - &.is-pro { - background: linear-gradient(135deg, rgba(245, 243, 255, 0.8) 0%, rgba(250, 245, 255, 0.8) 100%); - border-color: rgba(237, 233, 254, 0.95); - - .descriptor-icon-wrap { - background: rgba(124, 58, 237, 0.12); - color: #7c3aed; - } - - .descriptor-body .desc-line { - color: #334155; - - .mode-name, strong { - color: #1e1b4b; - } - } - } - } - - .error-banner { - display: flex; - align-items: center; - gap: 6px; - padding: 8px 12px; - background: $rose-light; - border: 1px solid $rose-border; - border-radius: 8px; - color: $rose-dark; - font-size: 12px; - margin-bottom: 10px; - - .error-icon { - font-size: 16px; - flex-shrink: 0; - } - } - - .composer-action-bar { - display: flex; - align-items: center; - justify-content: space-between; - padding-top: 10px; - border-top: 1px solid rgba(241, 245, 249, 0.95); - gap: 12px; - flex-wrap: wrap; - - .bar-left { - display: flex; - align-items: center; - - .model-toggle-capsule { - display: inline-flex; - align-items: center; - background: rgba(241, 245, 249, 0.85); - padding: 3px; - border-radius: 18px; - gap: 3px; - border: 1px solid rgba(226, 232, 240, 0.88); - width: 172px; - box-sizing: border-box; - - .model-toggle-btn { - flex: 1 1 0; - width: 50%; - height: 32px; - padding: 0 10px; - border-radius: 15px; - border: none; - background: transparent; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - outline: none; - box-sizing: border-box; - white-space: nowrap; - - .btn-icon { - font-size: 16px; - color: #64748b; - flex-shrink: 0; - transition: color 0.15s ease; - } - - .btn-text { - font-size: 13px; - font-weight: 500; - color: #475569; - line-height: 1; - } - - &:hover:not(.active) { - background: rgba(255, 255, 255, 0.7); - - .btn-icon, .btn-text { - color: #0f172a; - } - } - - &.active { - background: #ffffff; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04); - - .btn-icon { - color: #1a73e8; - } - .btn-text { - color: #0f172a; - font-weight: 600; - } - } - - &.btn-pro.active { - background: linear-gradient(135deg, #6366f1 0%, #7c3aed 100%); - box-shadow: 0 2px 8px rgba(99, 102, 241, 0.35); - - .btn-icon, .btn-text { - color: #ffffff !important; - } - } - - &:active { - transform: scale(0.97); - } - } - } - } - - .bar-right { - display: flex; - align-items: center; - gap: 12px; - - .char-count { - font-size: 11.5px; - color: #94a3b8; - font-variant-numeric: tabular-nums; - } - - .btn-run-composer { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - height: 38px; - padding: 0 20px; - @include glass-primary-btn; - border-radius: 12px; - font-family: inherit; - font-size: 13.5px; - font-weight: 700; - cursor: pointer; - letter-spacing: -0.01em; - - &.btn-run-pro { - background: linear-gradient(135deg, rgba(99, 102, 241, 0.92) 0%, rgba(124, 58, 237, 0.85) 100%); - box-shadow: 0 4px 14px rgba(99, 102, 241, 0.32), inset 0 1px 1px rgba(255, 255, 255, 0.45); - - &:hover:not(:disabled) { - background: linear-gradient(135deg, rgba(99, 102, 241, 1) 0%, rgba(124, 58, 237, 0.95) 100%); - box-shadow: 0 6px 20px rgba(99, 102, 241, 0.42), inset 0 1px 1px rgba(255, 255, 255, 0.6); - } - } - - .run-icon { - font-size: 18px; - line-height: 1; - transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); - } - - &:hover:not(:disabled) { - .run-icon { - transform: scale(1.15) rotate(15deg); - } - } - - .spinner { - width: 14px; - height: 14px; - border: 2px solid rgba(255, 255, 255, 0.4); - border-top-color: #ffffff; - border-radius: 50%; - animation: spin 0.8s linear infinite; - } - } - } - } - } - - /* Pro Mode Standalone Outputter Card (Minimalist Apple/Linear Style) */ - .pro-outputter-standalone-card { - background: rgba(255, 255, 255, 0.88); - backdrop-filter: blur(20px) saturate(180%); - -webkit-backdrop-filter: blur(20px) saturate(180%); - border: 1px solid rgba(226, 232, 240, 0.95); - border-radius: 16px; - padding: 12px 18px 14px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02), 0 8px 24px -6px rgba(15, 23, 42, 0.04); - display: flex; - flex-direction: column; - gap: 8px; - animation: slideUpFade 0.2s cubic-bezier(0.4, 0, 0.2, 1); - - .outputter-card-header { - display: flex; - align-items: baseline; - gap: 6px; - - .outputter-title { - font-size: 13px; - font-weight: 700; - color: #0f172a; - letter-spacing: -0.01em; - } - - .outputter-optional-tag { - font-size: 11.5px; - font-weight: 500; - color: #94a3b8; - } - } - - .outputter-input-wrapper { - display: flex; - align-items: center; - background: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 10px; - padding: 0 12px; - height: 38px; - transition: all 0.18s ease; - - &:focus-within { - background: #ffffff; - border-color: #7c3aed; - box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1); - } - - .outputter-standalone-input { - flex: 1; - border: none; - outline: none; - background: transparent; - font-family: inherit; - font-size: 13px; - color: #0f172a; - - &::placeholder { - color: #94a3b8; - font-weight: 400; - } - } - - .btn-clear-standalone { - border: none; - background: transparent; - color: #94a3b8; - cursor: pointer; - padding: 3px; - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - transition: color 0.15s ease, background 0.15s ease; - - &:hover { - color: #475569; - background: rgba(0, 0, 0, 0.05); - } - - .material-symbols-outlined { - font-size: 16px; - } - } - } - } - - - /* Pro tuning sliders and hover cards */ - .pro-tuning-card { - --tip-w: 320px; - --thumb-w: 22px; - --thumb-h: 14px; - --col-gap: 28px; - --accent: #6366f1; - position: relative; - background: rgba(255, 255, 255, 0.88); - backdrop-filter: blur(20px) saturate(180%); - -webkit-backdrop-filter: blur(20px) saturate(180%); - border: 1px solid rgba(226, 232, 240, 0.95); - border-radius: 16px; - padding: 12px 18px 14px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02), 0 8px 24px -6px rgba(15, 23, 42, 0.04); - display: flex; - flex-direction: column; - gap: 10px; - animation: slideUpFade 0.2s cubic-bezier(0.4, 0, 0.2, 1); - - &.tip-open { - z-index: 30; - } - - .tuning-card-header { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 10px; - min-height: 16px; - - .tuning-title { - font-size: 13px; - font-weight: 700; - color: #0f172a; - letter-spacing: -0.01em; - } - - .btn-tuning-reset { - border: none; - background: transparent; - padding: 0; - font-family: inherit; - font-size: 12px; - font-weight: 500; - color: #94a3b8; - cursor: pointer; - transition: color 0.15s ease; - - &:hover { - color: #475569; - } - - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 3px; - border-radius: 3px; - } - } - } - - .tuning-sliders { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 4px var(--col-gap); - } - - .tuning-slider { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; - /* Hover card clamp range, relative to this slider's track. The left - column may spill right across the gap; the right column may spill - left. Overridden below when the columns stack. */ - --tip-min: 0px; - --tip-max: calc(100% - var(--tip-w)); - - &.verify { - --tip-max: calc(200% + var(--col-gap) - var(--tip-w)); - } - - &.explore { - --accent: #0ea5e9; - --tip-min: calc(-100% - var(--col-gap)); - } - - .slider-head { - display: flex; - align-items: baseline; - gap: 8px; - font-size: 13px; - line-height: 18px; - color: #0f172a; - - .slider-name { - font-weight: 400; - color: #334155; - } - - .slider-value { - font-weight: 600; - } - } - - .slider-ends { - display: flex; - justify-content: space-between; - font-size: 11.5px; - line-height: 14px; - color: #94a3b8; - } - - .slider-track-wrap { - position: relative; - height: 26px; - display: flex; - align-items: center; - } - - .slider-rail { - position: relative; - width: 100%; - height: 6px; - border-radius: 3px; - background: #e2e8f0; - } - - .slider-fill { - position: absolute; - top: 0; - bottom: 0; - left: 0; - width: calc(var(--pct) * (100% - var(--thumb-w)) + var(--thumb-w) / 2); - border-radius: 3px; - background: color-mix(in srgb, var(--accent) 24%, #e2e8f0); - transition: width 0.15s ease; - } - - .slider-notch { - position: absolute; - top: 50%; - left: calc(var(--n) * (100% - var(--thumb-w)) + var(--thumb-w) / 2); - width: 4px; - height: 4px; - border-radius: 50%; - background: #94a3b8; - transform: translate(-50%, -50%); - transition: background 0.15s ease; - z-index: 1; - - &.reached { - background: rgba(255, 255, 255, 0.95); - } - } - - .slider-thumb { - position: absolute; - top: 50%; - left: calc(var(--pct) * (100% - var(--thumb-w)) + var(--thumb-w) / 2); - width: var(--thumb-w); - height: var(--thumb-h); - border-radius: 5px; - background: #ffffff; - border: 1px solid rgba(15, 23, 42, 0.1); - box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12), 0 2px 6px rgba(15, 23, 42, 0.08); - transform: translate(-50%, -50%); - pointer-events: none; - transition: left 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; - } - - /* End of track: a pixel front sweeps from the thumb to the left end, - a solid fill follows it until the whole rail has changed colour, - and the thumb emits one ripple. */ - .slider-solid { - position: absolute; - inset: 0; - border-radius: 3px; - background: var(--accent); - transform-origin: right center; - transform: scaleX(1); - animation: solidSweep 1s cubic-bezier(0.4, 0, 0.2, 1) 0.2s both; - pointer-events: none; - } - - .slider-dither { - position: absolute; - inset: 0; - display: block; - overflow: hidden; - border-radius: 3px; - pointer-events: none; - - rect { - fill: var(--accent); - animation: ditherIn 0.3s ease-out both; - } - } - - .thumb-ripple { - position: absolute; - top: 50%; - left: calc(var(--pct) * (100% - var(--thumb-w)) + var(--thumb-w) / 2); - width: var(--thumb-w); - height: var(--thumb-w); - border-radius: 50%; - border: 1.5px solid var(--accent); - transform: translate(-50%, -50%) scale(0.6); - opacity: 0; - pointer-events: none; - animation: thumbRipple 0.6s ease-out forwards; - } - - &.dragging .slider-thumb { - border-color: color-mix(in srgb, var(--accent) 45%, transparent); - box-shadow: - 0 1px 2px rgba(15, 23, 42, 0.12), - 0 2px 8px rgba(15, 23, 42, 0.1), - 0 0 0 4px color-mix(in srgb, var(--accent) 14%, transparent); - } - - /* Native range input does drag, keyboard, and a11y; it is invisible - but its thumb geometry matches the drawn thumb so hits line up. */ - .slider-input { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - margin: 0; - opacity: 0; - cursor: pointer; - -webkit-appearance: none; - appearance: none; - background: transparent; - - &::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: var(--thumb-w); - height: 26px; - } - - &::-moz-range-thumb { - width: var(--thumb-w); - height: 26px; - border: 0; - } - } - - .slider-input:focus-visible ~ .slider-thumb, - &:has(.slider-input:focus-visible) .slider-thumb { - box-shadow: - 0 1px 2px rgba(15, 23, 42, 0.12), - 0 0 0 2px #ffffff, - 0 0 0 4px var(--accent); - } - - .tuning-tip-arrow { - position: absolute; - bottom: calc(100% + 2px); - left: calc(var(--pct) * (100% - var(--thumb-w)) + var(--thumb-w) / 2); - width: 10px; - height: 10px; - background: #ffffff; - border-right: 1px solid rgba(226, 232, 240, 1); - border-bottom: 1px solid rgba(226, 232, 240, 1); - transform: translate(-50%, 50%) rotate(45deg); - z-index: 41; - pointer-events: none; - } - - .tuning-tip { - position: absolute; - bottom: calc(100% + 7px); - left: clamp( - var(--tip-min), - calc(var(--pct) * (100% - var(--thumb-w)) + var(--thumb-w) / 2 - var(--tip-w) / 2), - var(--tip-max) - ); - width: var(--tip-w); - max-width: calc(100vw - 32px); - padding: 12px 14px; - background: #ffffff; - border: 1px solid rgba(226, 232, 240, 1); - border-radius: 12px; - box-shadow: 0 12px 32px -8px rgba(15, 23, 42, 0.18), 0 2px 6px rgba(15, 23, 42, 0.06); - z-index: 40; - pointer-events: none; - animation: tipPop 0.16s ease-out; - font-size: 12px; - line-height: 1.5; - color: #334155; - display: flex; - flex-direction: column; - gap: 8px; - - .tip-heading { - display: flex; - flex-direction: column; - gap: 1px; - - .tip-title { - font-size: 13.5px; - font-weight: 600; - color: #0f172a; - letter-spacing: -0.01em; - } - - .tip-latency { - font-size: 11.5px; - color: #94a3b8; - } - } - - .tip-tagline { - margin: 0; - color: #0f172a; - } - - .tip-section { - display: flex; - flex-direction: column; - gap: 2px; - - ul { - margin: 0; - padding-left: 16px; - display: flex; - flex-direction: column; - gap: 2px; - } - - li::marker { - color: #cbd5e1; - } - - &.skips li { - color: #64748b; - } - } - - .tip-section-title { - font-size: 10.5px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: #94a3b8; - } - - .tip-best { - display: flex; - flex-direction: column; - gap: 2px; - color: #475569; - } - } - } - - @media (max-width: 760px) { - .tuning-sliders { - grid-template-columns: minmax(0, 1fr); - gap: 10px; - } - - .tuning-slider { - &.verify, - &.explore { - --tip-min: 0px; - --tip-max: calc(100% - var(--tip-w)); - } - } - } - } - - /* Smart Suggestions Hub (Device-Aware, Flash vs Pro Tailored) */ - .smart-suggestions-container { - display: flex; - flex-direction: column; - gap: 12px; - margin-top: 10px; - - .suggestions-header-bar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - flex-wrap: wrap; - - .header-left { - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; - - .section-label { - display: inline-flex; - align-items: center; - gap: 6px; - font-family: inherit; - font-size: 13px; - font-weight: 700; - color: #0f172a; - letter-spacing: -0.01em; - - .section-icon { - font-size: 17px; - color: #6366f1; - animation: sparklePulse 3s ease-in-out infinite alternate; - } - - .device-context-pill { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 8px; - border-radius: 12px; - background: rgba(16, 185, 129, 0.1); - border: 1px solid rgba(16, 185, 129, 0.25); - color: #059669; - font-size: 11px; - font-weight: 600; - line-height: 1.3; - - .dev-icon { - font-size: 13px; - color: #059669; - } - } - } - - .category-tabs-group { - display: inline-flex; - align-items: center; - gap: 3px; - background: rgba(255, 255, 255, 0.65); - backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); - padding: 3px; - border-radius: 16px; - border: 1px solid rgba(226, 232, 240, 0.85); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02); - - .cat-tab-btn { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 4px 10px; - border-radius: 12px; - border: none; - background: transparent; - font-family: inherit; - font-size: 11.5px; - font-weight: 600; - color: #64748b; - cursor: pointer; - transition: all 0.18s ease; - white-space: nowrap; - - .tab-mini-icon { - font-size: 13px; - } - - &:hover:not(.active) { - color: #0f172a; - background: rgba(255, 255, 255, 0.6); - } - - &.active { - color: #0f172a; - background: #ffffff; - box-shadow: 0 2px 6px rgba(15, 23, 42, 0.08), inset 0 1px 0 #fff; - - &.cat-device { - color: #059669; - } - - &.cat-flash { - color: #1a73e8; - } - - &.cat-pro { - color: #7c3aed; - } - } - } - } - } - - .header-right { - .btn-shuffle-suggestions { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 5px 12px; - border-radius: 14px; - background: rgba(255, 255, 255, 0.72); - backdrop-filter: blur(12px); - border: 1px solid rgba(226, 232, 240, 0.85); - color: #64748b; - font-family: inherit; - font-size: 11.5px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - - .shuffle-icon { - font-size: 14px; - transition: transform 0.2s ease; - } - - &:hover { - background: #ffffff; - color: #0f172a; - border-color: rgba(203, 213, 225, 0.9); - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06); - - .shuffle-icon { - transform: rotate(45deg); - } - } - - &:active { - transform: scale(0.97); - } - } - } - } - - .suggestions-cards-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(285px, 1fr)); - gap: 12px; - - .suggestion-card { - display: flex; - flex-direction: column; - justify-content: space-between; - padding: 14px 16px; - border-radius: 16px; - background: rgba(255, 255, 255, 0.72); - backdrop-filter: blur(10px) saturate(180%); - -webkit-backdrop-filter: blur(10px) saturate(180%); - border: 1px solid rgba(226, 232, 240, 0.85); - box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.95); - cursor: pointer; - transition: background 0.22s cubic-bezier(0.4, 0, 0.2, 1), - transform 0.22s cubic-bezier(0.4, 0, 0.2, 1), - box-shadow 0.22s cubic-bezier(0.4, 0, 0.2, 1), - border-color 0.22s cubic-bezier(0.4, 0, 0.2, 1); - user-select: none; - position: relative; - overflow: hidden; - min-height: 128px; - - &:hover { - background: rgba(255, 255, 255, 0.95); - transform: translateY(-2px); - box-shadow: 0 8px 24px -4px rgba(15, 23, 42, 0.08), 0 2px 6px rgba(0, 0, 0, 0.02), inset 0 1px 1px #ffffff; - border-color: rgba(191, 219, 254, 0.95); - } - - &.is-pro-card:hover { - border-color: rgba(199, 210, 254, 0.95); - box-shadow: 0 8px 24px -4px rgba(99, 102, 241, 0.12), inset 0 1px 1px #ffffff; - } - - &:active { - transform: scale(0.985); - } - - .card-top-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - - .app-icons-cluster { - display: flex; - align-items: center; - gap: 6px; - - .app-icon-badge { - width: 26px; - height: 26px; - border-radius: 8px; - background: #f1f5f9; - display: flex; - align-items: center; - justify-content: center; - color: #475569; - border: 1px solid rgba(226, 232, 240, 0.9); - flex-shrink: 0; - - span { - font-size: 14px; - } - } - - .app-names-label { - font-size: 11.5px; - font-weight: 600; - color: #475569; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 140px; - } - } - - .mode-badge { - padding: 2px 7px; - border-radius: 8px; - font-size: 10.5px; - font-weight: 700; - line-height: 1.2; - flex-shrink: 0; - - &.mode-flash { - background: rgba(26, 115, 232, 0.1); - color: #1a73e8; - border: 1px solid rgba(26, 115, 232, 0.2); - } - - &.mode-pro { - background: rgba(99, 102, 241, 0.12); - color: #6366f1; - border: 1px solid rgba(99, 102, 241, 0.25); - } - } - } - - .card-body { - margin: 8px 0 6px; - - .card-title { - font-size: 13.5px; - font-weight: 600; - color: #0f172a; - margin: 0 0 3px; - line-height: 1.3; - letter-spacing: -0.01em; - } - - .card-desc { - font-size: 12px; - color: #64748b; - margin: 0; - line-height: 1.45; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - } - } - - - .card-footer-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 6px; - margin-top: 6px; - padding-top: 8px; - border-top: 1px solid rgba(241, 245, 249, 0.85); - - .tag-pill { - font-size: 11px; - font-weight: 600; - color: #64748b; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .use-prompt-action { - display: inline-flex; - align-items: center; - gap: 3px; - font-size: 11.5px; - font-weight: 600; - color: #1a73e8; - opacity: 0.85; - transition: all 0.15s ease; - flex-shrink: 0; - - .action-arrow { - font-size: 13px; - transition: transform 0.15s ease; - } - } - } - - &:hover .use-prompt-action { - opacity: 1; - - .action-arrow { - transform: translateX(2px); - } - } - } - } - } - } - - } -} - -// ============================================================================= -// KEYFRAME ANIMATIONS -// ============================================================================= -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(3px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes slideUpFade { - from { - opacity: 0; - transform: translateY(8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes slideDownFade { - from { - opacity: 0; - transform: translateY(-6px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes floatGlow1 { - 0% { - transform: translate(0, 0) scale(1); - } - 50% { - transform: translate(35px, 45px) scale(1.06); - } - 100% { - transform: translate(-25px, 25px) scale(0.96); - } -} - -@keyframes floatGlow2 { - 0% { - transform: translate(0, 0) scale(1); - } - 50% { - transform: translate(-40px, 40px) scale(1.08); - } - 100% { - transform: translate(20px, 60px) scale(0.94); - } -} - -@keyframes floatGlow3 { - 0% { - transform: translate(0, 0) scale(1); - } - 50% { - transform: translate(25px, -30px) scale(1.04); - } - 100% { - transform: translate(-30px, 15px) scale(0.97); - } -} - -/* Transform-only: animating opacity here overrode the .glow-pending/.glow-ready - fade transitions and kept both state glows permanently visible. */ -@keyframes readyPulseGlow { - 0% { - transform: scale(0.97); - } - 50% { - transform: scale(1.06); - } - 100% { - transform: scale(0.98); - } -} - -@keyframes statusDotPulse { - 0% { - transform: scale(0.95); - box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); - } - 70% { - transform: scale(1); - box-shadow: 0 0 0 5px rgba(16, 185, 129, 0); - } - 100% { - transform: scale(0.95); - box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); - } -} - -@keyframes sparklePulse { - 0% { - transform: scale(0.92) rotate(0deg); - opacity: 0.8; - } - 50% { - transform: scale(1.12) rotate(8deg); - opacity: 1; - } - 100% { - transform: scale(0.92) rotate(0deg); - opacity: 0.8; - } -} - -/* Respect the user's motion preference: freeze the purely decorative ambient - animations (aurora drift, pulses, sparkles). Functional spinners keep going. */ -@media (prefers-reduced-motion: reduce) { - .home-container .aurora-background .glow, - .sparkle-icon, - .status-dot, - .slider-dither rect, - .slider-solid, - .thumb-ripple { - animation: none !important; - } -} - -@keyframes ditherIn { - from { - opacity: 0; - } -} - -@keyframes solidSweep { - from { - transform: scaleX(0); - } - to { - transform: scaleX(1); - } -} - -@keyframes thumbRipple { - 0% { - opacity: 0.7; - transform: translate(-50%, -50%) scale(0.6); - } - 100% { - opacity: 0; - transform: translate(-50%, -50%) scale(2.6); - } -} - -@keyframes tipPop { - from { - opacity: 0; - transform: translateY(6px) scale(0.96); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} diff --git a/apps/showcase_ui/src/app/pages/home/home.component.ts b/apps/showcase_ui/src/app/pages/home/home.component.ts deleted file mode 100644 index e189b9ce5..000000000 --- a/apps/showcase_ui/src/app/pages/home/home.component.ts +++ /dev/null @@ -1,1111 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, signal, computed, effect, inject, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { Router, RouterLink } from '@angular/router'; -import { AgentService } from '../../services/agent.service'; -import { SystemService } from '../../services/system.service'; -import { - AdbServerConnectionResult, - AdbServerDevice, - DeviceInfo, - ProbeResult -} from '../../core/models/system.model'; -import { - AppReference, - SmartSuggestion, - SuggestionCategory -} from '../../core/data/smart-tasks.data'; -import { TaskRecommendationService } from '../../core/services/task-recommendation.service'; -import { - DEFAULT_EXPLORER_MODE, - DEFAULT_VERIFICATION_LEVEL, - EXPLORER_MODES, - ExplorerModeId, - TuningLevel, - VERIFICATION_LEVELS, - VerificationLevelId, - levelIndex, - notchPercent -} from '../../core/models/pro-tuning.model'; - -export type TuningKind = 'verify' | 'explore'; - -/** One 2x2 px square of the "maxed out" dither texture drawn over a slider rail. */ -export interface DitherPixel { - /** Horizontal position as a percentage of the rail width. */ - x: number; - /** Row offset in px (rail is 6 px tall, three 2 px rows). */ - y: number; - /** Resting opacity; squares near the thumb are stronger. */ - opacity: number; - /** Animation delay in ms so the texture spreads leftwards from the thumb. */ - delay: number; -} - -/** - * Seeded pixels keep the slider texture stable across renders. - * Delays increase with distance from the thumb to animate from right to left. - */ -function buildDitherPixels(count = 260, seed = 7): DitherPixel[] { - let state = seed >>> 0; - const rand = (): number => { - state = (Math.imul(state, 1664525) + 1013904223) >>> 0; - return state / 0x100000000; - }; - const pixels: DitherPixel[] = []; - for (let i = 0; i < count; i++) { - const x = rand() * 100; - const y = Math.floor(rand() * 3) * 2; - pixels.push({ - x: Math.round(x * 10) / 10, - y, - opacity: Math.round((0.35 + 0.4 * rand()) * 100) / 100, - // 8 ms per percent: the front takes ~0.8 s to reach the left end. - delay: Math.round((100 - x) * 8) - }); - } - return pixels; -} - -/** Shared view model for the verification and screen-reading sliders. */ -export interface TuningSliderVm { - kind: TuningKind; - name: string; - /** One-word meaning of each end of the track, e.g. ["Off", "Strict"]. */ - ends: readonly [string, string]; - ladder: readonly TuningLevel[]; - index: number; - level: TuningLevel; - /** 0..1 position of the thumb along the track. */ - fraction: number; -} - -export type { AppReference, SmartSuggestion, SuggestionCategory }; - -type AdbGuideTab = 'emulator' | 'usb' | 'wifi' | 'remote'; - - -@Component({ - selector: 'app-home', - standalone: true, - imports: [FormsModule], - templateUrl: './home.component.html', - changeDetection: ChangeDetectionStrategy.Eager, - styleUrl: './home.component.scss' -}) -export class HomeComponent implements OnInit, OnDestroy { - public agentService = inject(AgentService); - public systemService = inject(SystemService); - public taskRecService = inject(TaskRecommendationService); - private router = inject(Router); - - // High-level navigation mode: 'diagnostics' (System Setup Guide) vs 'launcher' (Task Execution) - public activeTab = signal<'diagnostics' | 'launcher'>('launcher'); - - // Interactive guide sub-tab inside the ADB section - public activeAdbGuideTab = signal('emulator'); - public emulatorSetupMode = signal<'studio' | 'cli'>('studio'); - - // Interactive guide tab for LLM / OCR credentials: 'gemini' | 'ocr' - public modelSetupMode = signal<'gemini' | 'custom'>('gemini'); - public showOcrConfig = signal(false); - public showFullConfigFile = signal(false); - - // Model & Environment configuration from backend - public modelConfigEnv = computed(() => this.systemService.modelConfigEnv()); - - // Google Gemini API Key State - public geminiKeyInput = signal(''); - public showGeminiKey = signal(false); - public isSavingGeminiKey = signal(false); - public isTestingGeminiKey = signal(false); - public geminiSaveMessage = signal(null); - public geminiSaveError = signal(null); - public isGeminiKeyEdited = signal(false); - - // Vision OCR API Key State - public ocrKeyInput = signal(''); - public showOcrKey = signal(false); - public isSavingOcrKey = signal(false); - public isTestingOcrKey = signal(false); - public ocrSaveMessage = signal(null); - public ocrSaveError = signal(null); - public isOcrKeyEdited = signal(false); - - // Clipboard copy state tracker for interactive feedback - public copiedId = signal(null); - - // Diagnostic re-check state - public isRefreshingDiagnostics = signal(false); - - // Wireless ADB Interactive connection signals - public wifiHost = signal('192.168.1.100'); - public wifiPort = signal('5555'); - public isConnectingWifi = signal(false); - public wifiConnectMessage = signal(null); - public wifiConnectError = signal(null); - public adbRestartFeedback = signal(null); - public showConnectionMethods = signal(false); - - // ADB server endpoint connection state - public remoteAdbHost = signal('127.0.0.1'); - public remoteAdbPort = signal('5038'); - public rememberRemoteAdb = signal(true); - public isConnectingRemoteAdb = signal(false); - public isActivatingRemoteAdb = signal(false); - public isSwitchingToLocalAdb = signal(false); - public remoteAdbMessage = signal(null); - public remoteAdbError = signal(null); - public remoteAdbDevices = signal([]); - public remoteAdbProbeResult = signal(null); - public adbServerStatus = computed(() => this.systemService.adbServerStatus()); - public isRemoteAdbServer = computed(() => this.systemService.isRemoteAdbServer()); - public remoteAdbHasReadyDevice = computed(() => - this.remoteAdbDevices().some(device => device.state === 'device') - ); - public isProbedRemoteAdbActive = computed(() => { - const tested = this.remoteAdbProbeResult()?.endpoint; - const active = this.adbServerStatus()?.endpoint; - return !!tested && !!active && tested.identity === active.identity; - }); - - public wifiCommand = computed(() => { - const h = this.wifiHost().trim() || ''; - const p = this.wifiPort().trim() || '5555'; - return `adb connect ${h}:${p}`; - }); - - // Task execution parameters - public selectedProfile = signal<'flash' | 'pro'>('flash'); - public taskGoal = signal(''); - public isSubmitting = signal(false); - public errorMessage = signal(null); - - // Pro Mode Outputter & Structured Output Configuration - public expectedOutput = signal(''); - public enableOutputter = signal(true); - public showOutputterDrawer = signal(false); - - // Slider indexes map to the API ids in VERIFICATION_LEVELS / EXPLORER_MODES. - public readonly verificationLevels = VERIFICATION_LEVELS; - public readonly explorerModes = EXPLORER_MODES; - public verificationIndex = signal( - levelIndex(VERIFICATION_LEVELS, DEFAULT_VERIFICATION_LEVEL, DEFAULT_VERIFICATION_LEVEL) - ); - public explorerIndex = signal( - levelIndex(EXPLORER_MODES, DEFAULT_EXPLORER_MODE, DEFAULT_EXPLORER_MODE) - ); - /** Effective defaults from the backend config (`GET /api/run/defaults`). */ - private tuningDefaults = signal<{ verification: VerificationLevelId; explorer: ExplorerModeId }>({ - verification: DEFAULT_VERIFICATION_LEVEL, - explorer: DEFAULT_EXPLORER_MODE - }); - /** True once the user moved a slider; backend defaults then stop overriding it. */ - private tuningTouched = signal(false); - /** Which slider's hover card is open (while hovering, dragging, or focused). */ - public activeTuningTip = signal(null); - /** Pixel cloud drawn over a rail once its slider reaches the last notch. */ - public readonly ditherPixels: readonly DitherPixel[] = buildDitherPixels(); - - public verificationLevel = computed>( - () => VERIFICATION_LEVELS[this.verificationIndex()] - ); - public explorerMode = computed>( - () => EXPLORER_MODES[this.explorerIndex()] - ); - public isTuningDefault = computed(() => { - const d = this.tuningDefaults(); - return this.verificationLevel().id === d.verification && this.explorerMode().id === d.explorer; - }); - public tuningSliders = computed(() => { - const vi = this.verificationIndex(); - const ei = this.explorerIndex(); - return [ - { - kind: 'verify', - name: 'Result check', - ends: ['Off', 'Strict'], - ladder: VERIFICATION_LEVELS, - index: vi, - level: VERIFICATION_LEVELS[vi], - fraction: notchPercent(vi, VERIFICATION_LEVELS.length) / 100 - }, - { - kind: 'explore', - name: 'Screen reading', - ends: ['Faster', 'Sharper'], - ladder: EXPLORER_MODES, - index: ei, - level: EXPLORER_MODES[ei], - fraction: notchPercent(ei, EXPLORER_MODES.length) / 100 - } - ]; - }); - - public notchFraction(index: number, count: number): number { - return notchPercent(index, count) / 100; - } - - public setTuningIndex(kind: TuningKind, raw: number | string): void { - const idx = Math.round(Number(raw)); - if (!Number.isFinite(idx)) return; - const ladder = kind === 'verify' ? VERIFICATION_LEVELS : EXPLORER_MODES; - const clamped = Math.min(Math.max(idx, 0), ladder.length - 1); - this.tuningTouched.set(true); - if (kind === 'verify') { - this.verificationIndex.set(clamped); - } else { - this.explorerIndex.set(clamped); - } - // Keyboard nudges and drags should keep the explanation visible. - this.activeTuningTip.set(kind); - } - - public showTuningTip(kind: TuningKind): void { - this.activeTuningTip.set(kind); - } - - public hideTuningTip(kind: TuningKind): void { - if (this.activeTuningTip() === kind) { - this.activeTuningTip.set(null); - } - } - - public resetTuning(): void { - const d = this.tuningDefaults(); - this.verificationIndex.set(levelIndex(VERIFICATION_LEVELS, d.verification, DEFAULT_VERIFICATION_LEVEL)); - this.explorerIndex.set(levelIndex(EXPLORER_MODES, d.explorer, DEFAULT_EXPLORER_MODE)); - this.tuningTouched.set(false); - } - - /** Pull the effective config defaults so the sliders start where artemis.jsonc is. */ - private loadProTuningDefaults(): void { - this.agentService.getProTuningDefaults().subscribe({ - next: (res) => { - const vIdx = levelIndex(VERIFICATION_LEVELS, res?.verification_level, DEFAULT_VERIFICATION_LEVEL); - const eIdx = levelIndex(EXPLORER_MODES, res?.explorer_mode, DEFAULT_EXPLORER_MODE); - this.tuningDefaults.set({ - verification: VERIFICATION_LEVELS[vIdx].id, - explorer: EXPLORER_MODES[eIdx].id - }); - if (!this.tuningTouched()) { - this.verificationIndex.set(vIdx); - this.explorerIndex.set(eIdx); - } - }, - // Defaults are a convenience; the built-in ladder defaults already apply. - error: () => undefined - }); - } - - public toggleOutputterDrawer(): void { - this.showOutputterDrawer.update((v) => !v); - } - - public applyOutputPreset(preset: string): void { - if (this.expectedOutput() === preset) { - this.expectedOutput.set(''); - } else { - this.expectedOutput.set(preset); - this.showOutputterDrawer.set(true); - } - } - - // Smart intent detection for model recommendation - public isIntentSuggestingPro = computed(() => { - const text = this.taskGoal().toLowerCase(); - if (!text.trim()) return false; - const keywords = [ - 'monitor', 'polling', 'poll', 'wait until', 'loop', 'keep watching', - 'crash', 'logcat', 'troubleshoot', 'diagnose', 'debug', 'investigate', - 'compare', 'extract', 'summarize', 'report', - '监控', '轮询', '等待', '一直', '直到', '崩溃', '闪退', '排查', '分析日志', '对比', '总结' - ]; - return keywords.some(k => text.includes(k)); - }); - - public showIntentSuggestion = computed(() => { - return this.isIntentSuggestingPro() && this.selectedProfile() === 'flash'; - }); - - // Computed helper states delegating to SystemService - public isReady = computed(() => this.systemService.isReady()); - public hasReadinessReport = computed(() => this.systemService.hasReadinessReport()); - public isLoading = computed(() => this.systemService.isLoading()); - public isRestartingAdb = computed(() => this.systemService.isRestartingAdb()); - public launchingAvd = computed(() => this.systemService.launchingAvd()); - public emulatorLaunchState = computed(() => this.systemService.emulatorLaunchState()); - public isEmulatorLaunching = computed(() => this.systemService.isEmulatorLaunching()); - public showLaunchLogs = signal(false); - - // Probes - public pythonProbe = computed(() => this.systemService.pythonProbe()); - public configProbe = computed(() => this.systemService.configProbe()); - public adbProbe = computed(() => this.systemService.adbProbe()); - public llmProbe = computed(() => this.systemService.llmProbe()); - public geminiProbe = computed(() => this.systemService.geminiProbe()); - public ocrProbe = computed(() => this.systemService.ocrProbe()); - public toolchainProbe = computed(() => this.systemService.toolchainProbe()); - - // Step-level readiness - public isEnvironmentReady = computed(() => this.systemService.isEnvironmentReady()); - public isCredentialsReady = computed(() => this.systemService.isCredentialsReady()); - public isSkipCredentialsCheck = computed(() => this.systemService.isSkipCredentialsCheck()); - public isDeviceReady = computed(() => this.systemService.isDeviceReady()); - - // Device information - public activeDevice = computed(() => this.systemService.activeDevice()); - public connectedDevices = computed(() => this.systemService.connectedDevices()); - public installedAvds = computed(() => this.systemService.installedAvds()); - public emulatorPath = computed(() => this.systemService.emulatorPath()); - public isEmulatorInPath = computed(() => this.systemService.isEmulatorInPath()); - public totalStepCount = computed(() => this.systemService.totalStepCount()); - public passedStepCount = computed(() => this.systemService.passedStepCount()); - public blockerCount = computed(() => this.systemService.blockerCount()); - public passedBlockerCount = computed(() => this.systemService.passedBlockerCount()); - - // Configured LLM providers from probe metadata - public configuredLlmProviders = computed(() => { - const meta = this.llmProbe()?.metadata; - if (meta && Array.isArray(meta['providers'])) { - return meta['providers']; - } - return []; - }); - - // Multi-OS detection & active OS selection - public selectedOs = signal<'linux' | 'darwin' | 'windows' | null>(null); - public effectiveOs = computed<'linux' | 'darwin' | 'windows'>(() => { - return this.selectedOs() || this.systemService.osType(); - }); - - public oneClickSetupCmd = computed(() => { - const os = this.effectiveOs(); - if (os === 'windows') { - return 'powershell -ExecutionPolicy Bypass -File scripts/install_deps.ps1'; - } - return 'bash scripts/install_deps.sh'; - }); - - public adbInstallCmd = computed(() => { - const os = this.effectiveOs(); - if (os === 'windows') { - return 'winget install Google.PlatformTools'; - } - if (os === 'darwin') { - return 'brew install android-platform-tools'; - } - return 'sudo apt-get install -y adb'; - }); - - public toolchainInstallCmd = computed(() => { - const os = this.effectiveOs(); - if (os === 'windows') { - return 'winget install Gyan.FFmpeg Genymobile.scrcpy'; - } - if (os === 'darwin') { - return 'brew install ffmpeg scrcpy'; - } - return 'sudo apt-get install -y ffmpeg scrcpy'; - }); - - public emuHypervisorTitle = computed(() => { - const os = this.effectiveOs(); - if (os === 'windows') return 'Enable Windows Hypervisor Platform (WHPX)'; - if (os === 'darwin') return 'Verify macOS Hypervisor / Install Tools'; - return 'Enable KVM Hardware Acceleration (Linux)'; - }); - - public emuHypervisorDesc = computed(() => { - const os = this.effectiveOs(); - if (os === 'windows') return 'Enable Windows Hypervisor Platform in PowerShell (Run as Administrator):'; - if (os === 'darwin') return 'macOS uses native Hypervisor.framework. Install SDK tools via Homebrew (or Studio):'; - return 'Ensure virtualization permissions are granted to your user account:'; - }); - - public emuHypervisorCmd = computed(() => { - const os = this.effectiveOs(); - if (os === 'windows') return 'Enable-WindowsOptionalFeature -Online -FeatureName HypervisorPlatform'; - if (os === 'darwin') return 'brew install --cask android-commandlinetools'; - return 'sudo apt-get install -y qemu-kvm libvirt-daemon-system && sudo adduser $USER kvm'; - }); - - public emuSdkInstallCmd = computed(() => { - const os = this.effectiveOs(); - if (os === 'darwin') { - return 'sdkmanager --install "system-images;android-34;google_apis;arm64-v8a" "emulator" "platform-tools"'; - } - return 'sdkmanager --install "system-images;android-34;google_apis;x86_64" "emulator" "platform-tools"'; - }); - - public emuCreateAvdCmd = computed(() => { - const os = this.effectiveOs(); - if (os === 'darwin') { - return 'avdmanager create avd -n Pixel_8_API_34 -k "system-images;android-34;google_apis;arm64-v8a" --device "pixel_8"'; - } - return 'avdmanager create avd -n Pixel_8_API_34 -k "system-images;android-34;google_apis;x86_64" --device "pixel_8"'; - }); - - // Flag indicating whether Google Cloud Vision OCR is configured - public isOcrConfigured = computed(() => { - const meta = this.ocrProbe()?.metadata; - return meta?.['configured'] === true; - }); - - public currentApiKey = computed(() => this.systemService.currentApiKey()); - public apiKeysMap = computed>(() => this.systemService.apiKeysMap()); - - public savedGeminiKey = computed(() => { - const keys = this.apiKeysMap(); - return keys['google'] || this.currentApiKey() || ''; - }); - - public isGeminiModified = computed(() => { - return this.geminiKeyInput().trim() !== this.savedGeminiKey().trim(); - }); - - public savedOcrKey = computed(() => { - const keys = this.apiKeysMap(); - return keys['ocr'] || ''; - }); - - public isOcrModified = computed(() => { - return this.ocrKeyInput().trim() !== this.savedOcrKey().trim(); - }); - - // Rich Smart Suggestions Library (Device-Aware, Flash vs Pro Tailored) - public readonly allSuggestions = this.taskRecService.allTasks; - - // Suggestion category filter & shuffle state - public selectedCategory = signal('all'); - public shuffleOffset = signal(0); - - // Set of installed package strings from active device - public installedPackages = computed>(() => { - const pkgs = this.activeDevice()?.installed_packages; - if (pkgs && Array.isArray(pkgs)) { - return new Set(pkgs); - } - return new Set(); - }); - - public filteredSuggestions = computed(() => { - return this.taskRecService.filterAndRankTasks( - this.installedPackages(), - this.selectedCategory(), - this.shuffleOffset() - ); - }); - - - - - private focusListener = () => { - // Silently re-check environment when user returns to the browser tab - this.systemService.fetchReadiness().subscribe(); - }; - - constructor() { - effect(() => { - const keys = this.systemService.apiKeysMap(); - const current = this.systemService.currentApiKey(); - const googleKey = keys['google'] || current || ''; - const ocrKey = keys['ocr'] || ''; - if (!this.isGeminiKeyEdited()) { - this.geminiKeyInput.set(googleKey); - } - if (!this.isOcrKeyEdited()) { - this.ocrKeyInput.set(ocrKey); - } - }); - } - - ngOnInit(): void { - - this.loadProTuningDefaults(); - // Initial fetch of system readiness & model configuration - this.systemService.fetchReadiness().subscribe(); - this.systemService.fetchModelConfigEnv().subscribe(); - this.systemService.fetchAdbServerStatus().subscribe({ - next: status => { - if (status.endpoint.mode === 'remote') { - this.remoteAdbHost.set(status.endpoint.host); - this.remoteAdbPort.set(String(status.endpoint.port)); - this.activeAdbGuideTab.set('remote'); - } - }, - error: () => {} - }); - - window.addEventListener('focus', this.focusListener); - } - - ngOnDestroy(): void { - window.removeEventListener('focus', this.focusListener); - } - - public setTab(tab: 'diagnostics' | 'launcher'): void { - this.activeTab.set(tab); - if (tab === 'diagnostics') { - this.systemService.fetchReadiness().subscribe(); - this.systemService.fetchModelConfigEnv().subscribe(); - } - } - - public setSelectedOs(os: 'linux' | 'darwin' | 'windows'): void { - this.selectedOs.set(os); - } - - public setAdbGuideTab(tab: AdbGuideTab): void { - this.activeAdbGuideTab.set(tab); - this.remoteAdbError.set(null); - this.remoteAdbMessage.set(null); - } - - public setEmulatorSetupMode(mode: 'studio' | 'cli'): void { - this.emulatorSetupMode.set(mode); - } - - public setModelSetupMode(mode: 'gemini' | 'custom'): void { - this.modelSetupMode.set(mode); - if (mode === 'custom') { - this.systemService.setSkipCredentialsCheck(true); - this.systemService.fetchModelConfigEnv().subscribe(); - } else { - this.systemService.setSkipCredentialsCheck(false); - } - } - - public toggleFullConfigFile(): void { - this.showFullConfigFile.update(v => !v); - } - - public toggleGeminiKeyVisibility(): void { - this.showGeminiKey.update(v => !v); - } - - public toggleOcrKeyVisibility(): void { - this.showOcrKey.update(v => !v); - } - - public toggleOcrConfig(): void { - this.showOcrConfig.update(v => !v); - } - - public onGeminiKeyChange(val: string): void { - this.geminiKeyInput.set(val); - this.isGeminiKeyEdited.set(true); - this.geminiSaveError.set(null); - this.geminiSaveMessage.set(null); - } - - public onOcrKeyChange(val: string): void { - this.ocrKeyInput.set(val); - this.isOcrKeyEdited.set(true); - this.ocrSaveError.set(null); - this.ocrSaveMessage.set(null); - } - - public saveGeminiKey(): void { - const key = this.geminiKeyInput().trim(); - if (!key) return; - this.isSavingGeminiKey.set(true); - this.geminiSaveError.set(null); - this.geminiSaveMessage.set(null); - - this.systemService.updateApiKey('google', key, true).subscribe({ - next: (res) => { - this.isSavingGeminiKey.set(false); - this.isGeminiKeyEdited.set(false); - this.geminiSaveMessage.set(res?.message || '✓ Gemini API key verified & saved successfully.'); - setTimeout(() => this.geminiSaveMessage.set(null), 5000); - }, - error: (err) => { - this.isSavingGeminiKey.set(false); - this.geminiSaveError.set(err?.error?.detail || err?.message || 'Failed to update Gemini API key.'); - } - }); - } - - public clearGeminiKey(): void { - this.geminiKeyInput.set(''); - this.isGeminiKeyEdited.set(false); - this.geminiSaveError.set(null); - this.geminiSaveMessage.set(null); - - if (this.savedGeminiKey().trim()) { - this.isSavingGeminiKey.set(true); - this.systemService.updateApiKey('google', '', true).subscribe({ - next: (res) => { - this.isSavingGeminiKey.set(false); - this.geminiSaveMessage.set(res?.message || '✓ Gemini API key cleared.'); - setTimeout(() => this.geminiSaveMessage.set(null), 5000); - }, - error: (err) => { - this.isSavingGeminiKey.set(false); - this.geminiSaveError.set(err?.error?.detail || err?.message || 'Failed to clear Gemini API key.'); - } - }); - } else { - this.geminiSaveMessage.set('✓ Gemini API key cleared.'); - setTimeout(() => this.geminiSaveMessage.set(null), 3000); - } - } - - public saveOcrKey(): void { - const key = this.ocrKeyInput().trim(); - if (!key) return; - this.isSavingOcrKey.set(true); - this.ocrSaveError.set(null); - this.ocrSaveMessage.set(null); - - this.systemService.updateApiKey('ocr', key, true).subscribe({ - next: (res) => { - this.isSavingOcrKey.set(false); - this.isOcrKeyEdited.set(false); - this.ocrSaveMessage.set(res?.message || '✓ Vision OCR API key verified & saved.'); - setTimeout(() => this.ocrSaveMessage.set(null), 5000); - }, - error: (err) => { - this.isSavingOcrKey.set(false); - this.ocrSaveError.set(err?.error?.detail || err?.message || 'Failed to update Vision OCR key.'); - } - }); - } - - public clearOcrKey(): void { - this.ocrKeyInput.set(''); - this.isOcrKeyEdited.set(false); - this.ocrSaveError.set(null); - this.ocrSaveMessage.set(null); - - if (this.savedOcrKey().trim()) { - this.isSavingOcrKey.set(true); - this.systemService.updateApiKey('ocr', '', true).subscribe({ - next: (res) => { - this.isSavingOcrKey.set(false); - this.ocrSaveMessage.set(res?.message || '✓ Vision OCR API key cleared.'); - setTimeout(() => this.ocrSaveMessage.set(null), 5000); - }, - error: (err) => { - this.isSavingOcrKey.set(false); - this.ocrSaveError.set(err?.error?.detail || err?.message || 'Failed to clear Vision OCR key.'); - } - }); - } else { - this.ocrSaveMessage.set('✓ Vision OCR API key cleared.'); - setTimeout(() => this.ocrSaveMessage.set(null), 3000); - } - } - - public testGeminiKey(): void { - const key = this.geminiKeyInput().trim(); - if (!key) return; - this.isTestingGeminiKey.set(true); - this.geminiSaveError.set(null); - this.geminiSaveMessage.set(null); - - this.systemService.testApiKey('google', key).subscribe({ - next: (res) => { - this.isTestingGeminiKey.set(false); - if (res?.valid) { - this.geminiSaveMessage.set(res?.message || '✓ Gemini API key is valid!'); - } else { - this.geminiSaveError.set(res?.message || 'Gemini API key verification failed.'); - } - setTimeout(() => this.geminiSaveMessage.set(null), 5000); - }, - error: (err) => { - this.isTestingGeminiKey.set(false); - this.geminiSaveError.set(err?.error?.detail || err?.message || 'Gemini API key test failed.'); - } - }); - } - - public testOcrKey(): void { - const key = this.ocrKeyInput().trim(); - if (!key) return; - this.isTestingOcrKey.set(true); - this.ocrSaveError.set(null); - this.ocrSaveMessage.set(null); - - this.systemService.testApiKey('ocr', key).subscribe({ - next: (res) => { - this.isTestingOcrKey.set(false); - if (res?.valid) { - this.ocrSaveMessage.set(res?.message || '✓ Vision OCR API key is valid!'); - } else { - this.ocrSaveError.set(res?.message || 'Vision OCR API key verification failed.'); - } - setTimeout(() => this.ocrSaveMessage.set(null), 5000); - }, - error: (err) => { - this.isTestingOcrKey.set(false); - this.ocrSaveError.set(err?.error?.detail || err?.message || 'Vision OCR API key test failed.'); - } - }); - } - - public getProviderDisplayName(tab: string): string { - switch (tab) { - case 'gemini': return 'Gemini'; - case 'ocr': return 'Vision OCR'; - default: return tab; - } - } - - public getProviderEnvVar(tab: string): string { - switch (tab) { - case 'gemini': return 'GEMINI_API_KEY'; - case 'ocr': return 'GOOGLE_VISION_API_KEY'; - default: return 'API_KEY'; - } - } - - public getProviderHint(tab: string): string { - switch (tab) { - case 'gemini': - return 'For a quick start, Google Gemini provides a free API key. Artemis also supports other models (OpenAI, Claude, OpenRouter, etc.)—you can configure your own API keys directly in .env or your environment.'; - case 'ocr': - return 'Google Cloud Vision API key for on-screen OCR text detection and UI grounding.'; - default: - return 'Configure your API key or use environment definitions.'; - } - } - - public skipCredentialsCheck(): void { - this.systemService.skipCredentialsCheck(); - } - - public getApiKeyPlaceholder(tab: string): string { - switch (tab) { - case 'gemini': return 'Enter Gemini API Key (e.g. AIzaSy...)'; - case 'ocr': return 'Enter Vision OCR API Key (e.g. AIzaSy...)'; - default: return 'Enter API Key...'; - } - } - - public isTabProviderActive(tab: string): boolean { - if (tab === 'ocr') { - return this.isOcrConfigured(); - } - const targetProvider = tab === 'gemini' ? 'google' : tab; - const providers = this.configuredLlmProviders(); - return providers.some(p => p.provider === targetProvider); - } - - public refreshReadiness(): void { - this.isRefreshingDiagnostics.set(true); - this.systemService.fetchReadiness(false, true).subscribe({ - next: () => { - this.systemService.fetchModelConfigEnv().subscribe({ - next: () => { - setTimeout(() => this.isRefreshingDiagnostics.set(false), 450); - }, - error: () => this.isRefreshingDiagnostics.set(false) - }); - }, - error: () => this.isRefreshingDiagnostics.set(false) - }); - } - - public restartAdbServer(): void { - this.adbRestartFeedback.set(null); - this.systemService.restartAdb().subscribe({ - next: (res) => { - this.adbRestartFeedback.set( - res?.restart_result?.skipped ? 'Devices Refreshed ✓' : 'ADB Refreshed ✓' - ); - setTimeout(() => this.adbRestartFeedback.set(null), 2500); - }, - error: () => { - this.adbRestartFeedback.set('Restart Failed'); - setTimeout(() => this.adbRestartFeedback.set(null), 3000); - } - }); - } - - public connectWifiDevice(): void { - if (this.isRemoteAdbServer()) { - this.wifiConnectError.set('Switch to local ADB before connecting a Wireless ADB device.'); - return; - } - const host = this.wifiHost().trim(); - const portStr = this.wifiPort().trim() || '5555'; - const port = parseInt(portStr, 10) || 5555; - - if (!host) { - this.wifiConnectError.set('Please enter a valid IP address.'); - return; - } - - this.isConnectingWifi.set(true); - this.wifiConnectError.set(null); - this.wifiConnectMessage.set(null); - - this.systemService.connectWirelessAdb(host, port).subscribe({ - next: (res) => { - this.isConnectingWifi.set(false); - const cr = res?.connect_result; - if (cr?.success) { - this.wifiConnectMessage.set(`Connected to ${host}:${port}!`); - setTimeout(() => this.wifiConnectMessage.set(null), 4000); - } else { - this.wifiConnectError.set(cr?.message || 'Connection failed. Please check phone IP & Wi-Fi.'); - } - }, - error: (err) => { - this.isConnectingWifi.set(false); - this.wifiConnectError.set(err?.error?.detail || 'Failed to connect. Please check adb connection.'); - } - }); - } - - public connectRemoteAdbServer(): void { - const host = this.remoteAdbHost().trim(); - const port = Number(this.remoteAdbPort().trim()); - - if (!host) { - this.remoteAdbError.set('Enter the host name or IP address of the ADB server.'); - return; - } - if (!Number.isInteger(port) || port < 1 || port > 65535) { - this.remoteAdbError.set('Enter a port between 1 and 65535.'); - return; - } - - this.isConnectingRemoteAdb.set(true); - this.remoteAdbError.set(null); - this.remoteAdbMessage.set(null); - this.remoteAdbDevices.set([]); - this.remoteAdbProbeResult.set(null); - - this.systemService.probeAdbServer(host, port).subscribe({ - next: response => { - this.isConnectingRemoteAdb.set(false); - const result = response.connection_result; - if (result.success) { - this.remoteAdbProbeResult.set(result); - this.remoteAdbDevices.set(result.devices || []); - this.remoteAdbMessage.set(result.message); - } else { - this.remoteAdbError.set(result.message); - } - }, - error: error => { - this.isConnectingRemoteAdb.set(false); - this.remoteAdbError.set( - error?.error?.detail || 'Unable to test the ADB server endpoint.' - ); - } - }); - } - - public activateRemoteAdbServer(): void { - const tested = this.remoteAdbProbeResult(); - if (!tested?.success) { - this.remoteAdbError.set('Test the endpoint before using it.'); - return; - } - - this.isActivatingRemoteAdb.set(true); - this.remoteAdbError.set(null); - this.systemService.connectAdbServer( - tested.endpoint.host, - tested.endpoint.port, - this.rememberRemoteAdb() - ).subscribe({ - next: response => { - this.isActivatingRemoteAdb.set(false); - const result = response.connection_result; - if (result.success) { - this.remoteAdbProbeResult.set(result); - this.remoteAdbDevices.set(result.devices || []); - this.remoteAdbMessage.set(result.message); - } else { - this.remoteAdbError.set(result.message); - } - }, - error: error => { - this.isActivatingRemoteAdb.set(false); - this.remoteAdbError.set( - error?.error?.detail || 'Unable to use the ADB server endpoint.' - ); - } - }); - } - - public updateRemoteAdbHost(value: string): void { - this.remoteAdbHost.set(value); - this.clearRemoteAdbProbe(); - } - - public updateRemoteAdbPort(value: string): void { - this.remoteAdbPort.set(value); - this.clearRemoteAdbProbe(); - } - - private clearRemoteAdbProbe(): void { - this.remoteAdbProbeResult.set(null); - this.remoteAdbDevices.set([]); - this.remoteAdbMessage.set(null); - this.remoteAdbError.set(null); - } - - public switchToLocalAdbServer(): void { - this.isSwitchingToLocalAdb.set(true); - this.remoteAdbError.set(null); - this.systemService.useLocalAdbServer(true).subscribe({ - next: response => { - this.isSwitchingToLocalAdb.set(false); - this.remoteAdbDevices.set([]); - this.remoteAdbMessage.set(response.connection_result.message); - }, - error: error => { - this.isSwitchingToLocalAdb.set(false); - this.remoteAdbError.set( - error?.error?.detail || 'Unable to switch back to the local ADB server.' - ); - } - }); - } - - public toggleConnectionMethods(): void { - this.showConnectionMethods.update(value => !value); - } - - public launchAvdEmulator(avdName: string): void { - if (this.isRemoteAdbServer()) { - return; - } - this.systemService.launchEmulator(avdName).subscribe(); - } - - public toggleLaunchLogs(): void { - this.showLaunchLogs.update(v => !v); - } - - public stopEmulator(): void { - this.systemService.stopEmulator().subscribe(); - } - - public dismissEmulatorStatus(): void { - this.systemService.dismissEmulatorStatus().subscribe(); - } - - public selectTargetDevice(serial: string): void { - this.systemService.selectDevice(serial).subscribe(); - } - - public getEmulatorCommand(avdName: string): string { - const p = this.emulatorPath(); - const cmd = this.isEmulatorInPath() ? 'emulator' : (p || '~/Android/Sdk/emulator/emulator'); - return `${cmd} -avd ${avdName}`; - } - - public copyToClipboard(text: string, id: string): void { - if (navigator.clipboard) { - navigator.clipboard.writeText(text).then(() => { - this.copiedId.set(id); - setTimeout(() => { - if (this.copiedId() === id) { - this.copiedId.set(null); - } - }, 2000); - }); - } - } - - public setProfile(profile: 'flash' | 'pro'): void { - this.selectedProfile.set(profile); - } - - public setCategory(cat: SuggestionCategory): void { - this.selectedCategory.set(cat); - } - - public shuffleSuggestions(): void { - this.shuffleOffset.update(v => v + 3); - } - - public getAppNamesDisplay(apps: AppReference[]): string { - return apps.map(a => a.name).join(' + '); - } - - public applySuggestion(item: SmartSuggestion): void { - this.taskGoal.set(item.goal); - this.selectedProfile.set(item.profile); - this.errorMessage.set(null); - } - - public applyQuickPrompt(promptGoal: string, profile?: 'flash' | 'pro'): void { - this.taskGoal.set(promptGoal); - if (profile) { - this.selectedProfile.set(profile); - } - this.errorMessage.set(null); - } - - - public proceedToLauncher(): void { - this.activeTab.set('launcher'); - } - - public runTask(): void { - const goal = this.taskGoal().trim(); - if (!goal) { - this.errorMessage.set('Please enter a task goal before running.'); - return; - } - - if (!this.isReady()) { - this.errorMessage.set('System prerequisites are not satisfied. Please review System Setup first.'); - this.activeTab.set('diagnostics'); - return; - } - - this.isSubmitting.set(true); - this.errorMessage.set(null); - - this.agentService - .runTask( - goal, - this.selectedProfile(), - this.selectedProfile() === 'pro' && this.expectedOutput().trim() - ? this.expectedOutput().trim() - : undefined, - this.selectedProfile() === 'pro' ? this.enableOutputter() : undefined, - this.selectedProfile() === 'pro' - ? { verificationLevel: this.verificationLevel().id, explorerMode: this.explorerMode().id } - : undefined - ) - .subscribe({ - next: () => { - this.isSubmitting.set(false); - this.router.navigate(['/workspace']); - }, - error: (err) => { - console.error('Failed to submit task from home page:', err); - this.isSubmitting.set(false); - this.errorMessage.set( - err?.error?.detail || 'Failed to submit task. Please check server connection.' - ); - } - }); - } -} diff --git a/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.html b/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.html deleted file mode 100644 index c15a889fb..000000000 --- a/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.html +++ /dev/null @@ -1,39 +0,0 @@ - - -
- -
-
-
-
-
- - -
- -
- - -
-
-
- - - -
diff --git a/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.scss b/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.scss deleted file mode 100644 index 9a01ecf60..000000000 --- a/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.scss +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -$bg-base: #e2e5ea; -$glass-bg: rgba(255, 255, 255, 0.45); -$glass-border: rgba(255, 255, 255, 0.6); - -.workspace-container { - position: relative; - display: flex; - height: 100vh; - width: 100vw; - background-color: $bg-base; - overflow: hidden; - - &.is-resizing { - cursor: col-resize; - user-select: none; - } - - /* --- Dynamic Aurora Background --- */ - .aurora-background { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 0; - overflow: hidden; - background: linear-gradient(135deg, #e2e5ea 0%, #cfd6df 100%); - - .glow { - position: absolute; - border-radius: 50%; - filter: blur(90px); - opacity: 0.5; - animation: aurora-drift 22s infinite alternate ease-in-out; - } - - .glow-1 { - width: 55vw; - height: 55vh; - background: rgba(94, 114, 228, 0.25); - top: -15%; - left: -5%; - animation-delay: 0s; - } - - .glow-2 { - width: 45vw; - height: 45vh; - background: rgba(45, 206, 137, 0.2); - bottom: -10%; - left: 30%; - animation-delay: -5s; - } - - .glow-3 { - width: 50vw; - height: 60vh; - background: rgba(206, 104, 172, 0.15); - top: 20%; - right: -10%; - animation-delay: -10s; - } - } - - /* --- Glassmorphism Panel --- */ - .glass-panel { - position: relative; - z-index: 1; - background: $glass-bg; - backdrop-filter: blur(24px) saturate(120%); - -webkit-backdrop-filter: blur(24px) saturate(120%); - border: 1px solid $glass-border; - box-shadow: 0 8px 32px rgba(31, 38, 135, 0.05); - } - - .left-panel { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - background: rgba(255, 255, 255, 0.35); - border-right: 1px solid rgba(255, 255, 255, 0.8); - border-left: none; - border-top: none; - border-bottom: none; - } - - .right-panel { - min-width: 300px; - flex-shrink: 0; - padding: 0; - border: none; - background-color: #ffffff; - box-shadow: -6px 0 24px rgba(0, 0, 0, 0.03); - height: 100%; - display: flex; - flex-direction: column; - box-sizing: border-box; - z-index: 2; - } - - .resizer { - width: 12px; - cursor: col-resize; - display: flex; - align-items: center; - justify-content: center; - z-index: 10; - transition: background 0.2s ease; - margin: 0 -6px; - - &:hover, .workspace-container.is-resizing & { - background: rgba(255, 255, 255, 0.15); - - .resizer-handle { - background: rgba(94, 114, 228, 0.8); - height: 60px; - } - } - - .resizer-handle { - width: 4px; - height: 40px; - background: rgba(0, 0, 0, 0.15); - border-radius: 4px; - transition: all 0.2s ease; - } - } -} - -@keyframes aurora-drift { - 0% { transform: translate(0, 0) scale(1); } - 50% { transform: translate(5%, 3%) scale(1.05); } - 100% { transform: translate(-2%, -5%) scale(0.95); } -} diff --git a/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.ts b/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.ts deleted file mode 100644 index 9152ce6c7..000000000 --- a/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, HostListener, ChangeDetectionStrategy } from '@angular/core'; - -import { LegacyAgentStreamComponent } from '../../components/legacy-agent-stream/legacy-agent-stream.component'; -import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component'; - -@Component({ - selector: 'app-legacy-workspace', - standalone: true, - imports: [LegacyAgentStreamComponent, ChatInterfaceComponent], - templateUrl: './legacy-workspace.component.html', - changeDetection: ChangeDetectionStrategy.Eager, - styleUrl: './legacy-workspace.component.scss' -}) -export class LegacyWorkspaceComponent { - public rightPanelWidth = typeof window !== 'undefined' ? Math.round(window.innerWidth / 3) : 450; - public isDragging = false; - - public onDragStart(event: MouseEvent): void { - this.isDragging = true; - event.preventDefault(); - } - - @HostListener('document:mousemove', ['$event']) - public onMouseMove(event: MouseEvent): void { - if (!this.isDragging) { - return; - } - - const newWidth = window.innerWidth - event.clientX; - const minWidth = 300; - const maxWidth = window.innerWidth - 300; - - if (newWidth >= minWidth && newWidth <= maxWidth) { - this.rightPanelWidth = newWidth; - } - } - - @HostListener('document:mouseup') - public onMouseUp(): void { - this.isDragging = false; - } -} diff --git a/apps/showcase_ui/src/app/pages/workspace/workspace.component.html b/apps/showcase_ui/src/app/pages/workspace/workspace.component.html deleted file mode 100644 index a44cd84e3..000000000 --- a/apps/showcase_ui/src/app/pages/workspace/workspace.component.html +++ /dev/null @@ -1,201 +0,0 @@ - - -
- -
- - - -
- - @if (errorMessage()) { -
- error - {{ errorMessage() }} - -
- } - - -
- -
- -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
- Start a new task... -
- - -
-
- -
- -
- - - @if (taskInput.trim() && !isSubmitting()) { - - } - - - @if (isTaskRunning()) { - - } - - - -
- - - -
-
- - - @if (isTaskRunning()) { - - } -
-
-
- - -
-
-
- - - - - - -
diff --git a/apps/showcase_ui/src/app/pages/workspace/workspace.component.scss b/apps/showcase_ui/src/app/pages/workspace/workspace.component.scss deleted file mode 100644 index 28535e473..000000000 --- a/apps/showcase_ui/src/app/pages/workspace/workspace.component.scss +++ /dev/null @@ -1,885 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -$bg-base: #e2e5ea; -$font-sans: 'Google Sans Text', 'Google Sans', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -$primary: #0f172a; -$primary-hover: #1e293b; -$rose: #ef4444; -$rose-dark: #dc2626; - -:host { - display: block; - height: 100vh; - width: 100vw; - overflow: hidden; - font-family: $font-sans; -} - -.workspace-container { - position: relative; - display: flex; - height: 100vh; - width: 100vw; - background-color: $bg-base; - overflow: hidden; - - &.is-resizing { - cursor: col-resize; - user-select: none; - } - - /* --- Glassmorphism Panel --- */ - .glass-panel { - position: relative; - z-index: 1; - background: #f4fafb; - border: none; - box-shadow: none; - } - - .left-panel { - flex: 1 0 500px; - display: flex; - flex-direction: column; - overflow: hidden; - background: #f4fafb; - border-right: 1px solid #e2e8f0; - border-left: none; - border-top: none; - border-bottom: none; - min-width: 450px; - height: 100%; - } - - .right-panel { - flex: 0 1 auto; - min-width: 250px; - max-width: 100%; - padding: 0; - border: none; - background-color: #ffffff; - box-shadow: -6px 0 24px rgba(0, 0, 0, 0.03); - height: 100%; - display: flex; - flex-direction: column; - box-sizing: border-box; - z-index: 2; - } - - .resizer { - width: 12px; - cursor: col-resize; - display: flex; - align-items: center; - justify-content: center; - z-index: 10; - transition: background 0.2s ease; - margin: 0 -6px; - - &:hover, .workspace-container.is-resizing & { - background: rgba(255, 255, 255, 0.15); - - .resizer-handle { - background: rgba(94, 114, 228, 0.8); - height: 60px; - } - } - - .resizer-handle { - width: 4px; - height: 40px; - background: rgba(0, 0, 0, 0.15); - border-radius: 4px; - transition: background 0.2s ease, height 0.2s ease; - } - } - - /* ========================================================================= */ - /* BOTTOM FLOATING COMMAND BAR (Proximity Reveal & Minimalist Glassmorphism) */ - /* ========================================================================= */ - .workspace-floating-bar-wrapper { - position: absolute; - bottom: 18px; - left: 50%; - transform: translateX(-50%); - width: 100%; - max-width: min(680px, calc(100% - 36px)); - z-index: 50; - display: flex; - flex-direction: column; - align-items: center; - pointer-events: none; - transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); - - /* Floating Toast / Error Notification */ - .floating-dock-toast { - pointer-events: auto; - margin-bottom: 8px; - display: flex; - align-items: center; - gap: 8px; - padding: 7px 14px; - border-radius: 12px; - font-size: 12.5px; - font-weight: 500; - box-shadow: 0 8px 24px -4px rgba(239, 68, 68, 0.15); - animation: slideUpFade 0.2s ease-out; - max-width: 90%; - - &.error { - background: rgba(254, 242, 242, 0.88); - backdrop-filter: blur(20px) saturate(180%); - -webkit-backdrop-filter: blur(20px) saturate(180%); - border: 1px solid rgba(254, 202, 202, 0.9); - color: #991b1b; - - .toast-icon { - font-size: 16px; - color: #ef4444; - flex-shrink: 0; - } - - .toast-text { - flex: 1; - line-height: 1.4; - } - - .btn-toast-close { - background: transparent; - border: none; - padding: 2px; - cursor: pointer; - color: #991b1b; - display: flex; - align-items: center; - justify-content: center; - border-radius: 4px; - opacity: 0.7; - transition: opacity 0.15s ease; - - &:hover { - opacity: 1; - background: rgba(0, 0, 0, 0.05); - } - - .material-symbols-outlined { - font-size: 15px; - } - } - } - } - - /* Dock Row: Flex container holding Main Card + Standalone Stop Button */ - .floating-dock-row { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - width: 100%; - pointer-events: none; - transition: gap 0.32s cubic-bezier(0.16, 1, 0.3, 1); - } - - /* Standalone Floating Stop Button (Separated from Capsule) */ - .standalone-stop-btn { - pointer-events: auto; - display: flex; - align-items: center; - justify-content: center; - width: 34px; - height: 34px; - min-width: 34px; - border-radius: 50%; - border: 1px solid rgba(255, 255, 255, 0.85); - background: rgba(239, 68, 68, 0.9); - backdrop-filter: blur(24px) saturate(190%) brightness(105%); - -webkit-backdrop-filter: blur(24px) saturate(190%) brightness(105%); - color: #ffffff; - box-shadow: 0 6px 18px -2px rgba(239, 68, 68, 0.4), inset 0 1px 1.5px rgba(255, 255, 255, 0.7); - cursor: pointer; - outline: none; - flex-shrink: 0; - transition: opacity 0.28s cubic-bezier(0.16, 1, 0.3, 1), - transform 0.28s cubic-bezier(0.16, 1, 0.3, 1), - width 0.28s cubic-bezier(0.16, 1, 0.3, 1), - min-width 0.28s cubic-bezier(0.16, 1, 0.3, 1), - margin 0.28s cubic-bezier(0.16, 1, 0.3, 1), - background 0.2s ease, - box-shadow 0.2s ease; - animation: popIn 0.25s cubic-bezier(0.16, 1, 0.3, 1); - - .material-symbols-outlined { - font-size: 17px; - line-height: 1; - } - - &:hover:not(:disabled) { - background: #dc2626; - transform: scale(1.1); - box-shadow: 0 8px 22px rgba(239, 68, 68, 0.55), inset 0 1px 1.5px rgba(255, 255, 255, 0.9); - } - - &:active:not(:disabled) { - transform: scale(0.93); - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - } - - /* Floating Dock Card Container */ - .floating-dock-card { - pointer-events: auto; - position: relative; - width: 100%; - border-radius: 18px; - box-sizing: border-box; - overflow: hidden; - transition: max-width 0.32s cubic-bezier(0.16, 1, 0.3, 1), - width 0.32s cubic-bezier(0.16, 1, 0.3, 1), - border-radius 0.28s cubic-bezier(0.16, 1, 0.3, 1), - box-shadow 0.28s ease, - background 0.28s ease, - transform 0.28s cubic-bezier(0.16, 1, 0.3, 1), - border-color 0.28s ease; - } - - /* ========================================================================= */ - /* AMBIENT LIQUID WAVE COLOR BLOCKS (Soft Diffused Aurora Glow Waves) */ - /* ========================================================================= */ - .dock-wave-container { - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 100%; - pointer-events: none; - border-radius: inherit; - overflow: hidden; - z-index: 0; - opacity: 0.78; - transition: opacity 0.35s ease, filter 0.35s ease; - - /* Flash Mode Color Stops (Soft Electric Blue, Violet, Amber, Cyan) */ - .wave-stop-1 { stop-color: #3b82f6; stop-opacity: 0.65; } - .wave-stop-2 { stop-color: #8b5cf6; stop-opacity: 0.7; } - .wave-stop-3 { stop-color: #f59e0b; stop-opacity: 0.65; } - .wave-stop-4 { stop-color: #06b6d4; stop-opacity: 0.6; } - - /* Pro Mode Color Stops (Soft Cosmic Nebula / Amethyst / Fuchsia) */ - &.is-pro { - .wave-stop-1 { stop-color: #7c3aed; stop-opacity: 0.7; } - .wave-stop-2 { stop-color: #ec4899; stop-opacity: 0.75; } - .wave-stop-3 { stop-color: #6366f1; stop-opacity: 0.65; } - .wave-stop-4 { stop-color: #a855f7; stop-opacity: 0.7; } - } - - /* Deep Ambient Aurora Glow Diffusion */ - .wave-glow-ambient { - position: absolute; - bottom: -8px; - left: 5%; - right: 5%; - height: 30px; - border-radius: 50%; - background: radial-gradient(ellipse at center, rgba(99, 102, 241, 0.4) 0%, rgba(245, 158, 11, 0.2) 50%, transparent 80%); - filter: blur(16px); - opacity: 0.7; - animation: glowBreathe 5s ease-in-out infinite alternate; - } - - &.is-pro .wave-glow-ambient { - background: radial-gradient(ellipse at center, rgba(168, 85, 247, 0.45) 0%, rgba(236, 72, 153, 0.25) 50%, transparent 80%); - } - - /* Wave Tracks Wrapper. The soft-liquid look comes from blurring each - wave individually: blurring the WRAPPER forced the GPU to re-run the - gaussian pass every animation frame (child transforms invalidate the - parent's filtered layer), while per-wave blur over transform-only - animations is rasterized once and then just composited. */ - .wave-tracks-wrapper { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 22px; - overflow: hidden; - mask-image: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.5) 60%, transparent 100%); - -webkit-mask-image: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.5) 60%, transparent 100%); - - .liquid-wave { - position: absolute; - bottom: 0; - left: 0; - width: 200%; - height: 100%; - will-change: transform; - filter: blur(8px); /* Silky liquid softness, applied per cached wave layer */ - - &.wave-back { - opacity: 0.45; - animation: waveSlide 12s ease-in-out infinite alternate; - } - - &.wave-mid { - opacity: 0.6; - animation: waveSlideReverse 9s ease-in-out infinite alternate; - } - - &.wave-front { - opacity: 0.75; - animation: waveSlide 6.5s ease-in-out infinite alternate; - } - } - } - } - - /* ======================================================================= */ - /* 1. DORMANT / RESTING CAPSULE STATE (Subtle Frosted Peek) */ - /* ======================================================================= */ - &.is-dormant { - .floating-dock-card { - max-width: 160px; - width: 160px; - margin: 0; - background: rgba(255, 255, 255, 0.65); - backdrop-filter: blur(26px) saturate(190%) brightness(104%); - -webkit-backdrop-filter: blur(26px) saturate(190%) brightness(104%); - border: 1px solid rgba(255, 255, 255, 0.85); - border-radius: 17px; - box-shadow: 0 6px 18px -2px rgba(15, 23, 42, 0.07), inset 0 1px 1.5px rgba(255, 255, 255, 0.95), inset 0 -1px 1px rgba(255, 255, 255, 0.3); - cursor: pointer; - opacity: 0.92; - transform: translateY(2px); - - .dock-wave-container { - opacity: 0.78; - .wave-tracks-wrapper { - height: 12px; - } - } - - &:hover { - opacity: 1; - transform: translateY(0); - background: rgba(255, 255, 255, 0.82); - border-color: rgba(255, 255, 255, 1); - box-shadow: 0 8px 24px -2px rgba(15, 23, 42, 0.12), inset 0 1px 1.5px rgba(255, 255, 255, 1); - - .dock-wave-container { - opacity: 0.95; - filter: drop-shadow(0 0 6px rgba(99, 102, 241, 0.35)); - } - } - - .capsule-peek-view { - display: flex; - opacity: 1; - } - - .expanded-card-content { - display: none; - opacity: 0; - } - } - } - - /* Capsule Peek View Layout */ - .capsule-peek-view { - position: relative; - z-index: 1; - display: flex; - align-items: center; - justify-content: center; - padding: 6px 14px; - min-height: 34px; - box-sizing: border-box; - user-select: none; - - .capsule-placeholder { - color: #475569; - font-size: 12px; - font-weight: 500; - letter-spacing: 0.1px; - white-space: nowrap; - text-align: center; - } - } - - /* ======================================================================= */ - /* 2. EXPANDED / ACTIVE STATE (Rich Frosted Command Box) */ - /* ======================================================================= */ - &.is-expanded { - .floating-dock-row { - gap: 0; - } - - .standalone-stop-btn { - opacity: 0; - width: 0; - min-width: 0; - margin: 0; - padding: 0; - border-width: 0; - transform: scale(0.5); - pointer-events: none; - } - - .floating-dock-card { - max-width: 100%; - width: 100%; - background: rgba(255, 255, 255, 0.68); - backdrop-filter: blur(32px) saturate(210%) brightness(105%); - -webkit-backdrop-filter: blur(32px) saturate(210%) brightness(105%); - border: 1px solid rgba(255, 255, 255, 0.82); - box-shadow: 0 16px 44px -8px rgba(15, 23, 42, 0.12), 0 2px 8px rgba(0, 0, 0, 0.02), inset 0 1.5px 2px rgba(255, 255, 255, 0.95), inset 0 -1px 2px rgba(255, 255, 255, 0.3); - opacity: 1; - transform: translateY(0); - - .dock-wave-container { - opacity: 0.88; - .wave-tracks-wrapper { - height: 22px; - } - } - - .capsule-peek-view { - display: none; - opacity: 0; - } - - .expanded-card-content { - display: block; - opacity: 1; - animation: cardFadeIn 0.25s cubic-bezier(0.16, 1, 0.3, 1); - } - } - } - - /* Focused State Glow */ - &.is-focused .floating-dock-card { - background: rgba(255, 255, 255, 0.78); - backdrop-filter: blur(36px) saturate(220%) brightness(106%); - -webkit-backdrop-filter: blur(36px) saturate(220%) brightness(106%); - border-color: rgba(59, 130, 246, 0.55); - box-shadow: 0 20px 48px -6px rgba(37, 99, 235, 0.16), 0 0 0 2.5px rgba(59, 130, 246, 0.18), inset 0 1.5px 2px rgba(255, 255, 255, 1); - - .dock-wave-container { - opacity: 1; - filter: drop-shadow(0 0 8px rgba(59, 130, 246, 0.35)); - } - } - - /* Expanded Content Rows */ - .expanded-card-content { - position: relative; - z-index: 1; - display: flex; - flex-direction: column; - width: 100%; - - .card-main-input-row { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 10px 3px 14px; - min-height: 48px; - box-sizing: border-box; - - .leading-status-icon { - width: 14px; - height: 14px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - - .pulse-indicator-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: #ef4444; - box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.6); - animation: pulseDot 1.4s infinite; - } - } - - .input-content-area { - flex: 1; - min-width: 0; - display: flex; - align-items: center; - - .dock-textarea { - width: 100%; - border: none; - outline: none; - background: transparent; - resize: none; - font-family: inherit; - font-size: 13.5px; - font-weight: 450; - line-height: 1.5; - color: #0f172a; - padding: 3px 0; - max-height: 120px; - box-sizing: border-box; - display: block; - - &::placeholder { - color: #64748b; - font-size: 12.5px; - font-weight: 400; - transition: color 0.15s ease; - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - } - } - - .dock-circle-btn { - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - border: none; - cursor: pointer; - outline: none; - flex-shrink: 0; - transition: background 0.18s cubic-bezier(0.16, 1, 0.3, 1), - color 0.18s cubic-bezier(0.16, 1, 0.3, 1), - transform 0.18s cubic-bezier(0.16, 1, 0.3, 1), - box-shadow 0.18s cubic-bezier(0.16, 1, 0.3, 1), - border-color 0.18s cubic-bezier(0.16, 1, 0.3, 1); - - .material-symbols-outlined { - font-size: 18px; - line-height: 1; - } - - .spinning-icon { - font-size: 17px; - animation: spin 1s linear infinite; - } - - /* Clear Button */ - &.btn-clear { - width: 26px; - height: 26px; - background: rgba(255, 255, 255, 0.45); - backdrop-filter: blur(8px); - border: 1px solid rgba(255, 255, 255, 0.6); - color: #64748b; - - &:hover { - background: rgba(255, 255, 255, 0.75); - color: #0f172a; - } - } - - /* Stop Button (Frosted Red) */ - &.btn-stop { - width: 36px; - height: 36px; - background: rgba(239, 68, 68, 0.9); - backdrop-filter: blur(8px); - color: #ffffff; - border: 1px solid rgba(255, 255, 255, 0.35); - box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3); - animation: popIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); - - &:hover:not(:disabled) { - background: #dc2626; - transform: scale(1.05); - box-shadow: 0 6px 16px rgba(239, 68, 68, 0.4); - } - - &:active:not(:disabled) { - transform: scale(0.95); - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - } - - /* Send Button */ - &.btn-send { - width: 36px; - height: 36px; - background: rgba(15, 23, 42, 0.88); - backdrop-filter: blur(12px); - border: 1px solid rgba(255, 255, 255, 0.25); - color: #ffffff; - box-shadow: 0 4px 14px rgba(15, 23, 42, 0.2), inset 0 1px 1px rgba(255, 255, 255, 0.25); - - &:disabled { - background: rgba(226, 232, 240, 0.6); - color: #94a3b8; - cursor: not-allowed; - box-shadow: none; - border-color: rgba(255, 255, 255, 0.5); - transform: none; - } - - &.has-content:not(:disabled) { - background: linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(15, 23, 42, 0.98)); - box-shadow: 0 4px 14px rgba(15, 23, 42, 0.25), inset 0 1px 1px rgba(255, 255, 255, 0.3); - - &:hover { - background: linear-gradient(135deg, #2563eb, #1d4ed8); - transform: scale(1.05); - box-shadow: 0 6px 18px rgba(37, 99, 235, 0.35); - } - } - - &:active:not(:disabled) { - transform: scale(0.95); - } - } - } - } - - /* Model Profile Selector & Status Footer Row */ - .card-footer-row { - display: flex; - align-items: center; - justify-content: space-between; - padding: 3px 14px 8px 14px; - box-sizing: border-box; - - .footer-left-group { - display: flex; - align-items: center; - gap: 8px; - - /* Segmented Model Toggle with Frosted Glass */ - .model-profile-toggle { - display: inline-flex; - align-items: center; - background: rgba(255, 255, 255, 0.45); - backdrop-filter: blur(14px); - -webkit-backdrop-filter: blur(14px); - border: 1px solid rgba(255, 255, 255, 0.65); - box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.8), 0 1px 3px rgba(0, 0, 0, 0.03); - border-radius: 8px; - padding: 2px; - gap: 2px; - - .profile-toggle-pill { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 3.5px; - width: 62px; - min-width: 62px; - height: 22px; - border-radius: 6px; - border: none; - background: transparent; - color: #64748b; - font-family: inherit; - font-size: 11px; - font-weight: 550; - cursor: pointer; - transition: color 0.16s cubic-bezier(0.16, 1, 0.3, 1), - background 0.16s cubic-bezier(0.16, 1, 0.3, 1), - box-shadow 0.16s cubic-bezier(0.16, 1, 0.3, 1); - outline: none; - user-select: none; - box-sizing: border-box; - - .pill-icon { - font-size: 13px; - line-height: 1; - flex-shrink: 0; - } - - .pill-label { - line-height: 1; - } - - &:hover:not(.active) { - color: #1e293b; - background: rgba(255, 255, 255, 0.65); - } - - &.active { - background: rgba(255, 255, 255, 0.92); - backdrop-filter: blur(8px); - color: #0f172a; - font-weight: 700; - box-shadow: 0 1.5px 4px rgba(0, 0, 0, 0.08), inset 0 1px 1.5px rgba(255, 255, 255, 1); - - .icon-flash { - color: #d97706; - } - - .icon-pro { - color: #7c3aed; - } - } - } - } - } - - .footer-right-status { - .running-status-pill { - display: inline-flex; - align-items: center; - gap: 4.5px; - font-size: 10.5px; - font-weight: 600; - color: #dc2626; - background: rgba(254, 226, 226, 0.7); - padding: 2px 7px; - border-radius: 10px; - - .running-pulse-dot { - width: 5.5px; - height: 5.5px; - border-radius: 50%; - background: #ef4444; - animation: pulseDot 1.2s infinite; - } - } - } - } - } - } - - /* When viewport shrinks to approximately 2/3 of standard desktop width (<= 1150px), hide right panel and leave only agent-stream */ - @media (max-width: 1150px) { - .right-panel, .resizer { - display: none !important; - } - - .left-panel { - border-right: none !important; - width: 100% !important; - flex: 1 1 100% !important; - min-width: 0 !important; - } - } -} - -/* Animations */ -@keyframes popIn { - 0% { - opacity: 0; - transform: scale(0.8); - } - 100% { - opacity: 1; - transform: scale(1); - } -} - -@keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -@keyframes slideUpFade { - from { - opacity: 0; - transform: translateY(8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes cardFadeIn { - from { - opacity: 0; - transform: translateY(4px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes pulseDot { - 0% { - transform: scale(0.95); - box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); - } - 70% { - transform: scale(1); - box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); - } - 100% { - transform: scale(0.95); - box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); - } -} - -/* Fluid Wave Horizontal Slide (Seamless loop) */ -@keyframes waveSlide { - 0% { - transform: translate3d(0, 0, 0); - } - 100% { - transform: translate3d(-50%, 0, 0); - } -} - -@keyframes waveSlideReverse { - 0% { - transform: translate3d(-50%, 0, 0); - } - 100% { - transform: translate3d(0, 0, 0); - } -} - -/* Ambient Radial Glow Breathing */ -@keyframes glowBreathe { - 0% { - opacity: 0.55; - transform: scaleX(0.92); - } - 100% { - opacity: 0.95; - transform: scaleX(1.08); - } -} - -/* Respect the user's motion preference: freeze the decorative liquid waves - and pulses while keeping the frosted dock visuals intact. */ -@media (prefers-reduced-motion: reduce) { - .liquid-wave, - .wave-glow-ambient, - .pulse-indicator-dot, - .running-pulse-dot { - animation: none !important; - } -} diff --git a/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts b/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts deleted file mode 100644 index 2ecb521f6..000000000 --- a/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts +++ /dev/null @@ -1,323 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, ChangeDetectionStrategy, NgZone, DestroyRef, inject, computed, signal, ViewChild, ElementRef, OnInit } from '@angular/core'; - -import { FormsModule } from '@angular/forms'; -import { AgentStreamComponent } from '../../components/agent-stream/agent-stream.component'; -import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component'; -import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component'; -import { AgentService } from '../../services/agent.service'; - -@Component({ - selector: 'app-workspace', - standalone: true, - imports: [ - FormsModule, - AgentStreamComponent, - ChatInterfaceComponent, - FloatingVideoPlayerComponent -], - templateUrl: './workspace.component.html', - styleUrl: './workspace.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class WorkspaceComponent implements OnInit { - public agentService = inject(AgentService); - private zone = inject(NgZone); - private destroyRef = inject(DestroyRef); - - // Default right panel width to 1/3 of the screen (or 450px as fallback) - public rightPanelWidth = signal( - typeof window !== 'undefined' ? Math.round(window.innerWidth / 3) : 450 - ); - public isDragging = signal(false); - private dragWidthRafId: number | null = null; - private pendingDragWidth = 0; - - // Floating Command Bar State. taskInput is backed by a signal so computed - // expressions (isBarExpanded) genuinely track it under OnPush. - private taskInputSignal = signal(''); - public get taskInput(): string { return this.taskInputSignal(); } - public set taskInput(value: string) { this.taskInputSignal.set(value); } - public isSubmitting = signal(false); - public errorMessage = signal(null); - public selectedProfile = signal<'flash' | 'pro'>('flash'); - - // Expand States (Signals for 0-latency reactivity) - public isHoveringCard = signal(false); - public isInputFocused = signal(false); - - @ViewChild('dockInput') public dockInputRef?: ElementRef; - - ngOnInit(): void { - if (typeof localStorage !== 'undefined') { - const saved = localStorage.getItem('artemis_selected_profile'); - if (saved === 'flash' || saved === 'pro') { - this.selectedProfile.set(saved); - } - } - - // The global ⌘K/Ctrl+K shortcut is registered outside the Angular zone so - // ordinary typing never schedules an extra change-detection pass. - this.zone.runOutsideAngular(() => { - window.addEventListener('keydown', this.onGlobalKeyDown); - }); - this.destroyRef.onDestroy(() => { - window.removeEventListener('keydown', this.onGlobalKeyDown); - this.detachDragListeners(); - }); - } - - /** - * Set agent architecture profile ('flash' vs 'pro') - */ - public setProfile(profile: 'flash' | 'pro', event?: MouseEvent): void { - if (event) { - event.stopPropagation(); - } - this.selectedProfile.set(profile); - if (typeof localStorage !== 'undefined') { - localStorage.setItem('artemis_selected_profile', profile); - } - } - - /** - * Computed boolean whether the currently viewed task is actively running or paused. - * Only displays the stop/cancel button when inspecting an active task. - */ - public isTaskRunning = computed(() => { - return this.agentService.isCurrentSessionRunning(); - }); - - /** - * Dynamic tooltip and label indicating which task will be stopped - */ - public stopButtonTitle = computed(() => { - const session = this.agentService.currentSession(); - if (session?.initial_goal) { - const truncated = session.initial_goal.length > 45 - ? session.initial_goal.substring(0, 42) + '...' - : session.initial_goal; - return `Stop current task: "${truncated}"`; - } - const curId = this.agentService.currentSessionId(); - if (curId) { - return `Stop current task (${curId})`; - } - return 'Stop current running task'; - }); - - /** - * Computed boolean whether the command bar should be in its expanded state: - * - Mouse is hovering directly on the command card - * - Input textarea is focused - * - User has entered task text (drafting) - */ - public isBarExpanded = computed(() => { - return ( - this.isHoveringCard() || - this.isInputFocused() || - this.taskInput.trim().length > 0 - ); - }); - - /** - * Focus input handler - */ - public onInputFocus(): void { - this.isInputFocused.set(true); - } - - /** - * Blur input handler - */ - public onInputBlur(): void { - this.isInputFocused.set(false); - } - - /** - * Click on the resting capsule or dock card to focus textarea - */ - public onCardClick(event: MouseEvent): void { - const target = event.target as HTMLElement; - // Don't steal focus if clicking action buttons or textarea directly - if (target.closest('button') || target.tagName.toLowerCase() === 'textarea') { - return; - } - this.focusInput(); - } - - /** - * Focus textarea programmatically - */ - public focusInput(): void { - setTimeout(() => { - this.dockInputRef?.nativeElement?.focus(); - }, 30); - } - - /** - * Clear typed task input - */ - public clearInput(event?: MouseEvent): void { - if (event) { - event.stopPropagation(); - } - this.taskInput = ''; - if (this.dockInputRef?.nativeElement) { - this.dockInputRef.nativeElement.style.height = 'auto'; - this.dockInputRef.nativeElement.focus(); - } - } - - /** - * Auto-resize textarea as user types multi-line tasks - */ - public onTextareaInput(event: Event): void { - const textarea = event.target as HTMLTextAreaElement; - if (!textarea) return; - textarea.style.height = 'auto'; - const newHeight = Math.min(Math.max(textarea.scrollHeight, 24), 120); - textarea.style.height = `${newHeight}px`; - } - - /** - * Global keyboard shortcut (⌘K or Ctrl+K) to focus input bar from anywhere - */ - private onGlobalKeyDown = (event: KeyboardEvent): void => { - if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { - event.preventDefault(); - this.focusInput(); - } - }; - - /** - * Handle enter key in floating dock textarea - */ - public onKeyDown(event: KeyboardEvent): void { - if (event.key === 'Enter' && !event.shiftKey) { - event.preventDefault(); - this.submitTask(); - } - } - - /** - * Submit a new task instruction - */ - public submitTask(): void { - const goal = this.taskInput.trim(); - if (!goal || this.isSubmitting()) { - return; - } - - this.isSubmitting.set(true); - this.errorMessage.set(null); - - if (this.dockInputRef?.nativeElement) { - this.dockInputRef.nativeElement.blur(); - } - this.isInputFocused.set(false); - - this.agentService.runTask(goal, this.selectedProfile()).subscribe({ - next: (res) => { - this.taskInput = ''; - if (this.dockInputRef?.nativeElement) { - this.dockInputRef.nativeElement.style.height = 'auto'; - } - this.isSubmitting.set(false); - this.agentService.fetchStatus(); - }, - error: (err) => { - console.error('Failed to submit task:', err); - this.isSubmitting.set(false); - this.errorMessage.set(err.error?.detail || 'The runner is busy. Please wait for current task to finish.'); - setTimeout(() => { - this.errorMessage.set(null); - }, 5000); - } - }); - } - - /** - * Stop currently viewed task - */ - public stopTask(event?: MouseEvent): void { - if (event) { - event.stopPropagation(); - } - if (!this.isTaskRunning()) { - return; - } - const targetSessionId = this.agentService.currentSessionId(); - this.isSubmitting.set(true); - this.errorMessage.set(null); - this.agentService.stopTask(targetSessionId, false); - setTimeout(() => { - this.isSubmitting.set(false); - }, 400); - } - - /** - * Handle mouse down on resizer bar to start dragging. The move/up listeners - * are attached only for the duration of the drag and run outside the Angular - * zone: idle mouse movement over the workspace never triggers change - * detection, and drag updates are coalesced to one per animation frame. - */ - public onDragStart(event: MouseEvent): void { - this.isDragging.set(true); - event.preventDefault(); - this.zone.runOutsideAngular(() => { - document.addEventListener('mousemove', this.onMouseMove); - document.addEventListener('mouseup', this.onMouseUp); - }); - } - - private onMouseMove = (event: MouseEvent): void => { - if (!this.isDragging()) { - return; - } - - const newWidth = window.innerWidth - event.clientX; - const minWidth = 250; - const maxWidth = window.innerWidth - 300; - - // Apply boundary limits to prevent panels from shrinking too much - if (newWidth >= minWidth && newWidth <= maxWidth) { - this.pendingDragWidth = newWidth; - if (this.dragWidthRafId === null) { - this.dragWidthRafId = requestAnimationFrame(() => { - this.dragWidthRafId = null; - this.rightPanelWidth.set(this.pendingDragWidth); - }); - } - } - }; - - private onMouseUp = (): void => { - this.isDragging.set(false); - this.detachDragListeners(); - }; - - private detachDragListeners(): void { - document.removeEventListener('mousemove', this.onMouseMove); - document.removeEventListener('mouseup', this.onMouseUp); - if (this.dragWidthRafId !== null) { - cancelAnimationFrame(this.dragWidthRafId); - this.dragWidthRafId = null; - } - } -} diff --git a/apps/showcase_ui/src/app/services/agent.service.spec.ts b/apps/showcase_ui/src/app/services/agent.service.spec.ts deleted file mode 100644 index c17e334d4..000000000 --- a/apps/showcase_ui/src/app/services/agent.service.spec.ts +++ /dev/null @@ -1,418 +0,0 @@ -import { signal, computed } from '@angular/core'; -import { of } from 'rxjs'; - -import { AgentService } from './agent.service'; - -describe('AgentService live LLM retry timeline', () => { - function createServiceWithoutPolling(): AgentService { - const service = Object.create(AgentService.prototype) as AgentService; - service.sessionLogs = signal([]); - service.isSessionContentLoading = signal(false); - service.startupProgressBySession = signal({}); - (service as any).pendingStartupProgress = signal([]); - (service as any).sessionLoadGeneration = 0; - (service as any).sessionSnapshotRequestId = 0; - (service as any).sessionSnapshotAppliedId = 0; - (service as any).pendingSnapshotRequests = new Set(); - return service; - } - - it('orders startup milestones and replaces duplicate stages', () => { - const service = createServiceWithoutPolling(); - - (service as any).appendStartupProgress({ - session_id: 'session-1', - stage: 'device', - message: 'Checking device', - timestamp: 102 - }, 'session-1'); - (service as any).appendStartupProgress({ - session_id: 'session-1', - stage: 'queued', - message: 'Task queued', - timestamp: 100 - }, 'session-1'); - (service as any).appendStartupProgress({ - session_id: 'session-1', - stage: 'device', - message: 'Device connected', - timestamp: 103 - }, 'session-1'); - - const events = service.startupProgressBySession()['session-1']; - expect(events.map((event) => event.stage)).toEqual(['queued', 'device']); - expect(events[1].message).toBe('Device connected'); - }); - - it('normalizes a live retry event into the historical trace contract', () => { - const service = createServiceWithoutPolling(); - const event = { - trace_id: 'retry-1', - step_id: 'step-1', - timestamp: 100, - error: '503 high demand', - delay: 1.5, - provider: 'google', - source: 'provider_sdk', - recoverable: true, - request_id: 'request-1', - scheduled_at: 100 - }; - - (service as any).appendLiveLLMRetryTrace(event, 'session-1'); - - const [log] = service.sessionLogs(); - expect(log.type).toBe('trace_recorded'); - expect(log.data).toEqual(jasmine.objectContaining({ - trace_id: 'retry-1', - session_id: 'session-1', - step_id: 'step-1', - type: 'llm_call', - name: 'llm_retry', - status: 'retrying' - })); - expect(log.data.payload).toEqual(jasmine.objectContaining({ - error: '503 high demand', - delay: 1.5, - provider: 'google', - source: 'provider_sdk', - request_id: 'request-1' - })); - }); - - it('upserts duplicate live retry events instead of adding another row', () => { - const service = createServiceWithoutPolling(); - const event = { - trace_id: 'retry-1', - timestamp: 100, - delay: 1.5, - provider: 'google', - source: 'provider_sdk', - request_id: 'request-1', - scheduled_at: 100 - }; - - (service as any).appendLiveLLMRetryTrace(event, 'session-1'); - (service as any).appendLiveLLMRetryTrace({ ...event, error: 'updated 503' }, 'session-1'); - - expect(service.sessionLogs().length).toBe(1); - expect(service.sessionLogs()[0].data.payload.error).toBe('updated 503'); - }); - - it('replaces an old history snapshot while preserving live retry events', () => { - const service = createServiceWithoutPolling(); - (service as any).currentSessionId = signal('session-1'); - (service as any).http = { - get: () => of([{ - step_id: 'step-1', - timestamp: 100, - generic_tools: [{ trace_id: 'persisted-retry', name: 'llm_retry' }] - }]) - }; - service.sessionLogs.set([ - { type: 'step_updated', history_snapshot: true, data: { step_id: 'old-step' } }, - { type: 'trace_recorded', data: { trace_id: 'live-retry', name: 'llm_retry' } } - ]); - service.isSessionContentLoading.set(true); - - (service as any).backfillSessionSteps('session-1'); - - const logs = service.sessionLogs(); - expect(logs.length).toBe(2); - expect(logs[0].history_snapshot).toBeTrue(); - expect(logs[0].data.step_id).toBe('step-1'); - expect(logs[1].data.trace_id).toBe('live-retry'); - expect(service.isSessionContentLoading()).toBeFalse(); - }); - - it('attaches the persisted checker transcript to the attempt it belongs to', () => { - const service = createServiceWithoutPolling(); - (service as any).currentSessionId = signal('session-1'); - (service as any).http = { - get: () => of({ - records: [ - { attempt_id: 'abc#1', checkpoint_id: 'abc', subgoal_text: 'Create the alarm', trace_id: 't-1', item_text: 'alarm exists', kind: 'verify', status: 'passed', evidence: 'seen', ts: 10 }, - { attempt_id: 'final#1', checkpoint_id: 'final', trace_id: 't-2', item_text: 'alarm exists', kind: 'verify', status: 'passed', evidence: 'seen', ts: 20 } - ], - streams: [ - { - attempt_id: 'abc#1', - trace_id: 't-1', - ts: 11, - segments: [ - { execution_id: 'e-1', role: 'thought', when: 8, text: 'Looking' }, - { execution_id: 'e-1', role: 'answer', when: 9, text: 'Seen it' } - ] - } - ], - run_outcome: null - }) - }; - service.sessionLogs.set([{ type: 'checker_event', checks_snapshot: true, data: { attempt_id: 'stale' } }]); - - service.fetchChecks('session-1'); - - const logs = service.sessionLogs(); - expect(logs.map((l) => l.data.attempt_id)).toEqual(['abc#1', 'final#1']); - expect(logs[0].data.stream_segments).toEqual([ - { execution_id: 'e-1', stream_type: 'thinking', text: 'Looking', timestamp: new Date(8000).toISOString(), isCompleted: true }, - { execution_id: 'e-1', stream_type: 'text', text: 'Seen it', timestamp: new Date(9000).toISOString(), isCompleted: true } - ]); - expect('stream_segments' in logs[1].data).toBeFalse(); - }); - - it('follows a just-started task even when status polling saw it first', () => { - const service = createServiceWithoutPolling(); - (service as any).http = { - post: () => of({ tasks: [{ session_id: 'new-session' }] }) - }; - service.agentStatus = signal('running'); - service.runningSessionId = signal('new-session'); - service.userPinnedSessionId = signal(null); - (service as any).sessions = signal([{ - session_id: 'new-session', - status: 'running' - }]); - const selectSpy = spyOn(service, 'selectSession'); - - service.runTask('test goal').subscribe(); - - expect(selectSpy).toHaveBeenCalledWith('new-session', false); - }); - - it('keeps the paused state when the backend says there is nothing to resume', () => { - const service = createServiceWithoutPolling(); - (service as any).http = { post: () => of({ status: 'not_paused' }) }; - (service as any).rawSessions = signal([{ session_id: 'session-1', status: 'paused' }]); - (service as any).pendingQueue = signal([]); - service.agentStatus = signal('paused'); - service.runningSessionId = signal('session-1'); - service.isPaused = signal(true); - service.pausedError = signal('503 unavailable'); - const statusSpy = spyOn(service, 'fetchStatus'); - - service.resumeTask(); - - expect(service.agentStatus()).toBe('paused'); - expect(service.isPaused()).toBeTrue(); - expect(statusSpy).toHaveBeenCalled(); - }); - - it('moves the active session to running only after resume succeeds', () => { - const service = createServiceWithoutPolling(); - (service as any).http = { post: () => of({ status: 'resumed' }) }; - (service as any).rawSessions = signal([{ session_id: 'session-1', status: 'paused' }]); - (service as any).pendingQueue = signal([]); - service.agentStatus = signal('paused'); - service.runningSessionId = signal('session-1'); - service.isPaused = signal(true); - service.pausedError = signal('503 unavailable'); - spyOn(service, 'fetchStatus'); - - service.resumeTask(); - - expect(service.agentStatus()).toBe('running'); - expect(service.isPaused()).toBeFalse(); - expect((service as any).rawSessions()[0].status).toBe('running'); - }); -}); - -describe('AgentService recording finalization lifecycle', () => { - function createVideoService(response: any): AgentService { - const service = Object.create(AgentService.prototype) as AgentService; - (service as any).http = { get: () => of(response) }; - (service as any).rawSessions = signal([ - { session_id: 'session-1', status: 'completed', recording_status: 'recording' } - ]); - (service as any).activeVideoSessionId = 'session-1'; - (service as any).videoRequestGeneration = 1; - (service as any).videoRetryTimer = null; - (service as any).videoWaitStartedAt = Date.now(); - service.activeVideoUrl = signal(null); - service.activeVideoSegments = signal([]); - service.isVideoLoading = signal(false); - service.recordingPlaybackStatus = signal('idle'); - service.recordingPlaybackMessage = signal(''); - service.shouldAutoplayVideo = signal(true); - service.playerMode = signal<'video' | 'steps'>('video'); - (service as any).hasCurrentSessionStepFrames = computed(() => false); - (service as any).currentSessionStepFrames = computed(() => []); - return service; - } - - it('keeps unfinished media out of the video element and schedules another readiness check', () => { - const service = createVideoService({ - session_id: 'session-1', - status: 'processing', - has_video: false, - video_url: null, - retry_after_ms: 750 - }); - const retrySpy = spyOn(service, 'scheduleVideoRetry'); - - (service as any).requestSessionVideo('session-1', 1); - - expect(service.recordingPlaybackStatus()).toBe('processing'); - expect(service.isVideoLoading()).toBeTrue(); - expect(service.activeVideoUrl()).toBeNull(); - expect(retrySpy).toHaveBeenCalledWith('session-1', 1, 750); - }); - - it('publishes finalized media and preserves the automatic replay request', () => { - const service = createVideoService({ - session_id: 'session-1', - status: 'ready', - has_video: true, - video_url: '/videos/recording.mp4?v=1', - video_segments: [ - { url: '/videos/recording.mp4?v=1', start: 0, duration: 5, width: 1080, height: 1920 } - ] - }); - - (service as any).requestSessionVideo('session-1', 1); - - expect(service.recordingPlaybackStatus()).toBe('ready'); - expect(service.isVideoLoading()).toBeFalse(); - expect(service.activeVideoUrl()).toBe('/videos/recording.mp4?v=1'); - expect(service.activeVideoSegments().length).toBe(1); - expect(service.shouldAutoplayVideo()).toBeTrue(); - }); - - it('stops polling and exposes a terminal recording failure', () => { - const service = createVideoService({ - session_id: 'session-1', - status: 'failed', - has_video: false, - video_url: null, - message: 'ffmpeg failed' - }); - - (service as any).requestSessionVideo('session-1', 1); - - expect(service.recordingPlaybackStatus()).toBe('failed'); - expect(service.isVideoLoading()).toBeFalse(); - expect(service.recordingPlaybackMessage()).toBe('ffmpeg failed'); - }); -}); - -describe('AgentService video analysis seeking', () => { - it('publishes repeatable, clamped seek requests for the floating player', () => { - const service = Object.create(AgentService.prototype) as AgentService; - service.videoSeekRequest = signal<{ seconds: number; requestId: number } | null>(null); - (service as any).videoSeekRequestId = 0; - - service.requestVideoSeek(-5); - const first = service.videoSeekRequest(); - service.requestVideoSeek(0); - const second = service.videoSeekRequest(); - - expect(first?.seconds).toBe(0); - expect(second?.seconds).toBe(0); - expect(second?.requestId).toBeGreaterThan(first?.requestId || 0); - }); -}); - -describe('AgentService task cancellation and active session tracking', () => { - it('computes isCurrentSessionRunning true only when viewing an active running/paused session', () => { - const service = Object.create(AgentService.prototype) as any; - service.currentSessionId = signal(null); - service.rawSessions = signal([]); - service.activeTasks = signal([]); - service.pendingQueue = signal([]); - service.agentStatus = signal('idle'); - service.runningSessionId = signal(null); - service.runningGoal = signal(null); - service.activeSessionTracking = new Map(); - - service.currentSession = computed(() => { - const curId = service.currentSessionId(); - if (!curId) return null; - return service.sessions().find((s: any) => s.session_id === curId) || null; - }); - - service.sessions = computed(() => { - return service.rawSessions(); - }); - - service.isCurrentSessionRunning = computed(() => { - const curId = service.currentSessionId(); - if (!curId) return false; - const session = service.currentSession(); - if (session) { - return session.status === 'running' || session.status === 'paused'; - } - const isActiveStatus = service.agentStatus() === 'running' || service.agentStatus() === 'paused'; - if (isActiveStatus && service.runningSessionId() === curId) { - return true; - } - if (service.activeTasks().some((at: any) => at.session_id === curId)) { - return true; - } - return false; - }); - - // 1. No session selected -> false - expect(service.isCurrentSessionRunning()).toBeFalse(); - - // 2. Completed session selected -> false - service.rawSessions.set([ - { session_id: 'sess-completed', status: 'completed', initial_goal: 'Done', start_time: 100 }, - { session_id: 'sess-running', status: 'running', initial_goal: 'Running', start_time: 101 } - ]); - service.currentSessionId.set('sess-completed'); - expect(service.isCurrentSessionRunning()).toBeFalse(); - - // 3. Active running session selected -> true - service.currentSessionId.set('sess-running'); - expect(service.isCurrentSessionRunning()).toBeTrue(); - - // 4. Paused session selected -> true - service.rawSessions.set([ - { session_id: 'sess-paused', status: 'paused', initial_goal: 'Paused', start_time: 102 } - ]); - service.currentSessionId.set('sess-paused'); - expect(service.isCurrentSessionRunning()).toBeTrue(); - }); - - it('stops a specific session by passing its session_id', () => { - const service = Object.create(AgentService.prototype) as AgentService; - service.currentSessionId = signal('sess-2'); - service.runningSessionId = signal('sess-1'); - service.runningGoal = signal('Goal 1'); - service.agentStatus = signal('running'); - service.isPaused = signal(false); - service.pausedError = signal(null); - service.isRetrying = signal(false); - service.sessionLogs = signal([]); - service.activeTasks = signal([{ session_id: 'sess-1' }, { session_id: 'sess-2' }]); - (service as any).pendingQueue = signal([]); - (service as any).rawSessions = signal([ - { session_id: 'sess-1', status: 'running', initial_goal: 'Goal 1' }, - { session_id: 'sess-2', status: 'running', initial_goal: 'Goal 2' } - ]); - service.sessions = computed(() => (service as any).rawSessions()); - - let postedUrl = ''; - let postedPayload: any = null; - (service as any).http = { - post: (url: string, payload: any) => { - postedUrl = url; - postedPayload = payload; - return of({}); - } - }; - (service as any).setSessionStatus = (sid: string, st: string) => {}; - (service as any).fetchStatus = () => {}; - (service as any).fetchSessions = () => {}; - - // Stop sess-2 specifically - service.stopTask('sess-2', false); - - expect(postedUrl).toContain('session_id=sess-2'); - expect(postedPayload?.session_id).toBe('sess-2'); - // Because sess-1 is still running, agentStatus should NOT be reset to idle - expect(service.agentStatus()).toBe('running'); - // activeTasks should have filtered out sess-2 - expect(service.activeTasks().map((at: any) => at.session_id)).toEqual(['sess-1']); - }); -}); diff --git a/apps/showcase_ui/src/app/services/agent.service.ts b/apps/showcase_ui/src/app/services/agent.service.ts deleted file mode 100644 index b063c99b8..000000000 --- a/apps/showcase_ui/src/app/services/agent.service.ts +++ /dev/null @@ -1,2012 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Injectable, signal, inject, computed, DestroyRef, NgZone } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { Observable } from 'rxjs'; - -import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage } from '../core/models/session.model'; -import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model'; -import { StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE, PersistedCheckerStream, StreamSegment } from '../core/models/stream.model'; -import { extractStepReplayFrames } from '../utils/action-formatter.util'; -import { persistedStreamToSegments } from '../utils/stream-aggregator.util'; -export type { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice }; - -const SESSION_CACHE_KEY = 'artemis.sessions.v1'; - -export interface VideoSegment { - url: string; - /** Back-to-back playlist timeline start (seconds); gaps between segments are not represented. */ - start: number; - duration: number; - width: number; - height: number; - /** Session-relative first-frame offset in milliseconds (manifest v2). */ - offset_ms?: number; - duration_ms?: number; -} - -export type RecordingPlaybackStatus = - | 'idle' - | 'live' - | 'processing' - | 'ready' - | 'failed' - | 'unavailable'; - -export interface StartupProgressEvent { - session_id?: string; - stage: string; - message: string; - timestamp: number; -} - -interface SessionVideoResponse { - session_id: string; - status?: 'processing' | 'ready' | 'failed' | 'unavailable'; - has_video: boolean; - video_url: string | null; - video_segments?: VideoSegment[]; - retry_after_ms?: number; - message?: string; -} - -@Injectable({ - providedIn: 'root' -}) -export class AgentService { - private http = inject(HttpClient); - private zone = inject(NgZone); - private activePauseCardKey: string | null = null; - - // Signals to expose state to components - private rawSessions = signal([]); - private pendingQueue = signal([]); - public activeTasks = signal([]); - // Persistent tracking of active/pending sessions across polling boundaries - private activeSessionTracking = new Map(); - - public sessions = computed(() => { - const raw = this.rawSessions(); - const pending = this.pendingQueue(); - const status = this.agentStatus(); - const goal = this.runningGoal(); - const runId = this.runningSessionId(); - const activeList = this.activeTasks(); - - const sessionMap = new Map(); - // Lookup maps keep the per-session merge O(1) instead of scanning the - // pending/active lists multiple times for every raw session. - const pendingById = new Map(pending.map((p) => [p.session_id, p])); - const activeById = new Map(activeList.map((at) => [at.session_id, at])); - - // 1. First add raw sessions from DB - raw.forEach((s) => { - const activeMatch = activeById.get(s.session_id); - const pendingMatch = pendingById.get(s.session_id); - const isCurrentActive = ((status === 'running' || status === 'paused') && runId === s.session_id) - || !!activeMatch; - const isTerminal = s.status === 'completed' || s.status === 'success' || s.status === 'failed' || s.status === 'cancelled'; - let sStatus = s.status; - if (!isTerminal) { - const isPending = !!pendingMatch && !isCurrentActive; - sStatus = isCurrentActive ? (status === 'paused' ? 'paused' : 'running') : (isPending ? 'pending' : s.status); - } else { - sStatus = (s.status === 'success') ? 'completed' : s.status; - } - let serial = s.device_serial || s.device_id || activeMatch?.device_id || pendingMatch?.device_serial || null; - if (!serial && s.device_info) { - try { - const info = typeof s.device_info === 'string' ? JSON.parse(s.device_info) : s.device_info; - serial = info?.device_id || info?.device_serial || null; - } catch { - // ignore - } - } - const finalSession: Session = { - ...s, - status: sStatus, - device_serial: serial, - model_info: isCurrentActive && this.activeModel() ? this.activeModel()! : s.model_info - }; - sessionMap.set(s.session_id, finalSession); - if (isTerminal) { - this.activeSessionTracking.delete(s.session_id); - } else if (sStatus === 'running' || sStatus === 'paused' || sStatus === 'pending') { - this.activeSessionTracking.set(s.session_id, finalSession); - } - }); - - // 2. Add pending queue sessions if not yet in raw sessions - pending.forEach((p) => { - if (!sessionMap.has(p.session_id)) { - const activeMatch = activeById.get(p.session_id); - const isCurrentRunning = ((status === 'running' || status === 'paused') && runId === p.session_id) - || !!activeMatch; - const finalPending: Session = { - ...p, - status: isCurrentRunning ? (status === 'paused' ? 'paused' : 'running') : (p.status || 'pending'), - device_serial: p.device_serial || p.device_id || activeMatch?.device_id || null - }; - sessionMap.set(p.session_id, finalPending); - this.activeSessionTracking.set(p.session_id, finalPending); - } - }); - - // 3. Ensure all currently active running sessions are present (multi-device & external runs) - if (activeList.length > 0) { - activeList.forEach((at) => { - const sid = at.session_id || `active-${at.device_id}`; - const existing = sessionMap.get(sid); - if (!existing) { - const newSession: Session = { - session_id: sid, - initial_goal: at.goal || goal || '', - start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000), - status: 'running', - model_info: this.activeModel() || undefined, - device_serial: at.device_id || null - }; - sessionMap.set(sid, newSession); - this.activeSessionTracking.set(sid, newSession); - } else if (existing.status === 'pending') { - const updated: Session = { - ...existing, - status: 'running', - device_serial: at.device_id || existing.device_serial - }; - sessionMap.set(sid, updated); - this.activeSessionTracking.set(sid, updated); - } - }); - } else if ((status === 'running' || status === 'paused') && runId && !sessionMap.has(runId)) { - const activeSession: Session = { - session_id: runId, - initial_goal: goal || '', - start_time: Date.now() / 1000, - status, - model_info: this.activeModel() || undefined - }; - sessionMap.set(runId, activeSession); - this.activeSessionTracking.set(runId, activeSession); - } - - // 4. Bridge transient queue-to-running transition gaps - this.activeSessionTracking.forEach((ts, sid) => { - if (!sessionMap.has(sid)) { - sessionMap.set(sid, { - ...ts, - status: 'running' - }); - } - }); - - return Array.from(sessionMap.values()).sort((a, b) => b.start_time - a.start_time); - }); - - public currentSessionId = signal(null); - public sessionLogs = signal([]); // Dynamic array of all raw events received - public isSessionContentLoading = signal(false); - public agentStatus = signal('idle'); // Status of the agent runner process - public runningSessionId = signal(null); - public runningGoal = signal(null); - public isPaused = signal(false); - public pausedError = signal(null); - public isRetrying = signal(false); - public retryMessage = signal(null); - /** Architecture / LLM of the task the status poll reports as running. */ - public activeModel = signal<{ name: string; id: string; provider: string } | null>(null); - public userPinnedSessionId = signal(null); // Pinned session if user explicitly selected a non-running task - public startupProgressBySession = signal>({}); - private pendingStartupProgress = signal([]); - - public currentStartupProgress = computed(() => { - const sessionId = this.currentSessionId(); - if (sessionId) { - const normalized = String(sessionId).trim().toLowerCase(); - const bySession = this.startupProgressBySession(); - const direct = bySession[sessionId]; - if (direct?.length) return direct; - const matched = Object.entries(bySession).find( - ([key]) => key.trim().toLowerCase() === normalized - ); - if (matched?.[1]?.length) return matched[1]; - } - return this.pendingStartupProgress(); - }); - - // Notes and Tab States - public currentNotes = signal>({}); - public selectedNoteKey = signal('task_plan.md'); - public activeTab = signal<'tasks' | 'notes'>('tasks'); - - // Video Replay Floating Window States - public isVideoWindowOpen = signal(false); - public isVideoMinimized = signal(false); - public activeVideoUrl = signal(null); - public activeVideoSegments = signal([]); - public activeVideoTitle = signal(''); - public isVideoLoading = signal(false); - public recordingPlaybackStatus = signal('idle'); - public recordingPlaybackMessage = signal(''); - public shouldAutoplayVideo = signal(false); - public videoSeekRequest = signal<{ seconds: number; requestId: number } | null>(null); - public stepSeekRequest = signal<{ index: number; requestId: number } | null>(null); - public playerMode = signal<'video' | 'steps'>('video'); - public streamResetEvent = signal(null); - private activeVideoSessionId: string | null = null; - private videoSeekRequestId = 0; - private stepSeekRequestId = 0; - private videoRequestGeneration = 0; - private videoRetryTimer: ReturnType | null = null; - private videoWaitStartedAt = 0; - - /** - * Step logs only. Streaming text chunks cannot change replay frames, so the - * custom equality keeps downstream frame extraction from re-running on every - * llm_stream update of the sessionLogs signal. - */ - private stepLogsForReplay = computed( - () => this.sessionLogs().filter( - (log) => log && (log.type === 'step_updated' || log.type === 'step_recorded') - ), - { equal: (a, b) => a.length === b.length && a.every((log, i) => log === b[i]) } - ); - - /** - * Computed step replay frames from current session logs - */ - public currentSessionStepFrames = computed(() => { - return extractStepReplayFrames(this.stepLogsForReplay()); - }); - - public hasCurrentSessionStepFrames = computed(() => { - return this.currentSessionStepFrames().length > 0; - }); - - public setPlayerMode(mode: 'video' | 'steps'): void { - this.playerMode.set(mode); - } - - public togglePlayerMode(): void { - this.playerMode.update((mode) => (mode === 'video' ? 'steps' : 'video')); - } - - public requestStepSeek(index: number): void { - this.stepSeekRequestId++; - this.stepSeekRequest.set({ index, requestId: this.stepSeekRequestId }); - } - - /** - * Clear user-pinned selection so subsequent runs automatically follow active runner - */ - public clearUserPinnedSession(): void { - this.userPinnedSessionId.set(null); - } - - /** - * Computed active/viewed session - */ - public currentSession = computed(() => { - const curId = this.currentSessionId(); - if (!curId) return null; - return this.sessions().find(s => s.session_id === curId) || null; - }); - - /** - * Architecture / LLM of the session being viewed. - * - * Each session row carries its own `model_info` (resolved by the backend), - * so a Pro session stays "Pro" while a Flash task runs elsewhere. Only a - * session that has not reached the list yet falls back to the running model. - */ - public viewedModel = computed(() => { - const current = this.currentSession(); - if (current?.model_info) return current.model_info; - return this.activeModel(); - }); - - /** - * Computed whether the currently viewed session is actively running or paused. - * Returns false when viewing completed/failed/cancelled tasks, or when no session is selected. - */ - public isCurrentSessionRunning = computed(() => { - const curId = this.currentSessionId(); - if (!curId) return false; - const session = this.currentSession(); - if (session) { - return session.status === 'running' || session.status === 'paused'; - } - // Fallback if session was just submitted/selected but not yet merged into sessions list - const isActiveStatus = this.agentStatus() === 'running' || this.agentStatus() === 'paused'; - if (isActiveStatus && this.runningSessionId() === curId) { - return true; - } - if (this.activeTasks().some((at) => at.session_id === curId)) { - return true; - } - return false; - }); - - /** - * Computed video URL for the currently viewed session - */ - public currentSessionVideoUrl = computed(() => { - const curId = this.currentSessionId(); - if (!curId) return null; - const session = this.sessions().find(s => s.session_id === curId); - return session?.video_url || null; - }); - - public currentSessionRecordingStatus = computed(() => { - const session = this.currentSession(); - const status = session?.recording_status; - return status === 'recording' || status === 'finalizing' || status === 'processing' - ? 'processing' - : status; - }); - - private eventSource: EventSource | null = null; - private statusInterval: any = null; - private sessionLoadGeneration = 0; - private sessionSnapshotRequestId = 0; - private sessionSnapshotAppliedId = 0; - private pendingSnapshotRequests = new Set(); - - // Streaming chunks are coalesced into one signal update per short window so a - // chatty LLM stream cannot force a full recompute pass per SSE chunk. - private pendingStreamChunks = new Map(); - private streamFlushTimer: ReturnType | null = null; - private lastQueueSignature: string | null = null; - private lastActiveTasksSignature: string | null = null; - private lastPersistedSessionsJson: string | null = null; - private onVisibilityChange = () => { - if (typeof document !== 'undefined' && !document.hidden) { - this.fetchStatus(); - this.fetchSessions(); - } - }; - - constructor() { - this.restoreSessionsCache(); - this.fetchSessions(); - this.startStatusPolling(); - this.ensureLiveStream(); - if (typeof document !== 'undefined') { - document.addEventListener('visibilitychange', this.onVisibilityChange); - } - inject(DestroyRef).onDestroy(() => this.destroy()); - } - - /** - * Check if any task is currently running - */ - public isRunningTask = computed(() => { - if (this.agentStatus() === 'running' || this.agentStatus() === 'paused') return true; - return this.sessions().some(s => s.status === 'running' || s.status === 'paused'); - }); - - /** - * Effective Pro tuning defaults (verification level, explorer mode) from the - * backend agent config, so the launcher sliders start where the config is. - */ - public getProTuningDefaults(): Observable { - return this.http.get('/api/run/defaults'); - } - - /** Session-wide token totals, live executor context size and the run's tuning. */ - public getSessionUsage(sessionId: string): Observable { - return this.http.get(`/api/sessions/${encodeURIComponent(sessionId)}/usage`); - } - - /** - * Run a new task by submitting to backend queue - */ - public runTask( - goal: string, - profile: string = 'flash', - expectedOutput?: string, - enableOutputter?: boolean, - proTuning?: ProTuningOptions - ): Observable { - return new Observable((obs) => { - const submittedEvent: StartupProgressEvent = { - stage: 'submitting', - message: 'Submitting the task', - timestamp: Date.now() / 1000 - }; - this.pendingStartupProgress.set([submittedEvent]); - const payload: any = { goal, profile }; - if (expectedOutput && expectedOutput.trim()) { - payload.expected_output = expectedOutput.trim(); - } - if (enableOutputter !== undefined) { - payload.enable_outputter = enableOutputter; - } - if (proTuning?.verificationLevel) { - payload.verification_level = proTuning.verificationLevel; - } - if (proTuning?.explorerMode) { - payload.explorer_mode = proTuning.explorerMode; - } - this.clearUserPinnedSession(); - this.http.post('/api/run', payload).subscribe({ - next: (res) => { - if (res && res.tasks && res.tasks.length > 0) { - const newSessionId = res.tasks[0].session_id; - if (newSessionId) { - this.appendStartupProgress( - { ...submittedEvent, session_id: newSessionId }, - newSessionId - ); - this.pendingStartupProgress.set([]); - const isCurrentlyRunning = - this.agentStatus() === 'running' || - this.sessions().some((s) => s.status === 'running'); - const activeSessionId = this.runningSessionId() - || this.sessions().find((s) => s.status === 'running' || s.status === 'paused')?.session_id; - // The status poll can observe the new runner before /api/run - // returns. In that case it is still the task we just submitted, - // not an older task that should remain selected. - if (!isCurrentlyRunning || activeSessionId === newSessionId) { - this.selectSession(newSessionId, false); - } - } - } - this.pendingStartupProgress.set([]); - obs.next(res); - obs.complete(); - }, - error: (err) => { - this.pendingStartupProgress.set([]); - obs.error(err); - } - }); - }); - } - - /** - * Stop the currently viewed task (or specified task), and optionally clear queue. - * Supports stopping any task across any ingress/platform (Web, CLI, MCP, SDK). - */ - public stopTask(targetOrStopAll?: string | boolean | null, stopAll: boolean = false): void { - let targetSessionId: string | null = null; - let effectiveStopAll = stopAll; - - if (typeof targetOrStopAll === 'boolean') { - effectiveStopAll = targetOrStopAll; - } else if (typeof targetOrStopAll === 'string' && targetOrStopAll.trim()) { - targetSessionId = targetOrStopAll.trim(); - } else { - // Default to currently viewed session, or currently running session - targetSessionId = this.currentSessionId() || this.runningSessionId() || null; - } - - if (!effectiveStopAll && !targetSessionId) { - targetSessionId = this.sessions().find( - (session) => session.status === 'running' || session.status === 'paused' - )?.session_id || null; - } - - // Keep the task's terminal state stable while the stop request and the - // persisted session update cross the network. Clearing the global runner - // state first used to make the session merger infer "completed" from the - // still-stale DB row, causing a brief completed -> cancelled flicker. - if (targetSessionId) { - this.setSessionStatus(targetSessionId, 'cancelled'); - } - - // Determine if other active sessions are still executing across multiple devices - const otherRunningSessions = this.sessions().filter( - (s) => (s.status === 'running' || s.status === 'paused') && s.session_id !== targetSessionId - ); - - // Apply optimistic updates: only set idle if effectiveStopAll is true or no other tasks are running - if (effectiveStopAll || otherRunningSessions.length === 0) { - this.agentStatus.set('idle'); - this.runningSessionId.set(null); - this.runningGoal.set(null); - } else if (this.runningSessionId() === targetSessionId) { - this.runningSessionId.set(otherRunningSessions[0].session_id); - this.runningGoal.set(otherRunningSessions[0].initial_goal || null); - } - - this.isPaused.set(false); - this.pausedError.set(null); - this.isRetrying.set(false); - this.invalidateStatusSignatures(); - if (effectiveStopAll) { - this.pendingQueue.set([]); - } - - // Mark active streaming / pending logs as finished if viewing target session - if (!targetSessionId || this.currentSessionId() === targetSessionId) { - this.flushStreamChunks(); - this.sessionLogs.update((logs) => { - if (!logs.some((l) => l.type === 'llm_stream' && !l.data?.isCompleted)) { - return logs; - } - return logs.map((l) => { - if (l.type === 'llm_stream' && !l.data?.isCompleted) { - return { ...l, data: { ...l.data, isCompleted: true } }; - } - return l; - }); - }); - } - - // Optimistically remove from activeTasks - if (targetSessionId) { - this.activeTasks.update((list) => list.filter((at) => at.session_id !== targetSessionId)); - } - - let url = `/api/stop?all=${effectiveStopAll}`; - if (!effectiveStopAll && targetSessionId) { - url += `&session_id=${encodeURIComponent(targetSessionId)}`; - } - - const payload: any = { all: effectiveStopAll }; - if (targetSessionId) { - payload.session_id = targetSessionId; - } - - this.http.post(url, payload).subscribe({ - next: () => { - this.fetchStatus(); - this.fetchSessions(); - }, - error: (err) => { - console.error('Failed to stop task:', err); - this.fetchSessions(); - this.fetchStatus(); - } - }); - } - - /** - * Resume paused task - */ - public resumeTask(): void { - this.http.post<{ status?: string }>('/api/resume', {}).subscribe({ - next: (response) => { - if (response?.status !== 'resumed') { - // A stale recovery control must not optimistically change the task - // back to running when the backend says there is nothing to resume. - this.fetchStatus(); - return; - } - const resumedSessionId = this.runningSessionId(); - this.isPaused.set(false); - this.pausedError.set(null); - this.agentStatus.set('running'); - this.setSessionStatus(resumedSessionId, 'running'); - this.fetchStatus(); - }, - error: (err) => { - console.error('Failed to resume task:', err); - } - }); - } - - /** - * Fetch all past and active sessions from the backend - */ - public fetchSessions(): void { - this.http.get('/api/sessions').subscribe({ - next: (data) => { - this.rawSessions.set(data); - this.persistSessionsCache(data); - // On initial load, if nothing is selected, not pinned, not running, and sessions exist, select latest - if (!this.currentSessionId() && !this.userPinnedSessionId() && this.agentStatus() !== 'running' && data.length > 0) { - this.selectSession(data[0].session_id, false); - } - }, - error: (err) => { - console.error('Failed to fetch sessions from backend:', err); - } - }); - } - - /** - * Delete an individual session / task - */ - public deleteSession(sessionId: string): Observable { - // 1. Optimistically update local session state immediately - this.invalidateStatusSignatures(); - this.rawSessions.update((list) => list.filter((s) => s.session_id !== sessionId)); - this.persistSessionsCache(this.rawSessions()); - this.pendingQueue.update((list) => list.filter((s) => s.session_id !== sessionId)); - - if (this.userPinnedSessionId() === sessionId) { - this.userPinnedSessionId.set(null); - } - - if (this.currentSessionId() === sessionId) { - this.selectSession('', false); - if (this.isVideoWindowOpen()) { - this.closeVideoPlayer(); - } - const runningId = this.runningSessionId(); - if (runningId && this.agentStatus() === 'running') { - this.selectSession(runningId, false); - } - } - - // 2. Send API request - return new Observable((obs) => { - this.http.post(`/api/sessions/${sessionId}/delete`, {}).subscribe({ - next: (res) => { - this.fetchSessions(); - this.fetchStatus(); - obs.next(res); - obs.complete(); - }, - error: (err) => { - console.error(`Failed to delete session ${sessionId}:`, err); - this.fetchSessions(); - this.fetchStatus(); - obs.error(err); - } - }); - }); - } - - /** - * Clear all sessions, tasks, and history - */ - public clearAllHistory(): Observable { - // 1. Optimistically clear all local session state immediately - this.invalidateStatusSignatures(); - this.userPinnedSessionId.set(null); - this.rawSessions.set([]); - this.clearSessionsCache(); - this.pendingQueue.set([]); - this.selectSession('', false); - if (this.isVideoWindowOpen()) { - this.closeVideoPlayer(); - } - - // 2. Send API request - return new Observable((obs) => { - this.http.post('/api/cleanup', {}).subscribe({ - next: (res) => { - this.fetchSessions(); - this.fetchStatus(); - obs.next(res); - obs.complete(); - }, - error: (err) => { - console.error('Failed to cleanup history:', err); - this.fetchSessions(); - this.fetchStatus(); - obs.error(err); - } - }); - }); - } - - /** - * Connect to the Server-Sent Events stream for a specific session - * @param sessionId The ID of the session to select - * @param isUserAction Whether this selection was explicitly triggered by user interaction - */ - public selectSession(sessionId: string, isUserAction: boolean = false): void { - if (isUserAction) { - if (sessionId && sessionId === this.runningSessionId() && this.agentStatus() === 'running') { - // User explicitly clicked the currently running task -> follow live runner - this.userPinnedSessionId.set(null); - } else if (sessionId) { - // User explicitly clicked a non-running or historical task -> pin to it - this.userPinnedSessionId.set(sessionId); - } else { - this.userPinnedSessionId.set(null); - } - } - - if (!sessionId) { - this.sessionLoadGeneration++; - this.pendingSnapshotRequests.clear(); - this.discardPendingStreamChunks(); - this.isSessionContentLoading.set(false); - this.currentSessionId.set(null); - this.sessionLogs.set([]); - this.currentNotes.set({}); - return; - } - - if (this.currentSessionId() === sessionId) { - return; // Already selected - } - - const loadGeneration = ++this.sessionLoadGeneration; - this.pendingSnapshotRequests.clear(); - this.discardPendingStreamChunks(); - this.sessionSnapshotAppliedId = 0; - this.isSessionContentLoading.set(true); - this.currentSessionId.set(sessionId); - this.sessionLogs.set([]); // Reset logs for new session selection - this.activePauseCardKey = null; - this.isPaused.set(false); - this.pausedError.set(null); - this.fetchNotes(sessionId); - this.fetchChecks(sessionId); - - // If video window is currently open, dynamically sync/refresh video for new session - if (this.isVideoWindowOpen()) { - this.openVideoPlayer(sessionId); - } - - console.debug(`Selecting session: ${sessionId}`); - // Start reading the persisted snapshot immediately - this.backfillSessionSteps(sessionId, loadGeneration); - this.ensureLiveStream(); - } - - /** - * Ensure persistent single-channel live stream is established via /api/stream. - * Eliminates reconnect tearing, race conditions, and disconnect dropouts. - */ - public ensureLiveStream(): void { - if (this.eventSource) { - return; - } - - console.debug('Establishing persistent unified live stream via /api/stream'); - // The stream is registered outside the Angular zone: high-frequency SSE - // callbacks must not schedule a change-detection pass each. Signal writes - // still notify the render scheduler, so the UI stays live. - this.zone.runOutsideAngular(() => { - this.eventSource = new EventSource('/api/stream'); - - this.eventSource.addEventListener('info', () => { - // Reconcile current session if active - const curId = this.currentSessionId(); - if (curId) { - this.backfillSessionSteps(curId); - } - }); - - this.eventSource.addEventListener('keep-alive', () => { - // Keep-alive tick - }); - - const eventTypes = [ - 'llm_stream', - 'llm_stream_reset', - 'trace_recorded', - 'step_recorded', - 'step_updated', - 'background_tasks_updated', - 'task_paused', - 'task_resumed', - 'llm_retrying', - 'startup_progress', - 'session_started', - 'session_ended', - 'recording_ready', - 'recording_failed', - 'checker_event' - ]; - - eventTypes.forEach((eventType) => { - this.eventSource?.addEventListener(eventType, (event: MessageEvent) => { - try { - const parsedData = JSON.parse(event.data); - const evtSessionId = parsedData?.session_id; - - // Preserve ordering: buffered stream text must land before any - // non-stream event is appended to the session logs. - if (eventType !== 'llm_stream') { - this.flushStreamChunks(); - } - - if (eventType === 'recording_ready' || eventType === 'recording_failed') { - if ( - this.isVideoWindowOpen() - && evtSessionId - && String(evtSessionId) === String(this.activeVideoSessionId) - ) { - if (eventType === 'recording_ready') { - this.refreshActiveRecording(true); - } else { - this.cancelVideoRetry(); - this.isVideoLoading.set(false); - this.activeVideoUrl.set(null); - this.activeVideoSegments.set([]); - this.recordingPlaybackStatus.set('failed'); - this.recordingPlaybackMessage.set( - parsedData?.error || 'Recording finalization failed.' - ); - } - } - return; - } - - if (eventType === 'background_tasks_updated') { - this.fetchStatus(); - return; - } - - if (eventType === 'session_started') { - this.agentStatus.set('running'); - if (parsedData?.session_id) { - this.runningSessionId.set(parsedData.session_id); - } - if (parsedData?.initial_goal) { - this.runningGoal.set(parsedData.initial_goal); - } - this.fetchSessions(); - - // Auto-follow: if user has not explicitly pinned a historical session, switch view to new active session - if (!this.userPinnedSessionId() && parsedData?.session_id) { - this.selectSession(parsedData.session_id, false); - } - return; - } - - if (eventType === 'session_ended') { - const endedId = evtSessionId || this.runningSessionId(); - if (endedId) { - this.applySessionEndedStatus(endedId, parsedData); - this.invalidateStatusSignatures(); - this.activeTasks.update(list => list.filter(at => at.session_id !== endedId)); - } - const remaining = this.activeTasks(); - if (remaining.length > 0) { - this.agentStatus.set('running'); - this.runningSessionId.set(remaining[0].session_id); - this.runningGoal.set(remaining[0].goal || null); - } else if (this.pendingQueue().length > 0) { - const nextPending = this.pendingQueue()[0]; - this.agentStatus.set('running'); - this.runningSessionId.set(nextPending.session_id); - this.runningGoal.set(nextPending.initial_goal || null); - } else { - this.agentStatus.set('idle'); - this.runningSessionId.set(null); - this.runningGoal.set(null); - } - this.isRetrying.set(false); - this.isPaused.set(false); - this.fetchSessions(); - this.fetchStatus(); - if (endedId) { - this.fetchNotes(endedId); - if (String(endedId) === String(this.currentSessionId() || '')) { - // The exit final review / run outcome may have landed while the - // stream was reconnecting: reconcile from the persisted ledger. - this.fetchChecks(endedId); - } - if ( - this.isVideoWindowOpen() - && this.activeVideoSessionId === endedId - && this.recordingPlaybackStatus() === 'live' - ) { - this.beginRecordingFinalization(endedId); - } - } - return; - } - - if (eventType === 'startup_progress') { - const targetSid = evtSessionId || this.currentSessionId(); - if (targetSid) { - this.appendStartupProgress(parsedData, targetSid); - const curId = this.currentSessionId(); - if ( - !this.userPinnedSessionId() && - (!curId || String(targetSid).trim().toLowerCase() !== String(curId).trim().toLowerCase()) - ) { - this.agentStatus.set('running'); - this.runningSessionId.set(targetSid); - this.selectSession(targetSid, false); - } - } - return; - } - - // If the event is for a different session than what user is currently viewing, ignore for logs - const curId = this.currentSessionId(); - if (evtSessionId && curId && String(evtSessionId).trim().toLowerCase() !== String(curId).trim().toLowerCase()) { - return; - } - - if (eventType === 'llm_retrying') { - this.isRetrying.set(true); - const attempt = Number(parsedData.attempt || 0); - const max = Number(parsedData.max_retries || 0); - const delay = Number(parsedData.delay || 0); - const attemptText = attempt && max ? ` (Attempt ${attempt}/${max})` : ''; - const delayText = delay > 0 ? `; retrying in ${delay.toFixed(2).replace(/\.00$/, '')}s` : ''; - this.retryMessage.set(`AI service is temporarily busy${attemptText}${delayText}...`); - if (curId) { - this.appendLiveLLMRetryTrace(parsedData, curId); - } - return; - } - - if (eventType === 'task_paused') { - this.isPaused.set(true); - this.isRetrying.set(false); - this.agentStatus.set('paused'); - if (curId) { - this.runningSessionId.set(curId); - this.setSessionStatus(curId, 'paused'); - const pauseError = parsedData.error || 'AI call failed'; - this.pausedError.set(pauseError); - this.appendPausedErrorCard( - pauseError, - curId, - parsedData.timestamp, - parsedData.step_id, - parsedData - ); - } - return; - } - - if (eventType === 'task_resumed') { - this.isPaused.set(false); - this.isRetrying.set(false); - this.pausedError.set(null); - if (curId && this.runningSessionId() === curId) { - this.agentStatus.set('running'); - this.setSessionStatus(curId, 'running'); - } - this.activePauseCardKey = null; - return; - } - - if (eventType === 'step_updated' || eventType === 'step_recorded' || eventType === 'llm_stream') { - this.isRetrying.set(false); - } - - if (eventType === 'llm_stream_reset') { - const streamExecId = parsedData?.stream_exec_id || parsedData?.stream_execution_id; - if (streamExecId) { - for (const key of Array.from(this.pendingStreamChunks.keys())) { - if (key.startsWith(`${streamExecId}|`) || key === streamExecId) { - this.pendingStreamChunks.delete(key); - } - } - } - const resetMessage = parsedData?.message || DEFAULT_STREAM_RESET_MESSAGE; - this.streamResetEvent.set({ - stream_exec_id: streamExecId, - stream_execution_id: streamExecId, - step_id: parsedData?.step_id, - session_id: parsedData?.session_id, - action: parsedData?.action || 'discard', - reason: parsedData?.reason || 'mid_stream_failure', - category: parsedData?.category, - error: parsedData?.error, - message: resetMessage, - retry_attempt: parsedData?.retry_attempt, - timestamp: Date.now() - }); - - this.sessionLogs.update((logs) => { - let updated = false; - const next = logs.map((log) => { - if (log.type === 'llm_stream' && log.data?.execution_id === streamExecId) { - updated = true; - return { - ...log, - data: { - ...log.data, - isReset: true, - resetMessage - } - }; - } - return log; - }); - if (!updated && streamExecId) { - next.push({ - type: 'llm_stream', - timestamp: new Date().toISOString(), - session_id: parsedData?.session_id, - data: { - execution_id: streamExecId, - step_id: parsedData?.step_id, - stream_type: 'text', - text: '', - isCompleted: false, - isReset: true, - resetMessage - } - }); - } - return next; - }); - return; - } - - if (eventType === 'llm_stream') { - this.queueStreamChunk(parsedData); - } else { - this.sessionLogs.update((logs) => { - // Copying every log object on every event is wasted work when no - // stream is open; only rewrite entries that actually change. - const hasOpenStream = logs.some( - (l) => l.type === 'llm_stream' && !l.data.isCompleted - ); - const updatedLogs = !hasOpenStream ? [...logs] : logs.map((l) => { - if (l.type === 'llm_stream' && !l.data.isCompleted) { - return { - ...l, - data: { ...l.data, isCompleted: true } - }; - } - return l; - }); - - const evtTime = typeof parsedData?.timestamp === 'number' - ? new Date(parsedData.timestamp * 1000).toISOString() - : new Date().toISOString(); - - const nextLog = { - type: eventType, - timestamp: evtTime, - data: parsedData - }; - - if (eventType === 'trace_recorded' && parsedData?.trace_id) { - const existingTraceIndex = updatedLogs.findIndex((log) => - log.type === 'trace_recorded' - && log.data?.trace_id === parsedData.trace_id - ); - if (existingTraceIndex > -1) { - const deduplicatedLogs = [...updatedLogs]; - deduplicatedLogs[existingTraceIndex] = nextLog; - return deduplicatedLogs; - } - } - - return [...updatedLogs, nextLog]; - }); - } - - if (eventType === 'trace_recorded' && parsedData) { - const trName = parsedData.name || ''; - if (['save_note', 'read_note', 'update_note', 'append_note', 'list_notes', 'outputter'].includes(trName.toLowerCase())) { - const curSessionId = this.currentSessionId(); - if (curSessionId) { - this.fetchNotes(curSessionId); - } - } - } - } catch (e) { - console.error(`Failed to parse ${eventType} event data:`, e); - this.sessionLogs.update((logs) => [ - ...logs, - { - type: eventType, - timestamp: new Date().toISOString(), - data: event.data - } - ]); - } - }); - }); - - this.eventSource.onerror = (err) => { - console.warn('Persistent live stream issue, browser will auto-reconnect:', err); - }; - }); - } - - /** - * Buffer one llm_stream chunk; the buffer is flushed into sessionLogs at most - * once per short window (and immediately before any non-stream event so the - * relative ordering of stream text and step events is preserved). - */ - private queueStreamChunk(parsedData: any): void { - const execId = parsedData.execution_id; - const stepId = parsedData.step_id; - const sessionId = parsedData.session_id || null; - // The emitting agent's trace: lets the aggregator route a concurrent - // agent's stream (e.g. the Checker) to its own block instead of the - // Operator step that happens to be current. - const parentTraceId = parsedData.parent_trace_id || null; - const streamType = parsedData.stream_type || 'text'; - const key = `${execId}|${streamType}`; - const chunk = parsedData.chunk !== undefined ? parsedData.chunk : (parsedData.text || ''); - const pending = this.pendingStreamChunks.get(key); - if (pending) { - pending.chunk += chunk; - if (stepId) pending.stepId = stepId; - if (sessionId) pending.sessionId = sessionId; - if (parentTraceId) pending.parentTraceId = parentTraceId; - } else { - this.pendingStreamChunks.set(key, { execId, stepId, sessionId, parentTraceId, streamType, chunk }); - } - if (!this.streamFlushTimer) { - const delay = typeof document !== 'undefined' && document.hidden ? 500 : 80; - this.streamFlushTimer = setTimeout(() => this.flushStreamChunks(), delay); - } - } - - private discardPendingStreamChunks(): void { - this.pendingStreamChunks?.clear(); - if (this.streamFlushTimer) { - clearTimeout(this.streamFlushTimer); - this.streamFlushTimer = null; - } - } - - private flushStreamChunks(): void { - if (this.streamFlushTimer) { - clearTimeout(this.streamFlushTimer); - this.streamFlushTimer = null; - } - if (!this.pendingStreamChunks?.size) return; - const batches = Array.from(this.pendingStreamChunks.values()); - this.pendingStreamChunks.clear(); - - this.sessionLogs.update((logs) => { - let next = [...logs]; - for (const batch of batches) { - const existingIndex = next.findIndex( - (l) => l.type === 'llm_stream' && - l.data.execution_id === batch.execId && - (l.data.stream_type || 'text') === batch.streamType - ); - - if (existingIndex > -1) { - const existingLog = next[existingIndex]; - next[existingIndex] = { - ...existingLog, - session_id: batch.sessionId || existingLog.session_id, - data: { - ...existingLog.data, - session_id: batch.sessionId || existingLog.data?.session_id, - text: (existingLog.data.text || '') + batch.chunk, - step_id: batch.stepId || existingLog.data.step_id, - parent_trace_id: batch.parentTraceId || existingLog.data.parent_trace_id || null - } - }; - } else { - // A new execution closes the previous open stream of the same agent - // lane only; concurrent agents (Operator vs. Checker) keep theirs. - next = next.map((l) => { - if (l.type === 'llm_stream' && - (l.data.stream_type || 'text') === batch.streamType && - !l.data.isCompleted && - (l.data.parent_trace_id || null) === (batch.parentTraceId || null)) { - return { ...l, data: { ...l.data, isCompleted: true } }; - } - return l; - }); - next.push({ - type: 'llm_stream', - timestamp: new Date().toISOString(), - session_id: batch.sessionId || undefined, - data: { - execution_id: batch.execId, - session_id: batch.sessionId || undefined, - step_id: batch.stepId, - parent_trace_id: batch.parentTraceId || null, - text: batch.chunk, - stream_type: batch.streamType, - isCompleted: false - } - }); - } - } - return next; - }); - } - - /** - * Load a fresh persisted snapshot without replacing events already received - * from SSE. Snapshot logs are replaceable, while live logs remain append-only; - * the stream aggregator merges matching steps and trace IDs within each step. - */ - private backfillSessionSteps(sessionId: string, loadGeneration = this.sessionLoadGeneration): void { - const requestId = ++this.sessionSnapshotRequestId; - this.pendingSnapshotRequests.add(requestId); - this.http.get(`/api/sessions/${sessionId}/steps`).subscribe({ - next: (steps) => { - this.pendingSnapshotRequests.delete(requestId); - if ( - this.currentSessionId() !== sessionId - || loadGeneration !== this.sessionLoadGeneration - || requestId < this.sessionSnapshotAppliedId - ) return; - this.sessionSnapshotAppliedId = requestId; - const historicalLogs = steps.map((step) => ({ - type: 'step_updated', - session_id: sessionId, - timestamp: new Date((step.timestamp || Date.now() / 1000) * 1000).toISOString(), - data: step, - history_snapshot: true - })); - - this.sessionLogs.update((logs) => [ - ...historicalLogs, - ...logs.filter((log) => !log.history_snapshot) - ]); - this.isSessionContentLoading.set(false); - }, - error: (err) => { - this.pendingSnapshotRequests.delete(requestId); - console.error('Failed to backfill session steps:', err); - if ( - this.currentSessionId() === sessionId - && loadGeneration === this.sessionLoadGeneration - && this.pendingSnapshotRequests.size === 0 - ) { - this.isSessionContentLoading.set(false); - } - } - }); - - this.http.get(`/api/sessions/${sessionId}/startup_progress`).subscribe({ - next: (events) => { - if (Array.isArray(events) && events.length > 0) { - this.startupProgressBySession.update((current) => { - const existing = current[sessionId] || []; - const merged = [...existing]; - for (const ev of events) { - if (!merged.some((m) => m.stage === ev.stage)) { - merged.push(ev); - } - } - return { - ...current, - [sessionId]: merged - }; - }); - } - }, - error: () => {} - }); - } - - private restoreSessionsCache(): void { - try { - const cached = localStorage.getItem(SESSION_CACHE_KEY); - if (!cached) return; - const sessions = JSON.parse(cached); - if (Array.isArray(sessions)) { - this.lastPersistedSessionsJson = cached; - this.rawSessions.set(sessions); - } - } catch { - this.clearSessionsCache(); - } - } - - private persistSessionsCache(sessions: Session[]): void { - try { - const serialized = JSON.stringify(sessions); - if (serialized === this.lastPersistedSessionsJson) return; - this.lastPersistedSessionsJson = serialized; - localStorage.setItem(SESSION_CACHE_KEY, serialized); - } catch { - // Storage can be unavailable in private browsing or embedded contexts. - } - } - - private clearSessionsCache(): void { - this.lastPersistedSessionsJson = null; - try { - localStorage.removeItem(SESSION_CACHE_KEY); - } catch { - // No-op when browser storage is unavailable. - } - } - - /** - * Convert the dedicated live retry event into the same trace shape used by - * history APIs. This keeps live streaming and post-refresh rendering on one - * data contract instead of maintaining a separate retry UI path. - */ - private appendLiveLLMRetryTrace(data: any, sessionId: string): void { - const timestampSeconds = typeof data?.timestamp === 'number' - ? (data.timestamp > 1e11 ? data.timestamp / 1000 : data.timestamp) - : Date.now() / 1000; - const traceId = data?.trace_id - || `llm-retry-live-${data?.request_id || 'unknown'}-${timestampSeconds}`; - const payload = { - error: data?.error, - delay: data?.delay, - attempt: data?.attempt, - max_retries: data?.max_retries, - provider: data?.provider, - source: data?.source, - recoverable: data?.recoverable, - request_id: data?.request_id, - scheduled_at: data?.scheduled_at ?? timestampSeconds - }; - const retryTrace = { - trace_id: traceId, - session_id: sessionId, - step_id: data?.step_id || null, - type: 'llm_call', - name: 'llm_retry', - timestamp: timestampSeconds, - status: 'retrying', - payload - }; - - this.sessionLogs.update((logs) => { - const existingIndex = logs.findIndex((log) => - log.type === 'trace_recorded' - && ( - log.data?.trace_id === traceId - || ( - data?.request_id - && log.data?.payload?.request_id === data.request_id - && Number(log.data?.payload?.scheduled_at) === Number(payload.scheduled_at) - ) - ) - ); - const retryLog = { - type: 'trace_recorded', - session_id: sessionId, - timestamp: new Date(timestampSeconds * 1000).toISOString(), - data: retryTrace - }; - if (existingIndex < 0) return [...logs, retryLog]; - - const updatedLogs = [...logs]; - updatedLogs[existingIndex] = retryLog; - return updatedLogs; - }); - } - - /** - * Preserve pause failures in the normal failed-LLM card stream. New runners - * emit a persisted failed trace first; this fallback also supports older - * runners whose task_paused event only carried an error string. - */ - private appendPausedErrorCard( - error: unknown, - sessionId: string, - timestamp?: number | string, - stepId?: string | null, - details?: any - ): void { - const errorText = typeof error === 'string' ? error : JSON.stringify(error); - const pauseKey = `${sessionId}:${errorText}`; - if (this.activePauseCardKey === pauseKey) return; - - this.sessionLogs.update((logs) => { - const alreadyRecorded = logs.some((log) => { - const traces = log?.type === 'trace_recorded' - ? [log.data] - : (Array.isArray(log?.data?.generic_tools) ? log.data.generic_tools : []); - return traces.some((trace: any) => - trace?.type === 'llm_call' - && trace?.status === 'failed' - && String(trace?.payload?.error ?? trace?.error ?? '') === errorText - ); - }); - if (alreadyRecorded) return logs; - - const latestStepId = stepId || [...logs].reverse().find((log) => - (log.type === 'step_updated' || log.type === 'step_recorded') && log.data?.step_id - )?.data?.step_id || null; - const timestampMs = typeof timestamp === 'number' - ? (timestamp < 1e11 ? timestamp * 1000 : timestamp) - : (timestamp ? new Date(timestamp).getTime() : Date.now()); - - return [ - ...logs, - { - type: 'trace_recorded', - session_id: sessionId, - timestamp: new Date(timestampMs).toISOString(), - data: { - trace_id: `task-paused-${sessionId}-${timestampMs}`, - session_id: sessionId, - step_id: latestStepId, - type: 'llm_call', - name: 'llm_pause', - status: 'failed', - timestamp: timestampMs / 1000, - payload: { - error: errorText, - pause: true, - request_id: details?.request_id, - provider: details?.provider, - waited_seconds: details?.waited_seconds, - retries: Array.isArray(details?.retries) ? details.retries : [] - } - } - } - ]; - }); - this.activePauseCardKey = pauseKey; - } - - private pollCounter: number = 0; - - private setSessionStatus(sessionId: string | null, status: Session['status']): void { - if (!sessionId) return; - - this.invalidateStatusSignatures(); - this.rawSessions.update((sessions) => sessions.map((session) => - session.session_id === sessionId ? { ...session, status } : session - )); - this.pendingQueue.update((sessions) => sessions.map((session) => - session.session_id === sessionId ? { ...session, status } : session - )); - } - - private applySessionEndedStatus(sessionId: unknown, data: any): void { - if (!sessionId) return; - - const reportedStatus = String(data?.status || '').toLowerCase(); - const status: Session['status'] | null = data?.was_stopped_manually || reportedStatus === 'cancelled' - ? 'cancelled' - : reportedStatus === 'failed' - ? 'failed' - : reportedStatus === 'completed' || reportedStatus === 'success' - ? 'completed' - : null; - - if (status) { - this.activeSessionTracking.delete(String(sessionId)); - this.setSessionStatus(String(sessionId), status); - this.invalidateStatusSignatures(); - this.activeTasks.update((list) => list.filter((at) => at.session_id !== String(sessionId))); - } - } - - /** - * Start periodic status polling from the backend - */ - private startStatusPolling(): void { - this.fetchStatus(); - // The interval runs outside the Angular zone so a poll tick alone never - // schedules change detection; only actual signal updates do. - this.zone.runOutsideAngular(() => { - this.statusInterval = setInterval(() => { - if (typeof document !== 'undefined' && document.hidden) { - return; // Paused while the tab is in the background; resumed by visibilitychange. - } - this.fetchStatus(); - this.pollCounter++; - // Periodically refresh sessions every 6 seconds (every 3 polling intervals of 2s) to stay in sync with external DB modifications - if (this.pollCounter % 3 === 0) { - this.fetchSessions(); - } - }, 2000); - }); - } - - /** - * Optimistic local mutations of queue/active state must force the next poll - * to re-apply backend data even when its payload has not changed. - */ - private invalidateStatusSignatures(): void { - this.lastQueueSignature = null; - this.lastActiveTasksSignature = null; - } - - /** - * Fetch current agent runner process status - */ - public fetchStatus(): void { - this.http.get('/api/status').subscribe({ - next: (data) => { - if (data && data.status) { - const oldStatus = this.agentStatus(); - const oldRunningSessionId = this.runningSessionId(); - const isActive = data.status === 'running' || data.status === 'paused'; - this.agentStatus.set(data.status); - this.runningSessionId.set(data.session_id || null); - this.runningGoal.set(data.goal || null); - if (data.model_info) { - this.activeModel.set(data.model_info); - } - this.isPaused.set(data.status === 'paused'); - - if (data.status === 'paused') { - this.isRetrying.set(false); - const pauseError = data.paused_error - || this.pausedError() - || 'AI model request failed. The task is paused.'; - this.pausedError.set(pauseError); - if (data.session_id && this.currentSessionId() === data.session_id) { - this.appendPausedErrorCard(pauseError, data.session_id); - } - } else { - this.pausedError.set(null); - this.activePauseCardKey = null; - } - - // Skip signal updates when backend payloads did not change: setting a - // fresh array reference every 2s poll would force the whole sessions - // computed chain to re-derive for identical data. - const queueSignature = JSON.stringify(data.queue || []); - if (queueSignature !== this.lastQueueSignature) { - this.lastQueueSignature = queueSignature; - // Map backend pending queue to real Session / TaskQueueItem objects - const pending: Session[] = (data.queue || []).map((item: any, index: number) => { - if (typeof item === 'object' && item !== null) { - return { - session_id: item.session_id || `pending-task-${index}`, - initial_goal: item.goal || '', - start_time: item.start_time || item.created_at || (Date.now() / 1000 + index), - status: item.status || 'pending', - device_serial: item.device_serial || item.device_id || null - }; - } - return { - session_id: `task-queued-${index}`, - initial_goal: String(item), - start_time: (Date.now() / 1000) + index, - status: 'pending' - }; - }); - this.pendingQueue.set(pending); - } - const activeTasksSignature = JSON.stringify(data.active_tasks || []); - if (activeTasksSignature !== this.lastActiveTasksSignature) { - this.lastActiveTasksSignature = activeTasksSignature; - this.activeTasks.set(data.active_tasks || []); - } - - if (oldStatus !== data.status || oldRunningSessionId !== data.session_id) { - this.fetchSessions(); - } - - if ( - !isActive - && (oldStatus === 'running' || oldStatus === 'paused') - && oldRunningSessionId - && this.isVideoWindowOpen() - && this.activeVideoSessionId === oldRunningSessionId - && this.recordingPlaybackStatus() === 'live' - ) { - this.beginRecordingFinalization(oldRunningSessionId); - } - - // Auto-select the active session if user has not explicitly pinned a historical session - if (isActive && data.session_id) { - const currentId = this.currentSessionId(); - if (!currentId || (!this.userPinnedSessionId() && currentId !== data.session_id)) { - this.selectSession(data.session_id, false); - } - } - } - }, - error: (err) => { - console.error('Failed to fetch status from backend:', err); - this.agentStatus.set('offline'); - this.runningSessionId.set(null); - this.runningGoal.set(null); - } - }); - } - - /** - * Clean up event source connection and timers on service destruction - */ - public destroy(): void { - if (this.statusInterval) { - clearInterval(this.statusInterval); - this.statusInterval = null; - } - if (this.eventSource) { - this.eventSource.close(); - this.eventSource = null; - } - this.discardPendingStreamChunks(); - if (typeof document !== 'undefined') { - document.removeEventListener('visibilitychange', this.onVisibilityChange); - } - this.cancelVideoRetry(); - } - - /** - * Fetch notes for a specific session - */ - /** - * Backfill the Checker's attempts and run outcome from the persisted verdict - * ledger (`/api/sessions/{id}/checks`) as synthetic `checker_event` logs, so - * historical sessions show the same timeline blocks as live ones. Live - * events for attempts already known are merged by attempt id in the - * aggregator, so re-fetching is idempotent. - */ - public fetchChecks(sessionId: string): void { - if (!sessionId) return; - this.http.get<{ records?: any[]; streams?: PersistedCheckerStream[]; run_outcome?: any }>(`/api/sessions/${sessionId}/checks`).subscribe({ - next: (res) => { - if (this.currentSessionId() !== sessionId) return; - const snapshot = this.buildCheckerSnapshotLogs( - sessionId, res?.records || [], res?.run_outcome || null, res?.streams || [] - ); - this.sessionLogs.update((logs) => [ - ...logs.filter((log) => !log.checks_snapshot), - ...snapshot - ]); - }, - error: (err) => { - console.error(`Failed to fetch checks for session ${sessionId}:`, err); - } - }); - } - - /** - * `streams` are the attempts' persisted transcripts (what the Checker - * streamed while reaching its verdict): they become the attempt's - * `stream_segments`, the same shape the live `llm_stream` chunks build, so - * a reopened session interleaves Thought/Work text with tool rows exactly - * like the live view did. - */ - private buildCheckerSnapshotLogs( - sessionId: string, - records: any[], - runOutcome: any, - streams: PersistedCheckerStream[] = [] - ): any[] { - const byAttempt = new Map(); - const segmentsByAttempt = new Map(); - for (const stream of streams || []) { - if (!stream || !stream.attempt_id) continue; - const segments = persistedStreamToSegments(stream); - if (segments.length > 0) segmentsByAttempt.set(String(stream.attempt_id), segments); - } - for (const rec of records) { - if (!rec || !rec.attempt_id) continue; - const attemptId = String(rec.attempt_id); - const checkpointId = String(rec.checkpoint_id || ''); - let attempt = byAttempt.get(attemptId); - if (!attempt) { - attempt = { - event: 'attempt_finished', - phase: checkpointId === 'final' ? 'final' : 'checkpoint', - attempt_id: attemptId, - checkpoint_id: checkpointId, - subgoal_text: String(rec.subgoal_text || (checkpointId === 'final' - ? "Final review against the user's original goal" - : `Subgoal ${checkpointId.slice(0, 8)}`)), - anchor_step_id: rec.anchor_step_id ?? null, - trace_id: rec.trace_id ?? null, - status: 'done', - verdicts: [], - findings: [], - ts: typeof rec.ts === 'number' ? rec.ts : undefined, - session_id: sessionId - }; - byAttempt.set(attemptId, attempt); - } - if (typeof rec.ts === 'number') { - attempt.ts = attempt.ts === undefined ? rec.ts : Math.min(attempt.ts, rec.ts); - } - if (!attempt.trace_id && rec.trace_id) { - attempt.trace_id = rec.trace_id; - } - attempt.verdicts.push({ - item_text: String(rec.item_text || ''), - kind: String(rec.kind || ''), - status: String(rec.status || ''), - evidence: String(rec.evidence || ''), - suggestion: rec.suggestion ? String(rec.suggestion) : '', - when: rec.when ? String(rec.when) : undefined - }); - } - - const logs: any[] = []; - for (const attempt of byAttempt.values()) { - const statuses = new Set(attempt.verdicts.map((v: any) => v.status)); - if (statuses.size === 1) { - const only = [...statuses][0]; - if (only === 'superseded' || only === 'unchecked') attempt.status = only; - } - const ts = attempt.ts ?? Date.now() / 1000; - attempt.timestamp = ts; - const segments = segmentsByAttempt.get(attempt.attempt_id); - if (segments) attempt.stream_segments = segments; - logs.push({ - type: 'checker_event', - session_id: sessionId, - timestamp: new Date(ts * 1000).toISOString(), - checks_snapshot: true, - data: attempt - }); - } - if (runOutcome && typeof runOutcome === 'object') { - const lastTs = logs.length > 0 - ? Math.max(...logs.map((l) => new Date(l.timestamp).getTime() / 1000)) - : Date.now() / 1000; - const ts = lastTs + 0.001; - logs.push({ - type: 'checker_event', - session_id: sessionId, - timestamp: new Date(ts * 1000).toISOString(), - checks_snapshot: true, - data: { event: 'run_outcome', phase: 'outcome', ...runOutcome, ts, timestamp: ts, session_id: sessionId } - }); - } - return logs; - } - - public fetchNotes(sessionId: string): void { - if (!sessionId) { - this.currentNotes.set({}); - return; - } - this.http.get(`/api/sessions/${sessionId}/notes`).subscribe({ - next: (res) => { - if (res && res.notes) { - this.currentNotes.set(res.notes); - - // Default to task_plan.md if available, otherwise first note - const keys = Object.keys(res.notes); - if (keys.length > 0) { - const currentSelected = this.selectedNoteKey(); - if (!keys.includes(currentSelected)) { - if (keys.includes('task_plan.md')) { - this.selectedNoteKey.set('task_plan.md'); - } else { - this.selectedNoteKey.set(keys[0]); - } - } - } - } - }, - error: (err) => { - console.error(`Failed to fetch notes for session ${sessionId}:`, err); - } - }); - } - - /** - * Open the video player for a given session or the current session - */ - public openVideoPlayer( - sessionId?: string, - videoUrl?: string, - title?: string, - seekSeconds?: number, - stepIndex?: number - ): void { - const targetSessionId = sessionId || this.currentSessionId(); - const session = this.sessions().find(s => s.session_id === targetSessionId); - const targetUrl = videoUrl || session?.video_url || null; - const goalTitle = title || session?.initial_goal || (targetSessionId ? `Task: ${targetSessionId.slice(0, 8)}...` : 'Screen Recording'); - - this.activeVideoTitle.set(goalTitle); - this.isVideoWindowOpen.set(true); - this.isVideoMinimized.set(false); - this.activeVideoSessionId = targetSessionId; - this.cancelVideoRetry(); - this.videoRequestGeneration++; - this.shouldAutoplayVideo.set(false); - this.recordingPlaybackMessage.set(''); - this.activeVideoSegments.set([]); - if (Number.isFinite(seekSeconds)) this.requestVideoSeek(Number(seekSeconds)); - if (Number.isFinite(stepIndex)) { - this.playerMode?.set('steps'); - this.requestStepSeek(Number(stepIndex)); - } - if (!targetSessionId) { - this.activeVideoUrl.set(null); - this.isVideoLoading.set(false); - this.recordingPlaybackStatus.set('unavailable'); - return; - } - - if (session?.status === 'running' || session?.status === 'paused') { - this.activeVideoUrl.set(null); - this.isVideoLoading.set(false); - this.recordingPlaybackStatus.set('live'); - return; - } - - this.activeVideoUrl.set(targetUrl); - this.shouldAutoplayVideo.set(true); - this.videoWaitStartedAt = Date.now(); - this.isVideoLoading.set(true); - this.recordingPlaybackStatus.set('processing'); - this.recordingPlaybackMessage.set('Loading screen recording...'); - if (targetUrl) { - this.playerMode?.set('video'); - } else if (this.hasCurrentSessionStepFrames?.()) { - this.playerMode?.set('steps'); - } else { - this.playerMode?.set('video'); - } - this.requestSessionVideo(targetSessionId, this.videoRequestGeneration); - } - - private appendStartupProgress(data: any, sessionId: string): void { - if (!data?.stage || !data?.message) return; - - const timestamp = typeof data.timestamp === 'number' - ? (data.timestamp > 1e11 ? data.timestamp / 1000 : data.timestamp) - : Date.now() / 1000; - const event: StartupProgressEvent = { - session_id: String(data.session_id || sessionId), - stage: String(data.stage), - message: String(data.message), - timestamp - }; - - const rawKey = String(data.session_id || sessionId).trim(); - this.startupProgressBySession.update((allEvents) => { - const current = [...(allEvents[rawKey] || allEvents[sessionId] || [])]; - const existingIndex = current.findIndex((item) => item.stage === event.stage); - if (existingIndex >= 0) { - current[existingIndex] = event; - } else { - current.push(event); - } - current.sort((a, b) => a.timestamp - b.timestamp); - const updated = { ...allEvents, [rawKey]: current }; - if (sessionId && sessionId !== rawKey) { - updated[sessionId] = current; - } - return updated; - }); - } - - private beginRecordingFinalization(sessionId: string): void { - if (this.activeVideoSessionId !== sessionId) return; - this.cancelVideoRetry(); - this.videoRequestGeneration++; - this.videoWaitStartedAt = Date.now(); - this.activeVideoUrl.set(null); - this.activeVideoSegments.set([]); - this.shouldAutoplayVideo.set(true); - this.isVideoLoading.set(true); - this.recordingPlaybackStatus.set('processing'); - this.recordingPlaybackMessage.set('Finalizing screen recording...'); - this.requestSessionVideo(sessionId, this.videoRequestGeneration); - } - - private requestSessionVideo(sessionId: string, generation: number): void { - this.http.get(`/api/sessions/${sessionId}/video`).subscribe({ - next: (res) => { - if (generation !== this.videoRequestGeneration || sessionId !== this.activeVideoSessionId) { - return; - } - const status = res.status || (res.has_video && res.video_url ? 'ready' : 'unavailable'); - if (status === 'ready' && res.video_url) { - this.cancelVideoRetry(); - this.isVideoLoading.set(false); - this.recordingPlaybackStatus.set('ready'); - this.recordingPlaybackMessage.set(''); - this.activeVideoUrl.set(res.video_url); - this.activeVideoSegments.set(res.video_segments || []); - this.playerMode?.set('video'); - this.rawSessions.update((list) => - list.map((s) => s.session_id === sessionId - ? { ...s, video_url: res.video_url || undefined, recording_status: 'ready' } - : s) - ); - return; - } - if (status === 'processing') { - this.activeVideoUrl.set(null); - this.activeVideoSegments.set([]); - this.isVideoLoading.set(true); - this.recordingPlaybackStatus.set('processing'); - this.recordingPlaybackMessage.set('Finalizing screen recording...'); - this.scheduleVideoRetry(sessionId, generation, res.retry_after_ms); - return; - } - - this.cancelVideoRetry(); - this.isVideoLoading.set(false); - this.activeVideoUrl.set(null); - this.activeVideoSegments.set([]); - this.recordingPlaybackStatus.set(status === 'failed' ? 'failed' : 'unavailable'); - this.recordingPlaybackMessage.set( - res.message || (status === 'failed' - ? 'Recording finalization failed.' - : 'No screen recording is available for this task.') - ); - if (this.hasCurrentSessionStepFrames?.()) { - this.playerMode?.set('steps'); - } - }, - error: () => { - if (generation !== this.videoRequestGeneration || sessionId !== this.activeVideoSessionId) { - return; - } - if (this.recordingPlaybackStatus() === 'processing') { - this.scheduleVideoRetry(sessionId, generation, 1000); - return; - } - this.isVideoLoading.set(false); - this.recordingPlaybackStatus.set('failed'); - this.recordingPlaybackMessage.set('Unable to load the screen recording.'); - if (this.hasCurrentSessionStepFrames?.()) { - this.playerMode?.set('steps'); - } - } - }); - } - - private scheduleVideoRetry(sessionId: string, generation: number, retryAfterMs = 1000): void { - this.cancelVideoRetry(); - if (Date.now() - this.videoWaitStartedAt > 120_000) { - this.isVideoLoading.set(false); - this.recordingPlaybackStatus.set('failed'); - this.recordingPlaybackMessage.set('Recording finalization timed out. You can retry.'); - return; - } - const delay = Math.max(500, Math.min(3000, retryAfterMs)); - this.videoRetryTimer = setTimeout(() => { - this.videoRetryTimer = null; - this.requestSessionVideo(sessionId, generation); - }, delay); - } - - private cancelVideoRetry(): void { - if (this.videoRetryTimer) { - clearTimeout(this.videoRetryTimer); - this.videoRetryTimer = null; - } - } - - private refreshActiveRecording(autoplay: boolean): void { - const sessionId = this.activeVideoSessionId; - if (!sessionId) return; - this.cancelVideoRetry(); - this.videoRequestGeneration++; - if (autoplay) this.shouldAutoplayVideo.set(true); - this.requestSessionVideo(sessionId, this.videoRequestGeneration); - } - - public retryVideoRecording(): void { - const sessionId = this.activeVideoSessionId; - if (!sessionId) return; - this.beginRecordingFinalization(sessionId); - } - - public consumeVideoAutoplay(): boolean { - const shouldAutoplay = this.shouldAutoplayVideo(); - this.shouldAutoplayVideo.set(false); - return shouldAutoplay; - } - - public requestVideoSeek(seconds: number): void { - if (!Number.isFinite(seconds)) return; - this.videoSeekRequest.set({ - seconds: Math.max(0, seconds), - requestId: ++this.videoSeekRequestId - }); - } - - /** - * Toggle the video player for the current session - */ - public toggleVideoPlayer(): void { - if (this.isVideoWindowOpen()) { - this.isVideoWindowOpen.set(false); - } else { - this.openVideoPlayer(); - } - } - - /** - * Close the video player - */ - public closeVideoPlayer(): void { - this.isVideoWindowOpen.set(false); - this.cancelVideoRetry(); - this.videoRequestGeneration++; - this.activeVideoSessionId = null; - } -} diff --git a/apps/showcase_ui/src/app/services/system.service.spec.ts b/apps/showcase_ui/src/app/services/system.service.spec.ts deleted file mode 100644 index bd2c6d51c..000000000 --- a/apps/showcase_ui/src/app/services/system.service.spec.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { provideHttpClient, withXhr } from '@angular/common/http'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { TestBed } from '@angular/core/testing'; - -import { SystemReadinessReport } from '../core/models/system.model'; -import { SystemService } from './system.service'; - -describe('SystemService readiness polling', () => { - let service: SystemService; - let http: HttpTestingController; - - const report = (timestamp: number): SystemReadinessReport => ({ - overall_ready: true, - blocker_count: 3, - passed_blocker_count: 3, - probes: [], - active_device: null, - os_type: 'windows', - timestamp - }); - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - SystemService, - provideHttpClient(withXhr()), - provideHttpClientTesting() - ] - }); - service = TestBed.inject(SystemService); - http = TestBed.inject(HttpTestingController); - service.stopAutoPolling(); - }); - - afterEach(() => http.verify()); - - it('shares one HTTP request across overlapping readiness callers', () => { - let firstTimestamp = 0; - let secondTimestamp = 0; - - service.fetchReadiness().subscribe(value => firstTimestamp = value.timestamp); - service.fetchReadiness(true).subscribe(value => secondTimestamp = value.timestamp); - - const request = http.expectOne('/api/system/readiness'); - request.flush(report(10)); - - expect(firstTimestamp).toBe(10); - expect(secondTimestamp).toBe(10); - expect(service.readinessReport()?.timestamp).toBe(10); - expect(service.isLoading()).toBeFalse(); - }); - - it('does not let an older snapshot replace a newer report', () => { - service.fetchReadiness().subscribe(); - http.expectOne('/api/system/readiness').flush(report(20)); - - service.fetchReadiness().subscribe(); - http.expectOne('/api/system/readiness').flush(report(10)); - - expect(service.readinessReport()?.timestamp).toBe(20); - }); - - it('requests a forced backend refresh for a manual re-check', () => { - service.fetchReadiness(false, true).subscribe(); - - const request = http.expectOne(req => - req.url === '/api/system/readiness' && req.params.get('force') === 'true' - ); - expect(request.request.params.get('force')).toBe('true'); - request.flush(report(30)); - }); - - it('loads the active ADB server endpoint', () => { - service.fetchAdbServerStatus().subscribe(); - - const request = http.expectOne('/api/system/adb/server'); - request.flush({ - endpoint: { - host: '127.0.0.1', - port: 5038, - socket: 'tcp:127.0.0.1:5038', - mode: 'remote', - is_local_default: false - } - }); - - expect(service.isRemoteAdbServer()).toBeTrue(); - expect(service.adbServerStatus()?.endpoint.port).toBe(5038); - }); - - it('activates an ADB server endpoint and applies its readiness report', () => { - service.connectAdbServer('127.0.0.1', 5038, true).subscribe(); - - const request = http.expectOne('/api/system/adb/server/connect'); - expect(request.request.body).toEqual({ - host: '127.0.0.1', - port: 5038, - persist: true - }); - request.flush({ - connection_result: { - success: true, - message: 'Connected.', - endpoint: { - host: '127.0.0.1', - port: 5038, - socket: 'tcp:127.0.0.1:5038', - mode: 'remote', - is_local_default: false - }, - devices: [] - }, - report: report(40) - }); - - expect(service.isRemoteAdbServer()).toBeTrue(); - expect(service.readinessReport()?.timestamp).toBe(40); - }); - - it('probes an ADB server without changing the active endpoint', () => { - service.probeAdbServer('127.0.0.1', 5038).subscribe(); - - const request = http.expectOne('/api/system/adb/server/probe'); - expect(request.request.body).toEqual({ - host: '127.0.0.1', - port: 5038, - persist: false - }); - request.flush({ - connection_result: { - success: true, - message: 'Endpoint is reachable.', - endpoint: { - host: '127.0.0.1', - port: 5038, - socket: 'tcp:127.0.0.1:5038', - identity: 'tcp:127.0.0.1:5038', - mode: 'remote', - is_local_default: false - }, - devices: [] - } - }); - - expect(service.adbServerStatus()).toBeNull(); - }); - - it('restores the standard local ADB server explicitly', () => { - service.useLocalAdbServer(true).subscribe(); - - const request = http.expectOne(req => - req.url === '/api/system/adb/server/local' && req.params.get('persist') === 'true' - ); - request.flush({ - connection_result: { - success: true, - message: 'Using local ADB.', - endpoint: { - host: '127.0.0.1', - port: 5037, - socket: 'tcp:127.0.0.1:5037', - mode: 'local', - is_local_default: true - }, - devices: [] - }, - report: report(50) - }); - - expect(service.isRemoteAdbServer()).toBeFalse(); - expect(service.adbServerStatus()?.endpoint.port).toBe(5037); - }); -}); diff --git a/apps/showcase_ui/src/app/services/system.service.ts b/apps/showcase_ui/src/app/services/system.service.ts deleted file mode 100644 index b69697d9f..000000000 --- a/apps/showcase_ui/src/app/services/system.service.ts +++ /dev/null @@ -1,610 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Injectable, signal, computed, inject, DestroyRef, NgZone } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { Observable, finalize, shareReplay, tap } from 'rxjs'; -import { - AdbServerConnectionResponse, - AdbServerStatus, - SystemReadinessReport, - DeviceInfo, - ProbeResult, - EmulatorLaunchState, - EmulatorLaunchStage -} from '../core/models/system.model'; - -@Injectable({ - providedIn: 'root' -}) -export class SystemService { - private http = inject(HttpClient); - - // Core reactive signals - public readinessReport = signal(null); - public hasReadinessReport = computed(() => this.readinessReport() !== null); - public isLoading = signal(false); - public isRestartingAdb = signal(false); - public adbServerStatus = signal(null); - public isRemoteAdbServer = computed( - () => this.adbServerStatus()?.endpoint.mode === 'remote' - ); - public launchingAvd = signal(null); - public emulatorLaunchState = signal(null); - public isEmulatorLaunching = computed(() => { - const s = this.emulatorLaunchState()?.status; - return s === 'starting' || s === 'waiting_for_adb' || s === 'booting'; - }); - public lastCheckedTime = signal(null); - private emulatorPollTimer: any = null; - - // Probe lookups - public pythonProbe = computed(() => this.probes().find(p => p.id === 'python_runtime') || null); - public configProbe = computed(() => this.probes().find(p => p.id === 'system_config') || null); - public toolchainProbe = computed(() => this.probes().find(p => p.id === 'toolchain') || null); - public adbProbe = computed(() => this.probes().find(p => p.id === 'android_adb') || null); - public llmProbe = computed(() => this.probes().find(p => p.id === 'gemini_api_key' || p.id === 'llm_api_key') || null); - public geminiProbe = computed(() => this.llmProbe()); - public ocrProbe = computed(() => this.probes().find(p => p.id === 'vision_ocr_key' || p.id === 'ocr_api_key') || null); - - // Grouped readiness helpers for the 3-step onboarding flow - public isEnvironmentReady = computed(() => { - const py = this.pythonProbe(); - const cfg = this.configProbe(); - const adb = this.adbProbe(); - const tc = this.toolchainProbe(); - const pyOk = py?.status === 'pass'; - const cfgOk = cfg?.status === 'pass'; - const adbInstalled = adb?.metadata?.['installed'] ?? (adb?.status !== 'fail'); - const tcOk = tc?.status === 'pass' || tc?.is_blocker === false; - return pyOk && cfgOk && adbInstalled && tcOk; - }); - - public isSkipCredentialsCheck = signal(false); - - public isCredentialsReady = computed(() => { - if (this.isSkipCredentialsCheck()) { - return true; - } - const llm = this.llmProbe(); - return llm?.status === 'pass'; - }); - - public isDeviceReady = computed(() => { - const adb = this.adbProbe(); - return adb?.status === 'pass'; - }); - - // Step-level counting matching the 3-step onboarding guide - public totalStepCount = computed(() => 3); - public passedStepCount = computed(() => { - let count = 0; - if (this.isEnvironmentReady()) count++; - if (this.isCredentialsReady()) count++; - if (this.isDeviceReady()) count++; - return count; - }); - - // Computed helper signals - public isReady = computed(() => { - return this.isEnvironmentReady() && this.isCredentialsReady() && this.isDeviceReady(); - }); - public blockerCount = computed(() => this.totalStepCount()); - public passedBlockerCount = computed(() => this.passedStepCount()); - public probes = computed(() => this.readinessReport()?.probes ?? []); - public activeDevice = computed(() => this.readinessReport()?.active_device ?? null); - public osType = computed<'linux' | 'darwin' | 'windows'>(() => { - const raw = this.readinessReport()?.os_type; - if (raw === 'windows' || raw === 'win32') return 'windows'; - if (raw === 'darwin' || raw === 'macos' || raw === 'mac') return 'darwin'; - return 'linux'; - }); - - public skipCredentialsCheck(): void { - this.isSkipCredentialsCheck.set(true); - } - - public setSkipCredentialsCheck(skip: boolean): void { - this.isSkipCredentialsCheck.set(skip); - } - - - // Device list from metadata - public connectedDevices = computed(() => { - const meta = this.adbProbe()?.metadata; - if (meta && Array.isArray(meta['devices'])) { - return meta['devices'] as DeviceInfo[]; - } - return []; - }); - - // Installed local AVD emulators from metadata - public installedAvds = computed(() => { - const meta = this.adbProbe()?.metadata; - if (meta && Array.isArray(meta['installed_avds'])) { - return meta['installed_avds'] as string[]; - } - return []; - }); - - public emulatorPath = computed(() => { - return (this.adbProbe()?.metadata?.['emulator_path'] as string) || null; - }); - - public isEmulatorInPath = computed(() => { - return (this.adbProbe()?.metadata?.['is_emulator_in_path'] as boolean) ?? true; - }); - - private pollingTimer: any = null; - private readinessRequest$: Observable | null = null; - private zone = inject(NgZone); - private lastAppliedReportJson: string | null = null; - private onVisibilityChange = () => { - if (typeof document !== 'undefined' && !document.hidden) { - this.fetchReadiness(true).subscribe({ error: () => {} }); - } - }; - - constructor() { - this.startAutoPolling(3000); - if (typeof document !== 'undefined') { - document.addEventListener('visibilitychange', this.onVisibilityChange); - } - inject(DestroyRef).onDestroy(() => { - this.stopAutoPolling(); - this.stopEmulatorStatusPolling(); - if (typeof document !== 'undefined') { - document.removeEventListener('visibilitychange', this.onVisibilityChange); - } - }); - } - - /** - * Start periodic auto-polling for system readiness changes. The interval - * runs outside the Angular zone (a tick alone must not trigger change - * detection) and pauses while the tab is hidden; a visibilitychange listener - * refreshes immediately when the tab becomes visible again. - */ - public startAutoPolling(intervalMs: number = 3000): void { - if (this.pollingTimer) { - clearInterval(this.pollingTimer); - } - this.zone.runOutsideAngular(() => { - this.pollingTimer = setInterval(() => { - if (typeof document !== 'undefined' && document.hidden) { - return; - } - // Perform silent background check without disturbing UI loading state - this.fetchReadiness(true).subscribe({ - error: () => {} // Silent catch - }); - }, intervalMs); - }); - } - - /** - * Stop auto-polling - */ - public stopAutoPolling(): void { - if (this.pollingTimer) { - clearInterval(this.pollingTimer); - this.pollingTimer = null; - } - } - - /** - * Fetch latest system readiness report from backend - * @param silent If true, updates signals silently without triggering global isLoading spinner - */ - public fetchReadiness( - silent: boolean = false, - forceRefresh: boolean = false - ): Observable { - if (!silent) { - this.isLoading.set(true); - } - - // Share one request across initialization, focus events, polling, and manual - // refreshes. This prevents a slow probe from creating an unbounded queue. - if (this.readinessRequest$) { - return this.readinessRequest$; - } - - const request$ = this.http.get('/api/system/readiness', { - params: forceRefresh ? { force: true } : {} - }).pipe( - tap({ - next: (report) => { - this.applyReadinessReport(report); - }, - error: (err) => { - console.error('Failed to fetch system readiness:', err); - } - }), - finalize(() => { - if (this.readinessRequest$ === request$) { - this.readinessRequest$ = null; - } - this.isLoading.set(false); - }), - shareReplay({ bufferSize: 1, refCount: false }) - ); - this.readinessRequest$ = request$; - return request$; - } - - private applyReadinessReport(report: SystemReadinessReport): void { - const current = this.readinessReport(); - if (current && report.timestamp < current.timestamp) { - return; - } - this.lastCheckedTime.set(new Date(report.timestamp * 1000)); - // A poll usually returns an identical report; setting a new object anyway - // would cascade through every probe computed for no visible change. - const serialized = JSON.stringify({ ...report, timestamp: 0 }); - if (serialized === this.lastAppliedReportJson) { - return; - } - this.lastAppliedReportJson = serialized; - this.readinessReport.set(report); - } - - /** - * Fetch current emulator launch progress state snapshot - */ - public fetchEmulatorStatus(): Observable { - return this.http.get('/api/system/emulator/status').pipe( - tap({ - next: (state) => { - this.emulatorLaunchState.set(state); - if (state.status === 'ready') { - this.launchingAvd.set(null); - this.stopEmulatorStatusPolling(); - this.fetchReadiness().subscribe(); - } else if (state.status === 'failed' || state.status === 'stopped' || state.status === 'idle') { - this.launchingAvd.set(null); - this.stopEmulatorStatusPolling(); - } else { - if (state.avd_name) { - this.launchingAvd.set(state.avd_name); - } - } - }, - error: (err) => { - console.error('Failed to fetch emulator status:', err); - } - }) - ); - } - - /** - * Start 1s interval polling for emulator boot progression - */ - public startEmulatorStatusPolling(): void { - if (this.emulatorPollTimer) { - clearInterval(this.emulatorPollTimer); - } - this.emulatorPollTimer = setInterval(() => { - this.fetchEmulatorStatus().subscribe({ - error: () => {} - }); - }, 1000); - } - - /** - * Stop polling for emulator boot status - */ - public stopEmulatorStatusPolling(): void { - if (this.emulatorPollTimer) { - clearInterval(this.emulatorPollTimer); - this.emulatorPollTimer = null; - } - } - - /** - * Launch a local AVD emulator in the background and continuously track boot progress - */ - public launchEmulator(avdName: string): Observable { - this.launchingAvd.set(avdName); - this.emulatorLaunchState.set({ - avd_name: avdName, - status: 'starting', - pid: null, - serial: null, - stage_message: 'Spawning emulator process...', - progress_percent: 15, - started_at: Date.now() / 1000, - elapsed_seconds: 0, - error: null, - logs: [`Initiating launch for AVD: ${avdName}...`], - can_retry: true, - }); - - return this.http.post('/api/system/emulator/launch', { avd_name: avdName }).pipe( - tap({ - next: (state) => { - this.emulatorLaunchState.set(state); - if (state.status === 'failed') { - this.launchingAvd.set(null); - } else { - this.startEmulatorStatusPolling(); - } - }, - error: (err) => { - console.error('Failed to launch emulator:', err); - this.launchingAvd.set(null); - const errorMsg = err?.error?.detail || err?.message || 'Failed to start emulator process.'; - this.emulatorLaunchState.set({ - avd_name: avdName, - status: 'failed', - pid: null, - serial: null, - stage_message: 'Failed to initiate launch.', - progress_percent: 0, - started_at: null, - elapsed_seconds: 0, - error: errorMsg, - logs: [errorMsg], - can_retry: true, - }); - } - }) - ); - } - - /** - * Terminate running emulator - */ - public stopEmulator(): Observable { - return this.http.post('/api/system/emulator/stop', {}).pipe( - tap({ - next: () => { - this.launchingAvd.set(null); - this.stopEmulatorStatusPolling(); - this.fetchEmulatorStatus().subscribe(); - this.fetchReadiness().subscribe(); - } - }) - ); - } - - /** - * Clear and dismiss emulator launch tracker state - */ - public dismissEmulatorStatus(): Observable { - return this.http.post('/api/system/emulator/dismiss', {}).pipe( - tap({ - next: () => { - this.emulatorLaunchState.set(null); - this.launchingAvd.set(null); - this.stopEmulatorStatusPolling(); - } - }) - ); - } - - /** - * Restart local ADB server and refresh readiness state - */ - public restartAdb(): Observable { - this.isRestartingAdb.set(true); - return this.http.post('/api/system/adb/restart', {}).pipe( - tap({ - next: (res) => { - if (res?.report) { - this.applyReadinessReport(res.report); - } - this.isRestartingAdb.set(false); - }, - error: (err) => { - console.error('Failed to restart ADB server:', err); - this.isRestartingAdb.set(false); - } - }) - ); - } - - /** - * Connect to an Android device over Wi-Fi - */ - public connectWirelessAdb(host: string, port: number = 5555): Observable { - return this.http.post('/api/system/adb/connect', { host, port }).pipe( - tap({ - next: (res) => { - if (res?.report) { - this.applyReadinessReport(res.report); - } - } - }) - ); - } - - /** - * Fetch the process-wide ADB server endpoint used by Artemis. - */ - public fetchAdbServerStatus(): Observable { - return this.http.get('/api/system/adb/server').pipe( - tap(status => this.adbServerStatus.set(status)) - ); - } - - /** - * Test an ADB server endpoint without changing the active connection. - */ - public probeAdbServer( - host: string, - port: number - ): Observable { - return this.http.post( - '/api/system/adb/server/probe', - { host, port, persist: false } - ); - } - - /** - * Validate and activate an ADB server endpoint. - */ - public connectAdbServer( - host: string, - port: number, - persist: boolean = true - ): Observable { - return this.http.post( - '/api/system/adb/server/connect', - { host, port, persist } - ).pipe( - tap(response => { - if (response.connection_result.success) { - this.adbServerStatus.set({ endpoint: response.connection_result.endpoint }); - if (response.report) { - this.applyReadinessReport(response.report); - } - } - }) - ); - } - - /** - * Switch Artemis back to the standard local ADB server. - */ - public useLocalAdbServer(persist: boolean = true): Observable { - return this.http.post( - '/api/system/adb/server/local', - {}, - { params: { persist } } - ).pipe( - tap(response => { - this.adbServerStatus.set({ endpoint: response.connection_result.endpoint }); - if (response.report) { - this.applyReadinessReport(response.report); - } - }) - ); - } - - /** - * Select a specific connected device serial as active target - */ - public selectDevice(serial: string): Observable { - this.isLoading.set(true); - return this.http.post('/api/system/devices/select', { serial }).pipe( - tap({ - next: (res) => { - if (res?.report) { - this.applyReadinessReport(res.report); - } - this.isLoading.set(false); - }, - error: (err) => { - console.error('Failed to select active device:', err); - this.isLoading.set(false); - } - }) - ); - } - - public currentApiKey = computed(() => { - return (this.llmProbe()?.metadata?.['current_key'] as string) || ''; - }); - - public apiKeysMap = computed>(() => { - return (this.llmProbe()?.metadata?.['api_keys'] as Record) || {}; - }); - - public modelConfigEnv = signal(null); - - /** - * Fetch current model configuration (artemis.jsonc) and environment (.env) status - */ - public fetchModelConfigEnv(): Observable { - return this.http.get('/api/system/model-config-env').pipe( - tap({ - next: (data) => { - this.modelConfigEnv.set(data); - }, - error: (err) => { - console.error('Failed to fetch model config and env:', err); - } - }) - ); - } - - /** - * Test and verify an API key against live provider endpoint without persisting - */ - public testApiKey(provider: string, apiKey: string, baseUrl?: string): Observable<{ valid: boolean; provider: string; message: string }> { - return this.http.post<{ valid: boolean; provider: string; message: string }>('/api/system/credentials/test', { - provider, - api_key: apiKey, - base_url: baseUrl - }); - } - - /** - * Update, verify, and configure API key for an LLM provider or Vision OCR - */ - public updateApiKey(provider: string, apiKey: string, persistToEnv: boolean = true): Observable { - return this.http.post('/api/system/credentials', { - provider, - api_key: apiKey, - persist_to_env: persistToEnv - }).pipe( - tap({ - next: (res) => { - if (res?.report) { - this.applyReadinessReport(res.report); - } - // Refresh model config & env after updating key - this.fetchModelConfigEnv().subscribe(); - }, - error: (err) => { - console.error(`Failed to update credentials for ${provider}:`, err); - } - }) - ); - } -} - -export interface ModelConfigEnvResponse { - config_path: string; - config_filename: string; - config_content: string; - default_model: { - provider?: string; - model?: string; - thinking_level?: string; - fallback?: { - provider?: string; - model?: string; - thinking_level?: string; - }; - }; - presets: Record; - env_path: string; - env_filename: string; - env_vars: Array<{ - name: string; - provider: string; - is_set: boolean; - preview: string | null; - description: string; - }>; -} - diff --git a/apps/showcase_ui/src/app/utils/action-formatter.util.spec.ts b/apps/showcase_ui/src/app/utils/action-formatter.util.spec.ts deleted file mode 100644 index 94817c600..000000000 --- a/apps/showcase_ui/src/app/utils/action-formatter.util.spec.ts +++ /dev/null @@ -1,513 +0,0 @@ -import { - getStepPreImageUrl, - getStepPostImageUrl, - extractStepReplayFrames, - getActionTargetText, - isActionFailed, - getActionErrorMessage, - extractActionExtraParams -} from './action-formatter.util'; - -describe('action-formatter.util screenshot chaining', () => { - it('should chain pre/post images correctly in FailureAnalyzer multi-step recovery', () => { - const stepData = { - step_id: 'step-06', - pre_image_name: '01a0e8d8cddbeb6a352e3bef1afbee0f0476a05b0fc62abb78e59c7b6aa16079', - post_image_name: '484361b1ab4b340415e01042e3496f0edc0549c837c897ece242835d12d7d71d', - action_taken: { - action: 'tap', - coordinates: [540, 720], - timestamp: 1787077538.0 - }, - last_execution_result: { - status: 'success', - repair_status: 'fixed', - execution: [ - { - attempts: ['Pre-execution validation failed'] - } - ] - }, - generic_tools: [ - { - trace_id: 'tool-save-note', - name: 'save_note', - type: 'tool', - timestamp: 1787077537.0, - payload: { args: { title: 'Note 1' } } - }, - { - trace_id: 'agent-failure-analyzer', - name: 'failure_analyzer', - agent_name: 'failure_analyzer', - type: 'agent', - timestamp: 1787077540.0, - payload: { - args: { - pre_screenshot_name: '01a0e8d8cddbeb6a352e3bef1afbee0f0476a05b0fc62abb78e59c7b6aa16079', - post_screenshot_name: 'fbdf17fa7a970d2c37aa52ae4336119e76a7d13ea457811cf8aaab19b3f53843' - } - } - }, - { - trace_id: 'action-click-1', - name: 'click', - type: 'action', - agent_name: 'failure_analyzer', - timestamp: 1787077607.0, - payload: { - args: { target: '[500, 300]' }, - result: { - outcome: 'Clicked successfully', - post_image_name: '18bd2ac49e64976f1e74e828ddae76e54da1587044b1d3fb01317dd202ff7c67' - } - } - }, - { - trace_id: 'action-click-seq-2', - name: 'click_sequence', - type: 'action', - agent_name: 'failure_analyzer', - timestamp: 1787077631.0, - payload: { - args: { sequence: '[[500, 300], [876, 360]]' }, - result: { - outcome: 'Sequence clicked successfully', - post_image_name: '484361b1ab4b340415e01042e3496f0edc0549c837c897ece242835d12d7d71d' - } - } - } - ] - }; - - const action1 = stepData.generic_tools[2]; // click - const action2 = stepData.generic_tools[3]; // click_sequence - - // Action 1: Pre should be the failed state screenshot (fbdf17fa...), Post should be Action 1's post (18bd2ac4...) - const act1Pre = getStepPreImageUrl(stepData, action1); - const act1Post = getStepPostImageUrl(stepData, action1); - expect(act1Pre).toBe('/images/fbdf17fa7a970d2c37aa52ae4336119e76a7d13ea457811cf8aaab19b3f53843'); - expect(act1Post).toBe('/images/18bd2ac49e64976f1e74e828ddae76e54da1587044b1d3fb01317dd202ff7c67'); - - // Action 2: "The next pre-action state is the previous post-action state" -> Pre MUST be Action 1's post (18bd2ac4...), Post should be Action 2's post (484361b1...) - const act2Pre = getStepPreImageUrl(stepData, action2); - const act2Post = getStepPostImageUrl(stepData, action2); - expect(act2Pre).toBe('/images/18bd2ac49e64976f1e74e828ddae76e54da1587044b1d3fb01317dd202ff7c67'); - expect(act2Post).toBe('/images/484361b1ab4b340415e01042e3496f0edc0549c837c897ece242835d12d7d71d'); - }); - - it('should handle standard single-action step correctly', () => { - const stepData = { - step_id: 'step-01', - pre_image_name: 'pre123', - post_image_name: 'post456', - action_taken: { - action: 'tap', - coordinates: [100, 200], - timestamp: 1000 - } - }; - - const preUrl = getStepPreImageUrl(stepData, stepData.action_taken); - const postUrl = getStepPostImageUrl(stepData, stepData.action_taken); - - expect(preUrl).toBe('/images/pre123'); - expect(postUrl).toBe('/images/post456'); - }); - - it('should resolve post screenshot for primary action when generic_tools contains action traces', () => { - const stepData = { - step_id: 'step-02', - pre_image_name: 'pre_screen_hash', - post_image_name: 'post_screen_hash', - action_taken: { - action: 'click', - coordinates: [500, 928], - args: { target: [500, 928] }, - timestamp: 1000 - }, - generic_tools: [ - { - trace_id: 'trace-click-internal', - type: 'action', - name: 'click', - timestamp: 1001, - payload: { - args: { target: '[500, 928]', times: '1', delay_ms: '100' }, - result: { - outcome: 'Clicked successfully.', - post_image_name: 'post_screen_hash', - status: 'success' - } - } - } - ] - }; - - const preUrl = getStepPreImageUrl(stepData, stepData.action_taken); - const postUrl = getStepPostImageUrl(stepData, stepData.action_taken); - - expect(preUrl).toBe('/images/pre_screen_hash'); - expect(postUrl).toBe('/images/post_screen_hash'); - }); -}); - -describe('extractStepReplayFrames', () => { - it('should return an empty array for empty or invalid logs', () => { - expect(extractStepReplayFrames([])).toEqual([]); - expect(extractStepReplayFrames(null as any)).toEqual([]); - }); - - it('should extract 1:1 ordered StepReplayFrames with action objects and pre/post images', () => { - const logs = [ - { - type: 'step_updated', - data: { - step_id: 's-1', - step_number: 1, - pre_image_name: 'img_screen_1', - post_image_name: 'img_screen_2', - action_taken: { action: 'tap', coordinates: [500, 1000] }, - timestamp: 100 - } - }, - { - type: 'step_updated', - data: { - step_id: 's-2', - step_number: 2, - pre_image_name: 'img_screen_2', - post_image_name: 'img_screen_3', - action_taken: { action: 'input_text', text: 'hello' }, - timestamp: 200 - } - } - ]; - - const frames = extractStepReplayFrames(logs); - expect(frames.length).toBe(2); - expect(frames[0].stepNumber).toBe(1); - expect(frames[0].imageUrl).toBe('/images/img_screen_1'); - expect(frames[0].preImageUrl).toBe('/images/img_screen_1'); - expect(frames[0].postImageUrl).toBe('/images/img_screen_2'); - expect(frames[0].action).toEqual({ action: 'tap', coordinates: [500, 1000] }); - - expect(frames[1].stepNumber).toBe(2); - expect(frames[1].imageUrl).toBe('/images/img_screen_2'); - expect(frames[1].preImageUrl).toBe('/images/img_screen_2'); - expect(frames[1].postImageUrl).toBe('/images/img_screen_3'); - expect(frames[1].action).toEqual({ action: 'input_text', text: 'hello' }); - }); - - it('should handle terminal step with only pre_image (e.g. cancelled / failed task)', () => { - const logs = [ - { - step_number: 1, - pre_image_name: 'img_start', - post_image_name: null, - action_taken: null, - timestamp: 100 - } - ]; - - const frames = extractStepReplayFrames(logs); - expect(frames.length).toBe(1); - expect(frames[0].imageUrl).toBe('/images/img_start'); - expect(frames[0].stepNumber).toBe(1); - }); - - it('should deduplicate steps with identical step_number and prefer real physical actions', () => { - const logs = [ - { - step_id: 'step-click', - step_number: 1, - pre_image_name: 'img_screen_1', - post_image_name: 'img_screen_2', - action_taken: { action: 'click', coordinates: [106, 101] }, - summary: 'In Step 1, I tapped the back arrow', - timestamp: 100 - }, - { - step_id: 'step-failed-report', - step_number: 1, - pre_image_name: 'img_screen_2', - post_image_name: null, - action_taken: { action: 'report_task_status', args: { status: 'failed' } }, - timestamp: 200 - }, - { - step_id: 'step-completed-report', - step_number: 2, - pre_image_name: 'img_screen_2', - post_image_name: null, - action_taken: { action: 'report_task_status', args: { status: 'completed' } }, - timestamp: 150 - } - ]; - - const frames = extractStepReplayFrames(logs); - // Duplicate step_number: 1 merges into Step 1, while Step 2 report is faithfully displayed - expect(frames.length).toBe(2); - expect(frames[0].stepNumber).toBe(1); - expect(frames[0].action?.action).toBe('click'); - expect(frames[0].actionText).toContain('Tapping Element'); - expect(frames[1].stepNumber).toBe(2); - expect(frames[1].title).toContain('Report Task Status'); - }); - - it('should enforce strictly sequential stepNumber without duplicates', () => { - const logs = [ - { - step_id: 's-1', - step_number: 1, - pre_image_name: 'img_1', - action_taken: { action: 'click', coordinates: [100, 100] }, - timestamp: 100 - }, - { - step_id: 's-2', - step_number: 2, - pre_image_name: 'img_2', - action_taken: { action: 'swipe', direction: 'up' }, - timestamp: 200 - } - ]; - - const frames = extractStepReplayFrames(logs); - expect(frames.length).toBe(2); - expect(frames[0].stepNumber).toBe(1); - expect(frames[1].stepNumber).toBe(2); - }); - - it('should start from Step 1 even when step_updated events lack step_number (preventing the Step 4 bug)', () => { - const logs = [ - { - type: 'step_recorded', - data: { - step_id: 'step1-uuid', - step_number: 1, - pre_image_name: 'img1', - post_image_name: 'img2', - action_taken: { action: 'manage_app', app_name: 'Maps' }, - timestamp: 100 - } - }, - { - type: 'step_recorded', - data: { - step_id: 'step2-uuid', - step_number: 2, - pre_image_name: 'img2', - post_image_name: 'img3', - action_taken: { action: 'input_text', text: 'Coffee' }, - timestamp: 200 - } - }, - { - type: 'step_recorded', - data: { - step_id: 'step3-uuid', - step_number: 3, - pre_image_name: 'img3', - post_image_name: 'img4', - action_taken: { action: 'press_key', key: 'ENTER' }, - timestamp: 300 - } - }, - // VisualStepSummarizer sends step_updated with only step_id and summary (no step_number) - { - type: 'step_updated', - data: { - step_id: 'step1-uuid', - summary: 'In Step 1, I launched Google Maps' - } - }, - { - type: 'step_updated', - data: { - step_id: 'step2-uuid', - summary: 'In Step 2, I typed Coffee' - } - }, - { - type: 'step_updated', - data: { - step_id: 'step3-uuid', - summary: 'In Step 3, I pressed ENTER' - } - } - ]; - - const frames = extractStepReplayFrames(logs); - expect(frames.length).toBe(3); - - // Frame 1 MUST start at Step 1, NOT Step 4! - expect(frames[0].stepNumber).toBe(1); - expect(frames[0].index).toBe(0); - expect(frames[0].title).toContain('Step 1'); - expect(frames[0].summary).toBe('In Step 1, I launched Google Maps'); - expect(frames[0].preImageUrl).toBe('/images/img1'); - - // Frame 2 MUST be Step 2 - expect(frames[1].stepNumber).toBe(2); - expect(frames[1].index).toBe(1); - expect(frames[1].title).toContain('Step 2'); - expect(frames[1].summary).toBe('In Step 2, I typed Coffee'); - - // Frame 3 MUST be Step 3 - expect(frames[2].stepNumber).toBe(3); - expect(frames[2].index).toBe(2); - expect(frames[2].title).toContain('Step 3'); - expect(frames[2].summary).toBe('In Step 3, I pressed ENTER'); - }); - - it('should filter non-visual steps (like pre-planning step 0) without shifting 1-based replay numbering', () => { - const logs = [ - // Virtual step 0 (pre-planning) with no screenshots - { - step_id: 'pre-planning', - step_number: 0, - summary: 'Planning the mission', - timestamp: 50, - pre_image_name: null, - post_image_name: null, - generic_tools: [{ name: 'save_note', type: 'tool' }] - }, - // Real step 1 with screenshots - { - step_id: 'real-step-1', - step_number: 1, - summary: 'Tapping search bar', - action_taken: { action: 'click', coordinates: [100, 200] }, - pre_image_name: 'shot1', - timestamp: 100 - }, - // Real step 2 with screenshots - { - step_id: 'real-step-2', - step_number: 2, - summary: 'Entering search keywords', - action_taken: { action: 'input_text', text: 'latte' }, - pre_image_name: 'shot2', - timestamp: 200 - } - ]; - - const frames = extractStepReplayFrames(logs); - expect(frames.length).toBe(2); - - // Frame 0 must be labeled Step 1 (not Step 2) - expect(frames[0].stepNumber).toBe(1); - expect(frames[0].rawStepNumber).toBe(1); - expect(frames[0].stepId).toBe('real-step-1'); - - // Frame 1 must be labeled Step 2 - expect(frames[1].stepNumber).toBe(2); - expect(frames[1].rawStepNumber).toBe(2); - expect(frames[1].stepId).toBe('real-step-2'); - }); -}); - -describe('getActionTargetText self-described targets', () => { - it('uses the model\'s target_description when no observed target_text exists', () => { - expect(getActionTargetText({ action: 'click', coordinates: [500, 900], target_description: 'play button' })) - .toBe('play button'); - expect(getActionTargetText({ name: 'long_press', args: { target: [500, 900], target_description: 'song row' } })) - .toBe('song row'); - }); - - it('prefers observed target_text over target_description when both are present', () => { - expect(getActionTargetText({ - action: 'click', - target_text: 'Play', - target_description: 'play button' - })).toBe('Play'); - }); - - it('chains per-point target_descriptions for click_sequence with the same arrow as coordinates', () => { - expect(getActionTargetText({ - action: 'click_sequence', - sequence: [[500, 300], [876, 360]], - target_descriptions: ['play button', 'close ×'] - })).toBe('play button → close ×'); - expect(getActionTargetText({ - name: 'click_sequence', - args: { sequence: [[500, 300], [876, 360], [100, 100]], target_descriptions: ['play button', 'close ×', 'back'] } - })).toBe('play button → close × → back'); - }); - - it('skips blank entries and falls back when click_sequence descriptions are missing or unusable', () => { - expect(getActionTargetText({ - action: 'click_sequence', - sequence: [[500, 300], [876, 360]], - target_descriptions: ['play button', '', null] - })).toBe('play button'); - expect(getActionTargetText({ action: 'click_sequence', sequence: [[500, 300]], target_descriptions: [] })).toBe(''); - expect(getActionTargetText({ action: 'click_sequence', sequence: [[500, 300]], target_descriptions: 'not-an-array' })).toBe(''); - expect(getActionTargetText({ - action: 'click_sequence', - sequence: [[500, 300]], - target_descriptions: [null], - target_description: 'single fallback' - })).toBe('single fallback'); - }); -}); - -describe('isActionFailed / getActionErrorMessage with Validator attempts', () => { - const stepWithAttempts = (attempts: any[]) => ({ - step_id: 'step-attempts', - last_execution_result: { - status: 'dispatched', - execution: [{ attempts }] - } - }); - const action = { action: 'click', coordinates: [500, 900] }; - - it('treats a retry that ended in Dispatched as not failed', () => { - const stepData = stepWithAttempts(['Execution error: x', 'Dispatched']); - expect(isActionFailed(action, stepData)).toBeFalse(); - }); - - it('treats a terminal non-Dispatched attempt as failed and surfaces its text', () => { - const stepData = stepWithAttempts(['Execution error: y']); - expect(isActionFailed(action, stepData)).toBeTrue(); - expect(getActionErrorMessage(action, stepData)).toBe('y'); - }); - - it('keeps a generic error prefix verbatim and reports the last failing attempt', () => { - const stepData = stepWithAttempts(['Pre-execution validation failed: first', 'Error: y']); - expect(isActionFailed(action, stepData)).toBeTrue(); - expect(getActionErrorMessage(action, stepData)).toBe('Error: y'); - }); - - it('marks replay frames as dispatched rather than success', () => { - const frames = extractStepReplayFrames([ - { step_id: 's-1', step_number: 1, pre_image_name: 'img1', action_taken: action, timestamp: 100 }, - { - step_id: 's-2', step_number: 2, pre_image_name: 'img2', action_taken: action, timestamp: 200, - last_execution_result: { execution: [{ attempts: ['Execution error: y'] }] } - } - ]); - expect(frames[0].status).toBe('dispatched'); - expect(frames[1].status).toBe('failed'); - }); -}); - -describe('extractActionExtraParams', () => { - it('hides target_description and target_descriptions from the extra parameter list', () => { - const single = extractActionExtraParams({ - action: 'click', - coordinates: [500, 900], - target_description: 'play button', - times: 2 - }); - expect(single.map(p => p.key)).toEqual(['Times']); - - const sequence = extractActionExtraParams({ - name: 'click_sequence', - args: { sequence: [[1, 2], [3, 4]], target_descriptions: ['a', 'b'], interval_ms: 50 } - }); - expect(sequence.map(p => p.key)).not.toContain('Target Descriptions'); - expect(sequence.map(p => p.key)).toContain('Interval Ms'); - }); -}); diff --git a/apps/showcase_ui/src/app/utils/action-formatter.util.ts b/apps/showcase_ui/src/app/utils/action-formatter.util.ts deleted file mode 100644 index 2d9ace8d2..000000000 --- a/apps/showcase_ui/src/app/utils/action-formatter.util.ts +++ /dev/null @@ -1,1015 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ActionParam, StepReplayFrame } from '../core/models/stream.model'; -import { extractNumbersFromCoordinateValue, isPureDirectionString, parseSequenceCoordinates, unwrapTraceAction } from './image-overlay.util'; -import { cleanErrorMessage, joinTargetDescriptions } from './tool-formatter.util'; - -/** - * Safely parse JSON string if it looks like an object/array, otherwise return original value - */ -export function safeParseJson(val: any): any { - if (typeof val === 'string' && (val.trim().startsWith('{') || val.trim().startsWith('['))) { - try { - return JSON.parse(val); - } catch { - return val; - } - } - return val; -} - -// WeakMap memo for parsed action objects to avoid repeated JSON.parse and object allocation across change detection cycles -const actionObjectCache = new WeakMap(); - -/** - * Safely extract action object from possible array or nested structure - */ -export function getActionObject(action: any): any { - if (!action) return null; - if (Array.isArray(action)) { - return action.length > 0 ? getActionObject(action[0]) : null; - } - if (typeof action === 'object') { - const cached = actionObjectCache.get(action); - if (cached !== undefined) return cached; - const res = unwrapTraceAction(action); - actionObjectCache.set(action, res); - return res; - } - return action; -} - -/** - * Check if the action is a user interaction action on Android - */ -export function isAndroidAction(action: any): boolean { - if (!action) return false; - let name = ''; - if (typeof action === 'string') { - name = action.toLowerCase(); - } else { - const act = getActionObject(action); - if (!act) return false; - name = (act.name || act.action || '').toLowerCase(); - } - return [ - 'tap', 'click', 'click_sequence', 'input', 'input_text', 'swipe', 'scroll', 'press_key', - 'press_home', 'press_back', 'launch_app', 'manage_app', 'open_app', - 'focus_and_input_text', 'focus_and_clear_text', 'long_press', 'long_press_on', - 'wait_for_delay', 'wait', 'drag', 'drag_and_drop' - ].includes(name); -} - -/** - * Check if the action or tool is a task completion / submission report - */ -export function isReportStatusAction(action: any): boolean { - if (!action) return false; - let name = ''; - if (typeof action === 'string') { - name = action.toLowerCase(); - } else { - const act = getActionObject(action); - if (!act) return false; - name = (act.name || act.action || '').toLowerCase(); - } - name = name.replace(/^(_)?(self\.)?exec_/, ''); - return name === 'report_task_status' || name === 'report_status' || name === 'submit_task_status'; -} - -/** - * Get status of report_task_status (completed / failed) - */ -export function getReportStatusValue(action: any): string { - const act = getActionObject(action); - if (!act) return 'completed'; - const args = act.args && typeof act.args === 'object' ? act.args : (act.payload?.args || act.payload || act); - const status = args.status || act.status; - if (typeof status === 'string') { - return status.toLowerCase(); - } - return 'completed'; -} - -/** - * Get explanation of report_task_status - */ -export function getReportStatusExplanation(action: any): string { - const act = getActionObject(action); - if (!act) return ''; - const args = act.args && typeof act.args === 'object' ? act.args : (act.payload?.args || act.payload || act); - return args.explanation || args.reason || args.summary || act.explanation || act.reason || act.summary || ''; -} - -/** - * Get Material Symbol icon name for Android action - */ -export function getActionIcon(action: any): string { - const act = getActionObject(action); - if (!act) return 'ads_click'; - const name = (act.name || act.action || '').toLowerCase(); - switch (name) { - case 'tap': - case 'click': - case 'click_sequence': - return 'ads_click'; - case 'input': - case 'input_text': - case 'focus_and_input_text': - return 'keyboard'; - case 'focus_and_clear_text': - case 'clear_text': - return 'backspace'; - case 'swipe': - case 'scroll': - return 'swipe'; - case 'drag': - case 'drag_and_drop': - return 'drag_indicator'; - case 'press_key': - case 'press_home': - case 'press_back': - return 'keyboard_tab'; - case 'launch_app': - case 'manage_app': - case 'open_app': - return 'open_in_new'; - case 'long_press': - case 'long_press_on': - return 'touch_app'; - case 'wait_for_delay': - case 'delay': - case 'wait': - return 'hourglass_empty'; - default: - return 'settings'; - } -} - -/** - * Get human-readable title for Android actions - */ -export function getActionTitle(action: any): string { - const act = getActionObject(action); - if (!act) return 'Action'; - const name = (act.name || act.action || '').toLowerCase(); - switch (name) { - case 'tap': - case 'click': - case 'tap_element': - case 'click_element': - return 'Tapping Element'; - case 'input': - case 'input_text': - case 'focus_and_input_text': - return 'Entering Text'; - case 'focus_and_clear_text': - case 'clear_text': - return 'Clearing Text'; - case 'swipe': - case 'scroll': { - const actObj = getActionObject(action); - const args = actObj?.args && typeof actObj.args === 'object' ? actObj.args : {}; - const dir = actObj?.direction || actObj?.gesture || args.direction || args.gesture || (typeof args.action === 'string' ? args.action : '') || (typeof actObj?.action === 'string' && actObj.action !== name ? actObj.action : ''); - if (dir && isPureDirectionString(dir)) { - return `Swiping Screen (${String(dir).toUpperCase()})`; - } - return 'Swiping Screen'; - } - case 'drag': - case 'drag_and_drop': - return 'Dragging Screen'; - case 'press_key': - case 'press_home': - case 'press_back': - return 'Pressing Hardware Key'; - case 'launch_app': - case 'open_app': - return 'Launching Application'; - case 'stop_app': - case 'close_app': - return 'Stopping Application'; - case 'manage_app': { - const actObj = getActionObject(action); - const actStr = (actObj?.action || actObj?.args?.action || '').toLowerCase(); - if (actStr === 'launch') return 'Launching Application'; - if (actStr === 'stop' || actStr === 'close') return 'Stopping Application'; - return 'Managing Application'; - } - case 'wait_for_delay': - case 'delay': - case 'wait': - return 'Waiting for Delay'; - case 'long_press': - case 'long_press_on': - return 'Long Pressing Element'; - case 'click_sequence': - return 'Clicking Sequence'; - default: - return name.replace(/_/g, ' ').replace(/\b\w/g, (c: string) => c.toUpperCase()); - } -} - -/** - * Get target text or element description for Android action - */ -export function getActionTargetText(action: any): string { - const act = getActionObject(action); - if (!act) return ''; - const name = (act.name || act.action || '').toLowerCase(); - const args = act.args && typeof act.args === 'object' ? act.args : {}; - - if (name.includes('app')) { - const rawApp = act.app_name || act.package_name || act.app || args.app_name || args.package_name || args.app || ''; - const app = rawApp ? (rawApp.charAt(0).toUpperCase() + rawApp.slice(1)) : ''; - const rawAction = act.action || args.action || ''; - const actionStr = rawAction ? (String(rawAction).charAt(0).toUpperCase() + String(rawAction).slice(1).toLowerCase()) : ''; - if (actionStr && app) { - return `${actionStr} ${app}`; - } - return app || actionStr || ''; - } - if (name.includes('delay') || name.includes('wait')) { - const ms = act.time_in_ms || act.delay_ms || act.delay_seconds || args.time_in_ms || args.delay_ms || args.delay_seconds || args.duration; - if (ms) return `${ms}ms`; - } - // Flash click_sequence carries one self-described target per point; render - // them in the same ' → ' chain that getActionCoords uses for the points. - if (name === 'click_sequence' || name === 'tap_sequence') { - const joined = joinTargetDescriptions(act.target_descriptions ?? args.target_descriptions); - if (joined) return joined; - } - // target_text is observed element text (index targets); target_description is - // what the model said it aimed at (coordinate targets). Same label either way. - return act.target_text || act.target_description || act.target_class || act.element_id - || args.target_text || args.target_description || args.element_id || ''; -} - -/** - * Get input label for Android action - */ -export function getActionInputLabel(action: any): string { - const act = getActionObject(action); - if (!act) return 'Input'; - const name = (act.name || act.action || '').toLowerCase(); - if (name.includes('delay') || name.includes('wait')) { - return 'Duration'; - } - if (name === 'swipe' || name === 'scroll' || name === 'drag' || name === 'drag_and_drop') { - const actObj = getActionObject(action); - const args = actObj?.args && typeof actObj.args === 'object' ? actObj.args : {}; - const dir = actObj?.direction || actObj?.gesture || args.direction || args.gesture || (typeof args.action === 'string' ? args.action : '') || (typeof actObj?.action === 'string' && actObj.action !== name ? actObj.action : ''); - if (dir && isPureDirectionString(dir)) { - return 'Direction'; - } - return 'Input'; - } - if (name === 'press_key' || name.includes('key')) { - return 'Key'; - } - if (name === 'input_text' || name.includes('input')) { - return 'Input Text'; - } - return 'Input'; -} - -/** - * Get input text or value for Android action - */ -export function getActionInputText(action: any): string { - const act = getActionObject(action); - if (!act) return ''; - const name = (act.name || act.action || '').toLowerCase(); - const args = act.args && typeof act.args === 'object' ? act.args : {}; - - if (name === 'press_key' || name.includes('key')) { - return act.key || act.keycode || args.key || args.keycode || ''; - } - if (name === 'swipe' || name === 'scroll' || name === 'drag' || name === 'drag_and_drop') { - const dir = act.direction || act.gesture || args.direction || args.gesture || (typeof args.action === 'string' ? args.action : '') || (typeof act.action === 'string' && act.action !== name ? act.action : ''); - if (dir && isPureDirectionString(dir)) { - return String(dir).toUpperCase(); - } - return ''; - } - if (name.includes('delay') || name.includes('wait')) { - const ms = act.time_in_ms || act.delay_ms || act.delay_seconds || args.time_in_ms || args.delay_ms || args.delay_seconds || args.duration; - if (ms) return `${ms}ms`; - } - return act.text || act.input_text || args.text || args.input_text || ''; -} - -/** - * Get coordinates string representation for Android action - */ -export function getActionCoords(action: any): string { - const act = getActionObject(action); - if (!act) return ''; - const args = act.args && typeof act.args === 'object' ? act.args : {}; - const name = (act.name || act.action || '').toLowerCase(); - - // Check sequence first if action is sequence or sequence arg is present - const isSequenceAction = name === 'click_sequence' || name === 'tap_sequence' || Boolean(act.sequence || args.sequence || act.normalized_sequence || args.normalized_sequence); - const rawSeq = act.normalized_sequence || args.normalized_sequence || act.sequence || args.sequence || act.targets || args.targets || (Array.isArray(act.coordinates) && Array.isArray(act.coordinates[0]) ? act.coordinates : null) || (Array.isArray(args.coordinates) && Array.isArray(args.coordinates[0]) ? args.coordinates : null); - const seqPoints = parseSequenceCoordinates(rawSeq); - if (seqPoints && seqPoints.length > 0 && (isSequenceAction || seqPoints.length > 1)) { - return seqPoints.map(pt => `[${pt[0]}, ${pt[1]}]`).join(' → '); - } - - // Check normalized start and end first - const normStart = extractNumbersFromCoordinateValue(act.normalized_start_coordinates || args.normalized_start_coordinates); - const normEnd = extractNumbersFromCoordinateValue(act.normalized_end_coordinates || args.normalized_end_coordinates); - if (normStart && normEnd && normStart.length === 2 && normEnd.length === 2) { - return `[${normStart[0]}, ${normStart[1]}] → [${normEnd[0]}, ${normEnd[1]}]`; - } - - const startCoords = extractNumbersFromCoordinateValue(act.start_coordinates || act.start_point || act.start || act.from || args.start_coordinates || args.start_point || args.start || args.from); - const endCoords = extractNumbersFromCoordinateValue(act.end_coordinates || act.end_point || act.end || act.to || args.end_coordinates || args.end_point || args.end || args.to); - if (startCoords && endCoords && startCoords.length === 2 && endCoords.length === 2) { - return `[${startCoords[0]}, ${startCoords[1]}] → [${endCoords[0]}, ${endCoords[1]}]`; - } - - const normCoords = extractNumbersFromCoordinateValue(act.normalized_coordinates || args.normalized_coordinates); - const coords = normCoords || - extractNumbersFromCoordinateValue(act.coordinates) || - extractNumbersFromCoordinateValue(args.coordinates) || - extractNumbersFromCoordinateValue(act.coords) || - extractNumbersFromCoordinateValue(args.coords) || - extractNumbersFromCoordinateValue(act.target) || - extractNumbersFromCoordinateValue(args.target) || - extractNumbersFromCoordinateValue(args.action) || - extractNumbersFromCoordinateValue(act.action) || - extractNumbersFromCoordinateValue(args.gesture) || - extractNumbersFromCoordinateValue(act.gesture); - - if (coords && Array.isArray(coords)) { - if (coords.length === 4) { - return `[${coords[0]}, ${coords[1]}] → [${coords[2]}, ${coords[3]}]`; - } - if (coords.length === 2) { - return `[${coords[0]}, ${coords[1]}]`; - } - return coords.join(', '); - } - - return ''; -} - -/** - * The Validator records "Dispatched" as the terminal attempt of an action the device - * accepted (kept only when a retry preceded it); any other terminal attempt is the - * failure text. Skipped burst members count as not dispatched. - */ -function attemptsFailed(attempts: any): boolean { - if (!Array.isArray(attempts) || attempts.length === 0) return false; - return String(attempts[attempts.length - 1]) !== 'Dispatched'; -} - -function lastFailedAttempt(attempts: any): string | null { - if (!Array.isArray(attempts)) return null; - const failing = attempts.filter((a: any) => String(a) !== 'Dispatched'); - return failing.length > 0 ? String(failing[failing.length - 1]) : null; -} - -/** - * Check if the action execution failed or encountered an execution failure/interception - */ -export function isActionFailed(action: any, stepData?: any): boolean { - const act = getActionObject(action); - if (act) { - if (act.status === 'failed' || act.status === 'error' || act.success === false) return true; - } - if (stepData) { - if (stepData.status === 'failed' || stepData.status === 'error') return true; - if (stepData.last_execution_result) { - const res = typeof stepData.last_execution_result === 'string' - ? (() => { try { return JSON.parse(stepData.last_execution_result); } catch { return null; } })() - : stepData.last_execution_result; - - if (res && typeof res === 'object') { - if (res.status === 'failed' || res.status === 'error' || res.success === false || res.is_successful === false) { - return true; - } - if (res.repair_status === 'fixed' || res.repair_status === 'failed' || res.repair_status === 'cannot_fix') { - return true; - } - if (Array.isArray(res.execution) && res.execution.length > 0) { - const firstExec = res.execution[0]; - if (firstExec && (firstExec.status === 'failed' || firstExec.status === 'error' || firstExec.error || attemptsFailed(firstExec.attempts))) { - return true; - } - } - } - } - } - return false; -} - -/** - * Get failure message or reason for an action - */ -export function getActionErrorMessage(action: any, stepData?: any): string { - const act = getActionObject(action); - if (act && (act.error || act.message || act.failure_reason)) { - return cleanErrorMessage(act.error || act.message || act.failure_reason); - } - if (stepData && stepData.last_execution_result) { - const res = typeof stepData.last_execution_result === 'string' - ? (() => { try { return JSON.parse(stepData.last_execution_result); } catch { return null; } })() - : stepData.last_execution_result; - - if (res && typeof res === 'object') { - if (Array.isArray(res.execution) && res.execution.length > 0) { - const firstExec = res.execution[0]; - if (firstExec) { - const failedAttempt = lastFailedAttempt(firstExec.attempts); - if (failedAttempt) { - return cleanErrorMessage(failedAttempt); - } - if (firstExec.error || firstExec.failure_reason || firstExec.message) { - return cleanErrorMessage(firstExec.error || firstExec.failure_reason || firstExec.message); - } - } - } - if (res.error || res.message || res.failure_reason) { - return cleanErrorMessage(res.error || res.message || res.failure_reason); - } - } - } - return 'Action Failed'; -} - -/** - * Get bounds string representation for Android action - */ -export function getActionBounds(action: any): string { - const act = getActionObject(action); - if (!act) return ''; - const bounds = act.target_bounds || act.bounds || (act.args && (act.args.target_bounds || act.args.bounds)); - if (Array.isArray(bounds)) { - return bounds.join(', '); - } - return bounds ? String(bounds) : ''; -} - -/** - * Get resource id for Android action target - */ -export function getActionResourceId(action: any): string { - const act = getActionObject(action); - if (!act) return ''; - return act.target_resource_id || act.resource_id || (act.args && (act.args.target_resource_id || act.args.resource_id)) || ''; -} - -/** - * Get class name for Android action target - */ -export function getActionClass(action: any): string { - const act = getActionObject(action); - if (!act) return ''; - return act.target_class || act.class_name || (act.args && (act.args.target_class || act.args.class_name)) || ''; -} - -/** - * Extract extra parameter key-value pairs for Android actions - */ -export function extractActionExtraParams(action: any, cache?: WeakMap): ActionParam[] { - const act = getActionObject(action); - if (!act || typeof act !== 'object') return []; - if (cache && cache.has(act)) { - return cache.get(act)!; - } - - const standardKeys = new Set([ - 'action', 'name', 'type', 'target_text', 'target_description', 'target_descriptions', - 'text', 'input_text', 'target', - 'coordinates', 'coords', 'target_bounds', 'bounds', 'target_resource_id', - 'resource_id', 'target_class', 'class_name', 'normalized_coordinates', - 'normalized_start_coordinates', 'normalized_end_coordinates', - 'start_coordinates', 'end_coordinates', 'start', 'end', 'from', 'to', - 'pre_image_name', 'post_image_name', 'pre_screenshot', 'post_screenshot', - 'before_screenshot', 'after_screenshot', 'status', 'success', 'timestamp', - 'created_at', 'start_time', 'execution_id', 'controller', 'agent', 'session_id', 'step_id', - 'app_name', 'package_name', 'app', 'key', 'keycode', 'time_in_ms', 'delay_ms', 'delay_seconds', 'duration', - 'args', 'kwargs', 'parameters', 'extra_params', 'direction', 'gesture' - ]); - - const result: ActionParam[] = []; - - const rawArgs = act.args || act.Args || act.kwargs || act.parameters; - let parsedArgs: any = {}; - if (rawArgs) { - if (typeof rawArgs === 'object') { - parsedArgs = rawArgs; - } else if (typeof rawArgs === 'string') { - try { - parsedArgs = JSON.parse(rawArgs); - } catch { - if (!rawArgs.includes('<') && !rawArgs.includes('object at')) { - parsedArgs = { details: rawArgs }; - } - } - } - } - - const mergedObj = { ...act, ...parsedArgs }; - - // Explicitly check duration if available and not already formatted - const dur = mergedObj.duration || mergedObj.duration_ms; - if (dur !== undefined && dur !== null && dur !== '') { - result.push({ key: 'Duration', value: typeof dur === 'number' ? `${dur}ms` : String(dur) }); - } - - for (const [k, v] of Object.entries(mergedObj)) { - const lowerK = k.toLowerCase(); - if (standardKeys.has(lowerK)) continue; - if (v === null || v === undefined || v === '') continue; - - let valStr = String(v); - if (valStr.includes('object at 0x') || valStr.startsWith(' c.toUpperCase()); - if (lowerK === 'time_in_ms' || lowerK === 'delay_ms') prettyKey = 'Delay'; - - result.push({ key: prettyKey, value: valStr }); - } - - if (cache) { - cache.set(act, result); - } - return result; -} - -/** - * Format image URL or path safely - */ -export function formatImageUrl(candidate: any): string | null { - if (!candidate || candidate === 'None' || candidate === 'null' || candidate === 'undefined') return null; - if (typeof candidate !== 'string') return null; - const trimmed = candidate.trim(); - if (!trimmed || trimmed === 'None' || trimmed === 'null' || trimmed === 'undefined') return null; - - if (trimmed.startsWith('data:') || trimmed.startsWith('http://') || trimmed.startsWith('https://')) { - return trimmed; - } - if (trimmed.startsWith('/local_file') || trimmed.startsWith('/images/') || trimmed.startsWith('/api/images/')) { - return trimmed; - } - if (trimmed.startsWith('file://')) { - return `/local_file?path=${encodeURIComponent(trimmed)}`; - } - if (trimmed.startsWith('/')) { - return `/local_file?path=${encodeURIComponent('file://' + trimmed)}`; - } - return `/images/${trimmed}`; -} - -/** - * Helper to check if an item is a generic tool trace rather than the step's primary action - */ -export function isGenericToolTrace(actionData: any, stepData?: any): boolean { - if (!actionData) return false; - if (actionData === stepData?.action_taken) return false; - if (Array.isArray(stepData?.action_taken)) { - if (stepData.action_taken === actionData || stepData.action_taken.includes(actionData)) { - return false; - } - } - if (actionData.is_primary_step_action) return false; - if (actionData.type === 'action' || isAndroidAction(actionData)) return false; - return Boolean(actionData.trace_id || actionData.payload || actionData.type === 'tool'); -} - -interface ResolvedImageEntry { - pre: string | null; - post: string | null; - hasExplicitPost: boolean; -} - -const stepImageMapCache = new WeakMap }>(); - - -export function extractItemPreImage(item: any): string | null { - const act = getActionObject(item); - if (!act) return null; - const payload = safeParseJson(act.payload); - const result = safeParseJson(act.result || payload?.result); - const args = safeParseJson(act.args || payload?.args); - - const candidate = - result?.pre_image_name || result?.pre_screenshot_name || result?.pre_screenshot || - payload?.pre_image_name || payload?.pre_screenshot_name || payload?.pre_screenshot || - args?.pre_image_name || args?.pre_screenshot_name || args?.pre_screenshot || - act.pre_image_name || act.pre_screenshot_name || act.pre_screenshot || act.before_screenshot || act.screenshot; - - return candidate ? String(candidate) : null; -} - -export function extractItemPostImage(item: any): string | null { - const act = getActionObject(item); - if (!act) return null; - const payload = safeParseJson(act.payload); - const result = safeParseJson(act.result || payload?.result); - const args = safeParseJson(act.args || payload?.args); - - const candidate = - result?.post_image_name || result?.post_screenshot_name || result?.post_screenshot || - payload?.post_image_name || payload?.post_screenshot_name || payload?.post_screenshot || - args?.post_image_name || args?.post_screenshot_name || args?.post_screenshot || - act.post_image_name || act.post_screenshot_name || act.post_screenshot || act.after_screenshot; - - return candidate ? String(candidate) : null; -} - -function getStepFailureScreenshot(stepData: any): string | null { - if (!stepData) return null; - if (stepData.failed_screenshot) return String(stepData.failed_screenshot); - if (stepData.failure_screenshot) return String(stepData.failure_screenshot); - if (stepData.failed_post_image_name) return String(stepData.failed_post_image_name); - - if (Array.isArray(stepData.generic_tools)) { - for (const tool of stepData.generic_tools) { - const act = getActionObject(tool); - const payload = safeParseJson(act?.payload); - const args = safeParseJson(act?.args || payload?.args); - const name = String(act?.name || act?.agent_name || '').toLowerCase(); - if (args?.post_screenshot_name) return String(args.post_screenshot_name); - if (args?.post_screenshot && (name.includes('failure') || name.includes('validator') || name.includes('diagnos'))) { - return String(args.post_screenshot); - } - if (act?.post_screenshot_name && (name.includes('failure') || name.includes('validator'))) { - return String(act.post_screenshot_name); - } - } - } - return null; -} - -function getEventTimestamp(obj: any): number { - if (!obj) return 0; - const ts = obj.timestamp ?? obj.start_time ?? obj.created_at; - if (typeof ts === 'number') { - return ts < 1e11 ? ts * 1000 : ts; - } - if (typeof ts === 'string') { - const parsed = new Date(ts).getTime(); - if (!isNaN(parsed) && parsed > 0) return parsed; - } - return 0; -} - -export function resolveStepImageMap(stepData: any): Map { - const emptyMap = new Map(); - if (!stepData || typeof stepData !== 'object') return emptyMap; - - const toolsLen = Array.isArray(stepData.generic_tools) ? stepData.generic_tools.length : 0; - const toolsImageSig = Array.isArray(stepData.generic_tools) - ? stepData.generic_tools.map((t: any) => `${t?.trace_id || ''}:${t?.post_image_name || t?.post_screenshot || ''}`).join(',') - : ''; - const cacheKey = `${stepData.step_id || ''}_${toolsLen}_${toolsImageSig}_${stepData.post_image_name || ''}_${stepData.pre_image_name || ''}`; - - const cached = stepImageMapCache.get(stepData); - if (cached && cached.key === cacheKey) { - return cached.map; - } - - const map = new Map(); - const events: Array<{ item: any; act: any; timestamp: number; isPrimary: boolean }> = []; - - const hasActionInTools = Array.isArray(stepData.generic_tools) && stepData.generic_tools.some( - (t: any) => t && (t.type === 'action' || isAndroidAction(t) || isReportStatusAction(t)) - ); - - if (!hasActionInTools && stepData.action_taken) { - events.push({ - item: stepData.action_taken, - act: getActionObject(stepData.action_taken), - timestamp: getEventTimestamp(stepData.action_taken), - isPrimary: true - }); - } - - if (Array.isArray(stepData.generic_tools)) { - stepData.generic_tools.forEach((t: any) => { - if (t) { - const isAction = t.type === 'action' || isAndroidAction(t) || isReportStatusAction(t); - events.push({ - item: t, - act: getActionObject(t), - timestamp: getEventTimestamp(t), - isPrimary: isAction - }); - } - }); - } - - events.sort((a, b) => a.timestamp - b.timestamp); - - const failureShot = getStepFailureScreenshot(stepData); - const hadPrimaryFailure = Boolean(failureShot) || (stepData.action_taken && isActionFailed(stepData.action_taken, stepData)); - const hasSubsequentActions = events.some(e => !e.isPrimary && (isAndroidAction(e.act) || Boolean(extractItemPostImage(e.act)))); - - let lastActionItem: any = null; - for (let i = events.length - 1; i >= 0; i--) { - const e = events[i]; - if (isAndroidAction(e.act) || extractItemPostImage(e.act) || e.isPrimary) { - lastActionItem = e.item; - break; - } - } - - let currentPreImage = stepData.pre_image_name || stepData.pre_screenshot || null; - let switchedToFailureShot = false; - - for (const e of events) { - const act = e.act; - const explicitPre = extractItemPreImage(act); - const explicitPost = extractItemPostImage(act); - - const agentName = String( - act?.agent_name || act?.agent || safeParseJson(act?.payload)?.agent_name || act?.name || '' - ).toLowerCase(); - const isRecoveryOrValidator = agentName.includes('failure') || agentName.includes('analyzer') || agentName.includes('validator') || agentName.includes('diagnos'); - - if (failureShot && !switchedToFailureShot && (isRecoveryOrValidator || (!e.isPrimary && hadPrimaryFailure))) { - currentPreImage = failureShot; - switchedToFailureShot = true; - } - - const resolvedPre = explicitPre || currentPreImage; - let resolvedPost: string | null = null; - let hasExplicitPost = false; - - if (explicitPost) { - resolvedPost = explicitPost; - hasExplicitPost = true; - } else if (e.isPrimary && !hadPrimaryFailure) { - resolvedPost = stepData.post_image_name || stepData.post_screenshot || null; - } else if (e.item === lastActionItem && !hadPrimaryFailure) { - resolvedPost = stepData.post_image_name || stepData.post_screenshot || null; - } else if (e.item === lastActionItem && isRecoveryOrValidator) { - resolvedPost = stepData.post_image_name || stepData.post_screenshot || null; - } - - // "The next pre-action state is the previous post-action state" - if (resolvedPost) { - currentPreImage = resolvedPost; - } - - const entry: ResolvedImageEntry = { pre: resolvedPre, post: resolvedPost, hasExplicitPost }; - map.set(e.item, entry); - if (act && act !== e.item) map.set(act, entry); - if (act?.trace_id) map.set(act.trace_id, entry); - if (e.item?.trace_id) map.set(e.item.trace_id, entry); - } - - // Ensure stepData.action_taken cross-resolves to the action images - if (stepData.action_taken && lastActionItem) { - const lastEntry = map.get(lastActionItem); - if (lastEntry) { - map.set(stepData.action_taken, lastEntry); - const actObj = getActionObject(stepData.action_taken); - if (actObj && actObj !== stepData.action_taken) { - map.set(actObj, lastEntry); - } - } - } - - stepImageMapCache.set(stepData, { key: cacheKey, map }); - return map; -} - -function lookupResolvedImages(stepData: any, actionData?: any): ResolvedImageEntry { - const map = resolveStepImageMap(stepData); - if (!actionData) { - return { - pre: stepData?.pre_image_name || stepData?.pre_screenshot || null, - post: stepData?.post_image_name || stepData?.post_screenshot || null, - hasExplicitPost: Boolean(stepData?.post_image_name || stepData?.post_screenshot) - }; - } - - const act = getActionObject(actionData); - - if (map.has(actionData)) return map.get(actionData)!; - if (act && map.has(act)) return map.get(act)!; - if (act?.trace_id && map.has(act.trace_id)) return map.get(act.trace_id)!; - - const explicitPre = extractItemPreImage(act); - const explicitPost = extractItemPostImage(act); - const pre = explicitPre || stepData?.pre_image_name || stepData?.pre_screenshot || null; - const post = explicitPost || (isGenericToolTrace(actionData, stepData) ? null : (stepData?.post_image_name || stepData?.post_screenshot || null)); - return { pre, post, hasExplicitPost: Boolean(explicitPost) }; -} - -/** - * Get pre-action screenshot URL - */ -export function getStepPreImageUrl(stepData: any, actionData?: any): string | null { - const resolved = lookupResolvedImages(stepData, actionData); - return formatImageUrl(resolved.pre); -} - -/** - * Get post-action screenshot URL - */ -export function getStepPostImageUrl(stepData: any, actionData?: any): string | null { - const resolved = lookupResolvedImages(stepData, actionData); - const postUrl = formatImageUrl(resolved.post); - const preUrl = formatImageUrl(resolved.pre); - - if (postUrl && preUrl && postUrl === preUrl && !resolved.hasExplicitPost) { - return null; - } - return postUrl; -} - -/** - * Extract an ordered array of visual StepReplayFrames from session logs or step blocks - */ -export function extractStepReplayFrames(logsOrSteps: any[]): StepReplayFrame[] { - if (!Array.isArray(logsOrSteps) || logsOrSteps.length === 0) { - return []; - } - - // Extract raw step data objects - const rawSteps: any[] = []; - for (const item of logsOrSteps) { - if (!item) continue; - if ((item.type === 'step_updated' || item.type === 'step_recorded' || item.type === 'step') && item.data) { - rawSteps.push(item.data); - } else if (item.step_number !== undefined || item.step_id !== undefined) { - rawSteps.push(item); - } - } - - // Unified dual-indexed step store: correlates by step_id and step_number - const stepsList: any[] = []; - const idToIndex = new Map(); - const numToIndex = new Map(); - - for (const step of rawSteps) { - const stepId = (step.step_id !== undefined && step.step_id !== null && String(step.step_id).trim() !== '') - ? String(step.step_id).trim() - : null; - const stepNum = (step.step_number !== undefined && step.step_number !== null) - ? Number(step.step_number) - : null; - - let targetIndex = -1; - if (stepId && idToIndex.has(stepId)) { - targetIndex = idToIndex.get(stepId)!; - } else if (stepNum !== null && numToIndex.has(stepNum)) { - targetIndex = numToIndex.get(stepNum)!; - } - - if (targetIndex >= 0) { - const existing = stepsList[targetIndex]; - - // When merging duplicate records (e.g. status reports vs real physical actions): - const existingAct = existing.action_taken; - const newAct = step.action_taken; - const preferExistingAction = existingAct && isAndroidAction(existingAct) && isReportStatusAction(newAct); - const preferNewAction = newAct && isAndroidAction(newAct) && isReportStatusAction(existingAct); - - const mergedStepNum = (stepNum !== null) ? stepNum : existing.step_number; - const mergedStepId = existing.step_id || step.step_id; - - const merged = { - ...existing, - ...step, - step_id: mergedStepId, - step_number: mergedStepNum, - pre_image_name: preferExistingAction - ? existing.pre_image_name - : (step.pre_image_name || existing.pre_image_name), - post_image_name: preferExistingAction - ? (existing.post_image_name || step.post_image_name) - : (step.post_image_name || existing.post_image_name), - pre_screenshot: preferExistingAction - ? existing.pre_screenshot - : (step.pre_screenshot || existing.pre_screenshot), - post_screenshot: preferExistingAction - ? (existing.post_screenshot || step.post_screenshot) - : (step.post_screenshot || existing.post_screenshot), - action_taken: preferExistingAction - ? existing.action_taken - : (preferNewAction ? newAct : (step.action_taken || existing.action_taken)), - last_execution_result: step.last_execution_result || existing.last_execution_result, - summary: preferExistingAction - ? (existing.summary || step.summary) - : (step.summary || existing.summary), - operator_raw_thinking: step.operator_raw_thinking || existing.operator_raw_thinking, - operator_native_thinking: step.operator_native_thinking || existing.operator_native_thinking, - generic_tools: step.generic_tools || existing.generic_tools, - timestamp: existing.timestamp || step.timestamp - }; - - stepsList[targetIndex] = merged; - if (mergedStepId) idToIndex.set(String(mergedStepId), targetIndex); - if (mergedStepNum !== undefined && mergedStepNum !== null) { - numToIndex.set(Number(mergedStepNum), targetIndex); - } - } else { - const newIndex = stepsList.length; - stepsList.push({ ...step }); - if (stepId) idToIndex.set(stepId, newIndex); - if (stepNum !== null) numToIndex.set(stepNum, newIndex); - } - } - - // Pre-resolve candidate images and actions for every step - interface VisualCandidate { - stepData: any; - act: any; - preUrl: string | null; - postUrl: string | null; - primaryImg: string; - } - - const visualCandidates: VisualCandidate[] = []; - - for (const stepData of stepsList) { - const act = stepData.action_taken || (Array.isArray(stepData.generic_tools) - ? stepData.generic_tools.find((t: any) => t && (t.type === 'action' || isAndroidAction(t))) - : null); - const preUrl = getStepPreImageUrl(stepData, act); - const postUrl = getStepPostImageUrl(stepData, act); - const primaryImg = preUrl || postUrl; - - // Only steps with a resolvable screen image qualify as replay frames - if (primaryImg) { - visualCandidates.push({ - stepData, - act, - preUrl, - postUrl, - primaryImg - }); - } - } - - // Sort strictly by original step_number ascending, then by timestamp - visualCandidates.sort((a, b) => { - const numA = Number(a.stepData.step_number ?? 99999); - const numB = Number(b.stepData.step_number ?? 99999); - if (numA !== numB) return numA - numB; - return Number(a.stepData.timestamp ?? 0) - Number(b.stepData.timestamp ?? 0); - }); - - // Construct guaranteed sequential 1-indexed frames - const frames: StepReplayFrame[] = []; - - for (let i = 0; i < visualCandidates.length; i++) { - const { stepData, act, preUrl, postUrl, primaryImg } = visualCandidates[i]; - const stepNum = i + 1; // 1-indexed sequential frame number (1, 2, 3...) - const title = act ? (getActionTitle(act) || 'Action') : `Step ${stepNum}`; - const coords = act ? getActionCoords(act) : ''; - const targetText = act ? getActionTargetText(act) : ''; - const actionDesc = coords ? `${title} (${coords})` : (targetText ? `${title} (${targetText})` : title); - const failed = isActionFailed(act, stepData); - const rawStepNum = (stepData.step_number !== undefined && stepData.step_number !== null) - ? Number(stepData.step_number) - : stepNum; - - frames.push({ - index: i, - stepNumber: stepNum, - rawStepNumber: rawStepNum, - stepId: String(stepData.step_id || `step-${stepNum}`), - title: `Step ${stepNum}: ${title}`, - actionText: actionDesc, - action: act, - actionType: act?.action || act?.name || 'action', - targetText, - coords, - imageUrl: primaryImg, - preImageUrl: preUrl, - postImageUrl: postUrl, - isPost: false, - timestamp: stepData.timestamp, - summary: stepData.summary || '', - status: failed ? 'failed' : 'dispatched' - }); - } - - return frames; -} diff --git a/apps/showcase_ui/src/app/utils/image-overlay.util.spec.ts b/apps/showcase_ui/src/app/utils/image-overlay.util.spec.ts deleted file mode 100644 index 2bc7bb3c0..000000000 --- a/apps/showcase_ui/src/app/utils/image-overlay.util.spec.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { drawActionCoordinatesOnOverlay, unwrapTraceAction } from './image-overlay.util'; -import { getActionCoords, getActionTargetText } from './action-formatter.util'; - -// Trace records as the DataEngine publishes them in `generic_tools`. -const PRO_TAP_TRACE = { - trace_id: 'pro-tap', - type: 'action', - name: 'tap', - timestamp: 1788478482.05, - status: 'success', - payload: { - action: { - action: 'tap', - coordinates: [922, 1710], - coordinate_space: 'pixel', - normalized_coordinates: [854, 705], - target_text: 'YouTube' - }, - success: true, - post_screenshot: null - } -}; - -const FLASH_CLICK_TRACE = { - trace_id: 'flash-click', - type: 'action', - name: 'click', - timestamp: 1788478305.12, - status: 'success', - payload: { - args: { target: [618, 705], target_text: '相册' }, - result: '{"outcome": "Clicked"}' - } -}; - -function fakeImage(): HTMLImageElement { - return { naturalWidth: 1080, naturalHeight: 2424 } as unknown as HTMLImageElement; -} - -describe('unwrapTraceAction', () => { - it('flattens a Pro action trace (payload.action)', () => { - const act = unwrapTraceAction(PRO_TAP_TRACE); - expect(act.action).toBe('tap'); - expect(act.normalized_coordinates).toEqual([854, 705]); - expect(act.target_text).toBe('YouTube'); - expect(act.trace_id).toBe('pro-tap'); - expect(act.timestamp).toBe(1788478482.05); - }); - - it('flattens a Flash action trace (payload.args)', () => { - const act = unwrapTraceAction(FLASH_CLICK_TRACE); - expect(act.action).toBe('click'); - expect(act.target).toEqual([618, 705]); - expect(act.args.target).toEqual([618, 705]); - }); - - it('passes plain action objects through untouched', () => { - const plain = { action: 'tap', normalized_coordinates: [10, 20] }; - expect(unwrapTraceAction(plain)).toBe(plain); - }); -}); - -describe('drawActionCoordinatesOnOverlay', () => { - it('draws the tap marker for a Pro action trace', () => { - const overlay = document.createElement('div'); - drawActionCoordinatesOnOverlay(fakeImage(), overlay, PRO_TAP_TRACE); - const marker = overlay.querySelector('.action-point-marker') as HTMLElement; - expect(marker).not.toBeNull(); - expect(marker.style.left).toBe('85.4%'); - expect(marker.style.top).toBe('70.5%'); - }); - - it('draws the tap marker for a Flash action trace', () => { - const overlay = document.createElement('div'); - drawActionCoordinatesOnOverlay(fakeImage(), overlay, FLASH_CLICK_TRACE); - expect(overlay.querySelector('.action-point-marker')).not.toBeNull(); - }); -}); - -describe('action card details from trace records', () => { - it('reads coordinates and target text from a Pro action trace', () => { - expect(getActionCoords(PRO_TAP_TRACE)).toContain('854'); - expect(getActionTargetText(PRO_TAP_TRACE)).toBe('YouTube'); - }); -}); diff --git a/apps/showcase_ui/src/app/utils/image-overlay.util.ts b/apps/showcase_ui/src/app/utils/image-overlay.util.ts deleted file mode 100644 index 5611fabf8..000000000 --- a/apps/showcase_ui/src/app/utils/image-overlay.util.ts +++ /dev/null @@ -1,662 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Unwrap Flash (`payload.args`) and Pro (`payload.action`) traces. - * Preserve trace metadata and leave plain action objects unchanged. - */ -export function unwrapTraceAction(record: any): any { - if (!record || typeof record !== 'object' || !record.payload) return record; - - let payload = record.payload; - if (typeof payload === 'string') { - try { - payload = JSON.parse(payload); - } catch { - return record; - } - } - if (!payload || typeof payload !== 'object') return record; - - const asObject = (v: any) => (Array.isArray(v) ? v[0] : v); - const candidates = [asObject(payload.args?.action), asObject(payload.action), asObject(payload.args)]; - const inner = candidates.find(c => c && typeof c === 'object'); - if (!inner) return record; - - return { - ...record, - ...inner, - args: (record.args && typeof record.args === 'object') ? record.args : inner, - name: record.name || inner.action || inner.name, - action: inner.action || record.name, - trace_id: record.trace_id, - timestamp: record.timestamp ?? inner.timestamp - }; -} - -/** - * Helper to parse any coordinate representation into an array of numbers. - * Handles: - * - [x, y] or [x1, y1, x2, y2] - * - "[[x1, y1], [x2, y2]]" or [[x1, y1], [x2, y2]] - * - "[920, 290, 920, 180]" or "920 290 920 180" or "920, 290, 920, 180" - * - "(920, 290, 920, 180)" - */ -export function extractNumbersFromCoordinateValue(val: any): number[] | null { - if (val === null || val === undefined) return null; - - if (Array.isArray(val)) { - if (val.length === 4 && val.every(v => typeof v === 'number' && !isNaN(v))) { - return val; - } - if (val.length === 2 && val.every(v => typeof v === 'number' && !isNaN(v))) { - return val; - } - if (val.length === 2 && Array.isArray(val[0]) && Array.isArray(val[1])) { - const p1 = extractNumbersFromCoordinateValue(val[0]); - const p2 = extractNumbersFromCoordinateValue(val[1]); - if (p1 && p2 && p1.length === 2 && p2.length === 2) { - return [p1[0], p1[1], p2[0], p2[1]]; - } - } - const flattened: number[] = []; - for (const item of val) { - if (typeof item === 'number' && !isNaN(item)) { - flattened.push(item); - } else if (typeof item === 'string') { - const matches = item.match(/-?\d+(?:\.\d+)?/g); - if (matches) { - matches.forEach(m => flattened.push(Number(m))); - } - } else if (Array.isArray(item)) { - const sub = extractNumbersFromCoordinateValue(item); - if (sub) flattened.push(...sub); - } - } - if (flattened.length === 4 || flattened.length === 2) { - return flattened; - } - } - - if (typeof val === 'string') { - const trimmed = val.trim(); - if (!trimmed) return null; - - try { - const parsed = JSON.parse(trimmed); - const res = extractNumbersFromCoordinateValue(parsed); - if (res) return res; - } catch {} - - const matches = trimmed.match(/-?\d+(?:\.\d+)?/g); - if (matches && (matches.length === 4 || matches.length === 2)) { - return matches.map(m => Number(m)); - } - } - - return null; -} - -/** - * Parse sequence of coordinates (e.g. [[x1, y1], [x2, y2]] or stringified format) - */ -export function parseSequenceCoordinates(seq: any): number[][] | null { - if (!seq) return null; - if (typeof seq === 'string') { - const trimmed = seq.trim(); - try { - const parsed = JSON.parse(trimmed); - const res = parseSequenceCoordinates(parsed); - if (res && res.length > 0) return res; - } catch {} - - const pairs: number[][] = []; - const pairRegex = /\[\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\]/g; - let m: RegExpExecArray | null; - while ((m = pairRegex.exec(trimmed)) !== null) { - pairs.push([Number(m[1]), Number(m[2])]); - } - if (pairs.length > 0) return pairs; - } - - if (Array.isArray(seq)) { - const points: number[][] = []; - for (const item of seq) { - const p = extractNumbersFromCoordinateValue(item); - if (p && p.length === 2) { - points.push([p[0], p[1]]); - } else if (Array.isArray(item) && item.length >= 2 && typeof item[0] === 'number' && typeof item[1] === 'number') { - points.push([item[0], item[1]]); - } - } - if (points.length > 0) return points; - } - return null; -} - -export function isPureDirectionString(str: any): boolean { - if (typeof str !== 'string') return false; - const s = str.trim().toLowerCase(); - if (/\d/.test(s)) return false; - return [ - 'up', 'down', 'left', 'right', - 'swipe up', 'swipe down', 'swipe left', 'swipe right', - 'scroll up', 'scroll down', 'scroll left', 'scroll right', - 'top', 'bottom' - ].includes(s); -} - -/** - * Draw action coordinates (points, click sequence, swipe lines) onto the image overlay element - */ -export function drawActionCoordinatesOnOverlay( - img: HTMLImageElement, - overlay: HTMLElement, - actionObj: any -): void { - if (!img || !overlay || !actionObj) return; - overlay.innerHTML = ''; - - const actions = Array.isArray(actionObj) ? actionObj : [actionObj]; - const naturalWidth = img.naturalWidth || 1080; - const naturalHeight = img.naturalHeight || 2400; - - actions.forEach(record => { - const act = unwrapTraceAction(record); - if (!act || typeof act !== 'object') return; - const actionName = (act.action || act.name || 'tap').toLowerCase(); - const args = (act.args && typeof act.args === 'object') ? act.args : {}; - - // Filter out non-touch / system actions that do not operate via touch coordinates - const nonDrawingActions = [ - 'manage_app', 'launch_app', 'open_app', 'stop_app', 'close_app', - 'press_key', 'press_home', 'press_back', 'wait_for_delay', 'wait', 'delay', - 'report_task_status', 'report_failure_analysis' - ]; - if (nonDrawingActions.includes(actionName)) { - return; - } - - // 1. Multi-point click sequence (High priority for click_sequence / tap_sequence) - const isSequenceAction = actionName.includes('sequence') || Boolean(act.sequence || args.sequence || act.normalized_sequence || args.normalized_sequence); - const rawSeq = act.normalized_sequence || args.normalized_sequence || act.sequence || args.sequence || act.targets || args.targets || (Array.isArray(act.coordinates) && Array.isArray(act.coordinates[0]) ? act.coordinates : null) || (Array.isArray(args.coordinates) && Array.isArray(args.coordinates[0]) ? args.coordinates : null); - const seqPoints = parseSequenceCoordinates(rawSeq); - - if (seqPoints && seqPoints.length > 0 && (isSequenceAction || seqPoints.length > 1)) { - const isNormSeq = Boolean(act.normalized_sequence || args.normalized_sequence) || seqPoints.every(p => p[0] <= 1000 && p[1] <= 1000); - // Draw connecting lines between consecutive points - for (let i = 0; i < seqPoints.length - 1; i++) { - createSequenceConnector( - seqPoints[i][0], seqPoints[i][1], - seqPoints[i + 1][0], seqPoints[i + 1][1], - naturalWidth, naturalHeight, - overlay, - isNormSeq - ); - } - // Draw numbered markers for each point in the sequence - seqPoints.forEach((pt, idx) => { - createPointMarker( - pt[0], pt[1], - actionName, - naturalWidth, naturalHeight, - overlay, - isNormSeq, - idx + 1, - seqPoints.length - ); - }); - return; - } - - // Determine if this is a swipe/scroll/drag gesture - const isSwipeLike = actionName.includes('swipe') || actionName.includes('scroll') || actionName.includes('drag') || actionName.includes('slide'); - - // 2. Check separate start and end coordinate objects - const normStart = extractNumbersFromCoordinateValue(act.normalized_start_coordinates || args.normalized_start_coordinates); - const normEnd = extractNumbersFromCoordinateValue(act.normalized_end_coordinates || args.normalized_end_coordinates); - const startCoords = normStart || extractNumbersFromCoordinateValue(act.start_coordinates || act.start_point || act.start || act.from || - args.start_coordinates || args.start_point || args.start || args.from); - const endCoords = normEnd || extractNumbersFromCoordinateValue(act.end_coordinates || act.end_point || act.end || act.to || - args.end_coordinates || args.end_point || args.end || args.to); - - if (startCoords && endCoords && startCoords.length >= 2 && endCoords.length >= 2) { - createLineMarker( - startCoords[0], startCoords[1], - endCoords[0], endCoords[1], - naturalWidth, naturalHeight, - overlay, - Boolean(normStart && normEnd), - actionName - ); - return; - } - - // 3. Check 4-element coordinate array in coordinates, target, action, gesture, etc. - const normCoords = extractNumbersFromCoordinateValue(act.normalized_coordinates || args.normalized_coordinates); - const isNorm = Boolean(normCoords); - const rawCoords = normCoords || - extractNumbersFromCoordinateValue(act.coordinates) || - extractNumbersFromCoordinateValue(args.coordinates) || - extractNumbersFromCoordinateValue(act.coords) || - extractNumbersFromCoordinateValue(args.coords) || - extractNumbersFromCoordinateValue(act.target) || - extractNumbersFromCoordinateValue(args.target) || - (isSwipeLike ? extractNumbersFromCoordinateValue(args.action) : null) || - (isSwipeLike ? extractNumbersFromCoordinateValue(act.action) : null) || - (isSwipeLike ? extractNumbersFromCoordinateValue(args.gesture) : null) || - (isSwipeLike ? extractNumbersFromCoordinateValue(act.gesture) : null); - - if (rawCoords && Array.isArray(rawCoords)) { - if (rawCoords.length === 4) { - createLineMarker( - rawCoords[0], rawCoords[1], rawCoords[2], rawCoords[3], - naturalWidth, naturalHeight, - overlay, - isNorm, - actionName - ); - return; - } else if (rawCoords.length === 2 && !isSwipeLike) { - createPointMarker(rawCoords[0], rawCoords[1], actionName, naturalWidth, naturalHeight, overlay, isNorm); - return; - } - } - - // 4. Check for directional swipe/scroll/drag gestures ONLY when action is swipe-like - if (isSwipeLike) { - const rawDir = (typeof args.action === 'string' && isPureDirectionString(args.action) ? args.action : null) || - (typeof act.action === 'string' && act.action !== actionName && isPureDirectionString(act.action) ? act.action : null) || - (isPureDirectionString(args.direction) ? args.direction : null) || - (isPureDirectionString(act.direction) ? act.direction : null) || - (isPureDirectionString(args.gesture) ? args.gesture : null) || - (isPureDirectionString(act.gesture) ? act.gesture : null) || ''; - const dirStr = String(rawDir).toLowerCase().trim(); - - if (dirStr) { - let sx = 500, sy = 750, ex = 500, ey = 250; - let label = 'SWIPE'; - - if (dirStr.includes('up') || dirStr.includes('top') || actionName.includes('up')) { - sx = 500; sy = 750; ex = 500; ey = 250; - label = 'SWIPE UP'; - } else if (dirStr.includes('down') || dirStr.includes('bottom') || actionName.includes('down')) { - sx = 500; sy = 250; ex = 500; ey = 750; - label = 'SWIPE DOWN'; - } else if (dirStr.includes('left') || actionName.includes('left')) { - sx = 800; sy = 500; ex = 200; ey = 500; - label = 'SWIPE LEFT'; - } else if (dirStr.includes('right') || actionName.includes('right')) { - sx = 200; sy = 500; ex = 800; ey = 500; - label = 'SWIPE RIGHT'; - } - - createLineMarker(sx, sy, ex, ey, naturalWidth, naturalHeight, overlay, true, label); - return; - } - } - - // 5. Default tap/click coordinate fallback - if (rawCoords && Array.isArray(rawCoords) && rawCoords.length === 2) { - createPointMarker(rawCoords[0], rawCoords[1], actionName, naturalWidth, naturalHeight, overlay, isNorm); - } - }); -} - -/** - * Helper to create and position an animated red dot point marker with edge-aware label - */ -export function createPointMarker( - x: number, - y: number, - actionType: string, - naturalWidth: number, - naturalHeight: number, - overlay: HTMLElement, - isNormalized: boolean = false, - sequenceIndex?: number, - totalSequence?: number -): void { - let pctX: number; - let pctY: number; - - // Accurate 0-1000 scale resolution - if (isNormalized || (!isNormalized && x <= 1000 && y <= 1000)) { - pctX = x / 10; - pctY = y / 10; - } else { - pctX = (x / (naturalWidth || 1080)) * 100; - pctY = (y / (naturalHeight || 2400)) * 100; - } - - // Safety clamp within container boundaries - const clampedPctX = Math.max(1.5, Math.min(98.5, pctX)); - const clampedPctY = Math.max(1.5, Math.min(98.5, pctY)); - - const marker = document.createElement('div'); - marker.className = 'action-point-marker'; - marker.style.position = 'absolute'; - marker.style.left = `${clampedPctX}%`; - marker.style.top = `${clampedPctY}%`; - marker.style.transform = 'translate(-50%, -50%)'; - marker.style.pointerEvents = 'none'; - marker.style.zIndex = '10'; - - const dotSize = sequenceIndex !== undefined ? 20 : 14; - const dot = document.createElement('div'); - dot.style.width = `${dotSize}px`; - dot.style.height = `${dotSize}px`; - dot.style.borderRadius = '50%'; - dot.style.backgroundColor = '#ef4444'; - dot.style.border = '2px solid white'; - dot.style.boxShadow = '0 0 6px rgba(0,0,0,0.5)'; - if (sequenceIndex !== undefined) { - dot.style.display = 'flex'; - dot.style.alignItems = 'center'; - dot.style.justifyContent = 'center'; - dot.style.fontSize = '11px'; - dot.style.fontWeight = 'bold'; - dot.style.color = 'white'; - dot.innerText = String(sequenceIndex); - } - - const pulseSize = dotSize * 2; - const pulse = document.createElement('div'); - pulse.style.position = 'absolute'; - pulse.style.top = `-${dotSize / 2 + 2}px`; - pulse.style.left = `-${dotSize / 2 + 2}px`; - pulse.style.width = `${pulseSize}px`; - pulse.style.height = `${pulseSize}px`; - pulse.style.borderRadius = '50%'; - pulse.style.border = '2px solid #ef4444'; - pulse.style.animation = 'pulse-animation 1.5s infinite ease-out'; - pulse.style.pointerEvents = 'none'; - - const label = document.createElement('div'); - if (sequenceIndex !== undefined) { - label.innerText = totalSequence && totalSequence > 1 ? `#${sequenceIndex} TAP` : actionType.toUpperCase(); - } else { - label.innerText = actionType.toUpperCase(); - } - label.style.position = 'absolute'; - label.style.backgroundColor = 'rgba(15, 23, 42, 0.9)'; - label.style.color = 'white'; - label.style.padding = '1.5px 5px'; - label.style.borderRadius = '3px'; - label.style.fontSize = '9px'; - label.style.fontWeight = 'bold'; - label.style.letterSpacing = '0.5px'; - label.style.whiteSpace = 'nowrap'; - label.style.boxShadow = '0 1px 4px rgba(0,0,0,0.4)'; - label.style.border = '1px solid rgba(255,255,255,0.2)'; - - // Smart edge-aware vertical positioning - if (clampedPctY > 85) { - label.style.bottom = `${dotSize + 2}px`; - label.style.top = 'auto'; - } else { - label.style.top = `${dotSize + 2}px`; - label.style.bottom = 'auto'; - } - - // Smart edge-aware horizontal positioning - if (clampedPctX > 82) { - label.style.right = '0px'; - label.style.left = 'auto'; - label.style.transform = 'none'; - } else if (clampedPctX < 18) { - label.style.left = '0px'; - label.style.right = 'auto'; - label.style.transform = 'none'; - } else { - label.style.left = '50%'; - label.style.right = 'auto'; - label.style.transform = 'translateX(-50%)'; - } - - marker.appendChild(pulse); - marker.appendChild(dot); - marker.appendChild(label); - overlay.appendChild(marker); -} - -/** - * Helper to draw connecting line/arrow between points in a click sequence - */ -export function createSequenceConnector( - x1: number, - y1: number, - x2: number, - y2: number, - naturalWidth: number, - naturalHeight: number, - overlay: HTMLElement, - isNormalized: boolean = false -): void { - const isNormCoord = isNormalized || (x1 <= 1000 && y1 <= 1000 && x2 <= 1000 && y2 <= 1000); - const pctX1 = isNormCoord ? x1 / 10 : (x1 / (naturalWidth || 1080)) * 100; - const pctY1 = isNormCoord ? y1 / 10 : (y1 / (naturalHeight || 2400)) * 100; - const pctX2 = isNormCoord ? x2 / 10 : (x2 / (naturalWidth || 1080)) * 100; - const pctY2 = isNormCoord ? y2 / 10 : (y2 / (naturalHeight || 2400)) * 100; - - const clampedX1 = Math.max(1.5, Math.min(98.5, pctX1)); - const clampedY1 = Math.max(1.5, Math.min(98.5, pctY1)); - const clampedX2 = Math.max(1.5, Math.min(98.5, pctX2)); - const clampedY2 = Math.max(1.5, Math.min(98.5, pctY2)); - - let svg = overlay.querySelector('svg'); - if (!svg) { - svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('width', '100%'); - svg.setAttribute('height', '100%'); - svg.style.position = 'absolute'; - svg.style.top = '0'; - svg.style.left = '0'; - svg.style.pointerEvents = 'none'; - svg.style.overflow = 'visible'; - svg.style.zIndex = '5'; - overlay.appendChild(svg); - } - - let defs = svg.querySelector('defs'); - if (!defs) { - defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); - svg.insertBefore(defs, svg.firstChild); - } - - const markerId = 'seq-arrow-' + Math.random().toString(36).substring(2, 9); - const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); - marker.setAttribute('id', markerId); - marker.setAttribute('viewBox', '0 0 12 12'); - marker.setAttribute('refX', '8'); - marker.setAttribute('refY', '6'); - marker.setAttribute('markerWidth', '6'); - marker.setAttribute('markerHeight', '6'); - marker.setAttribute('orient', 'auto'); - - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - path.setAttribute('d', 'M 1 2 L 10 6 L 1 10 z'); - path.setAttribute('fill', '#ef4444'); - path.setAttribute('stroke', '#ffffff'); - path.setAttribute('stroke-width', '1'); - - marker.appendChild(path); - defs.appendChild(marker); - - // Background white contrast line - const bgLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); - bgLine.setAttribute('x1', `${clampedX1}%`); - bgLine.setAttribute('y1', `${clampedY1}%`); - bgLine.setAttribute('x2', `${clampedX2}%`); - bgLine.setAttribute('y2', `${clampedY2}%`); - bgLine.setAttribute('stroke', '#ffffff'); - bgLine.setAttribute('stroke-width', '4'); - bgLine.setAttribute('stroke-linecap', 'round'); - bgLine.setAttribute('opacity', '0.8'); - - // Main red dashed sequence line - const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); - line.setAttribute('x1', `${clampedX1}%`); - line.setAttribute('y1', `${clampedY1}%`); - line.setAttribute('x2', `${clampedX2}%`); - line.setAttribute('y2', `${clampedY2}%`); - line.setAttribute('stroke', '#ef4444'); - line.setAttribute('stroke-width', '2.5'); - line.setAttribute('stroke-linecap', 'round'); - line.setAttribute('marker-end', `url(#${markerId})`); - line.setAttribute('stroke-dasharray', '4,4'); - - svg.appendChild(bgLine); - svg.appendChild(line); -} - -/** - * Helper to create and position an SVG arrow, high-contrast dashed line, and badge for swipes/drags - */ -export function createLineMarker( - x1: number, - y1: number, - x2: number, - y2: number, - naturalWidth: number, - naturalHeight: number, - overlay: HTMLElement, - isNormalized: boolean = false, - actionType: string = 'swipe' -): void { - const isNormCoord = isNormalized || (x1 <= 1000 && y1 <= 1000 && x2 <= 1000 && y2 <= 1000); - const pctX1 = isNormCoord ? x1 / 10 : (x1 / (naturalWidth || 1080)) * 100; - const pctY1 = isNormCoord ? y1 / 10 : (y1 / (naturalHeight || 2400)) * 100; - const pctX2 = isNormCoord ? x2 / 10 : (x2 / (naturalWidth || 1080)) * 100; - const pctY2 = isNormCoord ? y2 / 10 : (y2 / (naturalHeight || 2400)) * 100; - - const clampedX1 = Math.max(1.5, Math.min(98.5, pctX1)); - const clampedY1 = Math.max(1.5, Math.min(98.5, pctY1)); - const clampedX2 = Math.max(1.5, Math.min(98.5, pctX2)); - const clampedY2 = Math.max(1.5, Math.min(98.5, pctY2)); - - let svg = overlay.querySelector('svg'); - if (!svg) { - svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('width', '100%'); - svg.setAttribute('height', '100%'); - svg.style.position = 'absolute'; - svg.style.top = '0'; - svg.style.left = '0'; - svg.style.pointerEvents = 'none'; - svg.style.overflow = 'visible'; - svg.style.zIndex = '5'; - overlay.appendChild(svg); - } - - let defs = svg.querySelector('defs'); - if (!defs) { - defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); - svg.insertBefore(defs, svg.firstChild); - } - - const markerId = 'action-arrow-' + Math.random().toString(36).substring(2, 9); - const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); - marker.setAttribute('id', markerId); - marker.setAttribute('viewBox', '0 0 12 12'); - marker.setAttribute('refX', '8'); - marker.setAttribute('refY', '6'); - marker.setAttribute('markerWidth', '8'); - marker.setAttribute('markerHeight', '8'); - marker.setAttribute('orient', 'auto'); - - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - path.setAttribute('d', 'M 1 2 L 10 6 L 1 10 z'); - path.setAttribute('fill', '#ef4444'); - path.setAttribute('stroke', '#ffffff'); - path.setAttribute('stroke-width', '1.5'); - path.setAttribute('stroke-linejoin', 'round'); - - marker.appendChild(path); - defs.appendChild(marker); - - // Background white contrast line - const bgLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); - bgLine.setAttribute('x1', `${clampedX1}%`); - bgLine.setAttribute('y1', `${clampedY1}%`); - bgLine.setAttribute('x2', `${clampedX2}%`); - bgLine.setAttribute('y2', `${clampedY2}%`); - bgLine.setAttribute('stroke', '#ffffff'); - bgLine.setAttribute('stroke-width', '6'); - bgLine.setAttribute('stroke-linecap', 'round'); - bgLine.setAttribute('opacity', '0.9'); - - // Main red dashed line - const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); - line.setAttribute('x1', `${clampedX1}%`); - line.setAttribute('y1', `${clampedY1}%`); - line.setAttribute('x2', `${clampedX2}%`); - line.setAttribute('y2', `${clampedY2}%`); - line.setAttribute('stroke', '#ef4444'); - line.setAttribute('stroke-width', '3.5'); - line.setAttribute('stroke-linecap', 'round'); - line.setAttribute('marker-end', `url(#${markerId})`); - line.setAttribute('stroke-dasharray', '6,4'); - - // Start touch point circle with white border - const startCircleOuter = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - startCircleOuter.setAttribute('cx', `${clampedX1}%`); - startCircleOuter.setAttribute('cy', `${clampedY1}%`); - startCircleOuter.setAttribute('r', '6.5'); - startCircleOuter.setAttribute('fill', '#ef4444'); - startCircleOuter.setAttribute('stroke', 'white'); - startCircleOuter.setAttribute('stroke-width', '2'); - - const startCircleInner = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - startCircleInner.setAttribute('cx', `${clampedX1}%`); - startCircleInner.setAttribute('cy', `${clampedY1}%`); - startCircleInner.setAttribute('r', '2.5'); - startCircleInner.setAttribute('fill', 'white'); - - svg.appendChild(bgLine); - svg.appendChild(line); - svg.appendChild(startCircleOuter); - svg.appendChild(startCircleInner); - - // Floating Action badge at midpoint of swipe - const midX = (clampedX1 + clampedX2) / 2; - const midY = (clampedY1 + clampedY2) / 2; - - const label = document.createElement('div'); - label.className = 'action-point-marker action-line-label'; - label.style.position = 'absolute'; - label.style.left = `${midX}%`; - label.style.top = `${midY}%`; - label.style.transform = 'translate(-50%, -50%)'; - label.style.pointerEvents = 'none'; - label.style.zIndex = '10'; - - const badge = document.createElement('div'); - badge.innerText = actionType.toUpperCase(); - badge.style.backgroundColor = 'rgba(15, 23, 42, 0.92)'; - badge.style.color = 'white'; - badge.style.padding = '2px 6px'; - badge.style.borderRadius = '4px'; - badge.style.fontSize = '9px'; - badge.style.fontWeight = 'bold'; - badge.style.letterSpacing = '0.5px'; - badge.style.whiteSpace = 'nowrap'; - badge.style.boxShadow = '0 2px 6px rgba(0,0,0,0.5)'; - badge.style.border = '1px solid rgba(255,255,255,0.3)'; - - label.appendChild(badge); - overlay.appendChild(label); -} diff --git a/apps/showcase_ui/src/app/utils/markdown-parser.util.spec.ts b/apps/showcase_ui/src/app/utils/markdown-parser.util.spec.ts deleted file mode 100644 index 0fd04defa..000000000 --- a/apps/showcase_ui/src/app/utils/markdown-parser.util.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { parseNoteLines, parseNote, renderMarkdownToHtml } from './markdown-parser.util'; - -describe('markdown-parser.util verification & check lines', () => { - it('should parse - verify: line as type verify with checkKind verify', () => { - const markdown = `- [x] Parent task - - [x] Subtask 1 - - verify: Commute duration is recorded in note \`eta_details\``; - - const lines = parseNoteLines(markdown); - expect(lines.length).toBe(3); - expect(lines[0].type).toBe('checked'); - expect(lines[1].type).toBe('checked'); - - const verifyLine = lines[2]; - expect(verifyLine.type).toBe('verify'); - expect(verifyLine.checkKind).toBe('verify'); - expect(verifyLine.atEnd).toBe(false); - expect(verifyLine.segments.map(s => s.text).join('')).toBe('Commute duration is recorded in note eta_details'); - expect(verifyLine.segments.find(s => s.code)?.text).toBe('eta_details'); - }); - - it('should parse - assert: and - assert@end: lines', () => { - const markdown = `- [ ] Open app - - assert: the welcome screen shows up -- assert@end: final status is completed`; - - const lines = parseNoteLines(markdown); - expect(lines.length).toBe(3); - - expect(lines[1].type).toBe('verify'); - expect(lines[1].checkKind).toBe('assert'); - expect(lines[1].atEnd).toBe(false); - - expect(lines[2].type).toBe('verify'); - expect(lines[2].checkKind).toBe('assert'); - expect(lines[2].atEnd).toBe(true); - }); - - it('should parse - finding: lines', () => { - const markdown = `- [x] Step 1 - - finding: Unresolved verify failure`; - - const lines = parseNoteLines(markdown); - expect(lines[1].type).toBe('finding'); - expect(lines[1].checkKind).toBe('finding'); - expect(lines[1].segments[0].text).toBe('Unresolved verify failure'); - }); - - it('should group verify lines under parent milestone checks in parseNote', () => { - const note = `- [x] Open Maps - - [x] Search destination - - verify: Arrival time is visible in note \`eta\``; - - const parsed = parseNote(note); - expect(parsed.milestones.length).toBe(1); - expect(parsed.milestones[0].subSteps.length).toBe(1); - expect(parsed.milestones[0].subSteps[0].type).toBe('checked'); - expect(parsed.milestones[0].checks.length).toBe(1); - expect(parsed.milestones[0].checks[0].type).toBe('verify'); - expect(parsed.milestones[0].checks[0].checkKind).toBe('verify'); - }); - - it('should render verify badges in renderMarkdownToHtml', () => { - const text = `- verify: status is ok`; - const html = renderMarkdownToHtml(text); - expect(html).toContain('class="md-verify-item"'); - expect(html).toContain('verify'); - expect(html).toContain('status is ok'); - }); -}); diff --git a/apps/showcase_ui/src/app/utils/markdown-parser.util.ts b/apps/showcase_ui/src/app/utils/markdown-parser.util.ts deleted file mode 100644 index 0b3e377a2..000000000 --- a/apps/showcase_ui/src/app/utils/markdown-parser.util.ts +++ /dev/null @@ -1,383 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../core/models/markdown.model'; -import { CheckerResult } from '../core/models/stream.model'; - -/** - * Split text into bold, code, and plain segments - */ -export function parseMarkdownSegments(text: string): MarkdownSegment[] { - if (!text) return []; - const parts = text.split(/(\*\*.*?\*\*|`.*?`)/g); - return parts - .filter(part => part !== '') - .map(part => { - if (part.startsWith('**') && part.endsWith('**')) { - return { - text: part.slice(2, -2), - bold: true, - code: false - }; - } else if (part.startsWith('`') && part.endsWith('`')) { - return { - text: part.slice(1, -1), - bold: false, - code: true - }; - } else { - return { - text: part, - bold: false, - code: false - }; - } - }); -} - -/** - * Parse markdown note content into structured lines (checklists, headers, list items) - */ -export function parseNoteLines(content: string): MarkdownLine[] { - if (!content) return []; - return content.split('\n').map(line => { - const trimmed = line.trim(); - const indent = line.length - line.trimStart().length; - - // Checkbox [x] - if (trimmed.startsWith('- [x] ') || trimmed.startsWith('- [X] ')) { - const rawText = trimmed.substring(6); - return { type: 'checked', segments: parseMarkdownSegments(rawText), indent }; - } - // Active checkbox [/] - if (trimmed.startsWith('- [/] ')) { - const rawText = trimmed.substring(6); - return { type: 'progress', segments: parseMarkdownSegments(rawText), indent }; - } - // Unchecked checkbox [ ] - if (trimmed.startsWith('- [ ] ')) { - const rawText = trimmed.substring(6); - return { type: 'unchecked', segments: parseMarkdownSegments(rawText), indent }; - } - // Check lines (- verify: / - assert: / - verify@end: / - assert@end:) - const checkMatch = trimmed.match(/^[-*]\s*(verify|assert)(@end)?\s*:\s*(.*)$/i); - if (checkMatch) { - const kind = checkMatch[1].toLowerCase() as 'verify' | 'assert'; - const atEnd = Boolean(checkMatch[2]); - const rawText = checkMatch[3].trim(); - return { - type: 'verify', - checkKind: kind, - atEnd, - segments: parseMarkdownSegments(rawText), - indent - }; - } - // System findings (- finding:) - const findingMatch = trimmed.match(/^[-*]\s*finding\s*:\s*(.*)$/i); - if (findingMatch) { - const rawText = findingMatch[1].trim(); - return { - type: 'finding', - checkKind: 'finding', - segments: parseMarkdownSegments(rawText), - indent - }; - } - // Regular list item - if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) { - const rawText = trimmed.substring(2); - return { type: 'list-item', segments: parseMarkdownSegments(rawText), indent }; - } - // Headers - if (trimmed.startsWith('# ')) { - const rawText = trimmed.substring(2); - return { type: 'h1', segments: parseMarkdownSegments(rawText), indent }; - } - if (trimmed.startsWith('## ')) { - const rawText = trimmed.substring(3); - return { type: 'h2', segments: parseMarkdownSegments(rawText), indent }; - } - if (trimmed.startsWith('### ')) { - const rawText = trimmed.substring(4); - return { type: 'h3', segments: parseMarkdownSegments(rawText), indent }; - } - // Default line - return trimmed ? { type: 'text', segments: parseMarkdownSegments(line), indent } : { type: 'empty', segments: [], indent: 0 }; - }); -} - -/** - * Group parsed markdown lines into title, milestones (checklists), and other lines - */ -export function parseNote(content: string): ParsedNote { - const lines = parseNoteLines(content); - const milestones: NoteMilestone[] = []; - const otherLines: MarkdownLine[] = []; - let title: string | null = null; - let currentMilestone: (NoteMilestone & { _indent?: number }) | null = null; - let milestoneCount = 0; - - for (const line of lines) { - if (line.type === 'empty') { - continue; - } - - if ((line.type === 'h1' || line.type === 'h2') && !title) { - title = line.segments.map(s => s.text).join(''); - continue; - } - - const isChecklistItem = ['checked', 'progress', 'unchecked'].includes(line.type); - - if (isChecklistItem && (!currentMilestone || line.indent <= (currentMilestone._indent ?? 0))) { - milestoneCount++; - currentMilestone = { - index: milestoneCount, - type: line.type as 'checked' | 'progress' | 'unchecked', - segments: line.segments, - subSteps: [], - checks: [], - _indent: line.indent - }; - milestones.push(currentMilestone); - } else if (currentMilestone) { - if (line.indent > (currentMilestone._indent ?? 0)) { - if (line.type === 'verify' || line.type === 'assert' || line.type === 'finding') { - currentMilestone.checks.push(line); - } else { - currentMilestone.subSteps.push(line); - } - } else { - otherLines.push(line); - } - } else { - otherLines.push(line); - } - } - - return { - title, - milestones, - otherLines - }; -} - -/** - * Parse the Checker JSON response block - */ -export function extractCheckerResult(text: string): CheckerResult | null { - if (!text) return null; - let cleanText = text.trim(); - if (cleanText.includes('```json')) { - const parts = cleanText.split('```json'); - if (parts.length > 1) { - cleanText = parts[1].split('```')[0].trim(); - } - } else if (cleanText.includes('```')) { - const parts = cleanText.split('```'); - if (parts.length > 1) { - cleanText = parts[1].split('```')[0].trim(); - } - } - - try { - const parsed = JSON.parse(cleanText); - if (parsed && typeof parsed === 'object' && 'success' in parsed) { - return { - success: Boolean(parsed.success), - reason: parsed.reason || 'No reason provided.' - }; - } - } catch { - // Ignore - } - return null; -} - -/** - * Helper to format inline markdown segments (bold, italic, code, strikethrough) - */ -function formatInlineMarkdown(text: string): string { - if (!text) return ''; - return text - // Inline code first (so asterisks inside code aren't formatted) - .replace(/`([^`]+)`/g, '$1') - // Bold + Italic - .replace(/\*\*\*([^*]+)\*\*\*/g, '$1') - .replace(/___([^_]+)___/g, '$1') - // Bold - .replace(/\*\*([^*]+)\*\*/g, '$1') - .replace(/__([^_]+)__/g, '$1') - // Italic - .replace(/\*([^*]+)\*/g, '$1') - .replace(/\b_([^_]+)_\b/g, '$1') - // Strikethrough - .replace(/~~([^~]+)~~/g, '$1'); -} - -/** - * Convert basic markdown text to safe HTML for agent logs/thinking - */ -export function renderMarkdownToHtml(text: string): string { - if (!text) return ''; - - // Clean up internal agent XML-like tags (e.g. , , etc.) - const cleanedText = text - .replace(/<\/?(thought|short_term_memory|strong_term_memory|reasoning|plan|task_plan|call_tool)[^\n>]*>?/gi, '') - .replace(/\r\n/g, '\n') - .replace(/\r/g, '\n') - .replace(/\n{3,}/g, '\n\n') - .trim(); - - // Escape HTML first to prevent XSS - const escaped = cleanedText - .replace(/&/g, '&') - .replace(//g, '>'); - - const rawLines = escaped.split('\n'); - const result: string[] = []; - let currentListType: 'ul' | 'ol' | null = null; - let inCodeBlock = false; - let codeBlockLang = ''; - let codeBlockLines: string[] = []; - - const closeList = () => { - if (currentListType) { - result.push(``); - currentListType = null; - } - }; - - for (let i = 0; i < rawLines.length; i++) { - const rawLine = rawLines[i]; - const trimmed = rawLine.trim(); - - // 1. Code blocks: ```lang ... ``` - if (trimmed.startsWith('```')) { - if (inCodeBlock) { - // End code block - const codeContent = codeBlockLines.join('\n'); - result.push(`
${codeContent}
`); - inCodeBlock = false; - codeBlockLines = []; - codeBlockLang = ''; - } else { - closeList(); - inCodeBlock = true; - codeBlockLang = trimmed.substring(3).trim(); - codeBlockLines = []; - } - continue; - } - - if (inCodeBlock) { - codeBlockLines.push(rawLine); - continue; - } - - // 2. Headings: # to ###### - const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/); - if (headingMatch) { - closeList(); - const level = headingMatch[1].length; - const headingContent = formatInlineMarkdown(headingMatch[2]); - result.push(`${headingContent}`); - continue; - } - - // 3. Horizontal Rule: ---, ***, ___ - if (/^(\-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { - closeList(); - result.push('
'); - continue; - } - - // 4. Blockquotes: > quote - if (trimmed.startsWith('> ') || trimmed.startsWith('>')) { - closeList(); - const quoteContent = formatInlineMarkdown(trimmed.replace(/^>\s?/, '')); - result.push(`
${quoteContent}
`); - continue; - } - - // 5. Unordered List: - item or * item - const ulMatch = rawLine.match(/^(\s*)([\*\-])\s+(.*)$/); - if (ulMatch) { - if (currentListType !== 'ul') { - closeList(); - currentListType = 'ul'; - result.push('
    '); - } - const spaces = ulMatch[1].length; - const subLevel = Math.max(0, Math.floor((spaces - 2) / 2)); - const indent = subLevel * 12; - const style = indent ? ` style="margin-left: ${indent}px"` : ''; - const itemText = ulMatch[3]; - const checkMatch = itemText.match(/^(verify|assert)(@end)?\s*:\s*(.*)$/i); - const findingMatch = itemText.match(/^finding\s*:\s*(.*)$/i); - if (checkMatch) { - const kind = checkMatch[1].toLowerCase(); - const atEnd = checkMatch[2] ? '@end' : ''; - const assertClass = kind === 'assert' ? ' assert' : ''; - const itemContent = formatInlineMarkdown(checkMatch[3].trim()); - result.push(`${kind}${atEnd}${itemContent}`); - } else if (findingMatch) { - const itemContent = formatInlineMarkdown(findingMatch[1].trim()); - result.push(`finding${itemContent}`); - } else { - const itemContent = formatInlineMarkdown(itemText); - result.push(`${itemContent}`); - } - continue; - } - - // 6. Ordered List: 1. item - const olMatch = rawLine.match(/^(\s*)(\d+)\.\s+(.*)$/); - if (olMatch) { - if (currentListType !== 'ol') { - closeList(); - currentListType = 'ol'; - result.push('
      '); - } - const spaces = olMatch[1].length; - const subLevel = Math.max(0, Math.floor((spaces - 2) / 2)); - const indent = subLevel * 12; - const style = indent ? ` style="margin-left: ${indent}px"` : ''; - const itemContent = formatInlineMarkdown(olMatch[3]); - result.push(`${itemContent}`); - continue; - } - - // 7. Regular paragraph / empty line - closeList(); - if (trimmed === '') { - result.push('
      '); - } else { - const lineContent = formatInlineMarkdown(rawLine); - result.push(`
      ${lineContent}
      `); - } - } - - if (inCodeBlock) { - const codeContent = codeBlockLines.join('\n'); - result.push(`
      ${codeContent}
      `); - } - - closeList(); - return result.join('\n'); -} diff --git a/apps/showcase_ui/src/app/utils/recording-timeline.util.spec.ts b/apps/showcase_ui/src/app/utils/recording-timeline.util.spec.ts deleted file mode 100644 index 0e3941428..000000000 --- a/apps/showcase_ui/src/app/utils/recording-timeline.util.spec.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { - hasSessionOffsets, - locateSessionTime, - locateTimelineTime, - sessionTimeToTimelineTime, - TimelineSegment -} from './recording-timeline.util'; - -describe('recording-timeline.util', () => { - // Recording started 1.2s after the session began. Segment 0 ran 10s, then - // scrcpy restarted for rotation (1.5s gap), segment 1 ran 5s, another 0.8s - // gap, and segment 2 ran 4s. - const v2Segments: TimelineSegment[] = [ - { start: 0, duration: 10, offset_ms: 1200, duration_ms: 10000 }, - { start: 10, duration: 5, offset_ms: 12700, duration_ms: 5000 }, - { start: 15, duration: 4, offset_ms: 18500, duration_ms: 4000 } - ]; - - const legacySegments: TimelineSegment[] = [ - { start: 0, duration: 10 }, - { start: 10, duration: 5 } - ]; - - describe('hasSessionOffsets', () => { - it('detects manifest v2 segments and rejects legacy or mixed lists', () => { - expect(hasSessionOffsets(v2Segments)).toBeTrue(); - expect(hasSessionOffsets(legacySegments)).toBeFalse(); - expect(hasSessionOffsets([v2Segments[0], legacySegments[0]])).toBeFalse(); - expect(hasSessionOffsets([])).toBeFalse(); - }); - }); - - describe('locateTimelineTime', () => { - it('maps back-to-back playlist times into segments', () => { - expect(locateTimelineTime(legacySegments, 3)).toEqual({ index: 0, localTime: 3, timelineTime: 3 }); - expect(locateTimelineTime(legacySegments, 10)).toEqual({ index: 1, localTime: 0, timelineTime: 10 }); - expect(locateTimelineTime(legacySegments, 12.5)).toEqual({ index: 1, localTime: 2.5, timelineTime: 12.5 }); - }); - - it('clamps out-of-range times into the first or last segment', () => { - expect(locateTimelineTime(legacySegments, -4)).toEqual({ index: 0, localTime: 0, timelineTime: 0 }); - expect(locateTimelineTime(legacySegments, 99)).toEqual({ index: 1, localTime: 5, timelineTime: 15 }); - expect(locateTimelineTime([], 3)).toBeNull(); - }); - }); - - describe('locateSessionTime', () => { - it('maps a session time inside a segment using its real offset', () => { - // T+4.2s is 3.0s into segment 0 (which started at T+1.2s). - const inFirst = locateSessionTime(v2Segments, 4.2)!; - expect(inFirst.index).toBe(0); - expect(inFirst.localTime).toBeCloseTo(3.0, 6); - expect(inFirst.timelineTime).toBeCloseTo(3.0, 6); - - // T+14.7s is 2.0s into segment 1 (started at T+12.7s), which sits at - // timeline 10s, so it must NOT drift to timeline 14.7s. - const inSecond = locateSessionTime(v2Segments, 14.7)!; - expect(inSecond.index).toBe(1); - expect(inSecond.localTime).toBeCloseTo(2.0, 6); - expect(inSecond.timelineTime).toBeCloseTo(12.0, 6); - - const inThird = locateSessionTime(v2Segments, 20.5)!; - expect(inThird.index).toBe(2); - expect(inThird.localTime).toBeCloseTo(2.0, 6); - expect(inThird.timelineTime).toBeCloseTo(17.0, 6); - }); - - it('snaps a time inside a restart gap to the start of the next segment', () => { - // Segment 0 ends at T+11.2s, segment 1 starts at T+12.7s. - expect(locateSessionTime(v2Segments, 11.9)).toEqual({ index: 1, localTime: 0, timelineTime: 10 }); - // Segment 1 ends at T+17.7s, segment 2 starts at T+18.5s. - expect(locateSessionTime(v2Segments, 18.0)).toEqual({ index: 2, localTime: 0, timelineTime: 15 }); - }); - - it('treats segment boundaries as inclusive of the end frame', () => { - const atEnd = locateSessionTime(v2Segments, 11.2)!; - expect(atEnd.index).toBe(0); - expect(atEnd.localTime).toBeCloseTo(10, 6); - expect(locateSessionTime(v2Segments, 12.7)).toEqual({ index: 1, localTime: 0, timelineTime: 10 }); - }); - - it('snaps times before the first frame and after the last frame', () => { - expect(locateSessionTime(v2Segments, 0)).toEqual({ index: 0, localTime: 0, timelineTime: 0 }); - expect(locateSessionTime(v2Segments, 0.5)).toEqual({ index: 0, localTime: 0, timelineTime: 0 }); - expect(locateSessionTime(v2Segments, 60)).toEqual({ index: 2, localTime: 4, timelineTime: 19 }); - expect(locateSessionTime(v2Segments, Number.NaN)).toEqual({ index: 0, localTime: 0, timelineTime: 0 }); - }); - - it('falls back to back-to-back timeline behaviour for legacy manifests', () => { - expect(locateSessionTime(legacySegments, 12.5)).toEqual({ index: 1, localTime: 2.5, timelineTime: 12.5 }); - expect(locateSessionTime([], 5)).toBeNull(); - }); - }); - - describe('sessionTimeToTimelineTime', () => { - it('returns the playlist position for a session time', () => { - expect(sessionTimeToTimelineTime(v2Segments, 14.7)).toBeCloseTo(12.0, 6); - expect(sessionTimeToTimelineTime(v2Segments, 11.9)).toBe(10); - expect(sessionTimeToTimelineTime(legacySegments, 7)).toBe(7); - }); - - it('passes the time through when no playlist is available', () => { - expect(sessionTimeToTimelineTime([], 7)).toBe(7); - expect(sessionTimeToTimelineTime([], -2)).toBe(0); - }); - }); -}); diff --git a/apps/showcase_ui/src/app/utils/recording-timeline.util.ts b/apps/showcase_ui/src/app/utils/recording-timeline.util.ts deleted file mode 100644 index 112c4f21c..000000000 --- a/apps/showcase_ui/src/app/utils/recording-timeline.util.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Pure helpers that map times onto the multi-segment screen recording. - * - * A recording is split into segments whenever scrcpy restarts (device - * rotation, crash recovery). The player plays them back to back on a - * "timeline" axis (`start`/`duration`, no gaps), while step timestamps live - * on the "session" axis (seconds since the DataEngine session start). Manifest - * v2 segments carry `offset_ms`/`duration_ms` on the session axis, which lets - * a session time land in the right segment even after each restart gap. - */ - -export interface TimelineSegment { - /** Back-to-back playlist start in seconds. */ - start: number; - /** Segment length in seconds. */ - duration: number; - /** Session-relative first-frame offset in milliseconds (manifest v2). */ - offset_ms?: number; - /** Segment length in milliseconds (manifest v2). */ - duration_ms?: number; -} - -export interface SegmentLocation { - /** Index into the segment list. */ - index: number; - /** Seconds into that segment's own media. */ - localTime: number; - /** Equivalent position on the back-to-back playlist axis, in seconds. */ - timelineTime: number; -} - -const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value)); - -/** True when every segment carries the session-relative v2 fields. */ -export function hasSessionOffsets(segments: readonly TimelineSegment[]): boolean { - return segments.length > 0 && segments.every( - (segment) => Number.isFinite(segment.offset_ms) && Number.isFinite(segment.duration_ms) - ); -} - -/** - * Locate a position on the back-to-back playlist axis (what the scrubber uses). - * Times past the end clamp into the last segment. - */ -export function locateTimelineTime( - segments: readonly TimelineSegment[], - timelineSeconds: number -): SegmentLocation | null { - if (!segments.length) return null; - const total = segments.reduce((sum, segment) => sum + Math.max(0, segment.duration), 0); - const target = clamp(Number.isFinite(timelineSeconds) ? timelineSeconds : 0, 0, total); - let index = segments.findIndex((segment) => target < segment.start + segment.duration); - if (index < 0) index = segments.length - 1; - const segment = segments[index]; - const localTime = clamp(target - segment.start, 0, Math.max(0, segment.duration)); - return { index, localTime, timelineTime: segment.start + localTime }; -} - -/** - * Locate a session-relative time (seconds since session start, the axis step - * timestamps use). A time inside `[offset, offset + duration]` of a segment maps - * into that segment; a time in the gap between two segments (scrcpy restart) - * snaps to the start of the next segment; a time before the first frame snaps - * to the first frame; a time after the last frame clamps to the last frame. - * - * Segments without session offsets (legacy manifests) are treated as if the - * session axis were the back-to-back timeline axis. - */ -export function locateSessionTime( - segments: readonly TimelineSegment[], - sessionSeconds: number -): SegmentLocation | null { - if (!segments.length) return null; - if (!hasSessionOffsets(segments)) { - return locateTimelineTime(segments, sessionSeconds); - } - const targetMs = Number.isFinite(sessionSeconds) ? sessionSeconds * 1000 : 0; - for (let index = 0; index < segments.length; index++) { - const segment = segments[index]; - const offsetMs = segment.offset_ms as number; - const endMs = offsetMs + (segment.duration_ms as number); - if (targetMs < offsetMs) { - // Before this segment's first frame: either before the recording began - // or inside the restart gap after the previous segment. Snap forward. - return { index, localTime: 0, timelineTime: segment.start }; - } - if (targetMs <= endMs) { - const localTime = clamp((targetMs - offsetMs) / 1000, 0, Math.max(0, segment.duration)); - return { index, localTime, timelineTime: segment.start + localTime }; - } - } - const lastIndex = segments.length - 1; - const last = segments[lastIndex]; - const localTime = Math.max(0, last.duration); - return { index: lastIndex, localTime, timelineTime: last.start + localTime }; -} - -/** - * Convert a session-relative time into the back-to-back playlist time the - * player scrubber understands. Returns 0 when there are no segments. - */ -export function sessionTimeToTimelineTime( - segments: readonly TimelineSegment[], - sessionSeconds: number -): number { - return locateSessionTime(segments, sessionSeconds)?.timelineTime ?? Math.max(0, sessionSeconds || 0); -} diff --git a/apps/showcase_ui/src/app/utils/run-info.util.spec.ts b/apps/showcase_ui/src/app/utils/run-info.util.spec.ts deleted file mode 100644 index 89bc65c9f..000000000 --- a/apps/showcase_ui/src/app/utils/run-info.util.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { - contextPercent, - formatCompactTokens, - formatElapsed, - parseRunTuning, - sessionElapsedSeconds, - tuningLabel -} from './run-info.util'; - -describe('run-info.util', () => { - describe('formatElapsed', () => { - it('formats seconds, minutes and hours with zero padding', () => { - expect(formatElapsed(0)).toBe('0s'); - expect(formatElapsed(42)).toBe('42s'); - expect(formatElapsed(65)).toBe('1m 05s'); - expect(formatElapsed(3725)).toBe('1h 02m 05s'); - }); - - it('treats invalid or negative input as zero', () => { - expect(formatElapsed(-5)).toBe('0s'); - expect(formatElapsed(undefined)).toBe('0s'); - expect(formatElapsed(Number.NaN)).toBe('0s'); - }); - }); - - describe('formatCompactTokens', () => { - it('uses plain, k and M units', () => { - expect(formatCompactTokens(842)).toBe('842'); - expect(formatCompactTokens(76_400)).toBe('76.4k'); - expect(formatCompactTokens(1_000)).toBe('1k'); - expect(formatCompactTokens(1_250_000)).toBe('1.25M'); - expect(formatCompactTokens(null)).toBe('0'); - }); - }); - - describe('contextPercent', () => { - it('reports one-decimal share of the window and clamps at 100', () => { - expect(contextPercent(150_000, 1_000_000)).toBe(15); - expect(contextPercent(123_456, 1_000_000)).toBe(12.3); - expect(contextPercent(2_000_000, 1_000_000)).toBe(100); - }); - - it('falls back to the 1M window and is null when usage is unknown', () => { - expect(contextPercent(250_000, undefined)).toBe(25); - expect(contextPercent(null, 1_000_000)).toBeNull(); - expect(contextPercent(undefined, 1_000_000)).toBeNull(); - }); - }); - - describe('sessionElapsedSeconds', () => { - it('counts to now while running and to end_time once finished', () => { - const session = { start_time: 1_000, end_time: 1_090 }; - expect(sessionElapsedSeconds(session, true, 1_200_000)).toBe(200); - expect(sessionElapsedSeconds(session, false, 1_200_000)).toBe(90); - }); - - it('is zero without a start time or session', () => { - expect(sessionElapsedSeconds(null, true, 5_000)).toBe(0); - expect(sessionElapsedSeconds({ start_time: 0 }, true, 5_000)).toBe(0); - }); - }); - - describe('tuningLabel', () => { - it('maps ids to the launcher labels and defaults unknown ids', () => { - expect(tuningLabel('verify', 'checkpoints')).toBe('Every step'); - expect(tuningLabel('verify', 'nonsense')).toBe('At the end'); - expect(tuningLabel('explore', 'ultra')).toBe('Close-up'); - expect(tuningLabel('explore', null)).toBe('Quick glance'); - }); - }); - - describe('parseRunTuning', () => { - it('reads run_tuning from a JSON string or an object', () => { - const asString = JSON.stringify({ - profile: 'pro', - run_tuning: { verification_level: 'strict', explorer_mode: 'pro' } - }); - expect(parseRunTuning(asString)).toEqual({ verification_level: 'strict', explorer_mode: 'pro' }); - expect(parseRunTuning({ run_tuning: { verification_level: 'off' } })).toEqual({ - verification_level: 'off', - explorer_mode: null - }); - }); - - it('is null for flash sessions, malformed JSON and empty tuning', () => { - expect(parseRunTuning(JSON.stringify({ profile: 'flash' }))).toBeNull(); - expect(parseRunTuning('{not json')).toBeNull(); - expect(parseRunTuning({ run_tuning: {} })).toBeNull(); - expect(parseRunTuning(null)).toBeNull(); - }); - }); -}); diff --git a/apps/showcase_ui/src/app/utils/run-info.util.ts b/apps/showcase_ui/src/app/utils/run-info.util.ts deleted file mode 100644 index 696b34b18..000000000 --- a/apps/showcase_ui/src/app/utils/run-info.util.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * Pure helpers behind the run info popover (the Flash / Pro chip in the - * task stream): elapsed time, compact token counts, context-window share - * and the plain-language labels of the Pro tuning a run was started with. - */ - -import { - EXPLORER_MODES, - VERIFICATION_LEVELS, - levelIndex, - DEFAULT_EXPLORER_MODE, - DEFAULT_VERIFICATION_LEVEL -} from '../core/models/pro-tuning.model'; -import { SessionRunTuning } from '../core/models/session.model'; - -/** Denominator for "context used" when the backend does not report one. */ -export const OPERATOR_CONTEXT_WINDOW_TOKENS = 1_000_000; - -/** `42s`, `12m 05s`, `1h 02m 05s`. Negative or invalid input reads as `0s`. */ -export function formatElapsed(totalSeconds: number | null | undefined): string { - const secs = Math.max(0, Math.floor(Number(totalSeconds) || 0)); - const hours = Math.floor(secs / 3600); - const minutes = Math.floor((secs % 3600) / 60); - const seconds = secs % 60; - const pad = (n: number) => String(n).padStart(2, '0'); - if (hours > 0) return `${hours}h ${pad(minutes)}m ${pad(seconds)}s`; - if (minutes > 0) return `${minutes}m ${pad(seconds)}s`; - return `${seconds}s`; -} - -/** `842`, `76.4k`, `1.2M` (no unit suffix; the caller labels the row). */ -export function formatCompactTokens(tokens: number | null | undefined): string { - const n = Math.max(0, Math.floor(Number(tokens) || 0)); - if (n < 1000) return String(n); - if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`; - return `${(n / 1_000_000).toFixed(2).replace(/\.?0+$/, '')}M`; -} - -/** Share of the context window in percent (one decimal), or null when unknown. */ -export function contextPercent( - usedTokens: number | null | undefined, - windowTokens: number | null | undefined -): number | null { - const used = Number(usedTokens); - const window = Number(windowTokens) || OPERATOR_CONTEXT_WINDOW_TOKENS; - if (!Number.isFinite(used) || used < 0 || usedTokens === null || usedTokens === undefined) { - return null; - } - const pct = (used / window) * 100; - return Math.min(100, Math.round(pct * 10) / 10); -} - -/** - * Seconds a session has been (or was) running. - * - * Live sessions count from `start_time` to `nowMs`; finished sessions use the - * recorded `end_time`. A session with no `start_time` reads as 0. - */ -export function sessionElapsedSeconds( - session: { start_time?: number; end_time?: number | null } | null | undefined, - isRunning: boolean, - nowMs: number -): number { - if (!session?.start_time) return 0; - const startMs = session.start_time * 1000; - const endMs = !isRunning && session.end_time ? session.end_time * 1000 : nowMs; - return Math.max(0, Math.round((endMs - startMs) / 1000)); -} - -/** Plain-language label for a tuning id, falling back to the ladder default. */ -export function tuningLabel(kind: 'verify' | 'explore', id: string | null | undefined): string { - if (kind === 'verify') { - return VERIFICATION_LEVELS[levelIndex(VERIFICATION_LEVELS, id, DEFAULT_VERIFICATION_LEVEL)].label; - } - return EXPLORER_MODES[levelIndex(EXPLORER_MODES, id, DEFAULT_EXPLORER_MODE)].label; -} - -/** - * Reads `run_tuning` out of a session's `device_info` (which the sessions API - * returns either as a JSON string or an already-parsed object). - */ -export function parseRunTuning(deviceInfo: unknown): SessionRunTuning | null { - let info: unknown = deviceInfo; - if (typeof info === 'string') { - try { - info = JSON.parse(info); - } catch { - return null; - } - } - if (!info || typeof info !== 'object') return null; - const tuning = (info as { run_tuning?: unknown }).run_tuning; - if (!tuning || typeof tuning !== 'object') return null; - const t = tuning as SessionRunTuning; - if (!t.verification_level && !t.explorer_mode) return null; - return { - verification_level: t.verification_level ?? null, - explorer_mode: t.explorer_mode ?? null - }; -} diff --git a/apps/showcase_ui/src/app/utils/stream-aggregator.util.spec.ts b/apps/showcase_ui/src/app/utils/stream-aggregator.util.spec.ts deleted file mode 100644 index d6f12129c..000000000 --- a/apps/showcase_ui/src/app/utils/stream-aggregator.util.spec.ts +++ /dev/null @@ -1,640 +0,0 @@ -import { consolidateLogsToBlocks, getSortedStepEvents, isBackendSwitchNote, persistedStreamToSegments } from './stream-aggregator.util'; - -describe('stream aggregator timeline ordering', () => { - it('interleaves text, tools, and actions by timestamp', () => { - const events = getSortedStepEvents({ - timestamp: 10, - operator_native_thinking: 'Thought content', - operator_native_thinking_timestamp: 11, - operator_raw_thinking: 'Work content', - operator_raw_thinking_timestamp: 13, - action_taken: { action: 'tap', timestamp: 15 }, - generic_tools: [ - { trace_id: 'tool-late', name: 'save_note', timestamp: 14 }, - { trace_id: 'tool-early', name: 'video_analyzer', timestamp: 12 } - ] - }); - - expect(events.map((event) => event.type)).toEqual([ - 'thinking', - 'tool', - 'text', - 'tool', - 'action' - ]); - expect(events.map((event) => event.timestamp)).toEqual([ - 11000, - 12000, - 13000, - 14000, - 15000 - ]); - }); - - it('uses a deterministic text-first fallback when only a step timestamp exists', () => { - const events = getSortedStepEvents({ - timestamp: 20, - operator_native_thinking: 'Thought content', - operator_raw_thinking: 'Work content', - action_taken: { action: 'tap' }, - generic_tools: [{ trace_id: 'tool-1', name: 'save_note' }] - }); - - expect(events.map((event) => event.type)).toEqual([ - 'thinking', - 'text', - 'action', - 'tool' - ]); - }); - - it('keeps the first live timestamp when later chunks update the same text stream', () => { - const blocks = consolidateLogsToBlocks([ - { - type: 'llm_stream', - timestamp: '2026-08-25T20:00:01.000Z', - data: { - execution_id: 'exec-1', - step_id: 'step-1', - stream_type: 'text', - text: 'First', - isCompleted: false - } - }, - { - type: 'trace_recorded', - timestamp: '2026-08-25T20:00:02.000Z', - data: { - trace_id: 'tool-1', - step_id: 'step-1', - type: 'tool', - name: 'save_note', - timestamp: '2026-08-25T20:00:02.000Z' - } - }, - { - type: 'llm_stream', - timestamp: '2026-08-25T20:00:03.000Z', - data: { - execution_id: 'exec-1', - step_id: 'step-1', - stream_type: 'text', - text: 'First and second', - isCompleted: false - } - } - ]); - - expect(blocks[0].data.operator_raw_thinking_timestamp) - .toBe('2026-08-25T20:00:01.000Z'); - expect(getSortedStepEvents(blocks[0].data).map((event) => event.type)) - .toEqual(['text', 'tool']); - }); -}); - -describe('stream aggregator checker lane', () => { - const started = { - type: 'checker_event', - timestamp: '2026-08-25T20:00:05.000Z', - data: { - event: 'attempt_started', - phase: 'checkpoint', - attempt_id: 'abc#1', - checkpoint_id: 'abc', - subgoal_text: 'Create the alarm', - trace_id: 'trace-checker-1', - items: [{ kind: 'verify', text: 'alarm exists' }], - ts: 1756152005 - } - }; - - it('creates a running checker block from attempt_started and finishes it by attempt id', () => { - const blocks = consolidateLogsToBlocks([ - started, - { - type: 'checker_event', - timestamp: '2026-08-25T20:00:09.000Z', - data: { - event: 'attempt_finished', - attempt_id: 'abc#1', - status: 'done', - verdicts: [{ item_text: 'alarm exists', kind: 'verify', status: 'passed', evidence: 'seen' }], - findings: [], - ts: 1756152009 - } - } - ]); - - expect(blocks.length).toBe(1); - expect(blocks[0].type).toBe('checker'); - expect(blocks[0].id).toBe('checker-abc#1'); - expect(blocks[0].data.isCompleted).toBeTrue(); - expect(blocks[0].data.status).toBe('done'); - expect(blocks[0].data.trace_id).toBe('trace-checker-1'); - expect(blocks[0].data.verdicts.length).toBe(1); - expect(blocks[0].data.duration).toBe(4); - }); - - it("routes the checker's stream and tool traces by parent trace id, not by the current step", () => { - const blocks = consolidateLogsToBlocks([ - { - type: 'step_recorded', - timestamp: '2026-08-25T20:00:01.000Z', - data: { step_id: 'step-1', step_number: 1, timestamp: 1756152001 } - }, - started, - { - type: 'llm_stream', - timestamp: '2026-08-25T20:00:06.000Z', - data: { - execution_id: 'exec-checker', - step_id: 'step-1', - parent_trace_id: 'trace-checker-1', - stream_type: 'thinking', - text: 'Inspecting the alarm list', - isCompleted: false - } - }, - { - type: 'trace_recorded', - timestamp: '2026-08-25T20:00:07.000Z', - data: { - trace_id: 'tool-probe', - parent_trace_id: 'trace-checker-1', - agent_name: 'checker', - type: 'tool', - name: 'probe_device', - step_id: 'step-1', - status: 'success' - } - } - ]); - - const step = blocks.find((b) => b.id === 'step-step-1')!; - const checker = blocks.find((b) => b.id === 'checker-abc#1')!; - expect(step.data.operator_native_thinking).toBeUndefined(); - expect(step.data.generic_tools || []).toEqual([]); - expect(checker.data.operator_native_thinking).toBe('Inspecting the alarm list'); - expect(checker.data.generic_tools.map((t: any) => t.trace_id)).toEqual(['tool-probe']); - expect(checker.data.step_id).toBeUndefined(); - expect(blocks.map((b) => b.id)).toEqual(['step-step-1', 'checker-abc#1']); - }); - - it('keeps one stream segment per checker turn so text and tool calls interleave by time', () => { - const turn = (execId: string, timestamp: string, text: string) => ({ - type: 'llm_stream', - timestamp, - data: { - execution_id: execId, - parent_trace_id: 'trace-checker-1', - stream_type: 'text', - text, - isCompleted: false - } - }); - const blocks = consolidateLogsToBlocks([ - started, - turn('exec-1', '2026-08-25T20:00:06.000Z', 'Let me look at step 2.'), - { - type: 'trace_recorded', - timestamp: '2026-08-25T20:00:07.000Z', - data: { - trace_id: 'tool-detail', - parent_trace_id: 'trace-checker-1', - agent_name: 'checker', - type: 'tool', - name: 'replay_steps', - status: 'success', - timestamp: '2026-08-25T20:00:07.000Z' - } - }, - turn('exec-2', '2026-08-25T20:00:08.000Z', 'The alarm is there.'), - turn('exec-1', '2026-08-25T20:00:08.500Z', 'Let me look at step 2. Done.') - ]); - - const checker = blocks.find((b) => b.id === 'checker-abc#1')!; - expect(checker.data.stream_segments.map((s: any) => s.execution_id)).toEqual(['exec-1', 'exec-2']); - // A later chunk of an earlier turn keeps that turn's first-seen time. - expect(checker.data.stream_segments[0].text).toBe('Let me look at step 2. Done.'); - expect(checker.data.stream_segments[0].timestamp).toBe('2026-08-25T20:00:06.000Z'); - const events = getSortedStepEvents(checker.data); - expect(events.map((e) => e.type)).toEqual(['text', 'tool', 'text']); - expect(events[0].data.text).toBe('Let me look at step 2. Done.'); - expect(events[2].data.text).toBe('The alarm is there.'); - }); - - it('rebuilds the persisted transcript into the same interleaved segments as the live stream', () => { - const transcript = { - attempt_id: 'abc#1', - checkpoint_id: 'abc', - phase: 'checkpoint', - trace_id: 'trace-checker-1', - ts: 1787688009, - truncated: false, - segments: [ - // 1787688006 = 2026-08-25T20:00:06Z - { execution_id: 'exec-1', role: 'thought', when: 1787688006, text: 'Inspecting the alarm list' }, - { execution_id: 'exec-1', role: 'answer', when: 1787688006.5, text: 'Let me look at step 2.' }, - { execution_id: 'exec-2', role: 'answer', when: 1787688008, text: 'The alarm is there.' }, - { execution_id: 'exec-3', role: 'answer', when: 1787688008.5, text: '' } - ] - }; - const segments = persistedStreamToSegments(transcript); - expect(segments).toEqual([ - { execution_id: 'exec-1', stream_type: 'thinking', text: 'Inspecting the alarm list', timestamp: '2026-08-25T20:00:06.000Z', isCompleted: true }, - { execution_id: 'exec-1', stream_type: 'text', text: 'Let me look at step 2.', timestamp: '2026-08-25T20:00:06.500Z', isCompleted: true }, - { execution_id: 'exec-2', stream_type: 'text', text: 'The alarm is there.', timestamp: '2026-08-25T20:00:08.000Z', isCompleted: true } - ]); - expect(persistedStreamToSegments(null)).toEqual([]); - - // Historical session: only the ledger backfill (attempt_finished carrying - // the rebuilt segments) and the relocated tool trace exist. - const tool = { - type: 'trace_recorded', - timestamp: '2026-08-25T20:00:07.000Z', - data: { - trace_id: 'tool-detail', - parent_trace_id: 'trace-checker-1', - agent_name: 'checker', - type: 'tool', - name: 'replay_steps', - status: 'success', - timestamp: '2026-08-25T20:00:07.000Z' - } - }; - const historical = consolidateLogsToBlocks([ - { - type: 'checker_event', - timestamp: '2026-08-25T20:00:09.000Z', - checks_snapshot: true, - data: { - event: 'attempt_finished', - phase: 'checkpoint', - attempt_id: 'abc#1', - checkpoint_id: 'abc', - trace_id: 'trace-checker-1', - status: 'done', - verdicts: [{ item_text: 'alarm exists', kind: 'verify', status: 'passed', evidence: 'seen' }], - findings: [], - ts: 1756152009, - stream_segments: segments - } - }, - tool - ]); - const block = historical.find((b) => b.id === 'checker-abc#1')!; - expect(block.data.stream_segments).toEqual(segments); - expect(block.data.operator_native_thinking).toBe('Inspecting the alarm list'); - expect(block.data.operator_raw_thinking).toBe('Let me look at step 2.\n\nThe alarm is there.'); - expect(block.data.operator_native_thinking_timestamp).toBe('2026-08-25T20:00:06.000Z'); - expect(block.data.operator_raw_thinking_timestamp).toBe('2026-08-25T20:00:06.500Z'); - const events = getSortedStepEvents(block.data); - expect(events.map((e) => e.type)).toEqual(['thinking', 'text', 'tool', 'text']); - expect(events.map((e) => e.data.text ?? e.data.name)).toEqual([ - 'Inspecting the alarm list', - 'Let me look at step 2.', - 'replay_steps', - 'The alarm is there.' - ]); - - // Live session: the same turns arrived as llm_stream chunks; a later - // ledger backfill must neither duplicate nor reorder them. - const chunk = (execId: string, streamType: string, timestamp: string, text: string) => ({ - type: 'llm_stream', - timestamp, - data: { execution_id: execId, parent_trace_id: 'trace-checker-1', stream_type: streamType, text, isCompleted: true } - }); - const live = consolidateLogsToBlocks([ - started, - chunk('exec-1', 'thinking', '2026-08-25T20:00:06.000Z', 'Inspecting the alarm list'), - chunk('exec-1', 'text', '2026-08-25T20:00:06.500Z', 'Let me look at step 2.'), - tool, - chunk('exec-2', 'text', '2026-08-25T20:00:08.000Z', 'The alarm is there.'), - { - type: 'checker_event', - timestamp: '2026-08-25T20:00:09.000Z', - data: { event: 'attempt_finished', attempt_id: 'abc#1', status: 'done', verdicts: [], findings: [], ts: 1756152009 } - }, - { - type: 'checker_event', - timestamp: '2026-08-25T20:00:09.000Z', - checks_snapshot: true, - data: { event: 'attempt_finished', attempt_id: 'abc#1', status: 'done', ts: 1756152009, stream_segments: segments } - } - ]); - const liveBlock = live.find((b) => b.id === 'checker-abc#1')!; - expect(liveBlock.data.stream_segments.map((s: any) => [s.execution_id, s.stream_type, s.text])).toEqual( - segments.map((s) => [s.execution_id, s.stream_type, s.text]) - ); - expect(getSortedStepEvents(liveBlock.data).map((e) => e.data.text ?? e.data.name)).toEqual( - events.map((e) => e.data.text ?? e.data.name) - ); - }); - - it('builds the run outcome block once and keeps it idempotent', () => { - const outcome = { - type: 'checker_event', - timestamp: '2026-08-25T20:00:20.000Z', - data: { event: 'run_outcome', task_status: 'completed', tests: { passed: 1, failed: 0, inconclusive: 0, unchecked: 0 } } - }; - const blocks = consolidateLogsToBlocks([outcome, outcome]); - expect(blocks.length).toBe(1); - expect(blocks[0].id).toBe('checker-outcome'); - expect(blocks[0].data.phase).toBe('outcome'); - expect(blocks[0].data.task_status).toBe('completed'); - }); -}); - -describe('stream aggregator step ownership', () => { - const plannerStream = { - type: 'llm_stream', - timestamp: '2026-09-02T03:57:25.000Z', - data: { execution_id: 'exec-planner', stream_type: 'text', text: 'I have formulated the task plan.', isCompleted: true } - }; - const tap = { trace_id: 'trace-tap-3', type: 'action', name: 'tap', timestamp: 1788374289.37, payload: { args: { action: 'tap' } } }; - - it('does not fold a step without streamed text into an earlier untagged stream block', () => { - const blocks = consolidateLogsToBlocks([ - plannerStream, - { - type: 'step_recorded', - timestamp: '2026-09-02T03:57:47.000Z', - data: { step_id: 's1', step_number: 1, operator_raw_thinking: 'Launching Maps', generic_tools: [] } - }, - { - type: 'step_recorded', - timestamp: '2026-09-02T03:58:09.000Z', - data: { step_id: 's3', step_number: 3, generic_tools: [tap] } - } - ]); - - expect(blocks.map((b) => b.id)).toEqual(['stream-exec-planner', 'step-s1', 'step-s3']); - expect(blocks[0].type).toBe('llm_stream'); - expect(blocks[0].data.generic_tools).toEqual([]); - expect(blocks[0].data.operator_raw_thinking).toBe('I have formulated the task plan.'); - expect(blocks[2].data.generic_tools.map((t: any) => t.trace_id)).toEqual(['trace-tap-3']); - }); - - it('creates the step block for a tagged trace that arrives before its step is recorded', () => { - const blocks = consolidateLogsToBlocks([ - { - type: 'step_recorded', - timestamp: '2026-09-02T03:58:03.000Z', - data: { step_id: 's2', step_number: 2, operator_raw_thinking: 'Dismissing the dialog', generic_tools: [] } - }, - { - type: 'trace_recorded', - timestamp: '2026-09-02T03:58:09.300Z', - data: { ...tap, step_id: 's3', status: 'running' } - }, - { - type: 'step_recorded', - timestamp: '2026-09-02T03:58:09.400Z', - data: { step_id: 's3', step_number: 3, generic_tools: [{ ...tap, status: 'success' }] } - } - ]); - - expect(blocks.map((b) => b.id)).toEqual(['step-s2', 'step-s3']); - expect(blocks[0].data.generic_tools).toEqual([]); - expect(blocks[1].data.generic_tools.length).toBe(1); - expect(blocks[1].data.generic_tools[0].status).toBe('success'); - }); - - it('still attaches untagged traces to the latest preceding block', () => { - const blocks = consolidateLogsToBlocks([ - plannerStream, - { - type: 'trace_recorded', - timestamp: '2026-09-02T03:57:26.000Z', - data: { trace_id: 'trace-note', type: 'tool', name: 'save_note', payload: { args: {} } } - } - ]); - - expect(blocks.length).toBe(1); - expect(blocks[0].data.generic_tools.map((t: any) => t.trace_id)).toEqual(['trace-note']); - }); - - it('keeps a mid-run hierarchy source switch in the step where it happened, as a note', () => { - const initial = { - type: 'trace_recorded', - timestamp: '2026-09-02T03:57:20.000Z', - data: { - trace_id: 'trace-backend-initial', - type: 'log', - name: 'hierarchy_backend', - payload: { backend: 'helper', previous_backend: null, message: 'UI hierarchy source: Artemis accessibility helper v1.1.3' } - } - }; - const switched = { - type: 'trace_recorded', - timestamp: '2026-09-02T03:57:26.000Z', - data: { - trace_id: 'trace-backend-switch', - type: 'log', - name: 'hierarchy_backend', - timestamp: '2026-09-02T03:57:26.000Z', - payload: { - backend: 'uiautomator', - previous_backend: 'helper', - reason: 'HelperUnavailable: tunnel gone', - message: 'UI hierarchy source switched from Artemis accessibility helper to UIAutomator2 because HelperUnavailable: tunnel gone' - } - } - }; - const ordinaryLog = { - type: 'trace_recorded', - timestamp: '2026-09-02T03:57:27.000Z', - data: { trace_id: 'trace-log', type: 'log', name: 'artemis.runtime', payload: { message: 'noise' } } - }; - - const blocks = consolidateLogsToBlocks([initial, plannerStream, switched, ordinaryLog]); - - // The initial line belongs to the startup block; ordinary logs stay out. - expect(blocks.length).toBe(1); - expect(blocks[0].data.generic_tools.map((t: any) => t.trace_id)).toEqual(['trace-backend-switch']); - expect(isBackendSwitchNote(blocks[0].data.generic_tools[0])).toBeTrue(); - expect(isBackendSwitchNote(initial.data)).toBeFalse(); - - const events = getSortedStepEvents(blocks[0].data); - expect(events.map((e) => e.type)).toEqual(['text', 'tool']); - }); -}); - -describe('stream aggregator checker history relocation', () => { - it('moves persisted checker tool traces from the operator step to the attempt block', () => { - const blocks = consolidateLogsToBlocks([ - { - type: 'step_updated', - timestamp: '2026-08-25T20:00:01.000Z', - history_snapshot: true, - data: { - step_id: 'step-1', - step_number: 1, - timestamp: 1756152001, - generic_tools: [ - { trace_id: 'trace-checker-1', type: 'agent', name: 'checker', status: 'success' }, - { trace_id: 'tool-probe', parent_trace_id: 'trace-checker-1', type: 'tool', name: 'probe_device', status: 'success' }, - { trace_id: 'tool-note', parent_trace_id: 'operator-trace', type: 'tool', name: 'save_note', status: 'success' } - ] - } - }, - { - type: 'checker_event', - timestamp: '2026-08-25T20:00:09.000Z', - checks_snapshot: true, - data: { - event: 'attempt_finished', - phase: 'checkpoint', - attempt_id: 'abc#1', - checkpoint_id: 'abc', - subgoal_text: 'Create the alarm', - trace_id: 'trace-checker-1', - status: 'done', - verdicts: [{ item_text: 'alarm exists', kind: 'verify', status: 'passed', evidence: 'seen' }], - ts: 1756152009 - } - } - ]); - - const step = blocks.find((b) => b.id === 'step-step-1')!; - const checker = blocks.find((b) => b.id === 'checker-abc#1')!; - expect(step.data.generic_tools.map((t: any) => t.trace_id)).toEqual(['tool-note']); - expect(checker.data.generic_tools.map((t: any) => t.trace_id)).toEqual(['tool-probe']); - }); -}); - -describe('stream aggregator single source of truth (Option A)', () => { - it('does not duplicate action_taken when generic_tools already contains the action trace', () => { - const events = getSortedStepEvents({ - timestamp: 100, - action_taken: [{ action: 'launch_app', app_name: 'Maps', timestamp: 102 }], - generic_tools: [ - { trace_id: 'tool-note', type: 'tool', name: 'update_note', timestamp: 101 }, - { trace_id: 'act-launch', type: 'action', name: 'launch_app', timestamp: 102, payload: { args: { action: { action: 'launch_app', app_name: 'Maps' } } } } - ] - }); - - expect(events.length).toBe(2); - expect(events.map(e => e.type)).toEqual(['tool', 'action']); - expect(events.map(e => e.data.name)).toEqual(['update_note', 'launch_app']); - }); - - it('retains multiple adb commands and tools alongside physical actions without dropping any', () => { - const events = getSortedStepEvents({ - timestamp: 100, - action_taken: [{ action: 'wait_for_delay', time_in_ms: 1000, timestamp: 105 }], - generic_tools: [ - { trace_id: 'adb-1', type: 'tool', name: 'run_adb_command', timestamp: 101, payload: { CommandLine: 'dumpsys telephony.registry' } }, - { trace_id: 'adb-2', type: 'tool', name: 'run_adb_command', timestamp: 102, payload: { CommandLine: 'am start -a ...' } }, - { trace_id: 'exp-1', type: 'tool', name: 'ask_explorer', timestamp: 103 }, - { trace_id: 'act-wait', type: 'action', name: 'wait_for_delay', timestamp: 104, payload: { args: { time_in_ms: 1000 } } } - ] - }); - - expect(events.length).toBe(4); - expect(events.map(e => e.type)).toEqual(['tool', 'tool', 'tool', 'action']); - expect(events.map(e => e.data.name)).toEqual(['run_adb_command', 'run_adb_command', 'ask_explorer', 'wait_for_delay']); - }); - - it('supports multi-action bursts sequentially from generic_tools', () => { - const events = getSortedStepEvents({ - timestamp: 100, - action_taken: [{ action: 'tap', coordinates: [200, 300] }], - generic_tools: [ - { trace_id: 'tap-1', type: 'action', name: 'tap', timestamp: 101, payload: { args: { action: { action: 'tap', coordinates: [100, 200] } } } }, - { trace_id: 'tap-2', type: 'action', name: 'tap', timestamp: 102, payload: { args: { action: { action: 'tap', coordinates: [200, 300] } } } } - ] - }); - - expect(events.length).toBe(2); - expect(events.every(e => e.type === 'action')).toBeTrue(); - expect(events[0].data.trace_id).toBe('tap-1'); - expect(events[1].data.trace_id).toBe('tap-2'); - }); - - it('correctly processes real trace 38fd934e Step 16 keeping ADB commands and wait_for_delay visible without duplicates', () => { - const step16 = { - step_id: '31e8c7ec-6b67-485e-94f4-355a853ccf8a', - step_number: 16, - action_taken: [{ action: 'wait_for_delay', time_in_ms: 1000 }], - operator_native_thinking: 'Let me check telephony and send SMS...', - operator_native_thinking_timestamp: 1788457630, - timestamp: 1788457630, - generic_tools: [ - { trace_id: 't-adb-1', parent_trace_id: 't-op', name: 'run_adb_command', type: 'tool', timestamp: 1788457631, status: 'success', payload: { args: { CommandLine: 'dumpsys telephony.registry' } } }, - { trace_id: 't-adb-1-sub', parent_trace_id: 't-adb-1', name: 'run_adb_command', type: 'tool', timestamp: 1788457631.5, status: 'success' }, - { trace_id: 't-adb-2', parent_trace_id: 't-op', name: 'run_adb_command', type: 'tool', timestamp: 1788457632, status: 'success', payload: { args: { CommandLine: 'am start -a android.intent.action.SENDTO ...' } } }, - { trace_id: 't-adb-2-sub', parent_trace_id: 't-adb-2', name: 'run_adb_command', type: 'tool', timestamp: 1788457632.5, status: 'success' }, - { trace_id: 't-exp', parent_trace_id: 't-op', name: 'ask_explorer', type: 'tool', timestamp: 1788457633, status: 'success', payload: { args: { query: 'any text on screen' } } }, - { trace_id: 't-exp-sub', parent_trace_id: 't-exp', name: 'ask_explorer', type: 'tool', timestamp: 1788457633.5, status: 'success' }, - { trace_id: 't-wait', parent_trace_id: 't-val', name: 'wait_for_delay', type: 'action', timestamp: 1788457635, status: 'success', payload: { args: { action: { action: 'wait_for_delay', time_in_ms: 1000 } } } }, - { trace_id: 't-safety', parent_trace_id: 't-wait', name: 'safety_net_pixel_validation', type: 'tool', timestamp: 1788457636, status: 'success' } - ] - }; - - const events = getSortedStepEvents(step16); - // 1 native thinking + 2 adb tools + 1 explorer tool + 1 wait_for_delay action - expect(events.length).toBe(5); - expect(events.map(e => e.type)).toEqual(['thinking', 'tool', 'tool', 'tool', 'action']); - expect(events.map(e => e.data.name || e.data.action || 'thinking')).toEqual([ - 'thinking', - 'run_adb_command', - 'run_adb_command', - 'ask_explorer', - 'wait_for_delay' - ]); - }); - - it('correctly handles mid-stream reset and supersedes the reset block on retry', () => { - // 1. Initial stream fails mid-stream and is marked reset - const resetLogs = [ - { - type: 'llm_stream', - timestamp: '2026-09-03T10:00:01.000Z', - data: { - execution_id: 'exec-failed-1', - stream_type: 'text', - text: 'Here is the aborted plan...', - isCompleted: false, - isReset: true, - resetMessage: 'A request error occurred during output generation, typically caused by lower API priority. Retrying automatically...' - } - } - ]; - - const initialBlocks = consolidateLogsToBlocks(resetLogs); - expect(initialBlocks.length).toBe(1); - expect(initialBlocks[0].data.isReset).toBeTrue(); - expect(initialBlocks[0].data.resetMessage).toBe('A request error occurred during output generation, typically caused by lower API priority. Retrying automatically...'); - - // getSortedStepEvents retains the reset state and message - const resetEvents = getSortedStepEvents(initialBlocks[0].data); - expect(resetEvents.length).toBe(1); - expect(resetEvents[0].type).toBe('text'); - expect(resetEvents[0].data.isReset).toBeTrue(); - - // 2. Retry stream arrives without step_id (e.g. pre-planning retry) - const retryLogs = [ - ...resetLogs, - { - type: 'llm_stream', - timestamp: '2026-09-03T10:00:03.000Z', - data: { - execution_id: 'exec-retry-2', - stream_type: 'text', - text: 'Here is the fresh completed plan.', - isCompleted: true - } - } - ]; - - const updatedBlocks = consolidateLogsToBlocks(retryLogs); - // Should supersede the reset block rather than creating an orphaned duplicate card - expect(updatedBlocks.length).toBe(1); - expect(updatedBlocks[0].data.execution_id).toBe('exec-retry-2'); - expect(updatedBlocks[0].data.isReset).toBeFalse(); - expect(updatedBlocks[0].data.operator_raw_thinking).toBe('Here is the fresh completed plan.'); - expect(updatedBlocks[0].data.isCompleted).toBeTrue(); - }); -}); - diff --git a/apps/showcase_ui/src/app/utils/stream-aggregator.util.ts b/apps/showcase_ui/src/app/utils/stream-aggregator.util.ts deleted file mode 100644 index 8b57e4787..000000000 --- a/apps/showcase_ui/src/app/utils/stream-aggregator.util.ts +++ /dev/null @@ -1,1021 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { StepBlock, PhaseBlock, StepEvent, StreamSegment, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE, PersistedCheckerStream } from '../core/models/stream.model'; -import { isAndroidAction, isReportStatusAction, getReportStatusExplanation, getReportStatusValue, getActionObject } from './action-formatter.util'; -import { getUniqueGenericTools, isInternalPlumbingTool } from './tool-formatter.util'; - -/** - * Helper to safely extract milliseconds timestamp - */ -export function getItemTimestamp(ts: any, fallback: number = 0): number { - if (ts !== undefined && ts !== null) { - if (typeof ts === 'number') { - return ts < 1e11 ? ts * 1000 : ts; - } - const parsed = new Date(ts).getTime(); - if (!isNaN(parsed) && parsed > 0) return parsed; - } - return fallback; -} - -/** - * Index of the `checker` block that owns a trace id (the Checker's own agent - * trace). The Checker runs concurrently with Operator steps, so its streamed - * reasoning and tool traces are routed by `parent_trace_id`, never by the - * step that happens to be current. - */ -function findCheckerBlockByTrace(blocks: StepBlock[], parentTraceId: any): number { - if (!parentTraceId) return -1; - const key = String(parentTraceId); - return blocks.findIndex(b => b.type === 'checker' && b.data?.trace_id && String(b.data.trace_id) === key); -} - -/** Latest still-running checker block (fallback when only the agent name is known). */ -function findRunningCheckerBlock(blocks: StepBlock[]): number { - for (let i = blocks.length - 1; i >= 0; i--) { - if (blocks[i].type === 'checker' && blocks[i].data?.isCompleted === false) return i; - } - return -1; -} - -/** - * Rebuild the live `StreamSegment` list of a Checker attempt from its - * persisted transcript, so a reopened session renders the same interleaved - * Thought/Work segments as the live stream did. - */ -export function persistedStreamToSegments(record: PersistedCheckerStream | null | undefined): StreamSegment[] { - if (!record || !Array.isArray(record.segments)) return []; - const segments: StreamSegment[] = []; - for (const seg of record.segments) { - if (!seg || typeof seg.text !== 'string' || !seg.text) continue; - const when = typeof seg.when === 'number' && seg.when > 0 ? seg.when : (record.ts || 0); - segments.push({ - execution_id: String(seg.execution_id || ''), - stream_type: seg.role === 'thought' ? 'thinking' : 'text', - text: seg.text, - timestamp: new Date(when * 1000).toISOString(), - isCompleted: true - }); - } - return segments; -} - -function segmentKey(segment: StreamSegment): string { - return `${segment.execution_id}|${segment.stream_type}`; -} - -/** - * Merge persisted segments into the ones a block already holds. Live chunks - * (complete by the time the transcript exists) win over the backfill copy of - * the same turn; turns only the transcript knows are added, then everything - * is kept in first-chunk order. - */ -function mergeStreamSegments(existing: any, incoming: any): StreamSegment[] | undefined { - const base: StreamSegment[] = Array.isArray(existing) ? [...existing] : []; - const extra: StreamSegment[] = Array.isArray(incoming) ? incoming : []; - if (extra.length === 0) return base.length > 0 ? base : undefined; - const seen = new Set(base.map(segmentKey)); - let added = false; - for (const segment of extra) { - const key = segmentKey(segment); - if (seen.has(key)) continue; - seen.add(key); - base.push(segment); - added = true; - } - if (added) { - base.sort((a, b) => getItemTimestamp(a.timestamp, 0) - getItemTimestamp(b.timestamp, 0)); - } - return base; -} - -/** - * Keep the flat `operator_*_thinking` fields (joined text + first-seen time) - * in step with the segment list for consumers that only know steps. - */ -function applyFlatStreamFields(data: any, segments: StreamSegment[]): void { - const of = (type: string) => segments.filter((s) => s.stream_type === type && s.text.trim()); - const joined = (type: string) => of(type).map((s) => s.text).join('\n\n'); - data.operator_native_thinking = joined('thinking'); - data.operator_raw_thinking = joined('text'); - const firstThinking = of('thinking')[0]; - const firstText = of('text')[0]; - if (firstThinking && !data.operator_native_thinking_timestamp) { - data.operator_native_thinking_timestamp = firstThinking.timestamp; - } - if (firstText && !data.operator_raw_thinking_timestamp) { - data.operator_raw_thinking_timestamp = firstText.timestamp; - } -} - -/** - * Apply one `checker_event` (attempt_started / attempt_finished / run_outcome) - * to the block list. Attempts are keyed by attempt id so live events and the - * ledger backfill merge instead of duplicating. - */ -function applyCheckerLog(blocks: StepBlock[], log: any): void { - const d = log.data || {}; - if (!d.event) return; - - if (d.event === 'run_outcome') { - const id = 'checker-outcome'; - const idx = blocks.findIndex(b => b.id === id); - const data = { - ...(idx > -1 ? blocks[idx].data : {}), - ...d, - phase: 'outcome', - status: 'done', - isCompleted: true, - generic_tools: [] - }; - const block: StepBlock = { id, type: 'checker', timestamp: log.timestamp, data }; - if (idx > -1) blocks[idx] = block; else blocks.push(block); - return; - } - - if (!d.attempt_id) return; - const id = `checker-${d.attempt_id}`; - const idx = blocks.findIndex(b => b.id === id); - const existing = idx > -1 ? blocks[idx] : null; - const ts = typeof d.ts === 'number' ? d.ts : (log.timestamp ? new Date(log.timestamp).getTime() / 1000 : undefined); - - if (d.event === 'attempt_started') { - const data = { - ...(existing?.data || {}), - ...d, - // A late "started" must not resurrect a finished attempt. - status: existing?.data?.isCompleted ? existing.data.status : 'running', - isCompleted: existing?.data?.isCompleted ?? false, - started_at: existing?.data?.started_at ?? ts, - verdicts: existing?.data?.verdicts || [], - findings: existing?.data?.findings || [], - generic_tools: existing?.data?.generic_tools || [] - }; - const block: StepBlock = { id, type: 'checker', timestamp: existing?.timestamp || log.timestamp, data }; - if (existing) blocks[idx] = block; else blocks.push(block); - return; - } - - if (d.event === 'attempt_finished') { - const startedAt = existing?.data?.started_at ?? ts; - const verdicts = Array.isArray(d.verdicts) ? d.verdicts : (existing?.data?.verdicts || []); - const items = existing?.data?.items?.length - ? existing.data.items - : verdicts.map((v: any) => ({ kind: v.kind, text: v.item_text, when: v.when })); - // The ledger backfill carries the attempt's persisted transcript as - // ready-made segments; a live block keeps the ones it streamed. - const streamSegments = mergeStreamSegments(existing?.data?.stream_segments, d.stream_segments); - const data: any = { - ...(existing?.data || {}), - ...d, - status: d.status || 'done', - verdicts, - items, - findings: Array.isArray(d.findings) ? d.findings : (existing?.data?.findings || []), - generic_tools: existing?.data?.generic_tools || [], - stream_segments: streamSegments, - started_at: startedAt, - finished_at: ts, - duration: startedAt !== undefined && ts !== undefined ? Math.max(0, ts - startedAt) : undefined, - isCompleted: true - }; - if (streamSegments) applyFlatStreamFields(data, streamSegments); - const block: StepBlock = { id, type: 'checker', timestamp: existing?.timestamp || log.timestamp, data }; - if (existing) blocks[idx] = block; else blocks.push(block); - } -} - -/** - * Persisted steps list the Checker's tool traces (and its agent trace) under - * the Operator step that was current while it ran, because traces are stored - * by step id. Once the checker blocks exist (ledger backfill carries the - * attempt's trace id), move those traces to the attempt they belong to and - * drop the agent trace itself. - */ -function relocateCheckerTools(blocks: StepBlock[]): void { - const checkerByTrace = new Map(); - blocks.forEach((b, i) => { - if (b.type === 'checker' && b.data?.trace_id) { - checkerByTrace.set(String(b.data.trace_id), i); - } - }); - if (checkerByTrace.size === 0) return; - - blocks.forEach((block, i) => { - if (block.type === 'checker') return; - const tools: any[] = block.data?.generic_tools || []; - if (tools.length === 0) return; - const keep: any[] = []; - let moved = false; - for (const tool of tools) { - const ownTrace = tool?.trace_id ? String(tool.trace_id) : ''; - const parent = tool?.parent_trace_id ? String(tool.parent_trace_id) : ''; - if (ownTrace && checkerByTrace.has(ownTrace) && tool?.type === 'agent') { - moved = true; // the Checker's own agent trace: not a tool of the step - continue; - } - const target = parent ? checkerByTrace.get(parent) : undefined; - if (target === undefined) { - keep.push(tool); - continue; - } - const checker = blocks[target]; - const existing: any[] = checker.data.generic_tools || []; - if (!existing.some((t) => t.trace_id && t.trace_id === tool.trace_id)) { - blocks[target] = { ...checker, data: { ...checker.data, generic_tools: [...existing, tool] } }; - } - moved = true; - } - if (moved) { - blocks[i] = { ...block, data: { ...block.data, generic_tools: keep } }; - } - }); -} - -/** - * A `hierarchy_backend` log trace that records a *switch* (helper -> UIAutomator2 - * or back). The initial "source: ..." line belongs to the startup block; only - * the mid-run change is worth a row in the timeline. - */ -export function isBackendSwitchNote(tool: any): boolean { - return tool?.type === 'log' - && tool?.name === 'hierarchy_backend' - && Boolean(tool?.payload?.previous_backend); -} - -/** Consolidate raw SSE logs into deduplicated and ordered StepBlocks. */ -export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] { - if (!rawLogs || rawLogs.length === 0) return []; - - const blocks: StepBlock[] = []; - - rawLogs.forEach(log => { - if (!log) return; - - if (log.type === 'checker_event') { - applyCheckerLog(blocks, log); - return; - } - - if (log.type === 'llm_stream') { - const execId = log.data?.execution_id || log.id; - const stepId = log.data?.step_id; - const streamType = log.data?.stream_type || 'text'; - const text = log.data?.text || ''; - const isCompleted = log.data?.isCompleted ?? false; - const isReset = log.data?.isReset ?? false; - const resetMessage = log.data?.resetMessage; - - // The Checker's own reasoning streams under its agent trace: route it to - // the attempt block, never to the Operator step that is current. - const checkerIndex = findCheckerBlockByTrace(blocks, log.data?.parent_trace_id); - if (checkerIndex > -1) { - const checkerBlock = blocks[checkerIndex]; - const checkerData: any = { ...checkerBlock.data }; - // The Checker is a multi-turn tool loop: every LLM turn streams under - // its own execution id. Keep one segment per turn (with its first-seen - // time) so the timeline can interleave the text with the tool calls it - // triggered, instead of collapsing all turns into one early block. - const segments: StreamSegment[] = Array.isArray(checkerData.stream_segments) - ? [...checkerData.stream_segments] - : []; - const segmentIndex = segments.findIndex( - (s) => s.execution_id === execId && s.stream_type === streamType - ); - const segment: StreamSegment = { - execution_id: execId, - stream_type: streamType === 'thinking' ? 'thinking' : 'text', - text, - timestamp: segmentIndex > -1 ? segments[segmentIndex].timestamp : log.timestamp, - isCompleted, - isReset, - resetMessage - }; - if (segmentIndex > -1) segments[segmentIndex] = segment; else segments.push(segment); - checkerData.stream_segments = segments; - // Flat fields stay as the joined text for consumers that only know steps. - if (streamType === 'thinking') { - checkerData.operator_native_thinking_timestamp = - checkerData.operator_native_thinking_timestamp || log.timestamp; - } else { - checkerData.operator_raw_thinking_timestamp = - checkerData.operator_raw_thinking_timestamp || log.timestamp; - } - applyFlatStreamFields(checkerData, segments); - if (execId && !checkerData.execution_id) { - checkerData.execution_id = execId; - } - blocks[checkerIndex] = { ...checkerBlock, data: checkerData }; - return; - } - - // Find existing block: match by stepId if available, or by execId, or active incomplete block - let existingIndex = -1; - if (stepId) { - existingIndex = blocks.findIndex(b => b.id === `step-${stepId}` || b.data?.step_id === stepId); - } - if (existingIndex === -1 && execId) { - existingIndex = blocks.findIndex(b => b.data?.execution_id === execId || b.id === `stream-${execId}`); - } - // If no stepId and not matched by execId, check if there is an unattached stream block that was reset. - // The retry stream supersedes that reset block rather than spawning an orphaned duplicate card. - if (existingIndex === -1 && !stepId) { - existingIndex = blocks.findIndex(b => b.type === 'llm_stream' && b.data?.isReset); - } - - if (existingIndex > -1) { - const existing = blocks[existingIndex]; - const updatedData = { ...existing.data }; - const existingResets: StreamResetNotice[] = - Array.isArray(updatedData.stream_resets) ? [...updatedData.stream_resets] : []; - - if (isReset) { - updatedData.isReset = true; - updatedData.resetMessage = resetMessage; - const msg = resetMessage || DEFAULT_STREAM_RESET_MESSAGE; - const resetId = execId || `reset-${log.timestamp || updatedData.step_id || 'stream'}`; - const existingResetIdx = existingResets.findIndex(r => r.id === resetId); - if (existingResetIdx > -1) { - existingResets[existingResetIdx] = { id: resetId, message: msg, isWaiting: true, streamType }; - } else { - existingResets.push({ id: resetId, message: msg, isWaiting: true, streamType }); - } - updatedData.stream_resets = existingResets; - } else if (execId && updatedData.execution_id !== execId) { - // New execution replacing a reset stream: mark prior resets as finished waiting - updatedData.stream_resets = existingResets.map(r => ({ ...r, isWaiting: false })); - updatedData.isReset = false; - updatedData.resetMessage = undefined; - updatedData.execution_id = execId; - } else if (!isReset && text && text.length > 0 && existingResets.some(r => r.isWaiting)) { - updatedData.stream_resets = existingResets.map(r => ({ ...r, isWaiting: false })); - updatedData.isReset = false; - updatedData.resetMessage = undefined; - } - - if (streamType === 'thinking') { - updatedData.operator_native_thinking = text; - updatedData.operator_native_thinking_timestamp = - updatedData.operator_native_thinking_timestamp || log.timestamp; - } else { - updatedData.operator_raw_thinking = text; - updatedData.operator_raw_thinking_timestamp = - updatedData.operator_raw_thinking_timestamp || log.timestamp; - } - if (stepId && !updatedData.step_id) { - updatedData.step_id = stepId; - } - if (execId && !updatedData.execution_id) { - updatedData.execution_id = execId; - } - updatedData.isCompleted = isCompleted; - if (isCompleted && updatedData.stream_resets) { - updatedData.stream_resets = updatedData.stream_resets.map((r: any) => ({ ...r, isWaiting: false })); - } - - blocks[existingIndex] = { - ...existing, - data: updatedData - }; - } else { - const blockId = stepId ? `step-${stepId}` : `stream-${execId}`; - const blockData: any = { - execution_id: execId, - step_id: stepId, - isCompleted: isCompleted, - isReset: isReset, - resetMessage: resetMessage, - stream_resets: isReset ? [{ - id: execId || `reset-${log.timestamp || stepId || 'stream'}`, - message: resetMessage || DEFAULT_STREAM_RESET_MESSAGE, - isWaiting: true, - streamType - }] : [], - generic_tools: [] - }; - if (streamType === 'thinking') { - blockData.operator_native_thinking = text; - blockData.operator_native_thinking_timestamp = log.timestamp; - } else { - blockData.operator_raw_thinking = text; - blockData.operator_raw_thinking_timestamp = log.timestamp; - } - - blocks.push({ - id: blockId, - type: stepId ? 'step' : 'llm_stream', - timestamp: log.timestamp, - data: blockData - }); - } - } else if (log.type === 'step_recorded' || log.type === 'step_updated') { - const stepId = log.data.step_id || log.data.step_number || 'unknown'; - // Find if there is an existing block for this step or an unattached stream block from this turn - let existingIndex = blocks.findIndex(b => b.id === `step-${stepId}` || b.data?.step_id === stepId); - - // No adoption of untagged stream blocks here: the Operator's streams - // always carry the step id (Perception / the Flash turn pre-allocate it - // before the LLM call), so a stream without one belongs to another agent - // (Planner, Checker...). A step whose Operator emitted only tool calls - // and no text used to steal the latest such block -- typically the - // Planner's -- and render its action inside it at the top of the timeline. - - if (existingIndex > -1) { - const existingBlock = blocks[existingIndex]; - const existingTools = existingBlock.data.generic_tools || []; - const newTools = log.data.generic_tools || []; - const mergedTools = [...existingTools]; - newTools.forEach((nt: any) => { - const matchIdx = mergedTools.findIndex(et => et.trace_id && nt.trace_id && et.trace_id === nt.trace_id); - if (matchIdx > -1) { - mergedTools[matchIdx] = { ...mergedTools[matchIdx], ...nt }; - } else { - mergedTools.push(nt); - } - }); - - const mergedData = { - ...existingBlock.data, - ...log.data, - operator_native_thinking: log.data.operator_native_thinking || existingBlock.data.operator_native_thinking || undefined, - operator_raw_thinking: log.data.operator_raw_thinking || existingBlock.data.operator_raw_thinking || undefined, - operator_native_thinking_timestamp: existingBlock.data.operator_native_thinking_timestamp - || log.data.operator_native_thinking_timestamp - || (log.data.operator_native_thinking ? log.timestamp : undefined), - operator_raw_thinking_timestamp: existingBlock.data.operator_raw_thinking_timestamp - || log.data.operator_raw_thinking_timestamp - || (log.data.operator_raw_thinking ? log.timestamp : undefined), - action_taken: log.data.action_taken || existingBlock.data.action_taken || undefined, - last_execution_result: log.data.last_execution_result || existingBlock.data.last_execution_result || undefined, - generic_tools: mergedTools, - isCompleted: true - }; - if (mergedData.stream_resets) { - mergedData.stream_resets = mergedData.stream_resets.map((r: any) => ({ ...r, isWaiting: false })); - } - - blocks[existingIndex] = { - ...existingBlock, - id: `step-${stepId}`, - type: 'step', - timestamp: existingBlock.timestamp || log.timestamp, - data: mergedData - }; - } else { - blocks.push({ - id: `step-${stepId}`, - type: 'step', - timestamp: log.timestamp, - data: { - ...log.data, - isCompleted: true, - operator_native_thinking_timestamp: log.data.operator_native_thinking_timestamp - || (log.data.operator_native_thinking ? log.timestamp : undefined), - operator_raw_thinking_timestamp: log.data.operator_raw_thinking_timestamp - || (log.data.operator_raw_thinking ? log.timestamp : undefined), - generic_tools: log.data.generic_tools || [] - } - }); - } - } else if (log.type === 'trace_recorded') { - const isAction = log.data.type === 'action'; - const isTool = log.data.type === 'tool' || isAction; - const isVisibleLLMEvent = log.data.type === 'llm_call' - && (log.data.status === 'failed' || log.data.status === 'retrying'); - if (!isTool && !isVisibleLLMEvent && !isBackendSwitchNote(log.data)) return; - - let stepId = log.data.step_id; - let existingIndex = -1; - - // 0. Tools invoked by the Checker belong to its attempt block (routed by - // the parent trace, falling back to the running attempt by agent name). - let checkerIndex = findCheckerBlockByTrace(blocks, log.data.parent_trace_id); - if (checkerIndex === -1 && String(log.data.agent_name || '').toLowerCase() === 'checker') { - checkerIndex = findRunningCheckerBlock(blocks); - } - if (checkerIndex > -1) { - existingIndex = checkerIndex; - } - - // 1. If this trace_id already exists in some block, update that block directly - if (existingIndex === -1 && log.data.trace_id) { - existingIndex = blocks.findIndex(b => - b.data?.generic_tools?.some((t: any) => t.trace_id === log.data.trace_id) - ); - } - - // 2. If not found by trace_id and stepId is present, find by step_id - if (existingIndex === -1 && stepId) { - existingIndex = blocks.findIndex(b => b.id === `step-${stepId}` || b.data?.step_id === stepId); - } - - // 3. If still not found and no stepId (e.g. pre-planning / initial planning phase) - if (existingIndex === -1 && !stepId) { - existingIndex = blocks.findIndex(b => b.id === 'step-pre-planning' || b.data?.step_id === 'pre-planning'); - } - - // 4. Untagged traces only: attach to the latest block that precedes - // them. A trace that names a step whose block does not exist yet - // (tool call before any streamed text, or an action recorded before - // the step in Flash) creates that step's block below instead of being - // glued to an earlier step, where the later `step_recorded` (which - // carries the same trace) would leave a duplicate card. - if (existingIndex === -1 && !stepId) { - const logTime = log.timestamp ? new Date(log.timestamp).getTime() : 0; - let maxStepTime = -1; - for (let i = 0; i < blocks.length; i++) { - const b = blocks[i]; - const bTime = b.timestamp ? new Date(b.timestamp).getTime() : 0; - if (bTime <= logTime && bTime >= maxStepTime) { - maxStepTime = bTime; - existingIndex = i; - } - } - } - - if (existingIndex > -1) { - const stepData = blocks[existingIndex].data; - const genericTools = stepData.generic_tools ? [...stepData.generic_tools] : []; - const existingToolIdx = log.data.trace_id - ? genericTools.findIndex((t: any) => t.trace_id === log.data.trace_id) - : -1; - - if (existingToolIdx > -1) { - genericTools[existingToolIdx] = { - ...genericTools[existingToolIdx], - ...log.data - }; - } else { - genericTools.push(log.data); - } - - blocks[existingIndex] = { - ...blocks[existingIndex], - data: { - ...stepData, - generic_tools: genericTools - } - }; - } else { - const fallbackStepId = stepId || 'pre-planning'; - blocks.push({ - id: `step-${fallbackStepId}`, - type: 'step', - timestamp: log.timestamp, - data: { - step_id: fallbackStepId, - isCompleted: true, - generic_tools: [log.data] - } - }); - } - } - }); - - relocateCheckerTools(blocks); - - blocks.sort((a, b) => { - const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0; - const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0; - if (Math.abs(timeA - timeB) > 50) { - return timeA - timeB; - } - // Order by step_number if available (0 is planning phase) - const getStepOrder = (block: StepBlock): number => { - if (block.data?.step_number !== undefined && block.data.step_number !== null) { - return Number(block.data.step_number); - } - if (block.data?.step_id === 'pre-planning' || block.data?.step_type === 'planning') { - return 0; - } - return 99999; - }; - - const stepNumA = getStepOrder(a); - const stepNumB = getStepOrder(b); - if (stepNumA !== stepNumB && stepNumA !== 99999 && stepNumB !== 99999) { - return stepNumA - stepNumB; - } - return timeA - timeB; - }); - - return blocks; -} - -/** - * Cache for the token-estimate fallback. Tool payloads can carry multi-megabyte - * base64 screenshots; serializing them on every recompute would block the main - * thread, so each payload object is measured exactly once. - */ -const payloadSizeCache = new WeakMap(); - -function estimatePayloadSize(payload: any): { isImage: boolean; length: number } { - if (typeof payload === 'string') { - return { - isImage: payload.includes('data:image/') || payload.includes('base64,'), - length: payload.length - }; - } - if (payload === null || typeof payload !== 'object') { - return { isImage: false, length: String(payload).length }; - } - const cached = payloadSizeCache.get(payload); - if (cached) return cached; - let payloadStr = ''; - try { - payloadStr = JSON.stringify(payload) || ''; - } catch { - payloadStr = String(payload); - } - const result = { - isImage: payloadStr.includes('data:image/') || payloadStr.includes('base64,'), - length: payloadStr.length - }; - payloadSizeCache.set(payload, result); - return result; -} - -/** - * Safely extract and aggregate token usage for a step block - */ -export function extractBlockTokens(block: StepBlock): { total: number; prompt: number; completion: number } { - if (!block || !block.data) return { total: 0, prompt: 0, completion: 0 }; - const data = block.data; - - // 1. Direct total_tokens or token_usage object - if (data.total_tokens !== undefined && data.total_tokens !== null && Number(data.total_tokens) > 0) { - const pr = data.token_usage?.prompt_tokens || data.token_usage?.input_tokens || 0; - const co = data.token_usage?.completion_tokens || data.token_usage?.output_tokens || 0; - return { total: Number(data.total_tokens), prompt: Number(pr), completion: Number(co) }; - } - if (data.token_usage && typeof data.token_usage === 'object') { - const pr = data.token_usage.prompt_tokens || data.token_usage.input_tokens || 0; - const co = data.token_usage.completion_tokens || data.token_usage.output_tokens || 0; - const to = data.token_usage.total_tokens || (pr + co); - if (to > 0) { - return { total: Number(to), prompt: Number(pr), completion: Number(co) }; - } - } - if (data.extra_metadata?.token_usage && typeof data.extra_metadata.token_usage === 'object') { - const u = data.extra_metadata.token_usage; - const pr = u.prompt_tokens || u.prompt_token_count || u.input_tokens || 0; - const co = u.completion_tokens || u.candidates_token_count || u.output_tokens || 0; - const to = u.total_tokens || u.total_token_count || (pr + co); - if (to > 0) { - return { total: Number(to), prompt: Number(pr), completion: Number(co) }; - } - } - - // 2. Generic tools traces (e.g. llm_call traces or tool payloads) - let sumPrompt = 0; - let sumCompletion = 0; - let sumTotal = 0; - if (data.generic_tools && Array.isArray(data.generic_tools)) { - data.generic_tools.forEach((t: any) => { - if (t && t.payload) { - const u = t.payload.token_usage || t.payload.usage_metadata; - if (u) { - const pr = u.prompt_tokens || u.prompt_token_count || u.input_tokens || 0; - const co = u.completion_tokens || u.candidates_token_count || u.output_tokens || 0; - const to = u.total_tokens || u.total_token_count || (pr + co); - sumPrompt += Number(pr); - sumCompletion += Number(co); - sumTotal += Number(to); - } - } - }); - } - if (sumTotal > 0) { - return { total: sumTotal, prompt: sumPrompt, completion: sumCompletion }; - } - - // 3. Fallback estimate from thoughts/messages/actions text if no tokens stored - let textLen = 0; - let numImages = 0; - if (data.operator_native_thinking) textLen += data.operator_native_thinking.length; - if (data.operator_raw_thinking) textLen += data.operator_raw_thinking.length; - if (data.generic_tools && Array.isArray(data.generic_tools)) { - data.generic_tools.forEach((t: any) => { - if (t.payload) { - const estimate = estimatePayloadSize(t.payload); - if (estimate.isImage) { - numImages += 1; - } else { - textLen += estimate.length; - } - } - }); - } - if (textLen > 20 || numImages > 0) { - const estimated = Math.round(textLen / 4) + (numImages * 258); - return { total: estimated, prompt: 0, completion: estimated }; - } - - return { total: 0, prompt: 0, completion: 0 }; -} - -/** - * Format token count with clean units (e.g. "842 tokens", "76.4k tokens", "1.2M tokens") - */ -export function formatTokenCount(tokens?: number): string { - if (!tokens || tokens <= 0) return '0 tokens'; - if (tokens < 1000) { - return `${tokens} tokens`; - } - if (tokens < 1_000_000) { - const inK = (tokens / 1000).toFixed(1).replace(/\.0$/, ''); - return `${inK}k tokens`; - } - const inM = (tokens / 1_000_000).toFixed(2).replace(/\.?0+$/, ''); - return `${inM}M tokens`; -} - -/** - * Group step blocks into chronological execution phases (segmented per work unit) - */ -export function groupBlocksToPhases(blocks: StepBlock[], sessionStartTime: number): PhaseBlock[] { - if (!blocks || blocks.length === 0) return []; - const phases: PhaseBlock[] = []; - - // Base start timestamp in ms - let previousEndTime = sessionStartTime ? sessionStartTime * 1000 : 0; - - blocks.forEach((block, index) => { - const blockTime = block.timestamp ? new Date(block.timestamp).getTime() : Date.now(); - - if (previousEndTime === 0 && blockTime > 0) { - previousEndTime = blockTime; - } - - let durationSeconds = 0; - if (block.data?.duration && typeof block.data.duration === 'number' && block.data.duration > 0) { - durationSeconds = Math.max(1, Math.round(block.data.duration)); - } else if (blockTime >= previousEndTime && previousEndTime > 0) { - durationSeconds = Math.max(1, Math.round((blockTime - previousEndTime) / 1000)); - } else { - durationSeconds = 1; - } - - const tokensInfo = extractBlockTokens(block); - - phases.push({ - id: `phase-${block.id || index}`, - durationSeconds, - tokens: tokensInfo.total > 0 ? tokensInfo.total : undefined, - promptTokens: tokensInfo.prompt > 0 ? tokensInfo.prompt : undefined, - completionTokens: tokensInfo.completion > 0 ? tokensInfo.completion : undefined, - blocks: [block] - }); - - previousEndTime = blockTime; - }); - - return phases; -} - -/** - * Returns all actions and tools inside a step card, sorted in true chronological sequence by timestamp - */ -export function getSortedStepEvents( - stepData: any, - cache?: WeakMap -): StepEvent[] { - if (!stepData || typeof stepData !== 'object') return []; - const toolsLen = stepData.generic_tools ? stepData.generic_tools.length : 0; - const toolsSignature = stepData.generic_tools && Array.isArray(stepData.generic_tools) - ? stepData.generic_tools.map((t: any) => `${t?.trace_id || t?.name || ''}:${t?.status || ''}`).join(',') - : ''; - const actionTs = stepData.action_taken ? (stepData.action_taken.timestamp || stepData.action_taken.start_time || stepData.action_taken.created_at) : null; - const nativeText = typeof stepData.operator_native_thinking === 'string' - ? stepData.operator_native_thinking.trim() - : ''; - const rawText = typeof stepData.operator_raw_thinking === 'string' - ? stepData.operator_raw_thinking.trim() - : ''; - const segments: StreamSegment[] | null = Array.isArray(stepData.stream_segments) && stepData.stream_segments.length > 0 - ? stepData.stream_segments - : null; - const signature = [ - toolsLen, - toolsSignature, - actionTs ?? '', - stepData.operator_native_thinking_timestamp ?? '', - stepData.operator_raw_thinking_timestamp ?? '', - nativeText.length, - rawText.length, - stepData.isReset ? '1' : '0', - stepData.resetMessage ?? '', - stepData.stream_resets ? stepData.stream_resets.map((r: any) => `${r.id}:${r.isWaiting ? 1 : 0}`).join(',') : '', - segments ? segments.map((s) => `${s.execution_id}:${s.stream_type}:${s.text.length}`).join(',') : '' - ].join('|'); - - if (cache) { - const cached = cache.get(stepData); - if (cached?.signature === signature) { - return cached.events; - } - } - - const events: Array<{ type: StepEvent['type']; data: any; timestamp: number; sequence: number }> = []; - const fallbackTime = getItemTimestamp(stepData.timestamp, 0); - let sequence = 0; - - // Text and tools share one timeline. Live streams carry their first-seen - // timestamps; persisted steps use the step timestamp as a stable fallback. - if (segments) { - // Multi-turn agent (Checker): one event per streamed turn, each at the - // time its first chunk arrived, so turns sort between the tool calls. - segments.forEach((segment) => { - if ((!segment.text || !segment.text.trim()) && !segment.isReset) return; - events.push({ - type: segment.stream_type === 'thinking' ? 'thinking' : 'text', - data: { - text: segment.text, - execution_id: segment.execution_id, - segment: true, - isReset: segment.isReset, - resetMessage: segment.resetMessage - }, - timestamp: getItemTimestamp(segment.timestamp, fallbackTime), - sequence: sequence++ - }); - }); - } else { - const hasThinkingResets = Array.isArray(stepData.stream_resets) && stepData.stream_resets.some((r: any) => r.streamType === 'thinking'); - if (nativeText || hasThinkingResets) { - events.push({ - type: 'thinking', - data: { text: stepData.operator_native_thinking || '' }, - timestamp: getItemTimestamp(stepData.operator_native_thinking_timestamp, fallbackTime), - sequence: sequence++ - }); - } - - if (rawText || (stepData.isReset && stepData.resetMessage) || (Array.isArray(stepData.stream_resets) && stepData.stream_resets.length > 0)) { - events.push({ - type: 'text', - data: { - text: stepData.operator_raw_thinking || '', - isReset: stepData.isReset, - resetMessage: stepData.resetMessage - }, - timestamp: getItemTimestamp(stepData.operator_raw_thinking_timestamp, fallbackTime), - sequence: sequence++ - }); - } - } - - // 2. Single Source of Truth & Fallback Resolution: - const uniqueTools = getUniqueGenericTools(stepData.generic_tools); - const hasActionInTools = uniqueTools.some((tool: any) => - tool && (tool.type === 'action' || isAndroidAction(tool) || isReportStatusAction(tool)) - ); - - // Fallback: only add action_taken if generic_tools did NOT already contain an action trace. - // Pushed before uniqueTools so that when timestamps are missing/equal, the primary action - // preserves deterministic sequence ordering ahead of generic fallback tools. - if (!hasActionInTools && stepData.action_taken && (isAndroidAction(stepData.action_taken) || isReportStatusAction(stepData.action_taken))) { - const actObj = getActionObject(stepData.action_taken); - const actTime = getItemTimestamp( - actObj?.timestamp ?? actObj?.start_time ?? actObj?.created_at, - fallbackTime - ); - events.push({ - type: 'action', - data: stepData.action_taken, - timestamp: actTime, - sequence: sequence++ - }); - } - - // Add all real tool calls and actions from the event stream. - uniqueTools.forEach((tool: any) => { - if (isInternalPlumbingTool(tool) && !isReportStatusAction(tool)) { - return; - } - - const isAction = tool.type === 'action' || isAndroidAction(tool) || isReportStatusAction(tool); - const toolTime = getItemTimestamp( - tool.timestamp ?? tool.start_time ?? tool.created_at, - fallbackTime - ); - events.push({ - type: isAction ? 'action' : 'tool', - data: tool, - timestamp: toolTime, - sequence: sequence++ - }); - }); - - // Sort chronologically. The explicit sequence makes equal/missing timestamp - // behavior deterministic across browsers and preserves the legacy fallback. - events.sort((a, b) => (a.timestamp - b.timestamp) || (a.sequence - b.sequence)); - - const result: StepEvent[] = events.map(e => ({ - type: e.type, - data: e.data, - timestamp: e.timestamp - })); - if (cache) { - cache.set(stepData, { signature, events: result }); - } - return result; -} - -/** - * Compile human-readable session summary from logs - */ -export function compileSessionSummary(logs: any[]): string { - if (!logs || logs.length === 0) return 'No logs available.'; - - // Check for goal completed / checker response / cancellation / report_task_status - for (let i = logs.length - 1; i >= 0; i--) { - const log = logs[i]; - if (log.type === 'session_ended') { - if (log.data?.status === 'cancelled' || log.data?.was_stopped_manually) { - return 'Task stopped manually.'; - } - } - if (log.type === 'llm_stream' && log.data?.text) { - const text = log.data.text; - if (text.includes('```json') && text.includes('"success"')) { - return 'Execution completed and verified by checker.'; - } - } - if (log.type === 'step_recorded' || log.type === 'step_updated') { - const act = log.data?.action_taken; - if (act && isReportStatusAction(act)) { - const exp = getReportStatusExplanation(act); - if (exp) return exp; - } - if (log.data?.status === 'completed') { - return log.data.message || 'Task completed successfully.'; - } - } - } - - return 'Execution session in progress or ended.'; -} - -/** - * Calculate total session duration in seconds - */ -export function computeSessionDuration(sessionStartTime: number, logs: any[]): number { - if (!sessionStartTime || !logs || logs.length === 0) return 0; - const startMs = sessionStartTime * 1000; - const lastLog = logs[logs.length - 1]; - const endMs = lastLog?.timestamp ? new Date(lastLog.timestamp).getTime() : Date.now(); - return Math.max(0, Math.round((endMs - startMs) / 1000)); -} - -/** - * Check if the planning loader should be visible (while running, waiting for next step/action and not actively typing a stream) - */ -export function checkPlanningLoader( - logs: any[], - isRunning: boolean, - isCurrentRunningSession: boolean = true -): boolean { - if (!isRunning || !isCurrentRunningSession) return false; - if (!logs || logs.length === 0) return true; - - // If the latest step or event indicates task completion, do not show loader - for (let i = logs.length - 1; i >= Math.max(0, logs.length - 3); i--) { - const log = logs[i]; - if (log.type === 'session_ended') return false; - if (log.type === 'step_recorded' || log.type === 'step_updated') { - const act = log.data?.action_taken; - if (act && (act.action === 'report_task_status' || act === 'report_task_status' || isReportStatusAction(act))) { - return false; - } - if (log.data?.status === 'completed' || log.data?.status === 'failed') { - return false; - } - } - } - - // If there is an active incomplete stream currently streaming chunks, hide the planning loader - const hasActiveStream = logs.some( - l => l && l.type === 'llm_stream' && l.data && l.data.isCompleted === false - ); - - if (hasActiveStream) { - return false; - } - - return true; -} diff --git a/apps/showcase_ui/src/app/utils/tool-formatter.util.spec.ts b/apps/showcase_ui/src/app/utils/tool-formatter.util.spec.ts deleted file mode 100644 index 2c5d92f46..000000000 --- a/apps/showcase_ui/src/app/utils/tool-formatter.util.spec.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { - cleanErrorMessage, - extractToolExtraParams, - getCompressionLabel, - getCompressionPhase, - getCompressionPhaseLabel, - isCompressionWaiting, - getToolDisplayLabel, - getToolIcon, - getToolTargetText, - getUniqueGenericTools, - getVideoAnalysisView, - joinTargetDescriptions, - shouldShowTool -} from './tool-formatter.util'; - -describe('compress_history timeline line', () => { - const trace = (status: string, args: Record) => ({ - type: 'tool', - name: 'compress_history', - status, - payload: { args } - }); - - it('is shown as a plain tool line with its own icon', () => { - const running = trace('running', { start_step: 12, end_step: 27 }); - expect(shouldShowTool(running)).toBeTrue(); - expect(getToolIcon(running)).toBe('compress'); - expect(getToolDisplayLabel(running)).toBe(getCompressionLabel(running)); - }); - - it('says which steps are being condensed while running', () => { - expect(getCompressionLabel(trace('running', { start_step: 12, end_step: 27 }))) - .toBe('Condensing steps 12–27 into a short memory to free up room…'); - expect(getCompressionLabel(trace('running', { start_step: 12, end_step: 27, note: 'retrying' }))) - .toBe('Retrying the memory summary for steps 12–27…'); - expect(getCompressionLabel(trace('running', { start_step: 5, end_step: 5 }))) - .toBe('Condensing step 5 into a short memory to free up room…'); - }); - - it('says the summary is ready but held while working memory is still small', () => { - const held = trace('running', { - start_step: 1, - end_step: 4, - note: 'held', - context_tokens: 20000, - context_budget: 80000, - swap_at_tokens: 28000 - }); - expect(getCompressionLabel(held)).toBe( - 'Short memory for steps 1–4 is ready; keeping the full record until working memory fills up · ≈ 20k of 28k tokens' - ); - expect(getCompressionLabel(trace('running', { start_step: 1, end_step: 4, note: 'held' }))) - .toBe('Short memory for steps 1–4 is ready; keeping the full record until working memory fills up'); - }); - - it('reports the size reduction and the working memory once done', () => { - const done = trace('success', { - start_step: 12, - end_step: 27, - source_tokens: 8400, - summary_tokens: 600, - context_tokens: 54000, - context_budget: 80000 - }); - expect(getCompressionLabel(done)).toBe( - 'Steps 12–27 condensed into a short memory · 8.4k → 600 tokens (14× smaller) · working memory ≈ 54k of 80k tokens' - ); - }); - - it('omits the working memory figure on lines that do not carry it', () => { - const done = trace('success', { start_step: 1, end_step: 4, source_tokens: 3000, summary_tokens: 1500 }); - expect(getCompressionLabel(done)).toBe('Steps 1–4 condensed into a short memory · 3k → 1.5k tokens (2× smaller)'); - }); - - it('explains a forced recap and a failed attempt in plain words', () => { - const forced = trace('success', { start_step: 1, end_step: 4, forced: true, context_tokens: 70000, context_budget: 80000 }); - expect(getCompressionLabel(forced)) - .toBe('Steps 1–4 condensed into a recap to free up memory · working memory ≈ 70k of 80k tokens'); - expect(getCompressionLabel(trace('failed', { start_step: 1, end_step: 4 }))) - .toBe("Couldn't condense steps 1–4 yet; keeping the full record and retrying later"); - }); - - describe('phase (args.phase from the backend)', () => { - it('reads the declared phase and maps it to one short plain-language label', () => { - const cases: Array<[string, string, string]> = [ - ['running', 'summarizing', 'Summarizing this stretch'], - ['running', 'ready', 'Summary ready; kept in reserve until the context fills up'], - ['success', 'applied', 'Replaced this stretch with its summary'], - ['failed', 'failed', 'Summary failed; full record kept'] - ]; - for (const [status, phase, label] of cases) { - const tool = trace(status, { start_step: 1, end_step: 4, phase }); - expect(getCompressionPhase(tool)).toBe(phase as any); - expect(getCompressionPhaseLabel(tool)).toBe(label); - } - }); - - it('falls back to status and note on traces that carry no phase', () => { - expect(getCompressionPhase(trace('running', { start_step: 1, end_step: 4 }))).toBe('summarizing'); - expect(getCompressionPhase(trace('running', { start_step: 1, end_step: 4, note: 'retrying' }))).toBe('summarizing'); - expect(getCompressionPhase(trace('running', { start_step: 1, end_step: 4, note: 'held' }))).toBe('ready'); - expect(getCompressionPhase(trace('success', { start_step: 1, end_step: 4 }))).toBe('applied'); - expect(getCompressionPhase(trace('failed', { start_step: 1, end_step: 4 }))).toBe('failed'); - expect(getCompressionPhase(trace('running', { start_step: 1, end_step: 4, phase: 'bogus' }))).toBe('summarizing'); - }); - - it('drives the timeline line from the phase even without a note', () => { - expect(getCompressionLabel(trace('running', { start_step: 1, end_step: 4, phase: 'ready' }))) - .toBe('Short memory for steps 1–4 is ready; keeping the full record until working memory fills up'); - expect(getCompressionLabel(trace('running', { start_step: 1, end_step: 4, phase: 'summarizing' }))) - .toBe('Condensing steps 1–4 into a short memory to free up room…'); - }); - - it('treats a ready-but-held summary as waiting, not running', () => { - expect(isCompressionWaiting(trace('running', { start_step: 1, end_step: 4, phase: 'ready' }))).toBeTrue(); - expect(isCompressionWaiting(trace('running', { start_step: 1, end_step: 4, note: 'held' }))).toBeTrue(); - expect(isCompressionWaiting(trace('running', { start_step: 1, end_step: 4, phase: 'summarizing' }))).toBeFalse(); - expect(isCompressionWaiting(trace('success', { start_step: 1, end_step: 4, phase: 'applied' }))).toBeFalse(); - expect(isCompressionWaiting({ type: 'tool', name: 'save_note', status: 'running', payload: { args: { phase: 'ready' } } })).toBeFalse(); - }); - }); -}); - -describe('cleanErrorMessage', () => { - it('removes repeated LLM wrapper labels while preserving the provider reason', () => { - expect(cleanErrorMessage('LLM Error: LLM Request Error: 503 model overloaded')) - .toBe('503 model overloaded'); - }); - - it('does not present an empty LLM wrapper label as an error reason', () => { - expect(cleanErrorMessage('LLM Error:')).toBe('Unknown error'); - }); -}); - -describe('getUniqueGenericTools retry aggregation', () => { - it('groups recoverable LLM retries while preserving every attempt', () => { - const tools = [ - { - trace_id: 'retry-1', - type: 'llm_call', - name: 'llm_retry', - status: 'retrying', - timestamp: 100, - payload: { error: '503 first', delay: 1.18, provider: 'google', source: 'provider_sdk', request_id: 'request-1' } - }, - { - trace_id: 'retry-2', - type: 'llm_call', - name: 'llm_retry', - status: 'retrying', - timestamp: 101, - payload: { error: '503 second', delay: 2.96, provider: 'google', source: 'provider_sdk', request_id: 'request-1' } - } - ]; - - const result = getUniqueGenericTools(tools); - - expect(result.length).toBe(1); - expect(result[0].name).toBe('llm_retry_group'); - expect(result[0].payload.retry_count).toBe(2); - expect(result[0].payload.total_delay).toBeCloseTo(4.14); - expect(result[0].payload.retries.map((retry: any) => retry.error)).toEqual([ - '503 first', - '503 second' - ]); - }); - - it('folds SDK retry rows into their matching terminal failure', () => { - const result = getUniqueGenericTools([ - { - trace_id: 'retry-1', - type: 'llm_call', - name: 'llm_retry', - status: 'retrying', - payload: { error: '503', delay: 1, provider: 'google', source: 'provider_sdk', request_id: 'request-1' } - }, - { - trace_id: 'failure-1', - type: 'llm_call', - name: 'llm_pause', - status: 'failed', - payload: { error: '503 exhausted', pause: true, request_id: 'request-1', retries: [{ delay: 1 }] } - } - ]); - - expect(result.map(tool => tool.name)).toEqual(['llm_pause']); - }); - - it('does not expose opaque retries from non-Gemini providers', () => { - const result = getUniqueGenericTools([ - { - trace_id: 'retry-openai', - type: 'llm_call', - name: 'llm_retry', - status: 'retrying', - payload: { error: '429', delay: 2, provider: 'openai', source: 'artemis_wrapper' } - }, - { - trace_id: 'failure-openai', - type: 'llm_call', - name: 'llm_pause', - status: 'failed', - payload: { error: '429 exhausted', pause: true, waited_seconds: 12 } - } - ]); - - expect(result.map(tool => tool.name)).toEqual(['llm_pause']); - }); -}); - -describe('video analysis timeline formatting', () => { - it('distinguishes a cache hit without presenting it as running', () => { - const view = getVideoAnalysisView({ - name: 'spawn_sub_agent', - status: 'success', - payload: { - args: { start_time: 0, end_time: 42 }, - result: 'CACHED VIDEO ANALYSIS: existing evidence' - } - }); - - expect(view?.outcome).toBe('complete'); - expect(view?.reuse).toBe('full'); - expect(view?.title).toBe('Reused video analysis'); - expect(view?.requestedRange).toEqual({ start: 0, end: 42 }); - }); - - it('collapses child video chunks into one stable note-style timeline item', () => { - const result = getUniqueGenericTools([ - { - trace_id: 'chunk-1', - parent_trace_id: 'video-agent-1', - type: 'tool', - name: 'spawn_sub_agent', - status: 'success', - timestamp: 100, - payload: { - args: { start_time: 0, end_time: 30, specific_query: 'find the result' }, - result: '[from 0.0s to 30.0s] Summary: first' - } - }, - { - trace_id: 'chunk-2', - parent_trace_id: 'video-agent-1', - type: 'tool', - name: 'spawn_sub_agent', - status: 'success', - timestamp: 101, - payload: { - args: { start_time: 30, end_time: 60, specific_query: 'find the result' }, - result: 'PARTIAL VIDEO ANALYSIS (successful chunks were persisted). Failed intervals: 45.0s-60.0s' - } - } - ]); - - expect(result.length).toBe(1); - expect(result[0].name).toBe('video_analysis'); - expect(result[0].trace_id).toBe('video-analysis-video-agent-1'); - expect(result[0].payload.result.outcome).toBe('partial'); - expect(result[0].payload.result.requested_range).toEqual({ start: 0, end: 60 }); - expect(getVideoAnalysisView(result[0])?.title).toBe('Video analysis partially completed'); - }); - - it('keeps video analyses from independent parent executions separate', () => { - const result = getUniqueGenericTools([ - { - trace_id: 'chunk-1', parent_trace_id: 'video-agent-1', type: 'tool', - name: 'spawn_sub_agent', status: 'success', - payload: { args: { start_time: 0, end_time: 10 }, result: 'first' } - }, - { - trace_id: 'chunk-2', parent_trace_id: 'video-agent-2', type: 'tool', - name: 'spawn_sub_agent', status: 'success', - payload: { args: { start_time: 10, end_time: 20 }, result: 'second' } - } - ]); - - expect(result.map(tool => tool.trace_id)).toEqual([ - 'video-analysis-video-agent-1', - 'video-analysis-video-agent-2' - ]); - }); -}); - -describe('shouldShowTool (Option A Single Source of Truth)', () => { - it('keeps ADB commands visible even when stepData.action_taken is present', () => { - const adbTool = { - name: 'run_adb_command', - type: 'tool', - payload: { CommandLine: 'dumpsys telephony.registry' } - }; - const stepData = { - action_taken: [{ action: 'wait_for_delay', time_in_ms: 1000 }] - }; - - expect(shouldShowTool(adbTool, stepData)).toBeTrue(); - }); - - it('keeps explorer calls visible when stepData.action_taken is present', () => { - const explorerTool = { - name: 'ask_explorer', - type: 'tool' - }; - const stepData = { - action_taken: [{ action: 'wait_for_delay', time_in_ms: 1000 }] - }; - - expect(shouldShowTool(explorerTool, stepData)).toBeTrue(); - }); - - it('filters out internal plumbing tools', () => { - const plumbingTool = { - name: 'safety_net_pixel_validation', - type: 'tool' - }; - expect(shouldShowTool(plumbingTool)).toBeFalse(); - }); -}); - -describe('getToolTargetText self-described targets', () => { - it('uses target_description for coordinate tools when no target_text is observed', () => { - expect(getToolTargetText({ - name: 'click', - payload: { args: { coordinates: [500, 900], target_description: 'play button' } } - })).toBe('play button'); - expect(getToolTargetText({ - name: 'exec_long_press', - args: { coordinates: [500, 900], target_description: 'song row' } - })).toBe('song row'); - }); - - it('prefers observed target_text over target_description', () => { - expect(getToolTargetText({ - name: 'click', - payload: { args: { target_text: 'Play', target_description: 'play button' } } - })).toBe('Play'); - }); - - it('chains click_sequence target_descriptions with the coordinate arrow', () => { - expect(getToolTargetText({ - name: 'click_sequence', - payload: { args: { sequence: [[500, 300], [876, 360]], target_descriptions: ['play button', 'close ×'] } } - })).toBe('play button → close ×'); - }); - - it('falls back gracefully when click_sequence descriptions are missing or short', () => { - expect(getToolTargetText({ - name: 'click_sequence', - payload: { args: { sequence: [[500, 300], [876, 360]] } } - })).toBe(''); - expect(getToolTargetText({ - name: 'click_sequence', - payload: { args: { sequence: [[500, 300], [876, 360]], target_descriptions: ['play button'] } } - })).toBe('play button'); - expect(getToolTargetText({ - name: 'click_sequence', - payload: { args: { sequence: [[500, 300]], target_descriptions: [null, ''], target_description: 'fallback' } } - })).toBe('fallback'); - expect(joinTargetDescriptions(undefined)).toBe(''); - expect(joinTargetDescriptions('play button')).toBe(''); - }); -}); - -describe('extractToolExtraParams', () => { - it('hides target_description and target_descriptions from the extra parameter list', () => { - const keys = extractToolExtraParams({ - name: 'click_sequence', - args: { sequence: [[1, 2]], target_descriptions: ['a'], target_description: 'b', interval_ms: 50 } - }).map(p => p.key); - expect(keys).not.toContain('Target Descriptions'); - expect(keys).not.toContain('Target Description'); - expect(keys).toContain('Interval Ms'); - }); -}); diff --git a/apps/showcase_ui/src/app/utils/tool-formatter.util.ts b/apps/showcase_ui/src/app/utils/tool-formatter.util.ts deleted file mode 100644 index 225c1f961..000000000 --- a/apps/showcase_ui/src/app/utils/tool-formatter.util.ts +++ /dev/null @@ -1,1419 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ActionParam } from '../core/models/stream.model'; -import { extractNumbersFromCoordinateValue, isPureDirectionString, parseSequenceCoordinates } from './image-overlay.util'; - -// Tool objects are replaced (not mutated) when a trace is updated, so a -// WeakMap keyed on the tool is a safe memo for the parsed-args lookup that -// templates would otherwise re-run (including JSON.parse) on every render. -const toolArgsCache = new WeakMap(); - -/** - * Extract arguments from tool payload or direct args field - */ -export function getToolArgs(tool: any): any { - if (!tool) return {}; - if (typeof tool === 'object') { - const cached = toolArgsCache.get(tool); - if (cached !== undefined) return cached; - const args = computeToolArgs(tool); - toolArgsCache.set(tool, args); - return args; - } - return computeToolArgs(tool); -} - -function computeToolArgs(tool: any): any { - if (tool.payload) { - let payloadObj = tool.payload; - if (typeof payloadObj === 'string') { - try { payloadObj = JSON.parse(payloadObj); } catch {} - } - if (payloadObj && typeof payloadObj === 'object') { - if (payloadObj.args && typeof payloadObj.args === 'object') { - return payloadObj.args; - } - return payloadObj; - } - } - if (tool.args) { - let argsObj = tool.args; - if (typeof argsObj === 'string') { - try { argsObj = JSON.parse(argsObj); } catch {} - } - if (argsObj && typeof argsObj === 'object') { - return argsObj; - } - } - return {}; -} - -/** - * Check if a tool is one of the five note-related tools - */ -export function isNoteTool(tool: any): boolean { - if (!tool || !tool.name) return false; - const cleanName = tool.name.replace(/^(_)?(self\.)?exec_/, ''); - const nameLower = cleanName.toLowerCase(); - return ['save_note', 'read_note', 'list_notes', 'update_note', 'append_note'].includes(nameLower); -} - -/** - * Check if a tool is video analysis related - */ -export function isVideoTool(tool: any): boolean { - if (!tool || !tool.name) return false; - const cleanName = tool.name.replace(/^(_)?(self\.)?exec_/, ''); - const nameLower = cleanName.toLowerCase(); - return [ - 'video_analysis', - 'video_analyzer', - 'video_analyzer_pure', - 'spawn_sub_agent', - 'analyze_audio_only' - ].includes(nameLower); -} - -export type VideoAnalysisOutcome = 'running' | 'recovering' | 'waiting' | 'complete' | 'partial' | 'failed'; - -export interface VideoAnalysisRange { - start: number; - end: number; - category?: string; - retryable?: boolean; -} - -export interface VideoAnalysisView { - outcome: VideoAnalysisOutcome; - title: string; - query: string; - summary: string; - reuse: 'none' | 'partial' | 'full'; - requestedRange: VideoAnalysisRange | null; - completedRanges: VideoAnalysisRange[]; - failedRanges: VideoAnalysisRange[]; - evidenceCount: number; - completedCount: number; - totalCount: number; - fallbackUsed: boolean; -} - -function numericRange(value: any): VideoAnalysisRange | null { - if (!value || typeof value !== 'object') return null; - const start = Number(value.start); - const end = Number(value.end); - if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return null; - return { - start, - end, - category: value.category ? String(value.category) : undefined, - retryable: value.retryable === true - }; -} - -function parseVideoResultText(text: string): Partial { - const value = text.trim(); - if (!value) return {}; - if (value.startsWith('CACHED VIDEO ANALYSIS:')) { - return { outcome: 'complete', reuse: 'full', summary: value.replace(/^CACHED VIDEO ANALYSIS:\s*/, '') }; - } - if (value.startsWith('PARTIAL VIDEO ANALYSIS')) { - return { outcome: 'partial', reuse: 'partial', summary: value }; - } - if (value.startsWith('All sub-agent chunks failed') || value.startsWith('Error:')) { - return { outcome: 'failed', summary: value }; - } - if (value.includes('Analysis is already in progress in another video agent')) { - return { outcome: 'waiting', summary: 'Another video agent is already analyzing this evidence.' }; - } - return { outcome: 'complete', summary: value }; -} - -/** Build the user-facing, backward-compatible video analysis state. */ -export function getVideoAnalysisView(tool: any): VideoAnalysisView | null { - if (!isVideoTool(tool)) return null; - const args = getToolArgs(tool); - const payload = tool?.payload && typeof tool.payload === 'object' ? tool.payload : {}; - const rawResult = payload.result ?? tool.result ?? null; - const structured = rawResult && typeof rawResult === 'object' ? rawResult : {}; - const parsed = typeof rawResult === 'string' ? parseVideoResultText(rawResult) : {}; - - let outcome = String(structured.outcome || payload.outcome || parsed.outcome || '').toLowerCase() as VideoAnalysisOutcome; - if (!['running', 'recovering', 'waiting', 'complete', 'partial', 'failed'].includes(outcome)) { - outcome = tool?.status === 'failed' || tool?.status === 'error' - ? 'failed' - : tool?.status === 'running' ? 'running' : 'complete'; - } - const recovering = structured.recovering === true || structured.fallback_used === true; - if (outcome === 'running' && recovering) outcome = 'recovering'; - - const start = Number(args.start_time ?? structured.requested_range?.start); - const end = Number(args.end_time ?? structured.requested_range?.end); - const requestedRange = Number.isFinite(start) && Number.isFinite(end) && end > start - ? { start, end } - : numericRange(structured.requested_range); - const completedRanges = Array.isArray(structured.completed_ranges) - ? structured.completed_ranges.map(numericRange).filter((range: VideoAnalysisRange | null): range is VideoAnalysisRange => Boolean(range)) - : []; - const failedRanges = Array.isArray(structured.failed_ranges) - ? structured.failed_ranges.map(numericRange).filter((range: VideoAnalysisRange | null): range is VideoAnalysisRange => Boolean(range)) - : []; - const completedCount = Number(structured.completed_count ?? completedRanges.length ?? 0); - const totalCount = Number(structured.total_count ?? (completedRanges.length + failedRanges.length)); - const titleByOutcome: Record = { - running: 'Analyzing screen recording', - recovering: 'Analyzing unfinished recording segment', - waiting: 'Waiting for existing video analysis', - complete: structured.reuse === 'full' || parsed.reuse === 'full' - ? 'Reused video analysis' - : 'Analyzed screen recording', - partial: 'Video analysis partially completed', - failed: 'Video analysis returned no result' - }; - - return { - outcome, - title: titleByOutcome[outcome], - query: String(args.specific_query || args.query || args.prompt || structured.query || ''), - summary: String(structured.summary || parsed.summary || ''), - reuse: (structured.reuse || parsed.reuse || 'none') as 'none' | 'partial' | 'full', - requestedRange, - completedRanges, - failedRanges, - evidenceCount: Number(structured.evidence_count || 0), - completedCount: Number.isFinite(completedCount) ? completedCount : 0, - totalCount: Number.isFinite(totalCount) ? totalCount : 0, - fallbackUsed: structured.fallback_used === true - }; -} - -export function formatVideoTime(seconds: number): string { - const safe = Math.max(0, Number(seconds) || 0); - const minutes = Math.floor(safe / 60); - const secs = Math.floor(safe % 60); - return `${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; -} - -/** - * Extract target video filename or description for video analysis tools - */ -export function getVideoToolTarget(tool: any): string | null { - if (!tool) return null; - const args = getToolArgs(tool); - if (args.video_path || args.video_file || args.file_path || args.file) { - const p = String(args.video_path || args.video_file || args.file_path || args.file); - return p.split('/').pop() || p; - } - if (args.time_description) { - return String(args.time_description); - } - if (args.start_time !== undefined || args.end_time !== undefined) { - const start = args.start_time ?? 0; - const end = args.end_time ? `${args.end_time}s` : 'end'; - return `${start}s - ${end}`; - } - if (args.purpose) { - return String(args.purpose); - } - return 'screen_record.mp4'; -} - -/** - * Canonical list of mobile device automation and ADB command tool names. - */ -export const DEVICE_ACTION_TOOL_NAMES = new Set([ - 'click', 'click_sequence', 'long_press', 'long_press_on', 'input', 'input_text', - 'swipe', 'scroll', 'press_key', 'press_home', 'press_back', 'launch_app', 'manage_app', 'open_app', - 'focus_and_input_text', 'focus_and_clear_text', 'tap', 'wait_for_delay', 'wait', - 'run_adb_command', 'run_short_adb_command' -]); - -/** - * Check if a tool represents a real mobile device action or ADB command. - */ -export function isDeviceActionTool(tool: any): boolean { - if (!tool || !tool.name) return false; - if (tool.type === 'llm_call') return false; - const cleanName = tool.name.replace(/^(_)?(self\.)?exec_/, '').toLowerCase(); - return DEVICE_ACTION_TOOL_NAMES.has(cleanName); -} - -/** - * Backward-compatible alias for isDeviceActionTool. - */ -export function isFailureAnalyzerActionTool(tool: any): boolean { - return isDeviceActionTool(tool); -} - -/** - * Framework-internal infrastructure tools that are not intended for end-user timeline display. - */ -export function isInternalPlumbingTool(tool: any): boolean { - if (!tool || !tool.name) return true; - const cleanName = tool.name.replace(/^(_)?(self\.)?exec_/, '').toLowerCase(); - if (cleanName.includes('safety_net')) return true; - if (cleanName === 'report_task_status' || cleanName === 'report_status' || cleanName === 'submit_task_status') { - return true; - } - return false; -} - -/** - * Determines whether a tool invocation should be displayed in the timeline. - */ -export function shouldShowTool(tool: any, _stepData?: any): boolean { - if (!tool || !tool.name) return false; - if (tool.type === 'llm_call') return false; - if (tool.type === 'agent') return false; - if (tool.type === 'log') return false; // system notes render as quiet rows, not tool cards - if (isInternalPlumbingTool(tool)) return false; - - return true; -} - -/** - * Extracts human-readable initiating agent name / role for a tool. - */ -export function getToolAgentName(tool: any): string | null { - if (!tool) return null; - const name = (tool.agent_name || tool.agent || '').toLowerCase(); - if (name.includes('failure') || name.includes('analyzer')) { - return null; // Omit self-healing label per user instruction - } - if (name.includes('outputter')) { - return 'Outputter'; - } - if (name.includes('validator')) { - return 'Validator'; - } - if (name.includes('diagnos')) { - return 'Diagnoser'; - } - if (name.includes('explorer')) { - return 'Explorer'; - } - return null; -} - -/** - * Filter out nested child wrapper duplicates while preserving independent executions at the same level - */ -export function getUniqueGenericTools(tools: any[] | undefined): any[] { - if (!tools) return []; - - // Only Google GenAI SDK retries are observable. Artemis wrapper retries and - // opaque provider retries must never be presented as provider internals. - const retryTools = tools.filter(tool => - tool?.type === 'llm_call' - && tool?.status === 'retrying' - && (tool?.name === 'llm_retry' || tool?.name === 'llm_retry_group') - && tool?.payload?.source === 'provider_sdk' - && ['google', 'gemini'].includes(String(tool?.payload?.provider || '').toLowerCase()) - ); - const retryKey = (tool: any) => String( - tool?.payload?.request_id || `legacy-${tool?.step_id || 'unassigned'}` - ); - const retryGroups = new Map(); - for (const retry of retryTools) { - const key = retryKey(retry); - retryGroups.set(key, [...(retryGroups.get(key) || []), retry]); - } - const terminalRequestIds = new Set( - tools - .filter(tool => tool?.type === 'llm_call' && tool?.status === 'failed') - .map(tool => tool?.payload?.request_id) - .filter((requestId): requestId is string => typeof requestId === 'string' && !!requestId) - ); - - const buildRetryAggregate = (group: any[]) => { - const firstRetry = group[0]; - const latestRetry = group[group.length - 1]; - return { - ...firstRetry, - trace_id: `llm-retry-group-${firstRetry?.payload?.request_id || firstRetry?.trace_id || firstRetry?.timestamp || 'unknown'}`, - name: 'llm_retry_group', - payload: { - ...(latestRetry?.payload || {}), - retry_count: group.length, - total_delay: group.reduce( - (total, retry) => total + (Number(retry?.payload?.delay) || 0), - 0 - ), - retries: group.map(retry => ({ - trace_id: retry.trace_id, - timestamp: retry.timestamp, - error: retry?.payload?.error || retry.error, - delay: Number(retry?.payload?.delay) || 0, - provider: retry?.payload?.provider, - source: retry?.payload?.source, - request_id: retry?.payload?.request_id, - scheduled_at: retry?.payload?.scheduled_at - })) - } - }; - }; - - const toolMap = new Map(); - for (const t of tools) { - if (t && t.trace_id) { - toolMap.set(t.trace_id, t); - } - } - - const cleanName = (nameStr: string) => (nameStr || '').replace(/^(_)?(self\.)?exec_/, '').toLowerCase().trim(); - - const result: any[] = []; - const seenTraces = new Set(); - const addedRetryGroups = new Set(); - - for (const tool of tools) { - if (!tool || !tool.name) continue; - if (tool.type === 'agent') continue; - - if (tool.type === 'llm_call' && tool.status === 'retrying') { - const groupKey = retryKey(tool); - const group = retryGroups.get(groupKey); - const requestId = tool?.payload?.request_id; - if ( - group - && !addedRetryGroups.has(groupKey) - && !(requestId && terminalRequestIds.has(requestId)) - ) { - result.push(buildRetryAggregate(group)); - addedRetryGroups.add(groupKey); - } - continue; - } - - if (tool.type === 'llm_call' && tool.status === 'failed') { - // Callback-level attempt errors are implementation details. The wrapper - // emits one terminal llm_pause trace after retries are exhausted. - if (tool.name === 'llm_pause' || tool?.payload?.pause === true) { - result.push(tool); - } - continue; - } - - const currClean = cleanName(tool.name); - - // Check if this tool is a nested child execution of a parent tool with the exact same tool name - let isNestedChildDuplicate = false; - if (tool.parent_trace_id) { - const parentTool = toolMap.get(tool.parent_trace_id); - if (parentTool && cleanName(parentTool.name) === currClean) { - isNestedChildDuplicate = true; - } - } - - if (!isNestedChildDuplicate) { - if (tool.trace_id) { - if (!seenTraces.has(tool.trace_id)) { - seenTraces.add(tool.trace_id); - // Always use the latest state from toolMap for this trace_id - result.push(toolMap.get(tool.trace_id) || tool); - } - } else { - result.push(tool); - } - } - } - return collapseVideoAnalysisTools(result); -} - -/** - * Present one note-style timeline row for one video-analyzer execution. Child - * chunk, audio, and wrapper traces stay available in the raw trace but do not - * look like repeated user-visible analyses. - */ -function collapseVideoAnalysisTools(tools: any[]): any[] { - const groups = new Map(); - const passthrough: Array<{ index: number; tool: any }> = []; - - tools.forEach((tool, index) => { - if (!isVideoTool(tool)) { - passthrough.push({ index, tool }); - return; - } - const cleanName = String(tool.name || '').replace(/^(_)?(self\.)?exec_/, '').toLowerCase(); - const groupId = cleanName === 'video_analyzer' || cleanName === 'video_analyzer_pure' - ? String(tool.trace_id || tool.parent_trace_id || `video-${index}`) - : String(tool.parent_trace_id || tool.trace_id || `video-${index}`); - const existing = groups.get(groupId); - if (existing) { - existing.tools.push(tool); - } else { - groups.set(groupId, { firstIndex: index, tools: [tool] }); - } - }); - - const collapsed = [...passthrough]; - for (const [groupId, group] of groups) { - const views = group.tools - .map(getVideoAnalysisView) - .filter((view): view is VideoAnalysisView => Boolean(view)); - if (!views.length) continue; - - const hasActive = views.some(view => view.outcome === 'running' || view.outcome === 'recovering'); - const completed = views.filter(view => view.outcome === 'complete'); - const hasPartial = views.some(view => view.outcome === 'partial'); - const failed = views.filter(view => view.outcome === 'failed'); - const waiting = views.filter(view => view.outcome === 'waiting'); - let outcome: VideoAnalysisOutcome; - if (hasActive) { - outcome = views.some(view => view.outcome === 'recovering') ? 'recovering' : 'running'; - } else if (hasPartial || (completed.length > 0 && failed.length > 0)) { - outcome = 'partial'; - } else if (completed.length > 0) { - outcome = 'complete'; - } else if (waiting.length > 0) { - outcome = 'waiting'; - } else { - outcome = 'failed'; - } - - const ranges = views - .map(view => view.requestedRange) - .filter((range): range is VideoAnalysisRange => Boolean(range)); - const requestedRange = ranges.length - ? { - start: Math.min(...ranges.map(range => range.start)), - end: Math.max(...ranges.map(range => range.end)) - } - : null; - const reuse = completed.length > 0 && completed.every(view => view.reuse === 'full') - ? 'full' - : views.some(view => view.reuse !== 'none') ? 'partial' : 'none'; - const base = group.tools[0]; - const structuredResult = { - outcome, - reuse, - requested_range: requestedRange, - completed_count: completed.length, - total_count: views.length, - evidence_count: views.reduce((sum, view) => sum + view.evidenceCount, 0), - query: views.find(view => view.query)?.query || '', - recovering: outcome === 'recovering', - fallback_used: views.some(view => view.fallbackUsed) - }; - - collapsed.push({ - index: group.firstIndex, - tool: { - ...base, - trace_id: `video-analysis-${groupId}`, - name: 'video_analysis', - status: outcome === 'running' || outcome === 'recovering' ? 'running' : 'success', - payload: { - ...(base.payload || {}), - args: { - ...(base.payload?.args || base.args || {}), - start_time: requestedRange?.start, - end_time: requestedRange?.end - }, - result: structuredResult - } - } - }); - } - - collapsed.sort((a, b) => a.index - b.index); - return collapsed.map(item => item.tool); -} - -/** - * Get note key for the tool if it exists - */ -export function getToolKey(tool: any): string | null { - if (!tool || !tool.name) return null; - const args = tool.payload?.args || tool.args; - const key = args?.key; - if (!key) return null; - return key.toLowerCase().endsWith('.md') ? key : `${key}.md`; -} - -/** - * Get display label for tools (e.g. for pills or headers) - */ -export function getToolDisplayLabel(tool: any, isFirstSaveNote: boolean = false): string { - if (!tool || !tool.name) return ''; - const cleanName = tool.name.replace(/^(_)?exec_/, ''); - const nameLower = cleanName.toLowerCase(); - const args = getToolArgs(tool); - - switch (nameLower) { - case 'manage_app': - case 'launch_app': { - const rawApp = args.app_name || args.package_name || args.app || ''; - const app = rawApp ? (rawApp.charAt(0).toUpperCase() + rawApp.slice(1)) : 'Application'; - const rawAction = args.action ? String(args.action).toLowerCase() : ''; - const verb = rawAction === 'launch' ? 'Launching' : (rawAction === 'stop' || rawAction === 'close' ? 'Stopping' : 'Managing'); - return `${verb} "${app}"`; - } - - case 'wait_for_delay': - case 'wait_delay': - case 'wait': { - const delay = args.delay_seconds || args.seconds || args.delay || args.duration; - return delay ? `Waiting for ${delay} second${Number(delay) > 1 ? 's' : ''}...` : 'Waiting for delay...'; - } - case 'wait_for_text': { - const text = args.text || args.target_text || ''; - return text ? `Waiting for text "${text}" to appear on screen` : 'Waiting for text on screen'; - } - - case 'input_text': - case 'input': { - const text = args.text || args.input_text || ''; - return text ? `Entering text "${text}" into field` : 'Entering text into input field'; - } - case 'focus_and_input_text': { - const text = args.text || args.input_text || ''; - return text ? `Focusing field and typing "${text}"` : 'Focusing field and entering text'; - } - case 'focus_and_clear_text': - return 'Focusing and clearing field text'; - - case 'click': - case 'tap': { - const target = args.target_text || args.text || args.query || ''; - return target ? `Tapping on "${target}"` : 'Tapping on screen element'; - } - case 'click_sequence': - return 'Executing click sequence'; - case 'long_press': { - const target = args.target_text || args.text || ''; - return target ? `Long pressing on "${target}"` : 'Long pressing screen element'; - } - case 'swipe': { - const dir = args.action || args.direction || ''; - return dir ? `Swiping ${String(dir).toUpperCase()} on screen` : 'Swiping screen'; - } - case 'press_key': { - const key = args.key || args.keycode || ''; - return key ? `Pressing key ${String(key).toUpperCase()}` : 'Pressing hardware key'; - } - - case 'save_note': - return isFirstSaveNote ? 'Creating note' : 'Saving note'; - case 'read_note': - return 'Reading note'; - case 'list_notes': - return 'Browsing all saved notes'; - case 'update_note': - return 'Updating note'; - case 'append_note': - return 'Updating note'; - - case 'object_detection': { - const q = Array.isArray(args.queries) ? args.queries.join(', ') : (args.queries || ''); - return q ? `Locating on screen: "${q}"` : 'Locating elements on screen'; - } - case 'ask_explorer': { - const query = args.query || args.prompt || ''; - return query ? `Searching on screen: "${query}"` : 'Searching on screen'; - } - case 'report_failure_analysis': { - const reason = args.reason || args.analysis || ''; - return reason ? `Investigating issue: ${reason}` : 'Investigating execution issue'; - } - case 'run_adb_command': - case 'run_short_adb_command': { - const cmd = args.command || args.cmd || ''; - return cmd ? `Running command: ${cmd}` : 'Running system command'; - } - case 'search_logs': - case 'read_logs': { - const q = args.query || args.filter || ''; - return q ? `Searching logs for "${q}"` : 'Analyzing system logs'; - } - case 'log_analyzer': - case 'output_analyzer': - return 'Analyzing logs'; - case 'diagnoser': - case 'diagnose': - return 'Diagnosing issue'; - case 'video_analyzer': - case 'video_analyzer_pure': - return 'Analyzing screen recording'; - case 'extract_segment_metadata': { - const start = args.start_time !== undefined ? `${args.start_time}s` : ''; - const end = args.end_time !== undefined ? `${args.end_time}s` : ''; - const range = (start && end) ? ` (${start} - ${end})` : (start ? ` (from ${start})` : ''); - return `Cropping screen recording segment${range}`; - } - case 'spawn_sub_agent': { - const q = args.specific_query || args.query || args.prompt || ''; - return q ? `Analyzing recording with sub-agent: "${q}"` : 'Analyzing recording with sub-agent'; - } - case 'analyze_audio_only': { - const q = args.specific_query || args.query || ''; - return q ? `Analyzing audio track: "${q}"` : 'Analyzing recording audio track'; - } - case 'search_history': { - const q = args.query || ''; - const range = Array.isArray(args.step_range) && args.step_range.length - ? ` in steps ${args.step_range[0]}–${args.step_range[args.step_range.length - 1]}` - : ''; - return q ? `Searching execution history for "${q}"${range}` : `Searching execution history${range}`; - } - case 'replay_steps': { - const n = args.start_step; - const end = args.end_step; - if (n !== undefined && n !== '' && end !== undefined && end !== null && end !== '' && String(end) !== String(n)) { - return `Reviewing steps ${n}–${end}`; - } - return n !== undefined && n !== '' ? `Reviewing step ${n}` : 'Reviewing step details'; - } - case 'get_step_screenshot': { - const n = args.step_number; - const variant = String(args.which || '').toLowerCase(); - if (variant === 'overlay') { - return n !== undefined && n !== '' ? `Looking at where step ${n}'s action landed` : 'Looking at where an action landed'; - } - const which = variant === 'post' ? 'after' : 'before'; - return n !== undefined && n !== '' ? `Looking at the screen ${which} step ${n}` : 'Looking at a step screenshot'; - } - case 'probe_device': { - const kind = args.kind ? String(args.kind).replace(/_/g, ' ') : ''; - return kind ? `Reading ${kind} from the device` : 'Reading device state'; - } - case 'outputter': - case 'output_synthesis': - return 'Synthesizing output report'; - case 'web_search': { - const q = args.query || ''; - return q ? `Searching web for "${q}"` : 'Searching the web'; - } - case 'read_url': - return 'Fetching web page'; - case 'compress_history': - return getCompressionLabel(tool); - - default: - return `Executing ${cleanName.split('_').map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}`; - } -} - -function formatTokenFigure(value: any): string { - const n = Number(value); - if (!Number.isFinite(n) || n < 0) return ''; - if (n < 1000) return `${Math.round(n)}`; - return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`; -} - -/** Format compression progress and token usage for the timeline. */ -export function getCompressionLabel(tool: any): string { - const args = getToolArgs(tool) || {}; - const start = Number(args.start_step); - const end = Number(args.end_step); - const hasRange = Number.isFinite(start) && Number.isFinite(end) && start > 0 && end > 0; - const range = hasRange - ? (start === end ? `step ${start}` : `steps ${start}–${end}`) - : 'earlier steps'; - const rangeCapitalized = range.charAt(0).toUpperCase() + range.slice(1); - const status = String(tool?.status || '').toLowerCase(); - const phase = getCompressionPhase(tool); - - if (status === 'failed' || phase === 'failed') { - return `Couldn't condense ${range} yet; keeping the full record and retrying later`; - } - if (status !== 'success') { - const note = String(args.note || '').toLowerCase(); - if (note === 'retrying') return `Retrying the memory summary for ${range}…`; - if (note === 'held' || phase === 'ready') { - const swapAt = Number(args.swap_at_tokens); - const heldContext = Number(args.context_tokens); - const heldParts = [`Short memory for ${range} is ready; keeping the full record until working memory fills up`]; - if (heldContext > 0 && swapAt > 0) { - heldParts.push(`≈ ${formatTokenFigure(heldContext)} of ${formatTokenFigure(swapAt)} tokens`); - } - return heldParts.join(' · '); - } - return `Condensing ${range} into a short memory to free up room…`; - } - - const parts: string[] = []; - const source = Number(args.source_tokens); - const summary = Number(args.summary_tokens); - if (args.forced) { - parts.push(`${rangeCapitalized} condensed into a recap to free up memory`); - } else { - parts.push(`${rangeCapitalized} condensed into a short memory`); - if (source > 0 && summary > 0) { - const factor = source / summary; - const factorText = factor >= 2 ? ` (${Math.round(factor)}× smaller)` : ''; - parts.push(`${formatTokenFigure(source)} → ${formatTokenFigure(summary)} tokens${factorText}`); - } - } - const context = Number(args.context_tokens); - const budget = Number(args.context_budget); - if (context > 0) { - parts.push(budget > 0 - ? `working memory ≈ ${formatTokenFigure(context)} of ${formatTokenFigure(budget)} tokens` - : `working memory ≈ ${formatTokenFigure(context)} tokens`); - } - return parts.join(' · '); -} - -/** - * Plain-language phases of one compress_history line. Mirrors - * COMPRESSION_PHASES in artemis/memory/chunking.py: the backend writes - * `args.phase`; the trace `status` (running/success/failed) is left alone. - */ -export type CompressionPhase = 'summarizing' | 'ready' | 'applied' | 'failed'; - -const COMPRESSION_PHASES: readonly CompressionPhase[] = ['summarizing', 'ready', 'applied', 'failed']; - -export function isCompressionTool(tool: any): boolean { - const name = String(tool?.name || '').toLowerCase().replace(/^(_)?exec_/, ''); - return name === 'compress_history'; -} - -/** - * Phase of a compress_history line. Prefers the backend's `args.phase`; - * older traces without it fall back to status + note (`held` ⇒ ready). - */ -export function getCompressionPhase(tool: any): CompressionPhase { - const args = getToolArgs(tool) || {}; - const declared = String(args.phase || '').toLowerCase() as CompressionPhase; - if (COMPRESSION_PHASES.includes(declared)) return declared; - const status = String(tool?.status || '').toLowerCase(); - if (status === 'failed') return 'failed'; - if (status === 'success') return 'applied'; - if (String(args.note || '').toLowerCase() === 'held') return 'ready'; - return 'summarizing'; -} - -/** One short, non-technical label per compression phase. */ -export function getCompressionPhaseLabel(tool: any): string { - switch (getCompressionPhase(tool)) { - case 'ready': - return 'Summary ready; kept in reserve until the context fills up'; - case 'applied': - return 'Replaced this stretch with its summary'; - case 'failed': - return 'Summary failed; full record kept'; - default: - return 'Summarizing this stretch'; - } -} - -/** - * A ready-but-held summary is waiting, not working: the card must not keep - * its running pulse even though the trace status is still `running`. - */ -export function isCompressionWaiting(tool: any): boolean { - return isCompressionTool(tool) && getCompressionPhase(tool) === 'ready'; -} - -/** - * Get Material Symbol icon for tools - */ -export function getToolIcon(tool: any): string { - if (!tool || !tool.name) return 'settings'; - const name = tool.name.toLowerCase().replace(/^(_)?exec_/, ''); - switch (name) { - case 'click': - case 'tap': - return 'ads_click'; - case 'click_sequence': - case 'long_press': - return 'touch_app'; - case 'input_text': - case 'input': - return 'keyboard'; - case 'swipe': - return 'swipe'; - case 'press_key': - return 'keyboard_tab'; - case 'manage_app': - return 'open_in_new'; - case 'wait_for_delay': - return 'timer'; - case 'wait_for_text': - return 'hourglass_empty'; - case 'object_detection': - return 'search'; - case 'ask_explorer': - return 'search'; - case 'report_failure_analysis': - return 'assessment'; - case 'run_adb_command': - case 'run_short_adb_command': - return 'terminal'; - case 'web_search': - return 'travel_explore'; - case 'read_url': - return 'language'; - case 'search_logs': - case 'read_logs': - case 'log_analyzer': - case 'output_analyzer': - return 'receipt_long'; - case 'diagnoser': - case 'diagnose': - return 'medical_services'; - case 'video_analyzer': - case 'video_analyzer_pure': - return 'video_camera_back'; - case 'extract_segment_metadata': - return 'crop'; - case 'spawn_sub_agent': - return 'smart_toy'; - case 'analyze_audio_only': - return 'graphic_eq'; - case 'search_history': - return 'find_in_page'; - case 'replay_steps': - return 'manage_search'; - case 'get_step_screenshot': - return 'image_search'; - case 'probe_device': - return 'sensors'; - case 'outputter': - case 'output_synthesis': - return 'assignment_turned_in'; - case 'compress_history': - return 'compress'; - default: - return 'build'; - } -} - -/** - * Get formatted title for a tool call card - */ -export function getToolTitle(tool: any): string { - if (!tool || !tool.name) return 'Tool Call'; - const cleanName = tool.name.replace(/^(_)?exec_/, ''); - const name = cleanName.toLowerCase(); - switch (name) { - case 'click': - case 'tap': - return 'Tapping Element'; - case 'click_sequence': - return 'Executing Click Sequence'; - case 'long_press': - return 'Long Pressing Element'; - case 'input_text': - case 'input': - return 'Entering Text'; - case 'swipe': - case 'scroll': { - const args = getToolArgs(tool); - const dir = args.direction || args.gesture || (typeof args.action === 'string' ? args.action : ''); - if (dir && isPureDirectionString(dir)) return `Swiping Screen (${String(dir).toUpperCase()})`; - return 'Swiping Screen'; - } - case 'drag': - case 'drag_and_drop': - return 'Dragging Screen'; - case 'press_key': - return 'Pressing Hardware Key'; - case 'manage_app': - case 'launch_app': { - const args = getToolArgs(tool); - const rawAction = args.action ? String(args.action).toLowerCase() : ''; - if (rawAction === 'launch') return 'Launching Application'; - if (rawAction === 'stop' || rawAction === 'close') return 'Stopping Application'; - return 'Managing Application'; - } - case 'wait_for_delay': - case 'wait_delay': - return 'Waiting for Delay'; - case 'wait_for_text': - return 'Waiting for Text'; - case 'object_detection': - return 'Locating Elements'; - case 'ask_explorer': - return 'Searching on Screen'; - case 'report_failure_analysis': - return 'Investigating Issue'; - case 'run_adb_command': - case 'run_short_adb_command': - return 'Running System Command'; - case 'web_search': - return 'Web Search'; - case 'read_url': - return 'Fetching Web Page'; - case 'search_logs': - case 'read_logs': - return 'Searching Logs'; - case 'log_analyzer': - case 'output_analyzer': - return 'Analyzing Logs'; - case 'diagnoser': - case 'diagnose': - return 'Diagnosing Issue'; - case 'video_analyzer': - case 'video_analyzer_pure': - return 'Analyzing Screen Recording'; - case 'extract_segment_metadata': - return 'Cropping Screen Recording'; - case 'spawn_sub_agent': - return 'Delegating Video Analysis'; - case 'analyze_audio_only': - return 'Analyzing Audio Track'; - default: - return cleanName.replace(/_/g, ' ').replace(/\b\w/g, (c: string) => c.toUpperCase()); - } -} - -/** - * Join per-point self-described targets (click_sequence `target_descriptions`) - * into one label, chained with ' → ' like the coordinate rendering. - * Returns '' when the value is missing or holds no usable text. - */ -export function joinTargetDescriptions(descriptions: any): string { - if (!Array.isArray(descriptions)) return ''; - const parts = descriptions - .map(d => (d === null || d === undefined) ? '' : String(d).trim()) - .filter(d => d.length > 0); - return parts.join(' → '); -} - -/** - * Get target text description for tools - */ -export function getToolTargetText(tool: any): string { - if (!tool || !tool.name) return ''; - const args = getToolArgs(tool); - const name = tool.name.toLowerCase().replace(/^(_)?exec_/, ''); - if (name === 'manage_app' || name === 'launch_app') { - const rawApp = args.app_name || args.package_name || args.app || ''; - const app = rawApp ? (rawApp.charAt(0).toUpperCase() + rawApp.slice(1)) : ''; - const rawAct = args.action ? String(args.action).toLowerCase() : ''; - const act = rawAct ? (rawAct.charAt(0).toUpperCase() + rawAct.slice(1)) : ''; - if (act && app) { - return `${act} ${app}`; - } - return app || act || ''; - } - if (name === 'swipe' && typeof args.action === 'string' && isPureDirectionString(args.action)) { - return args.action; - } - if (name === 'wait_for_text') { - return args.text || ''; - } - if (name === 'object_detection') { - if (Array.isArray(args.queries)) return args.queries.join(', '); - return args.queries || ''; - } - if (name === 'ask_explorer' || name === 'web_search') { - return args.query || args.prompt || ''; - } - if (name === 'read_url') { - return args.url || ''; - } - if (name === 'search_logs' || name === 'read_logs') { - return args.query || args.filter || ''; - } - if (name === 'report_failure_analysis') { - return args.status || args.reason || args.analysis || ''; - } - if (name === 'click_sequence' || name === 'tap_sequence') { - // One self-described target per point, chained like getToolCoords does. - const joined = joinTargetDescriptions(args.target_descriptions); - if (joined) return joined; - } - if (args.target && typeof args.target === 'string') { - return args.target; - } - if (args.target && typeof args.target === 'number') { - return `Element #${args.target}`; - } - if (args.index !== undefined) { - return `Element #${args.index}`; - } - return args.target_text || args.target_description || args.target_class || args.element || args.element_text || (name !== 'input_text' ? args.text : '') || ''; -} - -/** - * Get input label for tools - */ -export function getToolInputLabel(tool: any): string { - if (!tool || !tool.name) return 'Input'; - const name = tool.name.toLowerCase().replace(/^(_)?exec_/, ''); - if (name === 'wait_for_delay' || name === 'wait_delay' || name === 'delay' || name === 'wait') { - return 'Duration'; - } - if (name === 'swipe' || name === 'scroll' || name === 'drag' || name === 'drag_and_drop') { - const args = getToolArgs(tool); - const dir = args.direction || args.gesture || (typeof args.action === 'string' ? args.action : ''); - if (dir && isPureDirectionString(dir)) { - return 'Direction'; - } - return 'Input'; - } - if (name === 'press_key' || name === 'press_home' || name === 'press_back') { - return 'Key'; - } - if (name === 'input_text' || name === 'input') { - return 'Input Text'; - } - return 'Input'; -} - -/** - * Get input value for tools - */ -export function getToolInputText(tool: any): string { - if (!tool || !tool.name) return ''; - const args = getToolArgs(tool); - const name = tool.name.toLowerCase().replace(/^(_)?exec_/, ''); - if (name === 'press_key') { - return args.key || args.keycode || ''; - } - if (name === 'swipe' || name === 'scroll' || name === 'drag' || name === 'drag_and_drop') { - const dir = args.direction || args.gesture || (typeof args.action === 'string' ? args.action : ''); - if (dir && isPureDirectionString(dir)) { - return String(dir).toUpperCase(); - } - return ''; - } - if (name === 'wait_for_delay' || name === 'wait_delay') { - return args.time_in_ms ? `${args.time_in_ms}ms` : (args.delay_ms ? `${args.delay_ms}ms` : (args.time ? `${args.time}` : '')); - } - if (name === 'long_press' && args.duration) { - return `Duration: ${args.duration}ms`; - } - if (name === 'input_text' || name === 'input') { - return args.text || args.input_text || ''; - } - return args.input_text || ''; -} - -/** - * Get coordinates string representation for tools - */ -export function getToolCoords(tool: any): string { - if (!tool || !tool.name) return ''; - const args = getToolArgs(tool); - const cleanName = tool.name.replace(/^(_)?exec_/, ''); - const name = cleanName.toLowerCase(); - - // Check sequence first if action is sequence or sequence arg is present - const isSequenceAction = name === 'click_sequence' || name === 'tap_sequence' || Boolean(args.sequence || args.normalized_sequence); - const rawSeq = args.normalized_sequence || args.sequence || args.targets || (Array.isArray(args.coordinates) && Array.isArray(args.coordinates[0]) ? args.coordinates : null); - const seqPoints = parseSequenceCoordinates(rawSeq); - if (seqPoints && seqPoints.length > 0 && (isSequenceAction || seqPoints.length > 1)) { - return seqPoints.map(pt => `[${pt[0]}, ${pt[1]}]`).join(' → '); - } - - // Check normalized start and end first - const normStart = extractNumbersFromCoordinateValue(args.normalized_start_coordinates || args.start_coordinates || args.start); - const normEnd = extractNumbersFromCoordinateValue(args.normalized_end_coordinates || args.end_coordinates || args.end); - if (normStart && normEnd && normStart.length === 2 && normEnd.length === 2) { - return `[${normStart[0]}, ${normStart[1]}] → [${normEnd[0]}, ${normEnd[1]}]`; - } - - const coords = extractNumbersFromCoordinateValue(args.normalized_coordinates) || - extractNumbersFromCoordinateValue(args.coordinates) || - extractNumbersFromCoordinateValue(args.target) || - extractNumbersFromCoordinateValue(args.action) || - extractNumbersFromCoordinateValue(args.gesture) || - extractNumbersFromCoordinateValue(args.point); - - if (coords && Array.isArray(coords)) { - if (coords.length === 4) { - return `[${coords[0]}, ${coords[1]}] → [${coords[2]}, ${coords[3]}]`; - } - if (coords.length === 2) { - return `[${coords[0]}, ${coords[1]}]`; - } - return coords.join(', '); - } - - if (args.x !== undefined && args.y !== undefined) { - return `[${args.x}, ${args.y}]`; - } - return ''; -} - -/** - * Get analysis / reasoning text for tools - */ -export function getToolAnalysisText(tool: any): string { - if (!tool || !tool.name) return ''; - const args = getToolArgs(tool); - return args.analysis || args.reasoning || args.summary || ''; -} - -/** - * Check if tool is ADB command - */ -export function isAdbCommandTool(tool: any): boolean { - if (!tool || !tool.name) return false; - const name = tool.name.toLowerCase().replace(/^(_)?exec_/, ''); - return name === 'run_adb_command' || name === 'run_short_adb_command'; -} - -/** - * Get ADB command line string - */ -export function getAdbCommandLine(tool: any): string { - const args = getToolArgs(tool); - return args.CommandLine || ''; -} - -/** - * Get ADB working directory - */ -export function getAdbCwd(tool: any): string { - const args = getToolArgs(tool); - return args.Cwd || ''; -} - -/** - * Get ADB requested terminal id - */ -export function getAdbTerminalId(tool: any): string { - const args = getToolArgs(tool); - return args.RequestedTerminalID || ''; -} - -/** - * Get fallback generic details for unspecified tools - */ -export function getToolGenericDetails(tool: any): string { - if (!tool) return ''; - if (isAdbCommandTool(tool)) { - return ''; - } - if (getToolTargetText(tool) || getToolInputText(tool) || getToolCoords(tool) || getToolAnalysisText(tool)) { - return ''; - } - const args = getToolArgs(tool); - if (!args || typeof args !== 'object') return ''; - const ignoredKeys = new Set(['state', 'controller', 'ctx', 'session_id', 'step_id', 'trace_id', 'parent_trace_id', 'times', 'delay_ms']); - const entries = Object.entries(args).filter(([k, v]) => !ignoredKeys.has(k) && v !== undefined && v !== null && v !== ''); - if (entries.length === 0) return ''; - return entries.map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`).join(', '); -} - -/** - * Check if tool execution failed - */ -export function isToolFailed(tool: any): boolean { - if (!tool) return false; - if (tool.status === 'failed' || tool.status === 'error') return true; - const args = getToolArgs(tool); - if (args.status === 'failed' || args.status === 'error' || args.status === 'cannot_fix') return true; - return false; -} - -/** - * Get error message for a failed tool - */ -export function getToolErrorMessage(tool: any): string { - if (!tool) return 'Tool Failed'; - const args = getToolArgs(tool); - if (args.status === 'cannot_fix') return 'Status: cannot_fix'; - if (args.error || args.message || args.failure_reason) { - return args.error || args.message || args.failure_reason; - } - if (tool.error || tool.message) return tool.error || tool.message; - return 'Action Failed'; -} - -/** - * Extract extra parameters for generic tools - */ -export function extractToolExtraParams(toolData: any, cache?: WeakMap): ActionParam[] { - if (!toolData) return []; - const payload = toolData.payload || toolData.args || toolData; - if (!payload || typeof payload !== 'object') return []; - if (cache && cache.has(payload)) { - return cache.get(payload)!; - } - - const standardKeys = new Set([ - 'action', 'name', 'type', 'target_text', 'target_description', 'target_descriptions', - 'text', 'input_text', 'target', - 'coordinates', 'coords', 'target_bounds', 'bounds', 'target_resource_id', - 'resource_id', 'target_class', 'class_name', 'normalized_coordinates', - 'pre_image_name', 'post_image_name', 'pre_screenshot', 'post_screenshot', - 'before_screenshot', 'after_screenshot', 'status', 'success', 'timestamp', - 'created_at', 'start_time', 'execution_id', 'controller', 'agent', 'session_id', 'step_id', - 'app_name', 'package_name', 'app', 'key', 'keycode', 'time_in_ms', 'delay_ms', 'delay_seconds', 'duration', - 'args', 'kwargs', 'parameters', 'extra_params', 'payload', 'trace_id', 'result', 'error', 'analysis', 'details', 'command', 'cwd', 'terminal_id' - ]); - - const result: ActionParam[] = []; - - const rawArgs = toolData.args || toolData.Args || toolData.kwargs || toolData.parameters || toolData.payload; - let parsedArgs: any = {}; - if (rawArgs) { - if (typeof rawArgs === 'object') { - parsedArgs = rawArgs; - } else if (typeof rawArgs === 'string') { - try { - parsedArgs = JSON.parse(rawArgs); - } catch { - if (!rawArgs.includes('<') && !rawArgs.includes('object at')) { - parsedArgs = { details: rawArgs }; - } - } - } - } - - const mergedObj = { ...toolData, ...(typeof payload === 'object' ? payload : {}), ...parsedArgs }; - - for (const [k, v] of Object.entries(mergedObj)) { - const lowerK = k.toLowerCase(); - if (standardKeys.has(lowerK)) continue; - if (v === null || v === undefined || v === '') continue; - - let valStr = String(v); - if (valStr.includes('object at 0x') || valStr.startsWith(' c.toUpperCase()); - if (lowerK === 'time_in_ms' || lowerK === 'delay_ms') prettyKey = 'Delay'; - - result.push({ key: prettyKey, value: valStr }); - } - - if (cache) { - cache.set(payload, result); - } - return result; -} - -/** - * Check if text is genuine human thinking rather than raw JSON payload output - */ -export function isHumanThinking(text: string | null): boolean { - if (!text) return false; - let trimmed = text.trim(); - if (!trimmed) return false; - - // Strip markdown code blocks if wrapped: ```json ... ``` or ``` ... ``` - if (trimmed.startsWith('```')) { - const match = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); - if (match) { - trimmed = match[1].trim(); - } - } - - // Direct object or array start - if (trimmed.startsWith('{') || trimmed.startsWith('[')) { - return false; - } - - // Try parsing as JSON if it looks like JSON structures - try { - const parsed = JSON.parse(trimmed); - if (typeof parsed === 'object' && parsed !== null) { - return false; - } - } catch { - // Not valid standalone JSON, continue checks - } - - // Check for common JSON object detection / grounding / action / tool patterns - if (/^\s*\{.*"label"\s*:.*"results"\s*:/s.test(trimmed) || - /^\s*\{.*"point"\s*:.*"label"\s*:/s.test(trimmed) || - /^\s*\{.*"action"\s*:/s.test(trimmed) || - /^\s*\{.*"name"\s*:/s.test(trimmed) || - /^\s*\{\s*"[^"]+"\s*:\s*/.test(trimmed) || - /^\s*\[\s*\{\s*"[^"]+"\s*:\s*/.test(trimmed)) { - return false; - } - - return true; -} - -/** - * Clean up raw error messages for display in the UI - */ -export function cleanErrorMessage(rawError: any): string { - if (typeof rawError === 'object' && rawError !== null) { - if (rawError.message && typeof rawError.message === 'string') { - return cleanErrorMessage(rawError.message); - } - if (rawError.error?.message && typeof rawError.error?.message === 'string') { - return cleanErrorMessage(rawError.error.message); - } - if (rawError.detail && typeof rawError.detail === 'string') { - return cleanErrorMessage(rawError.detail); - } - try { - return JSON.stringify(rawError); - } catch { - return String(rawError); - } - } - - const errorStr = String(rawError).trim(); - if (!errorStr) return 'Unknown error'; - - // 1. Try regex extraction for "message": "..." - const doubleQuoteMsgMatch = errorStr.match(/"message"\s*:\s*"((?:[^"\\]|\\.)*)"/i); - if (doubleQuoteMsgMatch && doubleQuoteMsgMatch[1]) { - const unescaped = doubleQuoteMsgMatch[1].replace(/\\"/g, '"').replace(/\\n/g, ' ').trim(); - if (unescaped) return unescaped; - } - - // 2. Try regex extraction for 'message': '...' - const singleQuoteMsgMatch = errorStr.match(/'message'\s*:\s*['"]((?:[^'\\]|\\.)*)['"]/i); - if (singleQuoteMsgMatch && singleQuoteMsgMatch[1]) { - const unescaped = singleQuoteMsgMatch[1].replace(/\\'/g, "'").replace(/\\n/g, ' ').trim(); - if (unescaped) return unescaped; - } - - // 3. Try parsing JSON substrings inside the error string - const jsonMatch = errorStr.match(/\{[\s\S]*\}/); - if (jsonMatch) { - try { - const normalizedJsonStr = jsonMatch[0] - .replace(/'/g, '"') - .replace(/True/g, 'true') - .replace(/False/g, 'false') - .replace(/None/g, 'null'); - const parsed = JSON.parse(normalizedJsonStr); - if (parsed?.message) return String(parsed.message); - if (parsed?.error?.message) return String(parsed.error.message); - } catch { - // Ignore - } - } - - // 4. Fallback: truncate at trace dump headers or return clean message - let fallback = errorStr; - if (fallback.includes('=== Source Location Trace')) { - fallback = fallback.split('=== Source Location Trace')[0]; - } - if (fallback.includes('[type.googleapis.com')) { - fallback = fallback.split('[type.googleapis.com')[0]; - } - fallback = fallback - .replace(/^Pre-execution validation failed:\s*/i, '') - .replace(/^Pixel-level validation failed:\s*/i, '') - .replace(/^Execution error:\s*/i, '') - .replace(/^ServerError:\s*/i, '') - // LLM wrappers may add this prefix more than once. It is context, not the - // actual provider reason, and an empty prefix must not be shown as though - // it were a useful error message. - .replace(/^(?:LLM\s+(?:Request\s+)?Error\s*:\s*)+/i, '') - .trim(); - - return fallback || 'Unknown error'; -} diff --git a/apps/showcase_ui/src/index.html b/apps/showcase_ui/src/index.html deleted file mode 100644 index b399a9d1e..000000000 --- a/apps/showcase_ui/src/index.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - artemis - - - - - - - - - - - - - - - - diff --git a/apps/showcase_ui/src/main.ts b/apps/showcase_ui/src/main.ts deleted file mode 100644 index b200df948..000000000 --- a/apps/showcase_ui/src/main.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { bootstrapApplication } from '@angular/platform-browser'; -import { appConfig } from './app/app.config'; -import { AppComponent } from './app/app.component'; - -bootstrapApplication(AppComponent, appConfig) - .catch((err) => console.error(err)); diff --git a/apps/showcase_ui/src/styles.scss b/apps/showcase_ui/src/styles.scss deleted file mode 100644 index 0953441b1..000000000 --- a/apps/showcase_ui/src/styles.scss +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Outfit:wght@300;400;500;600;700&family=Roboto:wght@400;500;700&display=swap'); -@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200'); -@import '@angular/cdk/overlay-prebuilt.css'; - -.cdk-overlay-container { - z-index: 1200 !important; -} - -*, *::before, *::after { - box-sizing: border-box; - font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -} - -.material-symbols-outlined { - font-family: 'Material Symbols Outlined' !important; - font-weight: normal; - font-style: normal; - font-size: 20px; - line-height: 1; - letter-spacing: normal; - text-transform: none; - display: inline-block; - white-space: nowrap; - word-wrap: normal; - direction: ltr; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-rendering: optimizeLegibility; - font-feature-settings: 'liga'; - font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 20; -} - -body, input, button, select, textarea { - font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -} - -body { - margin: 0; - padding: 0; - color: #1e293b; - background-color: #f8f9fa; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} diff --git a/apps/showcase_ui/tsconfig.app.json b/apps/showcase_ui/tsconfig.app.json deleted file mode 100644 index 2d80af650..000000000 --- a/apps/showcase_ui/tsconfig.app.json +++ /dev/null @@ -1,23 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./out-tsc/app", - "types": [] - }, - "files": [ - "src/main.ts" - ], - "include": [ - "src/**/*.d.ts" - ], - "angularCompilerOptions": { - "extendedDiagnostics": { - "checks": { - "nullishCoalescingNotNullable": "suppress", - "optionalChainNotNullable": "suppress" - } - } - } -} diff --git a/apps/showcase_ui/tsconfig.json b/apps/showcase_ui/tsconfig.json deleted file mode 100644 index 5525117c6..000000000 --- a/apps/showcase_ui/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "compileOnSave": false, - "compilerOptions": { - "outDir": "./dist/out-tsc", - "strict": true, - "noImplicitOverride": true, - "noPropertyAccessFromIndexSignature": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "skipLibCheck": true, - "isolatedModules": true, - "esModuleInterop": true, - "experimentalDecorators": true, - "moduleResolution": "bundler", - "importHelpers": true, - "target": "ES2022", - "module": "ES2022" - }, - "angularCompilerOptions": { - "enableI18nLegacyMessageIdFormat": false, - "strictInjectionParameters": true, - "strictInputAccessModifiers": true, - "strictTemplates": true - } -} diff --git a/apps/showcase_ui/tsconfig.spec.json b/apps/showcase_ui/tsconfig.spec.json deleted file mode 100644 index cb33c9af2..000000000 --- a/apps/showcase_ui/tsconfig.spec.json +++ /dev/null @@ -1,23 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./out-tsc/spec", - "types": [ - "jasmine" - ] - }, - "include": [ - "src/**/*.spec.ts", - "src/**/*.d.ts" - ], - "angularCompilerOptions": { - "extendedDiagnostics": { - "checks": { - "nullishCoalescingNotNullable": "suppress", - "optionalChainNotNullable": "suppress" - } - } - } -} diff --git a/artemis/__init__.py b/artemis/__init__.py index d4ba44cfa..571a7f65a 100644 --- a/artemis/__init__.py +++ b/artemis/__init__.py @@ -12,33 +12,36 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""☕ ARTEMIS: Autonomous Multimodal Android Agent & Testing Framework.""" +"""ARTEMIS device-action core for the DSH plugin (contracted, D18). -from artemis._version import __version__ -from artemis import _legacy_imports +The smart agent stack (the ``Agent``/``Builders`` SDK, the ``artemis_client`` +task client and the LLM-facing interfaces) now lives in the DSH host. Importing +:mod:`artemis` only re-exports the device driver base; the device-action MCP +servers and drivers are imported directly by module path. -# Old import paths of the modules moved to third_party/ (deprecated). -_legacy_imports.install() +G72 route 4: the mcp_server provider subtree is back on this branch as an +external provider process, and ``third_party.mobile_use.main`` imports +``Agent``/``Builders`` from this namespace. They resolve through the module +``__getattr__`` below, which pulls :mod:`artemis.sdk` lazily — importing +``artemis`` alone still does not drag in the LLM stack. +""" -from artemis.interfaces.sdk.client import ArtemisClient, ConcurrencyMode -from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, Task -from artemis_client import Capabilities, Device, TaskHandle, TaskResult -from artemis.sdk.agent import Agent -from artemis.sdk.builders import Builders +from artemis._version import __version__ from artemis.drivers.base import BaseDeviceDriver __all__ = [ "__version__", - "ArtemisClient", - "Capabilities", - "ConcurrencyMode", - "Device", - "StreamEvent", - "StreamEventType", - "Agent", - "Builders", - "Task", - "TaskHandle", - "TaskResult", "BaseDeviceDriver", ] + + +def __getattr__(name: str): # noqa: D103 - PEP 562 lazy provider surface (G72) + if name == "Agent": + from artemis.sdk import Agent + + return Agent + if name == "Builders": + from artemis.sdk.builders import Builders + + return Builders + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/artemis/__main__.py b/artemis/__main__.py deleted file mode 100644 index 3199a3770..000000000 --- a/artemis/__main__.py +++ /dev/null @@ -1,18 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from artemis.interfaces.cli.main import app - -if __name__ == "__main__": - app() diff --git a/artemis/_legacy_imports.py b/artemis/_legacy_imports.py deleted file mode 100644 index 57b7686bd..000000000 --- a/artemis/_legacy_imports.py +++ /dev/null @@ -1,94 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Old import paths of the modules moved to ``third_party/mobile_use``. - -An old path resolves to the new module object itself, so isinstance checks, -module-level state and ``patch("old.path.x")`` still work. The first import of -an old path emits a ``DeprecationWarning``. - -This is a meta path finder rather than one stub file per path because -``artemis.agents.hopper`` is a package; a stub file would load -``artemis.agents.hopper.hopper`` a second time. -""" - -import importlib -import importlib.abc -import importlib.machinery -import sys -from types import ModuleType -import warnings - -_NEW_ROOT = "third_party.mobile_use" - -# Old module path -> new module path. -LEGACY_MODULES: dict[str, str] = { - "artemis.agents.hopper": f"{_NEW_ROOT}.agents.hopper", - "artemis.agents.hopper.hopper": f"{_NEW_ROOT}.agents.hopper.hopper", - "artemis.clients.ui_automator_client": f"{_NEW_ROOT}.clients.ui_automator_client", - "artemis.config.output": f"{_NEW_ROOT}.config.output", - "artemis.controllers.platform_specific_commands_controller": ( - f"{_NEW_ROOT}.controllers.platform_specific_commands_controller" - ), - "artemis.sdk.builders.index": f"{_NEW_ROOT}.sdk.builders.index", - "artemis.sdk.builders.task_request_builder": f"{_NEW_ROOT}.sdk.builders.task_request_builder", - "artemis.sdk.types.exceptions": f"{_NEW_ROOT}.sdk.types.exceptions", - "artemis.sdk.types.task": f"{_NEW_ROOT}.sdk.types.task", - "artemis.utils.cli_helpers": f"{_NEW_ROOT}.utils.cli_helpers", - "artemis.utils.decorators": f"{_NEW_ROOT}.utils.decorators", - "artemis.utils.file": f"{_NEW_ROOT}.utils.file", - "artemis.utils.logger": f"{_NEW_ROOT}.utils.logger", - "artemis.utils.media": f"{_NEW_ROOT}.utils.media", - "artemis.utils.shell_utils": f"{_NEW_ROOT}.utils.shell_utils", -} - - -class _AliasLoader(importlib.abc.Loader): - """Loader that hands back an already-importable module under another name.""" - - def __init__(self, target: str) -> None: - self._target = target - self._target_spec: importlib.machinery.ModuleSpec | None = None - - def create_module(self, spec: importlib.machinery.ModuleSpec) -> ModuleType: - warnings.warn( - f"'{spec.name}' is deprecated; import '{self._target}' instead.", - DeprecationWarning, - stacklevel=2, - ) - module = importlib.import_module(self._target) - self._target_spec = module.__spec__ - return module - - def exec_module(self, module: ModuleType) -> None: - # Nothing to execute. The import system has overwritten __spec__ with the - # old name; put the real spec back so reload() and pickling still work. - if self._target_spec is not None: - module.__spec__ = self._target_spec - - -class _LegacyModuleFinder(importlib.abc.MetaPathFinder): - """Resolve entries of :data:`LEGACY_MODULES` to their new modules.""" - - def find_spec(self, fullname, path=None, target=None): - new_name = LEGACY_MODULES.get(fullname) - if new_name is None: - return None - return importlib.machinery.ModuleSpec(fullname, _AliasLoader(new_name)) - - -def install() -> None: - """Register the finder once, ahead of the default path-based finders.""" - if not any(isinstance(finder, _LegacyModuleFinder) for finder in sys.meta_path): - sys.meta_path.insert(0, _LegacyModuleFinder()) diff --git a/artemis/agents/explorer/run_setup.py b/artemis/agents/explorer/run_setup.py index a25c81feb..170cf6de5 100644 --- a/artemis/agents/explorer/run_setup.py +++ b/artemis/agents/explorer/run_setup.py @@ -225,7 +225,7 @@ def _resolve_model_params(self) -> tuple: fallback_model = None thinking_level = None if llm_cfg: - model_name = llm_cfg.model + model_name = llm_cfg.model or DEFAULT_EXPLORER_MODEL if "/" in model_name: model_name = model_name.split("/")[-1] if getattr(llm_cfg, "temperature", None) is not None: diff --git a/artemis/agents/flash/runner.py b/artemis/agents/flash/runner.py index 3ea3f6865..f57d45941 100644 --- a/artemis/agents/flash/runner.py +++ b/artemis/agents/flash/runner.py @@ -83,7 +83,7 @@ from artemis.services.llm import ( RobustChatModelWrapper, acomplete, - get_google_llm, + get_default_deployment_llm, get_llm, invoke_llm_with_timeout_message, ) @@ -284,7 +284,7 @@ def _init_llm(self): except Exception as e: logger.warning(f"Failed to get operator LLM from config, using default: {e}") - return RobustChatModelWrapper(get_google_llm(model_name="gemini-2.5-flash"), self.ctx) + return RobustChatModelWrapper(get_default_deployment_llm(), self.ctx) def _render_system_prompt(self, tools_declaration: list) -> str: """Renders the system prompt from the flash_runner.md template. diff --git a/artemis/agents/flash/summarizer.py b/artemis/agents/flash/summarizer.py index 5b8a3cf98..1e71222d6 100644 --- a/artemis/agents/flash/summarizer.py +++ b/artemis/agents/flash/summarizer.py @@ -36,7 +36,11 @@ from artemis.context import ArtemisContext from artemis.memory.step_memory import JobKey, StepMemoryService -from artemis.services.llm import RobustChatModelWrapper, get_google_llm, get_llm +from artemis.services.llm import ( + RobustChatModelWrapper, + get_default_deployment_llm, + get_llm, +) from artemis.services.token_meter import record_llm_usage from artemis.utils.task_tree import format_actions_clean from artemis.utils.visualization import draw_action_overlay_on_image @@ -166,11 +170,11 @@ def __init__( self._model_name = target_model try: if model_name: - self._llm = get_google_llm(model_name=target_model, temperature=0.0) + self._llm = get_default_deployment_llm(model_name=target_model, temperature=0.0) else: - self._llm = get_llm(ctx, name="summarizer", is_utils=True) + self._llm = get_llm(ctx, name="summarizer") except Exception: - self._llm = get_google_llm(model_name=target_model, temperature=0.0) + self._llm = get_default_deployment_llm(model_name=target_model, temperature=0.0) try: configured = getattr(self._llm, "model", None) or getattr(self._llm, "model_name", None) if isinstance(configured, str) and configured: @@ -341,7 +345,7 @@ def _meter_lens_call(self, response: Any) -> None: """Meter one raw-model lens call as an ``llm_usage`` trace, best-effort. Gateway-wrapped models already meter at the wrapper exit; only the raw - ``get_google_llm`` bypass needs explicit metering here. Lens prompts + ``get_default_deployment_llm`` bypass needs explicit metering here. Lens prompts are tiny and must not overwrite the session's ``last_prompt_tokens`` (the compaction thresholds' live context base), hence ``update_last_prompt=False``. diff --git a/artemis/agents/planner/planner.py b/artemis/agents/planner/planner.py index 02be5c7c4..35eb7c186 100644 --- a/artemis/agents/planner/planner.py +++ b/artemis/agents/planner/planner.py @@ -274,6 +274,13 @@ async def __call__(self, state: State): human_message = Template(human_content).render(**render_kwargs) + # Conversation continuation: notes inherited from the previous task of + # the thread are prepended so the plan builds on them instead of + # starting from scratch. + prior_context = getattr(state, "prior_conversation_context", None) + if prior_context: + human_message = f"{prior_context}\n\n---\n\n{human_message}" + human_message_content = [{"type": "text", "text": human_message}] if screenshot_b64: human_message_content.append( diff --git a/artemis/config/__init__.py b/artemis/config/__init__.py index 46862a438..0675adcc6 100644 --- a/artemis/config/__init__.py +++ b/artemis/config/__init__.py @@ -12,34 +12,65 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Unified configuration package and single entry point for ARTEMIS settings, paths, and models.""" +"""Device-side configuration: settings, paths, runtime IPC and constants. + +The LLM/agent configuration (``config.llm``, ``config.agent``) and the output +event writer were removed with the smart stack (D18): the DSH host owns LLM +configuration, so importing this package must not pull in the LLM package. +G72 route 4 note: the mcp_server provider subtree (restored from main) needs a +slice of the agent config surface back. Those names resolve through the module +``__getattr__`` below, which imports ``config.agent`` lazily — the action face +keeps importing this package without pulling the LLM stack. +""" + + +def __getattr__(name: str): # noqa: D103 - PEP 562 lazy provider surface (G72) + _AGENT_SURFACE = { + "checker_overrides_for_level", + "CheckerConfig", + "ExplorerConfig", + "ExplorerVersion", + "FlashProfileConfig", + "MemoryRuntimeConfig", + "MemoryTranscriptConfig", + "OutputterConfig", + "ProProfileConfig", + "StepSummarizerConfig", + "VideoAnalyzerConfig", + "load_agent_config", + "resolve_explorer_version", + "run_tuning_for_profile", + } + _LLM_SURFACE = { + "AgentNodeWithFallback", + "CyFunctionDetector", + "LLM", + "LLMConfig", + "LLMUtilsNode", + "LLMUtilsNodeWithFallback", + "LLMWithFallback", + "deep_merge_llm_config", + "get_default_llm_config", + "initialize_llm_config", + "load_llm_config_override", + "parse_llm_config", + "validate_vertex_ai_credentials", + } + if name in _AGENT_SURFACE: + from artemis.config import agent + + return getattr(agent, name) + if name in _LLM_SURFACE: + from artemis.config import llm + + return getattr(llm, name) + if name in ("OutputConfig", "record_events", "prepare_output_files"): + from third_party.mobile_use.config import output as _output + + return getattr(_output, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + -from artemis.config.agent import ( - AgentGlobalConfig, - CheckerConfig, - CommitteeConfig, - DEFAULT_VERIFICATION_LEVEL, - ExplorerConfig, - FlashProfileConfig, - MemoryChunkingConfig, - MemoryConfig, - MemoryRecallConfig, - MemoryReplayConfig, - MemoryRuntimeConfig, - MemoryTranscriptConfig, - OutputterConfig, - PlannerValidationConfig, - ProProfileConfig, - StepSummarizerConfig, - VideoAnalyzerConfig, - load_agent_config, - resolve_explorer_version, - VERIFICATION_LEVEL_PRESETS, - VerificationLevel, - checker_overrides_for_level, - run_tuning_for_profile, - verification_level_for_checker, -) from artemis.config.constants import ( AGENT_CONFIG_FILENAME, DATA_ENGINE_DB_FILENAME, @@ -75,7 +106,6 @@ ENV_ARTEMIS_DEFAULT_PROFILE, ENV_ARTEMIS_DEVICE_ID, ENV_ARTEMIS_EDGE_PORT, - ENV_ARTEMIS_EXPLORER_VERSION, ENV_ARTEMIS_IPC_PORT, ENV_ARTEMIS_MCP_SERVER, ENV_ARTEMIS_TENANT_ID, @@ -97,21 +127,6 @@ LLMProvider, LLMUtilsNode, ) -from artemis.config.llm import ( - CyFunctionDetector, - LLM, - AgentNodeWithFallback, - LLMUtilsNodeWithFallback, - LLMConfig, - LLMConfigUtils, - LLMWithFallback, - deep_merge_llm_config, - get_default_llm_config, - initialize_llm_config, - load_llm_config_override, - parse_llm_config, - validate_vertex_ai_credentials, -) from artemis.config.paths import ( CONFIG_DIR, DB_PATH, @@ -156,11 +171,6 @@ Settings, settings, ) -from third_party.mobile_use.config.output import ( - OutputConfig, - prepare_output_files, - record_events, -) __all__ = [ # Settings & Singleton @@ -195,47 +205,6 @@ "get_ipc_port_file", "get_ls_address_file", "is_frozen_bundle", - # LLM Config & Models - "CyFunctionDetector", - "LLM", - "LLMWithFallback", - "LLMConfigUtils", - "LLMConfig", - "parse_llm_config", - "initialize_llm_config", - "get_default_llm_config", - "deep_merge_llm_config", - "load_llm_config_override", - "validate_vertex_ai_credentials", - # Agent & Tool Config - "AgentGlobalConfig", - "CheckerConfig", - "CommitteeConfig", - "DEFAULT_VERIFICATION_LEVEL", - "ExplorerConfig", - "FlashProfileConfig", - "MemoryChunkingConfig", - "MemoryConfig", - "MemoryRecallConfig", - "MemoryReplayConfig", - "MemoryRuntimeConfig", - "MemoryTranscriptConfig", - "OutputterConfig", - "PlannerValidationConfig", - "ProProfileConfig", - "StepSummarizerConfig", - "VideoAnalyzerConfig", - "load_agent_config", - "resolve_explorer_version", - "VERIFICATION_LEVEL_PRESETS", - "VerificationLevel", - "checker_overrides_for_level", - "run_tuning_for_profile", - "verification_level_for_checker", - # Output Config - "OutputConfig", - "prepare_output_files", - "record_events", # Runtime & IPC State "read_ipc_port", "write_ipc_port", @@ -249,9 +218,7 @@ "LLMProvider", "ExplorerVersion", "LLMUtilsNode", - "LLMUtilsNodeWithFallback", "AgentNode", - "AgentNodeWithFallback", # Filenames "LLM_CONFIG_FILENAME", "LLM_CONFIG_OVERRIDE_FILENAME", diff --git a/artemis/config/constants.py b/artemis/config/constants.py index 7e4d74258..9ec338bef 100644 --- a/artemis/config/constants.py +++ b/artemis/config/constants.py @@ -40,6 +40,11 @@ ENV_ANTIGRAVITY_LS_ADDRESS = "ANTIGRAVITY_LS_ADDRESS" ENV_ARTEMIS_MCP_SERVER = "ARTEMIS_MCP_SERVER" +# Model endpoint selection: the task worker pins its LLM deployment to one +# endpoint-library record, so the model chosen in the UI at submit time is the +# model that task runs with even if the global default changes afterwards. +ENV_ARTEMIS_MODEL_ENDPOINT = "ARTEMIS_MODEL_ENDPOINT" + # Device & ADB ENV_ADB_DEVICE_SERIAL = "ADB_DEVICE_SERIAL" ENV_ARTEMIS_DEVICE_ID = "ARTEMIS_DEVICE_ID" @@ -85,6 +90,7 @@ ARTEMIS_CONFIG_FILENAME = "artemis.jsonc" LLM_CONFIG_FILENAME = "llm-config.json" LLM_CONFIG_OVERRIDE_FILENAME = "llm-config.override.jsonc" +ENDPOINT_LIBRARY_FILENAME = "endpoint_library.json" AGENT_CONFIG_FILENAME = "agent_config.json" DATA_ENGINE_DB_FILENAME = "data_engine.db" IPC_PORT_FILENAME = ".artemis_ipc_port" diff --git a/artemis/config/core/__init__.py b/artemis/config/core/__init__.py deleted file mode 100644 index fbb0cd9d1..000000000 --- a/artemis/config/core/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Artemis unified configuration core.""" - -from artemis.config.core.engine import CascadingConfigEngine, config_engine -from artemis.config.core.interpolator import interpolate_config_value - -__all__ = [ - "CascadingConfigEngine", - "config_engine", - "interpolate_config_value", -] diff --git a/artemis/config/core/engine.py b/artemis/config/core/engine.py deleted file mode 100644 index 656c9d5ed..000000000 --- a/artemis/config/core/engine.py +++ /dev/null @@ -1,118 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Multi-source Cascading Configuration Engine.""" - -import json -from pathlib import Path -import re -from typing import Any - -from dotenv import load_dotenv - -from artemis.config.core.interpolator import interpolate_config_value -from artemis.platform import platform -from third_party.mobile_use.utils.logger import get_logger - -logger = get_logger(__name__) - - -def _strip_json_comments(text: str) -> str: - """Strip // and /* */ comments from JSONC text while preserving string literals.""" - pattern = r'//.*?$|/\*.*?\*/|\'(?:\\.|[^\\\'])*\'|"(?:\\.|[^\\"])*"' - - def replacer(match: re.Match) -> str: - s = match.group(0) - if s.startswith("/"): - return "" - return s - - return re.sub(pattern, replacer, text, flags=re.DOTALL | re.MULTILINE) - - -def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: - """Recursively deep merge two dictionaries.""" - merged = base.copy() - for key, value in override.items(): - if key in merged and isinstance(merged[key], dict) and isinstance(value, dict): - merged[key] = _deep_merge(merged[key], value) - else: - merged[key] = value - return merged - - -class CascadingConfigEngine: - """Manages 5-tier configuration hierarchy with automatic cascade and interpolation.""" - - def __init__(self, workspace_root: Path | None = None): - self.workspace_root = workspace_root or Path.cwd() - self._load_environment_files() - - def _load_environment_files(self) -> None: - """Load .env files from global config dir and workspace root.""" - # 1. Global user .env - global_env = platform.paths.config_dir / ".env" - if global_env.exists(): - load_dotenv(dotenv_path=global_env, override=False, encoding="utf-8") - - # 2. Legacy ~/.artemis/.env fallback - legacy_env = platform.paths.data_dir / ".env" - if legacy_env.exists() and legacy_env != global_env: - load_dotenv(dotenv_path=legacy_env, override=False, encoding="utf-8") - - # 3. Workspace .env - ws_env = self.workspace_root / ".env" - if ws_env.exists(): - load_dotenv(dotenv_path=ws_env, override=True, encoding="utf-8") - - def load_raw_config( - self, - cli_overrides: dict[str, Any] | None = None, - ) -> dict[str, Any]: - """Load and merge all config layers into a unified dictionary.""" - config: dict[str, Any] = {} - - # Tier 1: Global user configuration ($CONFIG_DIR/artemis.jsonc) - global_config_path = platform.paths.config_dir / "artemis.jsonc" - if global_config_path.exists(): - try: - raw_text = global_config_path.read_text(encoding="utf-8") - cleaned = _strip_json_comments(raw_text) - config = _deep_merge(config, json.loads(cleaned)) - except Exception as e: - logger.warning(f"Failed to load global config {global_config_path}: {e}") - - # Tier 2: Workspace project configuration (config/artemis.jsonc) - ws_config_path = self.workspace_root / "config" / "artemis.jsonc" - if not ws_config_path.exists(): - ws_config_path = self.workspace_root / "artemis.jsonc" - - if ws_config_path.exists(): - try: - raw_text = ws_config_path.read_text(encoding="utf-8") - cleaned = _strip_json_comments(raw_text) - config = _deep_merge(config, json.loads(cleaned)) - except Exception as e: - logger.warning(f"Failed to load workspace config {ws_config_path}: {e}") - - # Tier 3: CLI overrides - if cli_overrides: - config = _deep_merge(config, cli_overrides) - - # Apply variable interpolation across merged dictionary - interpolated = interpolate_config_value(config, workspace_root=self.workspace_root) - return interpolated - - -config_engine = CascadingConfigEngine() diff --git a/artemis/config/core/interpolator.py b/artemis/config/core/interpolator.py deleted file mode 100644 index 39c365307..000000000 --- a/artemis/config/core/interpolator.py +++ /dev/null @@ -1,78 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Dynamic configuration variable interpolation engine (${env:VAR}, ${path:NAME}).""" - -import os -from pathlib import Path -import re -from typing import Any - -from artemis.platform import platform - -_VAR_PATTERN = re.compile(r"\$\{([^}]+)\}") - - -def _resolve_variable(expr: str, workspace_root: Path | None = None) -> str: - """Resolve a single ${namespace:key:-default} expression.""" - expr = expr.strip() - namespace, _, rest = expr.partition(":") - if not rest: - # Defaults to environment variable lookup - return os.getenv(namespace, f"${{{expr}}}") - - key, _, default = rest.partition(":-") - - if namespace == "env": - return os.getenv(key, default) - elif namespace == "path": - paths = platform.paths - if key == "config_dir": - return str(paths.config_dir) - elif key == "data_dir": - return str(paths.data_dir) - elif key == "cache_dir": - return str(paths.cache_dir) - elif key == "logs_dir": - return str(paths.logs_dir) - elif key == "temp_dir": - return str(paths.temp_dir()) - elif key == "workspace_root" or key == "root_dir": - return str(workspace_root or Path.cwd()) - return default or f"${{{expr}}}" - elif namespace == "platform": - if key == "os_type": - return platform.os_type.value - elif key == "path_sep": - return platform.process.path_separator - return default or f"${{{expr}}}" - - return os.getenv(expr, default or f"${{{expr}}}") - - -def interpolate_config_value(val: Any, workspace_root: Path | None = None) -> Any: - """Recursively interpolate strings, lists, and dicts containing ${...} variables.""" - if isinstance(val, str): - if "${" not in val: - return val - - def replacer(match: re.Match) -> str: - return _resolve_variable(match.group(1), workspace_root) - - return _VAR_PATTERN.sub(replacer, val) - elif isinstance(val, dict): - return {k: interpolate_config_value(v, workspace_root) for k, v in val.items()} - elif isinstance(val, list): - return [interpolate_config_value(item, workspace_root) for item in val] - return val diff --git a/artemis/config/endpoint_library.py b/artemis/config/endpoint_library.py new file mode 100644 index 000000000..997a022b5 --- /dev/null +++ b/artemis/config/endpoint_library.py @@ -0,0 +1,151 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""The endpoint library: saved model endpoints the setup UI can offer. + +A record is a *saved choice*, not a runtime input: the model the agent actually +calls still comes from the ``default`` block of artemis.jsonc. What makes the +library more than UI bookkeeping is pinning — a task worker can point at one +record through ``ARTEMIS_MODEL_ENDPOINT`` and every model resolution in that +process follows it, so the endpoint the selector showed at submit time is the +endpoint that task runs with, even if the global default changes mid-flight. + +Both the admin console and the runtime read this module, so the record shape and +its file location have exactly one definition. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from artemis.config import paths + + +def library_file() -> Path: + """Resolve the file at call time so a test (or a relocated app dir) can redirect it.""" + return paths.get_endpoint_library_file() + + +#: The fields one record holds. Keys are the endpoint form's own vocabulary; +#: ``api_format`` is the wire protocol the runtime dispatches on. +RECORD_FIELDS = ("name", "api_format", "api_base", "model") + +#: Fields of the ``default`` block that belong to whichever endpoint is active. +#: The runtime reads them straight out of artemis.jsonc (services/llm.py), and +#: its ``api_base`` outranks OPENAI_BASE_URL, so a leftover keeps pointing at the +#: previous provider; ``fallback`` names a provider and a model too. Reasoning +#: knobs (thinking_level and friends) are deliberately absent — they describe how +#: the agent thinks, not which endpoint answers. +ENDPOINT_OWNED_KEYS = ("api_base", "api_key", "api_key_env", "fallback") + + +def to_default_overrides(record: dict[str, Any]) -> dict[str, Any | None]: + """Translate a record into ``default``-block writes; None means clear it. + + One definition for both writers — the setup UI's switch endpoint and a pinned + task worker — so switching and pinning can never disagree about which fields + a record owns. + """ + mapped: dict[str, Any | None] = { + "provider": str(record.get("api_format") or "").strip() or None, + "model": str(record.get("model") or "").strip() or None, + "provider_label": str(record.get("name") or "").strip() or None, + } + for key in ENDPOINT_OWNED_KEYS: + value = record.get(key) + if isinstance(value, dict): + mapped[key] = value or None + elif isinstance(value, str) and value.strip(): + mapped[key] = value.strip() + else: + mapped[key] = None + return mapped + + +def make_record(name: str, api_format: str, api_base: str, model: str) -> dict[str, str]: + """Normalize one record to the four fields the endpoint form collects.""" + return { + "name": (name or "").strip(), + "api_format": (api_format or "").strip(), + "api_base": (api_base or "").strip(), + "model": (model or "").strip(), + } + + +def read_library() -> list[dict[str, Any]]: + """Return the saved records in file order. + + A missing or unreadable file reads as empty rather than raising: the setup + screen must still show the live configuration, and a task pinned to a name + that has gone away fails loudly at resolution time instead of here. + """ + try: + data = json.loads(library_file().read_text(encoding="utf-8")) + except (OSError, ValueError): + return [] + records = data.get("endpoints", []) if isinstance(data, dict) else [] + return [ + record + for record in records + if isinstance(record, dict) and str(record.get("name") or "").strip() + ] + + +def write_library(records: list[dict[str, Any]]) -> None: + """Persist records; blank fields are dropped rather than stored.""" + target = library_file() + target.parent.mkdir(parents=True, exist_ok=True) + payload = { + "endpoints": [{key: value for key, value in record.items() if value} for record in records] + } + target.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def names() -> list[str]: + """Saved record names, in file order — the selector's whole universe.""" + return [str(record.get("name") or "").strip() for record in read_library()] + + +def find(name: str) -> dict[str, Any] | None: + """The record saved under ``name``, or None.""" + wanted = (name or "").strip() + return next( + (record for record in read_library() if str(record.get("name") or "").strip() == wanted), + None, + ) + + +def upsert(record: dict[str, Any]) -> None: + """Insert or replace the record of the same name, keeping list order.""" + records = read_library() + for index, existing in enumerate(records): + if str(existing.get("name") or "").strip() == str(record.get("name") or "").strip(): + records[index] = record + break + else: + records.append(record) + write_library(records) + + +def remove(name: str) -> bool: + """Drop one record; returns False when no record had that name.""" + wanted = (name or "").strip() + records = read_library() + remaining = [r for r in records if str(r.get("name") or "").strip() != wanted] + if len(remaining) == len(records): + return False + write_library(remaining) + return True diff --git a/artemis/config/llm.py b/artemis/config/llm.py index 641d2e198..eaf97d706 100644 --- a/artemis/config/llm.py +++ b/artemis/config/llm.py @@ -14,11 +14,13 @@ """LLM provider, model hierarchy, fallback chaining, and configuration loaders.""" +import os from pathlib import Path from typing import Any from artemis.config.constants import ( + ENV_ARTEMIS_MODEL_ENDPOINT, LLM_CONFIG_FILENAME, AgentNode, ) @@ -51,6 +53,7 @@ "get_default_llm_config", "initialize_llm_config", "lightweight_judge_default", + "load_default_model_cfg", "load_llm_config_override", "parse_llm_config", "validate_vertex_ai_credentials", @@ -58,17 +61,29 @@ def lightweight_judge_default() -> "LLMWithFallback": - """Factory default for the lightweight judge nodes (pixel safety net and - planner validation): a flash-lite model at temperature 0.""" + """Default for the lightweight judge nodes (pixel safety net and planner + validation): the deployment default endpoint at temperature 0. + + There is no built-in endpoint of any kind — everything inherits whatever + the user configured under ``default``; an unconfigured deployment yields + unconfigured judges, and model resolution fails with guidance instead of + assuming any provider. + """ + cfg = load_default_model_cfg() + provider = cfg.get("provider") + model = cfg.get("model") + if provider and model: + return LLMWithFallback( + provider=provider, + model=model, + temperature=0.0, + fallback=LLM(provider=provider, model=model, temperature=0.0), + ) return LLMWithFallback( - provider="google", - model="gemini-3.5-flash-lite", + provider=None, + model=None, temperature=0.0, - fallback=LLM( - provider="google", - model="gemini-3.1-flash-lite", - temperature=0.0, - ), + fallback=LLM(provider=None, model=None, temperature=0.0), ) @@ -130,22 +145,22 @@ def get_agent(self, item: AgentNode) -> LLMWithFallback: return val +def _resolve_llm_config_path() -> Path: + """Location of the active LLM config file (workspace artemis.jsonc, then fallbacks).""" + for candidate in ("artemis.jsonc", "artemis.json", LLM_CONFIG_FILENAME): + try: + return get_config_path(candidate) + except FileNotFoundError: + continue + return get_config_path(LLM_CONFIG_FILENAME, ROOT_DIR / LLM_CONFIG_FILENAME) + + def _expand_default_into_nodes(config_dict: dict) -> dict: """Expand unified config format with 'default' and 'nodes' into full LLMConfig schema.""" if "planner" in config_dict and "utils" in config_dict: return config_dict - default_model_cfg = config_dict.get( - "default", - { - "provider": "google", - "model": "gemini-3.8-flash", - "fallback": { - "provider": "google", - "model": "gemini-3.7-flash", - }, - }, - ) + default_model_cfg = dict(config_dict.get("default") or {}) nodes_override = config_dict.get("nodes", {}) @@ -197,22 +212,77 @@ def _expand_default_into_nodes(config_dict: dict) -> dict: return result +def _pinned_endpoint() -> dict[str, Any | None] | None: + """The default-block overrides for the record this process is pinned to. + + Pinning is how the model chosen in the setup UI survives into a running + task: the queue worker exports ``ARTEMIS_MODEL_ENDPOINT`` and every model + resolution in that process follows the named record. An unknown name raises + instead of falling back — silently running on a model nobody selected is the + failure mode worth avoiding most. + """ + from artemis.config.endpoint_library import to_default_overrides + + from artemis.config import endpoint_library + + name = os.environ.get(ENV_ARTEMIS_MODEL_ENDPOINT, "").strip() + if not name: + return None + record = endpoint_library.find(name) + if not record: + raise RuntimeError( + f"ARTEMIS_MODEL_ENDPOINT={name!r} is not in the endpoint library " + f"({endpoint_library.library_file()}). Saved: {endpoint_library.names()}" + ) + overrides = to_default_overrides(record) + if not overrides.get("provider") or not overrides.get("model"): + raise RuntimeError( + f"Endpoint record {name!r} needs both api_format and model before it can run a task." + ) + return overrides + + +def _apply_endpoint_override(config_dict: dict[str, Any]) -> dict[str, Any]: + """Layer the pinned record over the config's ``default`` entry.""" + overrides = _pinned_endpoint() + if not overrides: + return config_dict + default = dict(config_dict.get("default") or {}) + for key, value in overrides.items(): + if value is None: + default.pop(key, None) + else: + default[key] = value + return {**config_dict, "default": default} + + +def load_default_model_cfg() -> dict[str, Any]: + """Raw "default" entry of artemis.jsonc — the endpoint inheritance base. + + Nodes that configure only a model name (no provider/endpoint of their own) + inherit from this entry. There is no built-in endpoint: a missing or + unreadable config yields an empty default, and model resolution fails with + configuration guidance until the user provides one. A pinned endpoint + (``ARTEMIS_MODEL_ENDPOINT``) replaces the entry's endpoint-owned fields + here, which is what the Flash runner and the background compressors + resolve through. + """ + try: + with open(_resolve_llm_config_path(), encoding="utf-8") as f: + config_dict = load_jsonc(f) + except Exception as e: # pylint: disable=broad-exception-caught + logger.error(f"Failed to load llm config: {_resolve_llm_config_path()}. Error: {e}") + config_dict = {} + return dict(_apply_endpoint_override(config_dict).get("default") or {}) + + def parse_llm_config() -> LLMConfig: """Parse and instantiate LLMConfig from artemis.jsonc or llm-config.json.""" - config_path = None - for candidate in ("artemis.jsonc", "artemis.json", LLM_CONFIG_FILENAME): - try: - config_path = get_config_path(candidate) - break - except FileNotFoundError: - continue - - if not config_path: - config_path = get_config_path(LLM_CONFIG_FILENAME, ROOT_DIR / LLM_CONFIG_FILENAME) + config_path = _resolve_llm_config_path() try: with open(config_path, encoding="utf-8") as f: - config_dict = load_jsonc(f) + config_dict = _apply_endpoint_override(load_jsonc(f)) expanded_dict = _expand_default_into_nodes(config_dict) return LLMConfig.model_validate(expanded_dict) except Exception as e: diff --git a/artemis/config/paths.py b/artemis/config/paths.py index 6175a02e0..1f7c9b158 100644 --- a/artemis/config/paths.py +++ b/artemis/config/paths.py @@ -83,6 +83,27 @@ def get_env_file() -> Path: return get_app_dir() / ".env" if _use_user_app_dir() else ROOT_DIR / ".env" +def get_credentials_bindings_file() -> Path: + """Return the JSON file binding user-defined env var names to providers. + + Lives next to the canonical .env so each custom variable and its provider + mapping always travel together in both source checkouts and user-dir + installations. + """ + return get_env_file().parent / "credential_bindings.json" + + +def get_endpoint_library_file() -> Path: + """Return the JSON file holding the setup UI's saved endpoint library. + + A record store, not a runtime input: the endpoint the agent actually calls + stays in artemis.jsonc's ``default`` block. It sits next to the canonical + .env (like credential_bindings.json) so a checkout and a user-dir install + keep the library with the credentials its records point at. + """ + return get_env_file().parent / "endpoint_library.json" + + def get_default_traces_path() -> Path: """Returns default traces directory. diff --git a/artemis/config/settings.py b/artemis/config/settings.py index 182d7ec77..07e3bcb0e 100644 --- a/artemis/config/settings.py +++ b/artemis/config/settings.py @@ -34,11 +34,13 @@ ENV_OCR_API_KEY, ENV_OPEN_ROUTER_API_KEY, ENV_OPENAI_API_KEY, + ENV_OPENAI_BASE_URL, ENV_VISION_API_KEY, ENV_XAI_API_KEY, ) from artemis.config.paths import ( GLOBAL_APP_DIR, + get_credentials_bindings_file, get_data_engine_db_path, get_default_traces_path, get_env_file, @@ -56,6 +58,23 @@ logger = get_logger(__name__) +# Provider identity that authenticates with Google credentials (the Gemini +# Developer API plus Vertex AI). Kept local so ``config`` does not import the +# LLM package: the canonical set lives in ``artemis.llm.google.provider`` which +# is not part of the contracted device surface (D18). +_GOOGLE_FAMILY_PROVIDERS = frozenset({"google", "gemini", "vertexai", "vertex"}) + + +def _is_google_family_provider(value: object) -> bool: + """True for every provider that authenticates with Google credentials.""" + if value is None: + return False + normalized = ( + str(getattr(value, "value", value)).lower().replace("_", "").replace("-", "").strip() + ) + return normalized in _GOOGLE_FAMILY_PROVIDERS + + def is_placeholder_key(val: str | SecretStr | None) -> bool: """Check if an API credential is empty or an unconfigured placeholder value.""" if val is None: @@ -192,15 +211,13 @@ def get_api_key(self, provider: str) -> SecretStr | None: Returns: SecretStr containing the API key or None if not configured. """ - from artemis.llm.google.provider import is_google_family_provider - provider_lower = provider.lower() key: SecretStr | None = None - if is_google_family_provider(provider_lower): + if _is_google_family_provider(provider_lower): key = self.GOOGLE_API_KEY or self.GEMINI_API_KEY or self.GCP_API_KEY elif provider_lower in ("ocr", "vision", "google_vision"): key = self.OCR_API_KEY or self.VISION_API_KEY - elif provider_lower == "openai": + elif provider_lower in ("openai", "openai_responses"): key = self.OPENAI_API_KEY elif provider_lower in ("anthropic", "claude"): key = self.ANTHROPIC_API_KEY @@ -222,12 +239,10 @@ def set_api_key(self, provider: str, key: str, persist_to_env: bool = False) -> persist_to_env: Whether to save the key to the app directory's .env file. """ secret = SecretStr(key) - from artemis.llm.google.provider import is_google_family_provider - provider_lower = provider.lower() env_key_name = None - if is_google_family_provider(provider_lower): + if _is_google_family_provider(provider_lower): self.GOOGLE_API_KEY = secret self.GEMINI_API_KEY = secret self.GCP_API_KEY = secret @@ -235,7 +250,7 @@ def set_api_key(self, provider: str, key: str, persist_to_env: bool = False) -> os.environ[ENV_GOOGLE_API_KEY] = key os.environ[ENV_GEMINI_API_KEY] = key os.environ[ENV_GCP_API_KEY] = key - elif provider_lower == "openai": + elif provider_lower in ("openai", "openai_responses"): self.OPENAI_API_KEY = secret env_key_name = ENV_OPENAI_API_KEY os.environ[ENV_OPENAI_API_KEY] = key @@ -259,52 +274,109 @@ def set_api_key(self, provider: str, key: str, persist_to_env: bool = False) -> os.environ[ENV_VISION_API_KEY] = key if persist_to_env and env_key_name: - target_env_files = [get_env_file()] - seen_paths = set() - for env_file in target_env_files: - try: - env_file.parent.mkdir(parents=True, exist_ok=True) - resolved = env_file.resolve() - if resolved in seen_paths: - continue - seen_paths.add(resolved) - - lines = [] - if env_file.exists(): - lines = env_file.read_text(encoding="utf-8").splitlines() - - # Determine keys to update - keys_to_update = [env_key_name] - if is_google_family_provider(provider_lower): - keys_to_update = [ENV_GEMINI_API_KEY, ENV_GOOGLE_API_KEY, ENV_GCP_API_KEY] - elif provider_lower in ("ocr", "vision", "google_vision"): - keys_to_update = [ENV_OCR_API_KEY, ENV_VISION_API_KEY] - - new_lines = [] - updated_set = set() - for line in lines: - replaced = False - for k in keys_to_update: - if ( - line.startswith(f"{k}=") - or line.startswith(f"#{k}=") - or line.startswith(f"# {k}=") - ): - new_lines.append(f"{k}={key}") - updated_set.add(k) - replaced = True - break - if not replaced: - new_lines.append(line) - - # Ensure the primary key is present if not replaced - primary_key = keys_to_update[0] - if primary_key not in updated_set: - new_lines.append(f"{primary_key}={key}") - - env_file.write_text("\n".join(new_lines) + "\n", encoding="utf-8") - except Exception as e: - logger.warning(f"Could not persist {env_key_name} to {env_file}: {e}") + keys_to_update = [env_key_name] + if _is_google_family_provider(provider_lower): + keys_to_update = [ENV_GEMINI_API_KEY, ENV_GOOGLE_API_KEY, ENV_GCP_API_KEY] + elif provider_lower in ("ocr", "vision", "google_vision"): + keys_to_update = [ENV_OCR_API_KEY, ENV_VISION_API_KEY] + self.persist_env_values({k: key for k in keys_to_update}) + + def persist_env_values(self, keys_to_values: dict[str, str]) -> None: + """Upsert ``KEY=value`` pairs into the app directory's .env file. + + Existing (or commented-out) lines for a key are replaced in place and + missing keys are appended, so repeated saves never duplicate entries. + """ + target_env_files = [get_env_file()] + seen_paths = set() + for env_file in target_env_files: + try: + env_file.parent.mkdir(parents=True, exist_ok=True) + resolved = env_file.resolve() + if resolved in seen_paths: + continue + seen_paths.add(resolved) + + lines = [] + if env_file.exists(): + lines = env_file.read_text(encoding="utf-8").splitlines() + + new_lines = [] + updated_set = set() + for line in lines: + replaced = False + for k in keys_to_values: + if ( + line.startswith(f"{k}=") + or line.startswith(f"#{k}=") + or line.startswith(f"# {k}=") + ): + new_lines.append(f"{k}={keys_to_values[k]}") + updated_set.add(k) + replaced = True + break + if not replaced: + new_lines.append(line) + + # Ensure the primary key is present if not replaced + primary_key = next(iter(keys_to_values)) + if primary_key not in updated_set: + new_lines.append(f"{primary_key}={keys_to_values[primary_key]}") + + env_file.write_text("\n".join(new_lines) + "\n", encoding="utf-8") + except Exception as e: + logger.warning(f"Could not persist {list(keys_to_values)} to {env_file}: {e}") + + def set_openai_base_url(self, base_url: str | None, persist_to_env: bool = True) -> None: + """Point the OpenAI-compatible routing at a custom endpoint at runtime. + + Mirrors set_api_key: the settings object and process env are updated + immediately, and persist_to_env writes OPENAI_BASE_URL to the app + directory's .env file so the endpoint survives restarts. An empty or + None value clears the override. + """ + normalized = base_url.strip() if base_url else "" + if normalized: + self.OPENAI_BASE_URL = normalized + os.environ[ENV_OPENAI_BASE_URL] = normalized + else: + self.OPENAI_BASE_URL = None + os.environ.pop(ENV_OPENAI_BASE_URL, None) + if persist_to_env: + self.persist_env_values({ENV_OPENAI_BASE_URL: normalized}) + + def apply_custom_credential_bindings(self) -> int: + """Feed user-defined env var names into the provider credential store. + + Reads ``credential_bindings.json`` (custom variable name → provider, + managed from the setup UI) and applies every binding whose value is + present in the process environment — dotenv has already been loaded by + the time this runs, so custom names survive restarts. Known providers + are updated in memory only; the .env file is left untouched. Returns + the number of bindings applied. + """ + import json + + bindings_file = get_credentials_bindings_file() + applied = 0 + try: + if not bindings_file.exists(): + return 0 + data = json.loads(bindings_file.read_text(encoding="utf-8")) + entries = data.get("entries", []) if isinstance(data, dict) else [] + for entry in entries: + if not isinstance(entry, dict): + continue + name = str(entry.get("name") or "").strip() + provider = str(entry.get("provider") or "").strip() + value = os.environ.get(name, "").strip() + if not name or not provider or not value or is_placeholder_key(value): + continue + self.set_api_key(provider, value, persist_to_env=False) + applied += 1 + except Exception as e: + logger.warning(f"Could not apply custom credential bindings from {bindings_file}: {e}") + return applied # Singleton instance @@ -313,3 +385,7 @@ def set_api_key(self, provider: str, key: str, persist_to_env: bool = False) -> # Synchronize DATA_ENGINE_DB_PATH in environment for external sub-processes/tools if settings.DATA_ENGINE_DB_PATH: os.environ[ENV_DATA_ENGINE_DB_PATH] = str(settings.DATA_ENGINE_DB_PATH) + +# Apply user-defined credential bindings (custom env var names → providers) +# after dotenv has populated the environment, so custom names survive restarts. +settings.apply_custom_credential_bindings() diff --git a/artemis/controllers/__init__.py b/artemis/controllers/__init__.py index 0d3e14428..09aa07817 100644 --- a/artemis/controllers/__init__.py +++ b/artemis/controllers/__init__.py @@ -18,24 +18,12 @@ from artemis.drivers.base import BaseDeviceDriver, KeyCode, ScreenData, SwipeDirection from artemis.drivers.factory import create_driver, get_driver -from artemis.drivers.types import ( - Bounds, - CoordinatesSelectorRequest, - PercentagesSelectorRequest, - SwipeRequest, - TapOutput, -) __all__ = [ "BaseDeviceDriver", "KeyCode", "ScreenData", "SwipeDirection", - "Bounds", - "CoordinatesSelectorRequest", - "PercentagesSelectorRequest", - "SwipeRequest", - "TapOutput", "create_driver", "get_driver", ] diff --git a/artemis/controllers/unified_controller.py b/artemis/controllers/unified_controller.py index d0dc1d035..246a26d4c 100644 --- a/artemis/controllers/unified_controller.py +++ b/artemis/controllers/unified_controller.py @@ -322,8 +322,9 @@ def _segment_session_offsets( return offsets def _record_recording_failure(self, session: RecordingSession, message: str) -> None: - if self.ctx and self.ctx.data_engine: - self.ctx.data_engine.record_video_failure( + data_engine = getattr(self.ctx, "data_engine", None) + if data_engine is not None: + data_engine.record_video_failure( video_id=session.video_id, device_id=session.device_id, local_video_path=session.local_video_path, @@ -426,10 +427,11 @@ async def start_video_recording( video_id = uuid4() start_time = time.time() display_state = await get_android_display_state(device_id) + # Contraction: the data engine is no longer on the device surface. + # When absent, the recording anchors to its own first frame (offset 0). + _data_engine = getattr(self.ctx, "data_engine", None) data_engine_start_time = ( - self.ctx.data_engine.session_start_time - if (self.ctx and self.ctx.data_engine) - else start_time + _data_engine.session_start_time if _data_engine is not None else start_time ) session = RecordingSession( @@ -466,8 +468,8 @@ async def start_video_recording( stderr = await process.stderr.read() err_msg = stderr.decode() logger.error(f"scrcpy failed to start on {device_id}: {err_msg}") - if self.ctx and self.ctx.data_engine: - self.ctx.data_engine.record_video_start( + if _data_engine is not None: + _data_engine.record_video_start( video_id=video_id, device_id=device_id, local_video_path=local_video_path, @@ -493,8 +495,8 @@ async def start_video_recording( f"{timeline_note}" ) - if self.ctx and self.ctx.data_engine: - self.ctx.data_engine.record_video_start( + if _data_engine is not None: + _data_engine.record_video_start( video_id=video_id, device_id=device_id, local_video_path=local_video_path, @@ -620,9 +622,10 @@ async def stop_video_recording(self) -> VideoRecordingResult: remove_active_session(device_id) - # Persist update to local database if Data Engine is active - if self.ctx and self.ctx.data_engine: - self.ctx.data_engine.record_video_stop( + # Persist update to local database if a data engine is still attached. + data_engine = getattr(self.ctx, "data_engine", None) + if data_engine is not None: + data_engine.record_video_stop( video_id=session.video_id, device_id=device_id, local_video_path=final_video_path, diff --git a/artemis/core/diagnostics/__init__.py b/artemis/core/diagnostics/__init__.py index 55a4664f3..d411068e3 100644 --- a/artemis/core/diagnostics/__init__.py +++ b/artemis/core/diagnostics/__init__.py @@ -29,15 +29,7 @@ from artemis.core.diagnostics.engine import ReadinessEngine, readiness_engine from artemis.core.diagnostics.probes.adb_probe import AdbDeviceProbe from artemis.core.diagnostics.probes.base import BaseProbe -from artemis.core.diagnostics.probes.credentials_probe import ( - LLMCredentialsProbe, - VisionOCRProbe, -) -from artemis.core.diagnostics.probes.host_probe import IntegrationHostProbe -from artemis.core.diagnostics.probes.runtime_probe import ( - PythonRuntimeProbe, - SystemConfigProbe, -) +from artemis.core.diagnostics.probes.runtime_probe import PythonRuntimeProbe from artemis.core.diagnostics.probes.toolchain_probe import ToolchainProbe from artemis.core.diagnostics.readiness import ( CHECK_ORDER, @@ -63,12 +55,8 @@ "EmulatorLaunchStage", "EmulatorLaunchState", "BaseProbe", - "IntegrationHostProbe", "PythonRuntimeProbe", - "SystemConfigProbe", "AdbDeviceProbe", - "LLMCredentialsProbe", - "VisionOCRProbe", "ToolchainProbe", "DeviceInfo", "ProbeAction", diff --git a/artemis/core/diagnostics/engine.py b/artemis/core/diagnostics/engine.py index 97f9d20ca..d4ab911da 100644 --- a/artemis/core/diagnostics/engine.py +++ b/artemis/core/diagnostics/engine.py @@ -23,14 +23,7 @@ from artemis.core.diagnostics.adb_server_connection import adb_server_connection from artemis.core.diagnostics.probes.adb_probe import AdbDeviceProbe from artemis.core.diagnostics.probes.base import BaseProbe -from artemis.core.diagnostics.probes.credentials_probe import ( - LLMCredentialsProbe, - VisionOCRProbe, -) -from artemis.core.diagnostics.probes.runtime_probe import ( - PythonRuntimeProbe, - SystemConfigProbe, -) +from artemis.core.diagnostics.probes.runtime_probe import PythonRuntimeProbe from artemis.core.diagnostics.probes.toolchain_probe import ToolchainProbe from artemis.core.diagnostics.schema import ( DeviceInfo, @@ -62,10 +55,7 @@ class ReadinessEngine: def __init__(self): self._probes: dict[str, BaseProbe] = {} self._python_probe = PythonRuntimeProbe() - self._config_probe = SystemConfigProbe() self._toolchain_probe = ToolchainProbe() - self._credentials_probe = LLMCredentialsProbe() - self._ocr_probe = VisionOCRProbe() self._adb_probe = AdbDeviceProbe() self._report_cache: SystemReadinessReport | None = None self._report_cache_time = 0.0 @@ -73,12 +63,10 @@ def __init__(self): self._cache_generation = 0 self._report_lock = asyncio.Lock() - # Register default core probes in logical lifecycle order + # Register default core probes (device/host surface only; the LLM + # credentials/OCR and LLM system-config probes moved to the DSH host). self.register_probe(self._python_probe) - self.register_probe(self._config_probe) self.register_probe(self._toolchain_probe) - self.register_probe(self._credentials_probe) - self.register_probe(self._ocr_probe) self.register_probe(self._adb_probe) def register_probe(self, probe: BaseProbe) -> None: @@ -153,7 +141,10 @@ async def run_all( async with self._report_lock: # A refresh that completed while this caller waited satisfies even a # forced request that began before it, coalescing concurrent clicks. - if cacheable and self._report_cache_time >= request_started: + # Strictly greater: Windows' monotonic clock ticks at ~16ms, so an + # equal timestamp means the previous refresh predates this request + # and a forced refresh must rebuild instead of being swallowed. + if cacheable and self._report_cache_time > request_started: cached = self._cached_report(float("inf")) if cached is not None: return cached diff --git a/artemis/core/diagnostics/probes/adb_probe.py b/artemis/core/diagnostics/probes/adb_probe.py index 850de1d6b..9c81ada8b 100644 --- a/artemis/core/diagnostics/probes/adb_probe.py +++ b/artemis/core/diagnostics/probes/adb_probe.py @@ -232,6 +232,24 @@ async def run_dumpsys(*service_args: str) -> str: logger.debug(f"Failed to query lock state for {serial}: {exc}") return None + async def device_lock_state(self, serial: str, timeout_seconds: float = 1.0) -> bool | None: + """Public tri-state Keyguard check: ``True`` locked, ``False`` unlocked, ``None`` unknown. + + Delegates to :meth:`_get_confirmed_device_lock_state` so the double sampling that stops one + transient sample from flapping the verdict is never re-implemented by a caller. The default + bound is the one the submission gate itself uses (``probe_submission_readiness``), because a + caller asking "can I dispatch here?" needs the same answer that gate would give. + + ``None`` is a real answer, not a failure to answer: the gate treats an unknown lock state as + not-admissible (fail closed), so it must stay distinguishable from ``False``. + """ + adb_path = self._locate_adb() + if adb_path is None: + return None + return await self._get_confirmed_device_lock_state( + adb_path, serial, timeout_seconds=timeout_seconds + ) + async def _get_confirmed_device_lock_state( self, adb_path: str, diff --git a/artemis/core/diagnostics/probes/runtime_probe.py b/artemis/core/diagnostics/probes/runtime_probe.py index 2ac6a3732..b7afd980e 100644 --- a/artemis/core/diagnostics/probes/runtime_probe.py +++ b/artemis/core/diagnostics/probes/runtime_probe.py @@ -12,12 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Python Runtime and System Configuration Diagnostic Probes.""" +"""Python runtime diagnostic probe (contracted; the LLM config probe moved to host).""" import platform import sys -from artemis.config import parse_llm_config from artemis.core.diagnostics.probes.base import BaseProbe from artemis.core.diagnostics.schema import ( ProbeAction, @@ -101,69 +100,3 @@ async def probe(self) -> ProbeResult: ), ], ) - - -class SystemConfigProbe(BaseProbe): - """Probe verifying config/artemis.jsonc and settings integrity.""" - - @property - def probe_id(self) -> str: - return "system_config" - - @property - def category(self) -> ProbeCategory: - return ProbeCategory.RUNTIME - - @property - def is_blocker(self) -> bool: - return True - - async def probe(self) -> ProbeResult: - try: - cfg = parse_llm_config() - planner_model = getattr(getattr(cfg, "planner", None), "model", "Gemini 2.5 Flash") - metadata = { - "valid": True, - "config_file": "config/artemis.jsonc", - "planner_model": planner_model, - } - return ProbeResult( - id=self.probe_id, - category=self.category, - title="Artemis System Configuration", - status=ProbeStatus.PASS, - is_blocker=self.is_blocker, - summary="Config Valid", - description=f"System configuration loaded and validated (Planner model: {planner_model}).", - metadata=metadata, - actions=[ - ProbeAction( - action_type="hint", - label="Config Healthy", - payload="config/artemis.jsonc is loaded and valid.", - ) - ], - ) - except Exception as e: - metadata = { - "valid": False, - "config_file": "config/artemis.jsonc", - "error": str(e), - } - return ProbeResult( - id=self.probe_id, - category=self.category, - title="Artemis System Configuration", - status=ProbeStatus.FAIL, - is_blocker=self.is_blocker, - summary="Config Error", - description=f"Failed to parse config/artemis.jsonc: {e}", - metadata=metadata, - actions=[ - ProbeAction( - action_type="command", - label="Run Artemis Init", - payload="artemis init", - ) - ], - ) diff --git a/artemis/core/diagnostics/readiness.py b/artemis/core/diagnostics/readiness.py index 8f8f08533..a277d029f 100644 --- a/artemis/core/diagnostics/readiness.py +++ b/artemis/core/diagnostics/readiness.py @@ -22,27 +22,21 @@ from __future__ import annotations -import asyncio from collections.abc import Sequence from typing import Literal from artemis.core.diagnostics.engine import readiness_engine -from artemis.core.diagnostics.probes.host_probe import IntegrationHostProbe from artemis.core.diagnostics.schema import ProbeResult, ProbeStatus, SystemReadinessReport Verdict = Literal["ready", "degraded", "blocked"] -#: Fix order. Runtime problems mask everything after them, credentials mask -#: device problems (a task cannot start without a model), and the toolchain is -#: optional. +#: Fix order for the contracted device surface. The Python runtime comes first, +#: then the adb/device gate, then the optional toolchain. The LLM credentials, +#: system-config and MCP-integration-host probes moved to the DSH host. CHECK_ORDER: dict[str, int] = { "python_runtime": 0, - "system_config": 1, - "integration_host": 2, - "gemini_api_key": 3, - "android_adb": 4, - "toolchain": 5, - "vision_ocr_key": 6, + "android_adb": 1, + "toolchain": 2, } @@ -51,13 +45,9 @@ def sort_by_fix_order(results: Sequence[ProbeResult]) -> list[ProbeResult]: return sorted(results, key=lambda r: CHECK_ORDER.get(r.id, len(CHECK_ORDER))) -async def collect_readiness() -> tuple[SystemReadinessReport, ProbeResult]: - """Run the engine probes and the integration-host probe concurrently.""" - report, host = await asyncio.gather( - readiness_engine.run_all(force_refresh=True), - IntegrationHostProbe().probe(), - ) - return report, host +async def collect_readiness() -> SystemReadinessReport: + """Run the device/host readiness probes and return the coalesced report.""" + return await readiness_engine.run_all(force_refresh=True) def base_verdict(results: Sequence[ProbeResult]) -> Verdict: diff --git a/artemis/data_engine/engine.py b/artemis/data_engine/engine.py index 7850082bb..f637155a2 100644 --- a/artemis/data_engine/engine.py +++ b/artemis/data_engine/engine.py @@ -28,6 +28,7 @@ from uuid import UUID, uuid4 from artemis.config import PAUSE_FILE, get_ipc_port_file, read_ipc_port, settings +from artemis.config.constants import ENV_ARTEMIS_MODEL_ENDPOINT from artemis.context import ArtemisContext from artemis.data_engine.models import ( BackgroundTaskRecord, @@ -56,6 +57,17 @@ _FOREGROUND_APP_IGNORED = {"com.android.systemui"} +def _parse_submitted_at(raw: str | None) -> float | None: + """Submission wall-clock time handed over by the queue worker (best effort).""" + if not raw or not raw.strip(): + return None + try: + value = float(raw.strip()) + except ValueError: + return None + return value if value > 0 else None + + def _derive_foreground_app(ui_tree: Any | None) -> str | None: """Best-effort foreground package from a perception UI tree (M5). @@ -608,6 +620,17 @@ def start_session( start_time=self.session_start_time, device_info=device_info or {}, pid=os.getpid(), + # The queue worker pins its model through this env var; recording it + # makes "which endpoint produced this session" readable afterwards + # instead of inferred from whatever the default block says today. + model_endpoint=os.environ.get(ENV_ARTEMIS_MODEL_ENDPOINT, "").strip() or None, + # Conversation thread this submission continues (set by the queue + # worker for consecutive submissions of one console conversation). + conversation_id=os.environ.get("ARTEMIS_CONVERSATION_ID", "").strip() or None, + # Enqueue wall-clock time (set by the queue worker). The console + # orders chat rounds by it: the engine start moment reflects this + # task's device turn, not when the user actually sent the message. + submitted_at=_parse_submitted_at(os.environ.get("ARTEMIS_SUBMITTED_AT")), ) self.storage.create_session(session) try: @@ -641,8 +664,8 @@ def start_session( self._publish("session_started", session.model_dump()) return session_id - def end_session(self, status: str = "completed"): - """End the current session, updating its status and end time.""" + def end_session(self, status: str = "completed", error_message: str | None = None): + """End the current session, updating its status, end time and failure reason.""" if not self.current_session_id: return @@ -677,6 +700,8 @@ def end_session(self, status: str = "completed"): if session: session.end_time = end_time session.status = status + if error_message: + session.error_message = error_message else: session = SessionMetadata( session_id=session_id, @@ -684,6 +709,7 @@ def end_session(self, status: str = "completed"): start_time=self.session_start_time or end_time, end_time=end_time, status=status, + error_message=error_message, device_info=self.ctx.device.model_dump() if getattr(self, "ctx", None) and self.ctx.device else {}, diff --git a/artemis/data_engine/models.py b/artemis/data_engine/models.py index bd937e576..919166146 100644 --- a/artemis/data_engine/models.py +++ b/artemis/data_engine/models.py @@ -25,9 +25,24 @@ class SessionMetadata(BaseModel): start_time: float = Field(default_factory=time.time) end_time: float | None = None status: str = "running" # running, success, failed + error_message: str | None = None device_info: dict[str, Any] = Field(default_factory=dict) pid: int | None = None video_filepath: str | None = None + # Endpoint-library record this session pinned at submit time (None = ran on + # the global default). Recorded so "which model produced this" is readable + # after the fact instead of being inferred from today's configuration. + model_endpoint: str | None = None + # Conversation thread this session belongs to: consecutive submissions + # sharing a conversation_id form one chat thread in the console. Later + # submissions inherit prior context from the thread, so it behaves as + # one continuing conversation instead of isolated one-shot tasks. + conversation_id: str | None = None + # Submission (enqueue) wall-clock time. Round ordering in the console uses + # this instead of start_time: the engine only starts when the task gets its + # device turn, which is later than the enqueue moment of rounds still queued + # behind it — ordering by start_time reshuffles the chat as rounds launch. + submitted_at: float | None = None class ImageRecord(BaseModel): diff --git a/artemis/data_engine/storage.py b/artemis/data_engine/storage.py index 581737c5f..32525778b 100644 --- a/artemis/data_engine/storage.py +++ b/artemis/data_engine/storage.py @@ -124,6 +124,16 @@ def _init_db(self): except sqlite3.OperationalError: pass + try: + conn.execute("ALTER TABLE sessions ADD COLUMN error_message TEXT") + except sqlite3.OperationalError: + pass + + try: + conn.execute("ALTER TABLE sessions ADD COLUMN model_endpoint TEXT") + except sqlite3.OperationalError: + pass + conn.execute(""" CREATE TABLE IF NOT EXISTS images ( image_name TEXT PRIMARY KEY, @@ -269,6 +279,14 @@ def _init_db(self): conn.execute("ALTER TABLE background_tasks ADD COLUMN logs TEXT") except sqlite3.OperationalError: pass + try: + conn.execute("ALTER TABLE sessions ADD COLUMN conversation_id TEXT") + except sqlite3.OperationalError: + pass + try: + conn.execute("ALTER TABLE sessions ADD COLUMN submitted_at REAL") + except sqlite3.OperationalError: + pass conn.commit() logger.info(f"Database initialized at {self.db_path}") @@ -277,8 +295,8 @@ def create_session(self, session: SessionMetadata): with self._get_connection() as conn: conn.execute( """ - INSERT OR REPLACE INTO sessions (session_id, initial_goal, start_time, end_time, status, device_info, pid, video_filepath) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT OR REPLACE INTO sessions (session_id, initial_goal, start_time, end_time, status, device_info, pid, video_filepath, model_endpoint, conversation_id, submitted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( str(session.session_id), @@ -289,6 +307,9 @@ def create_session(self, session: SessionMetadata): json.dumps(session.device_info), session.pid, session.video_filepath, + session.model_endpoint, + session.conversation_id, + session.submitted_at, ), ) conn.commit() @@ -303,8 +324,8 @@ def update_session(self, session: SessionMetadata): with self._get_connection() as conn: conn.execute( """ - UPDATE sessions - SET end_time = ?, status = ?, device_info = ?, video_filepath = ? + UPDATE sessions + SET end_time = ?, status = ?, device_info = ?, video_filepath = ?, error_message = ? WHERE session_id = ? """, ( @@ -312,6 +333,7 @@ def update_session(self, session: SessionMetadata): session.status, json.dumps(session.device_info), session.video_filepath, + session.error_message, str(session.session_id), ), ) @@ -385,6 +407,69 @@ def update_session_video_path(self, session_id: UUID, video_path: str): ) conn.commit() + def get_conversation_prior_context( + self, + conversation_id: str, + exclude_session_id: str | None = None, + max_chars: int = 8000, + ) -> str | None: + """Notes of a conversation's latest finished session, as inherited context. + + Notes are the thread's durable memory (plan, milestones, findings), so + they travel across submissions of the same conversation. Content is + capped to keep the prompt injection bounded. ``None`` when the thread + has no finished session or no notes yet. + """ + latest = self.get_latest_session_in_conversation( + conversation_id, exclude_session_id=exclude_session_id + ) + if not latest: + return None + notes_dir = self.base_trace_dir / str(latest["session_id"]) / "notes" + if not notes_dir.is_dir(): + return None + sections: list[str] = [] + budget = max_chars + for md in sorted(notes_dir.glob("*.md")): + try: + content = md.read_text(encoding="utf-8", errors="replace").strip() + except OSError: + continue + if not content: + continue + section = f"### {md.stem}\n{content[:budget]}" + sections.append(section) + budget -= len(section) + if budget <= 0: + break + if not sections: + return None + goal = str(latest.get("initial_goal") or "") + header = ( + "This submission continues an ongoing conversation. The previous task" + f" of this thread ({goal!r}) produced the notes below; build on them" + " instead of starting from scratch unless the new goal says otherwise." + ) + return header + "\n\n" + "\n\n".join(sections) + + def get_latest_session_in_conversation( + self, conversation_id: str, exclude_session_id: str | None = None + ): + """Most recent finished session of a conversation, excluding one id. + + Used by conversation continuation: the next submission inherits the + thread's latest recorded outcome as prior context. + """ + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT * FROM sessions WHERE conversation_id = ? AND session_id != ? " + "AND end_time IS NOT NULL ORDER BY start_time DESC LIMIT 1", + (conversation_id, exclude_session_id or ""), + ) + row = cursor.fetchone() + return dict(row) if row else None + def get_video_recording(self, video_id: UUID) -> VideoRecordingRecord | None: """Retrieve a video recording record by ID.""" with self._get_connection() as conn: @@ -836,6 +921,9 @@ def get_session(self, session_id: UUID | str) -> SessionMetadata | None: device_info=json.loads(row_dict["device_info"]), pid=row_dict.get("pid"), video_filepath=row_dict.get("video_filepath"), + model_endpoint=row_dict.get("model_endpoint"), + conversation_id=row_dict.get("conversation_id"), + submitted_at=row_dict.get("submitted_at"), ) return None diff --git a/artemis/drivers/__init__.py b/artemis/drivers/__init__.py index c240b9494..55560759a 100644 --- a/artemis/drivers/__init__.py +++ b/artemis/drivers/__init__.py @@ -22,26 +22,12 @@ ) from artemis.drivers.factory import create_driver, get_driver from artemis.drivers.mock.mock_driver import MockDeviceDriver -from artemis.drivers.types import ( - Bounds, - CoordinatesSelectorRequest, - DeviceInfo, - PercentagesSelectorRequest, - SwipeRequest, - TapOutput, -) __all__ = [ "BaseDeviceDriver", "KeyCode", "ScreenData", "SwipeDirection", - "Bounds", - "CoordinatesSelectorRequest", - "PercentagesSelectorRequest", - "SwipeRequest", - "TapOutput", - "DeviceInfo", "create_driver", "get_driver", "MockDeviceDriver", diff --git a/artemis/drivers/cloud/cloud_driver.py b/artemis/drivers/cloud/cloud_driver.py deleted file mode 100644 index 4bfcb67df..000000000 --- a/artemis/drivers/cloud/cloud_driver.py +++ /dev/null @@ -1,117 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Remote Cloud Mobile Device Driver.""" - -from pathlib import Path -from typing import Literal -from artemis.drivers.base import BaseDeviceDriver, KeyCode, ScreenData, SwipeDirection -from third_party.mobile_use.utils.logger import get_logger - -logger = get_logger(__name__) - - -class CloudDeviceDriver(BaseDeviceDriver): - """Driver communicating with remote Android devices via Cloud Gateway RPC.""" - - def __init__( - self, - device_id: str = "cloud-device", - gateway_url: str = "http://127.0.0.1:8000", - session_id: str = "default-cloud-session", - width: int = 1080, - height: int = 2400, - ): - self._device_id = device_id - self.gateway_url = gateway_url - self.session_id = session_id - self._width = width - self._height = height - - @property - def device_id(self) -> str: - return self._device_id - - @property - def screen_size(self) -> tuple[int, int]: - return (self._width, self._height) - - async def connect(self) -> None: - logger.info(f"Connecting to Cloud Device {self._device_id} at {self.gateway_url}") - - async def disconnect(self) -> None: - logger.info(f"Disconnecting Cloud Device {self._device_id}") - - async def get_screen_data(self, skip_settling: bool = False) -> ScreenData: - return ScreenData( - screenshot_bytes=b"", - screenshot_base64="", - width=self._width, - height=self._height, - platform="cloud", - ) - - async def tap( - self, - x: int, - y: int, - duration_ms: int = 100, - times: int = 1, - delay_ms: int = 100, - ) -> bool: - return True - - async def long_press(self, x: int, y: int, duration_ms: int = 1000) -> bool: - return True - - async def swipe( - self, - start_x: int, - start_y: int, - end_x: int, - end_y: int, - duration_ms: int = 400, - ) -> bool: - return True - - async def swipe_direction( - self, - direction: SwipeDirection | Literal["up", "down", "left", "right"], - duration_ms: int = 400, - ) -> bool: - return True - - async def input_text(self, text: str, clear_existing: bool = True) -> bool: - return True - - async def press_key(self, key: KeyCode | str | int) -> bool: - return True - - async def launch_app(self, package_name: str) -> bool: - return True - - async def stop_app(self, package_name: str) -> bool: - return True - - async def get_current_package(self) -> str | None: - return None - - async def execute_shell(self, command: str, timeout_seconds: float = 15.0) -> str: - return f"cloud_output: {command}" - - async def start_video_recording(self, output_dir: Path | None = None) -> None: - pass - - async def stop_video_recording(self) -> str | None: - return None diff --git a/artemis/graph/state.py b/artemis/graph/state.py index 560104ff8..451662d15 100644 --- a/artemis/graph/state.py +++ b/artemis/graph/state.py @@ -66,6 +66,13 @@ class State(BaseModel): " prompt template)", take_last, ] = None + # ── Conversation continuation (inherited thread context) ───────────── + prior_conversation_context: Annotated[ + str | None, + "Notes/outcome of the previous task in this conversation, handed to" + " the Planner so consecutive submissions continue one thread", + take_last, + ] = None run_outcome: Annotated[ dict | None, "Machine-readable run outcome (task_status + test summary), populated" @@ -142,10 +149,13 @@ class State(BaseModel): ] = None @classmethod - def initial(cls, goal: str) -> "State": + def initial(cls, goal: str, prior_conversation_context: str | None = None) -> "State": """Single source for the graph's initial state. Every entrypoint (SDK, engine runners, tests) must construct the first State through here so required fields stay in one place. """ - return cls(initial_goal=goal) + return cls( + initial_goal=goal, + prior_conversation_context=prior_conversation_context, + ) diff --git a/artemis/interfaces/cli/commands/batch.py b/artemis/interfaces/cli/commands/batch.py deleted file mode 100644 index 225ba98fc..000000000 --- a/artemis/interfaces/cli/commands/batch.py +++ /dev/null @@ -1,278 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Batch tasks execution command (artemis batch).""" - -import asyncio -import json -import os -from pathlib import Path -from typing import Annotated - -from artemis.config import initialize_llm_config -from artemis.sdk import Agent -from artemis.sdk.builders import Builders -from third_party.mobile_use.sdk.types.task import AgentProfile -from third_party.mobile_use.utils.logger import get_logger -from rich.console import Console -from rich.table import Table -import typer - -logger = get_logger(__name__) - - -async def run_batch_tasks( - tasks: list[str], - profile_name: str = "pro", - delay_seconds: float = 5.0, - verification_level: str | None = None, - explorer_pro_mode: str | None = None, -) -> None: - """Executes a list of automation tasks sequentially. - - Args: - tasks: Goals to run one after another on a single agent. - profile_name: Execution profile ('flash' or 'pro') applied to every goal. - delay_seconds: Pause between successive goals. - verification_level: Coarse Checker preset ('off', 'final', 'checkpoints', - 'strict') for the Pro profile; ignored by Flash. - explorer_pro_mode: Explorer tier ('flash', 'pro', 'ultra') behind - ``ask_explorer`` under the Pro profile; ignored by Flash. - """ - if not os.environ.get("ARTEMIS_TASK_INGRESS"): - os.environ["ARTEMIS_TASK_INGRESS"] = "cli" - llm_config = initialize_llm_config() - profile = AgentProfile(name="default", llm_config=llm_config) - config_builder = Builders.AgentConfig.with_default_profile(profile) - if verification_level is not None: - config_builder.with_verification_level(verification_level) - if explorer_pro_mode is not None: - config_builder.with_explorer(pro_mode=explorer_pro_mode) - config = config_builder.build() - - agent = Agent(config=config) - await agent.init() - - console = Console() - results = [] - - try: - for idx, goal in enumerate(tasks, start=1): - console.rule(f"[bold cyan]Task {idx}/{len(tasks)}: {goal}[/bold cyan]") - status = "SUCCESS" - error_msg = "" - try: - result = await agent.run_task(goal=goal, profile=profile_name) - logger.info(f"Task {idx} completed: {result}") - except Exception as e: - status = "FAILED" - error_msg = str(e) - logger.error(f"Task {idx} failed: {e}") - - results.append({"task": goal, "status": status, "error": error_msg}) - - if idx < len(tasks) and delay_seconds > 0: - await asyncio.sleep(delay_seconds) - finally: - await agent.clean() - - # Print summary table - table = Table(title="Batch Execution Summary") - table.add_column("#", justify="right", style="cyan") - table.add_column("Task Goal", style="white") - table.add_column("Status", justify="center") - table.add_column("Notes", style="red") - - for i, res in enumerate(results, 1): - status_styled = ( - "[bold green]PASS[/bold green]" - if res["status"] == "SUCCESS" - else "[bold red]FAIL[/bold red]" - ) - table.add_row(str(i), res["task"], status_styled, res["error"]) - - console.print(table) - - -def batch_command( - tasks_file: Annotated[ - Path | None, - typer.Option( - "--file", - "-f", - help="Path to a text or JSON file containing task goals (one per line or JSON array).", - ), - ] = None, - goals: Annotated[ - list[str] | None, - typer.Argument( - help="Task goals specified directly on the command line.", - ), - ] = None, - profile: Annotated[ - str, - typer.Option( - "--profile", - "-p", - help="Execution profile to use for all tasks ('flash' or 'pro').", - ), - ] = "pro", - delay: Annotated[ - float, - typer.Option( - "--delay", - "-d", - help="Delay in seconds between successive tasks.", - ), - ] = 5.0, - standalone: Annotated[ - bool, - typer.Option( - "--standalone", - help="Run in standalone embedded mode without routing through Artemis Daemon.", - ), - ] = False, - verification_level: Annotated[ - str | None, - typer.Option( - "--verification-level", - help=( - "Checker preset for the Pro profile, applied to every goal: 'off' (no audit)," - " 'final' (exit review only, the default), 'checkpoints' (every plan" - " checkpoint + exit review), 'strict' (checkpoints with a larger repair" - " budget; a failed assert halts)." - ), - ), - ] = None, - explorer_pro_mode: Annotated[ - str | None, - typer.Option( - "--explorer-pro-mode", - help="Explorer tier behind ask_explorer under the Pro profile ('flash', 'pro', 'ultra').", - ), - ] = None, -) -> None: - """Execute multiple automation tasks in sequence.""" - task_list: list[str] = [] - - if tasks_file: - if not tasks_file.exists(): - typer.secho(f"Error: Tasks file '{tasks_file}' not found.", fg=typer.colors.RED) - raise typer.Exit(1) - - content = tasks_file.read_text(encoding="utf-8").strip() - if tasks_file.suffix.lower() == ".json": - try: - parsed = json.loads(content) - if isinstance(parsed, list): - task_list.extend(str(item) for item in parsed) - else: - typer.secho( - "Error: JSON file must contain a list of goals.", fg=typer.colors.RED - ) - raise typer.Exit(1) - except Exception as e: - typer.secho(f"Error parsing JSON file: {e}", fg=typer.colors.RED) - raise typer.Exit(1) - else: - task_list.extend( - [ - line.strip() - for line in content.splitlines() - if line.strip() and not line.startswith("#") - ] - ) - - if goals: - task_list.extend(goals) - - if not task_list: - typer.secho( - "Error: No tasks provided. Use --file or pass goals as arguments.", fg=typer.colors.RED - ) - raise typer.Exit(1) - - is_standalone = standalone or os.environ.get("ARTEMIS_STANDALONE") == "1" - if not is_standalone: - try: - from artemis.runtime import ( - ensure_daemon_running, - submit_batch_to_daemon, - wait_for_daemon_task, - ) - - console = Console() - console.print("[dim]Connecting to Artemis Daemon scheduler...[/dim]") - is_running, base_url = ensure_daemon_running(timeout=8.0, wait_ready=True) - if is_running and base_url: - console.print( - f"[bold green]✓[/bold green] Artemis Daemon active at [cyan]{base_url}[/cyan]" - ) - resp = submit_batch_to_daemon( - task_list, - profile=profile, - verification_level=verification_level, - explorer_mode=explorer_pro_mode, - base_url=base_url, - ) - if resp and resp.get("tasks"): - console.print( - f"[bold green]✓[/bold green] Enqueued {len(task_list)} batch tasks in Daemon scheduler.\n" - ) - results = [] - for idx, t_item in enumerate(resp["tasks"], 1): - sid = t_item.get("session_id") - goal = t_item.get("goal") - console.print( - f"[cyan]Waiting for Task {idx}/{len(task_list)}: '{goal}' (Session: {sid})...[/cyan]" - ) - final_sess = wait_for_daemon_task(sid, base_url=base_url, timeout=1800.0) - st = final_sess.get("status") - err = final_sess.get("error") or "" - results.append( - { - "task": goal, - "status": "SUCCESS" if st in ("completed", "success") else "FAILED", - "error": err, - } - ) - - table = Table(title="Batch Execution Summary") - table.add_column("#", justify="right", style="cyan") - table.add_column("Task Goal", style="white") - table.add_column("Status", justify="center") - table.add_column("Notes", style="red") - - for i, res in enumerate(results, 1): - status_styled = ( - "[bold green]PASS[/bold green]" - if res["status"] == "SUCCESS" - else "[bold red]FAIL[/bold red]" - ) - table.add_row(str(i), res["task"], status_styled, res["error"]) - - console.print(table) - return - except Exception as exc: - logger.debug(f"Batch daemon routing notice: {exc}") - - asyncio.run( - run_batch_tasks( - task_list, - profile_name=profile, - delay_seconds=delay, - verification_level=verification_level, - explorer_pro_mode=explorer_pro_mode, - ) - ) diff --git a/artemis/interfaces/cli/commands/doctor.py b/artemis/interfaces/cli/commands/doctor.py index 1c141ec2d..e3d4385a7 100644 --- a/artemis/interfaces/cli/commands/doctor.py +++ b/artemis/interfaces/cli/commands/doctor.py @@ -14,10 +14,11 @@ """System, environment, and device diagnostics (artemis doctor). -The command renders the same readiness probes the web console's device wizard -and the ``mobile_diagnose`` MCP tool run (:mod:`artemis.core.diagnostics`), so -the terminal, the browser and the IDE agree on one verdict. Only two rows are -CLI-specific and not engine probes: Node.js/npm and the Showcase UI build. +The command renders the device/host readiness probes +(:mod:`artemis.core.diagnostics`): the Python runtime, the adb/toolchain and +the connected device, plus the accessibility helper. The LLM credentials, +OCR, LLM system-config, Node.js/npm and Showcase UI rows moved to the DSH host +with the smart stack and web console. """ from __future__ import annotations @@ -26,7 +27,6 @@ from dataclasses import dataclass import json import platform as sys_platform -import shutil import subprocess from typing import Any @@ -36,7 +36,6 @@ from rich.table import Table import typer -from artemis.config.paths import ROOT_DIR from artemis.core.diagnostics.engine import readiness_engine from artemis.core.diagnostics.readiness import ( Verdict, @@ -46,7 +45,7 @@ sort_by_fix_order, ) from artemis.core.diagnostics.schema import ProbeResult, ProbeStatus -from artemis.platform import OSType, platform +from artemis.platform import platform from artemis.runtime.device_lock import DeviceExecutionLock @@ -77,8 +76,8 @@ class FixOutcome: async def _collect() -> list[ProbeResult]: - report, host = await collect_readiness() - return sort_by_fix_order([*report.probes, host]) + report = await collect_readiness() + return sort_by_fix_order(report.probes) async def _apply_fixes(results: list[ProbeResult]) -> list[FixOutcome]: @@ -133,68 +132,6 @@ async def _diagnose(fix: bool) -> tuple[list[ProbeResult], list[FixOutcome]]: # --------------------------------------------------------------------------- -def _npm_row() -> ExtraRow: - npm_path = shutil.which("npm") - if npm_path: - return ExtraRow( - key="nodejs_npm", - title="Node.js / npm", - status="pass", - status_markup="[bold green]✔ Installed[/bold green]", - summary="Installed", - detail=f"{npm_path} (Ready to build Showcase UI)", - ) - hint = ( - "winget install OpenJS.NodeJS.LTS" - if platform.os_type == OSType.WINDOWS - else "brew install node" - if platform.os_type == OSType.MACOS - else "sudo apt-get install -y nodejs npm" - ) - return ExtraRow( - key="nodejs_npm", - title="Node.js / npm", - status="missing", - status_markup="[dim]⚪ Optional[/dim]", - summary="Not Found", - detail=f"Not found in PATH. ({hint})", - ) - - -def _showcase_row() -> ExtraRow: - from artemis.resources import get_bundled_showcase_dist - - base_dist = ROOT_DIR / "apps" / "showcase_ui" / "dist" - candidates = [ - base_dist / "frontend" / "browser" / "index.html", - base_dist / "browser" / "index.html", - base_dist / "frontend" / "index.html", - base_dist / "index.html", - ] - found_showcase = next((p for p in candidates if p.exists()), None) - if found_showcase is None: - bundled_showcase = get_bundled_showcase_dist() - if bundled_showcase is not None: - found_showcase = bundled_showcase / "index.html" - if found_showcase: - return ExtraRow( - key="showcase_ui", - title="Showcase UI", - status="pass", - status_markup="[bold green]✔ Compiled[/bold green]", - summary="Compiled", - detail=f"{found_showcase.parent} (Angular ready)", - ) - return ExtraRow( - key="showcase_ui", - title="Showcase UI", - status="missing", - status_markup="[bold yellow]○ Not Compiled[/bold yellow]", - summary="Not Compiled", - detail="Run ./start.sh or artemis ui to auto-compile.", - ) - - def _helper_row(results: list[ProbeResult]) -> ExtraRow | None: """State of the Artemis accessibility helper on the one ready, idle device. @@ -332,8 +269,8 @@ def _render_footer(console: Console, verdict: Verdict, results: list[ProbeResult console.print( Panel( "[bold green]🎉 All system checks passed![/bold green]\n\n" - "Run your first automation task now:\n" - ' [bold cyan]artemis run "Open Settings and check Battery level"[/bold cyan]', + "Start the device-action surface the DSH plugin calls:\n" + " [bold cyan]artemis mcp --type adb[/bold cyan]", title="Status: Ready", expand=False, ) @@ -345,8 +282,8 @@ def _render_footer(console: Console, verdict: Verdict, results: list[ProbeResult Panel( "[bold green]Required checks passed.[/bold green] Optional components are " "missing; tasks can run, but some features (video replay, OCR) are off.\n\n" - "Run your first automation task now:\n" - ' [bold cyan]artemis run "Open Settings and check Battery level"[/bold cyan]', + "Start the device-action surface the DSH plugin calls:\n" + " [bold cyan]artemis mcp --type adb[/bold cyan]", title="Status: Ready (optional gaps)", expand=False, ) @@ -433,7 +370,7 @@ def doctor_command( ) -> None: """Run diagnostics to inspect system dependencies, device connectivity, and configuration.""" results, fixes = asyncio.run(_diagnose(fix)) - extras = [_npm_row(), _showcase_row()] + extras: list[ExtraRow] = [] helper_row = _helper_row(results) if helper_row is not None: extras.append(helper_row) diff --git a/artemis/interfaces/cli/commands/helper.py b/artemis/interfaces/cli/commands/helper.py index a07f7f0a8..ded6f3ad9 100644 --- a/artemis/interfaces/cli/commands/helper.py +++ b/artemis/interfaces/cli/commands/helper.py @@ -223,4 +223,49 @@ def helper_parity( raise typer.Exit(code=0 if report["ok"] else 1) +@helper_app.command("smoke") +def helper_smoke( + serial: SerialOption = None, + timeout: Annotated[ + float, typer.Option("--timeout", help="Seconds to wait for the observation.") + ] = 20.0, + as_json: Annotated[bool, typer.Option("--json", help="Print raw JSON.")] = False, +) -> None: + """Capture one screenshot + hierarchy dump exactly like a task, to prove the device is + actually observable (not just that ``adb devices`` lists it). + + UIAutomator hangs and placeholder screenshots otherwise only surface as a + failed task. This performs the same observation as the MCP tool and reports + a structured verdict with concrete repair steps. A device another Artemis + task is driving is reported as busy and skipped rather than disturbed. + """ + import asyncio + + from artemis.core.diagnostics.device_smoke import smoke_test_device + + target = _resolve_serial(serial) + report = asyncio.run(smoke_test_device(device_serial=target, timeout_seconds=timeout)) + if as_json: + typer.echo(json.dumps(report, indent=2, ensure_ascii=False)) + raise typer.Exit(code=0 if report["ok"] else 1) + + table = Table(title=f"Device smoke test on {report.get('serial') or target}") + table.add_column("Field", style="cyan") + table.add_column("Value") + table.add_row( + "Result", "[green]SMOKE OK[/green]" if report["ok"] else "[red]SMOKE FAILED[/red]" + ) + table.add_row("Elapsed", f"{report['elapsed_seconds']:.1f} s") + table.add_row("Screenshot bytes", str(report["screenshot_bytes"])) + table.add_row("UI elements", str(report["element_count"])) + table.add_row("Hierarchy backend", str(report["hierarchy_backend"])) + Console().print(table) + + if report.get("error"): + typer.secho(f" ! {report['error']}", fg=typer.colors.RED) + for step in report.get("fix", []): + typer.secho(f" - {step}", fg=typer.colors.YELLOW) + raise typer.Exit(code=0 if report["ok"] else 1) + + __all__ = ["helper_app"] diff --git a/artemis/interfaces/cli/commands/init.py b/artemis/interfaces/cli/commands/init.py deleted file mode 100644 index 9e7e7b090..000000000 --- a/artemis/interfaces/cli/commands/init.py +++ /dev/null @@ -1,193 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Interactive setup wizard for first-time ARTEMIS onboarding (artemis init).""" - -import shutil -import subprocess - -from rich.console import Console -from rich.panel import Panel -from rich.prompt import Prompt - -from artemis.config.paths import get_env_file - - -def init_command() -> None: - """Interactive quickstart wizard to configure API keys and connect your device.""" - console = Console() - console.print() - console.print( - Panel( - "[bold cyan]☕ Welcome to Artemis Mobile Testing Framework[/bold cyan]\n" - "[dim]Let's get you set up in 10 seconds![/dim]", - expand=False, - ) - ) - - # 1. Select Provider - console.print("\n[bold]Step 1: Choose Primary LLM Provider[/bold]") - console.print(" [1] Google Gemini (Recommended - Default)") - console.print(" [2] OpenAI (GPT-4o)") - console.print(" [3] Anthropic (Claude 3.5 Sonnet)") - console.print(" [4] OpenRouter") - - choice = Prompt.ask("Select provider", choices=["1", "2", "3", "4"], default="1") - provider_map = { - "1": ("GEMINI_API_KEY", "Google Gemini"), - "2": ("OPENAI_API_KEY", "OpenAI"), - "3": ("ANTHROPIC_API_KEY", "Anthropic"), - "4": ("OPEN_ROUTER_API_KEY", "OpenRouter"), - } - env_key, provider_name = provider_map[choice] - - # 2. Enter API Key - console.print(f"\n[bold]Step 2: Enter your {provider_name} API Key[/bold]") - api_key = Prompt.ask(f"Enter {env_key}", password=True) - while not api_key.strip(): - console.print("[bold red]API Key cannot be empty.[/bold red]") - api_key = Prompt.ask(f"Enter {env_key}", password=True) - - # 3. Scan for Android Devices - console.print("\n[bold]Step 3: Detecting Android Devices / Emulators...[/bold]") - detected_devices = [] - adb_path = shutil.which("adb") - if adb_path: - try: - res = subprocess.run( - [adb_path, "devices", "-l"], - capture_output=True, - text=True, - timeout=5, - ) - lines = [line_str.strip() for line_str in res.stdout.splitlines() if line_str.strip()] - for line in lines[1:]: - parts = line.split() - if len(parts) >= 2 and parts[1] == "device": - serial = parts[0] - extra = " ".join(parts[2:]) - detected_devices.append((serial, extra)) - except (OSError, subprocess.SubprocessError): - # adb probe failed or timed out; continue setup with no devices. - pass - - selected_serial = "" - if detected_devices: - console.print( - f"[bold green]✔ Found {len(detected_devices)} connected device(s):[/bold green]" - ) - for idx, (serial, extra) in enumerate(detected_devices, 1): - console.print(f" [{idx}] {serial} ({extra})") - if len(detected_devices) == 1: - selected_serial = detected_devices[0][0] - console.print(f"[dim]Auto-selected device: {selected_serial}[/dim]") - else: - dev_choice = Prompt.ask( - "Select device index", - choices=[str(i) for i in range(1, len(detected_devices) + 1)], - default="1", - ) - selected_serial = detected_devices[int(dev_choice) - 1][0] - else: - console.print( - "[bold yellow]⚠ No connected device detected right now.[/bold yellow]\n" - "[dim]Artemis will auto-detect your device when you connect it later.[/dim]" - ) - - # 4. Generate .env File - env_content = [ - "# ==============================================================================", - "# ☕ ARTEMIS Environment Configuration (Generated by 'artemis init')", - "# ==============================================================================", - f"{env_key}={api_key.strip()}", - "", - "# Android Device Configuration", - "ADB_HOST=127.0.0.1", - "ADB_PORT=5037", - ] - if selected_serial: - env_content.append(f"ADB_DEVICE_SERIAL={selected_serial}") - else: - env_content.append("# ADB_DEVICE_SERIAL=emulator-5554") - - env_content.extend( - [ - "", - "# Execution Defaults", - "ARTEMIS_DEFAULT_PROFILE=pro", - "ARTEMIS_DEFAULT_MODEL=gemini-2.5-flash", - "ARTEMIS_TRACES_DIR=./traces", - "", - ] - ) - - env_path = get_env_file() - env_path.parent.mkdir(parents=True, exist_ok=True) - env_path.write_text("\n".join(env_content), encoding="utf-8") - - # 5. Configure MCP for AI IDEs - console.print("\n[bold]Step 5: Connect Artemis to your AI IDE (MCP)[/bold]") - console.print(" [1] Antigravity / Jetski (Recommended - Default)") - console.print(" [2] Cursor") - console.print(" [3] Claude Desktop / Claude Code") - console.print(" [4] Windsurf") - console.print(" [5] VS Code / Cline / Roo Code") - console.print(" [6] Codex") - console.print(" [7] All supported IDEs") - console.print(" [8] Skip for now") - mcp_choice = Prompt.ask( - "Select IDE for MCP auto-install", - choices=["1", "2", "3", "4", "5", "6", "7", "8"], - default="1", - ) - if mcp_choice != "8": - target_map = { - "1": "antigravity", - "2": "cursor", - "3": "claude", - "4": "windsurf", - "5": "vscode", - "6": "codex", - "7": "all", - } - target = target_map.get(mcp_choice, "antigravity") - try: - from artemis.interfaces.cli.commands.mcp import install_mcp_config - from mcp_server.utils import env_utils - - proj_root = env_utils.get_project_root() - py_exe = env_utils.resolve_python_executable(proj_root) - installed_paths = install_mcp_config(target, py_exe, proj_root) - console.print( - f"[bold green]✔ Configured Artemis MCP in {len(installed_paths)} IDE location(s).[/bold green]" - ) - except Exception as e: - console.print(f"[yellow]Could not auto-install MCP config: {e}[/yellow]") - - console.print() - console.print( - Panel( - f"[bold green]🎉 Setup completed successfully![/bold green]\n\n" - f"Configured [bold cyan]{provider_name}[/bold cyan] in [dim]{env_path}[/dim]\n\n" - "Try running your first task:\n" - ' [bold cyan]uv run artemis run "Open YouTube and search for Lo-Fi Hip Hop"[/bold cyan]\n\n' - "Or connect/update your AI IDE anytime with:\n" - " [bold cyan]uv run artemis mcp --install all[/bold cyan]\n\n" - "Or run health diagnostics anytime with:\n" - " [bold cyan]uv run artemis doctor[/bold cyan]", - title="☕ Ready to Go!", - expand=False, - ) - ) - console.print() diff --git a/artemis/interfaces/cli/commands/mcp.py b/artemis/interfaces/cli/commands/mcp.py index a819d977f..a9a854851 100644 --- a/artemis/interfaces/cli/commands/mcp.py +++ b/artemis/interfaces/cli/commands/mcp.py @@ -12,683 +12,28 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Model Context Protocol (MCP) server command (artemis mcp).""" +"""Device-facing MCP server command (``artemis mcp``), contracted for D18. + +Only the device MCP servers remain: + +- ``adb`` -- the raw device-action tool surface over a real ADB device; +- ``mock`` -- the same tool surface over an in-memory device (no adb, no hardware); +- ``xml`` -- fuzzy XML / history search over a recorded trace store. + +The LLM-driven ``agent`` server and the IDE ``--install`` / ``--generate-config`` +rule injection now live in the DSH host and have been removed. +""" -import json -import os -from pathlib import Path -import re -import sys import threading -import tomllib from typing import Annotated -from mcp_server.base import mcp as agent_mcp -import mcp_server.tools # noqa: F401 -from mcp_server.utils import env_utils +import typer + from artemis.mcp.adb_server import mcp as adb_mcp from artemis.runtime import shutdown_awake_service, start_awake_service from third_party.mobile_use.utils.logger import get_logger -from rich.console import Console -from rich.syntax import Syntax -import typer logger = get_logger(__name__) -console = Console() - -ARTEMIS_MCP_TOOLS = ( - "mobile_run_task", - "mobile_manage_task", - "mobile_get_device_state", - "mobile_inspect_trace", - "mobile_diagnose", -) - - -def _eager_tools_config() -> dict[str, dict[str, bool]]: - """Return the eager-loading extension used by clients that support it.""" - return {tool_name: {"eager": True} for tool_name in ARTEMIS_MCP_TOOLS} - - -def _get_vscode_user_dir() -> Path: - """Returns the platform-specific VS Code user configuration directory.""" - if sys.platform == "darwin": - return Path.home() / "Library" / "Application Support" / "Code" / "User" - elif sys.platform == "win32": - appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming") - return Path(appdata) / "Code" / "User" - else: - return Path.home() / ".config" / "Code" / "User" - - -def _get_config_snippet(client: str, python_exe: str, project_root: str) -> dict: - """Generates MCP configuration dictionary for the specified client.""" - config_body = { - "command": python_exe, - "args": ["-m", "mcp_server"], - "env": { - "PYTHONUNBUFFERED": "1", - "PYTHONPATH": project_root, - "ARTEMIS_DESKTOP_NOTIFY": "true", - }, - } - config_with_cwd = {**config_body, "cwd": project_root} - - if client == "antigravity": - return { - "mcpServers": { - "artemis": { - **config_with_cwd, - # This is the documented way to expose every tool. Avoid - # proprietary eager fields in the current config so a - # strict parser can always load the server. - "disabledTools": [], - } - } - } - elif client == "jetski": - return { - "mcpServers": { - "artemis": { - **config_with_cwd, - "disabledTools": [], - # Retain the legacy Jetski eager extension only in its - # legacy config file. Current Antigravity gets the strict, - # documented configuration above as a safe fallback. - "tools": _eager_tools_config(), - } - } - } - elif client == "openclaw": - return { - "mcp": { - "servers": { - "artemis": { - **config_with_cwd, - "enabled": True, - "connectionTimeoutMs": 120000, - } - } - } - } - elif client == "codex": - return { - "mcp_servers": { - "artemis": { - **config_with_cwd, - # Codex has no eager/deferred MCP setting. Requiring the - # server makes it initialize during host startup, while an - # explicit allow-list guarantees all Artemis tools are - # registered. The host may still present schemas through - # tool search to control context size. - "enabled": True, - "required": True, - "startup_timeout_sec": 120, - "enabled_tools": list(ARTEMIS_MCP_TOOLS), - } - } - } - elif client == "vscode": - return {"servers": {"artemis": {**config_with_cwd, "type": "stdio"}}} - elif client in ("claude", "claude_code", "claude_desktop"): - return {"mcpServers": {"artemis": {**config_body, "type": "stdio"}}} - elif client == "cline": - return { - "mcpServers": { - "artemis": { - **config_body, - "disabled": False, - "autoApprove": [], - "timeout": 120, - } - } - } - elif client in ("roo", "roo_code"): - return { - "mcpServers": { - "artemis": { - **config_body, - "disabled": False, - "alwaysAllow": [], - "timeout": 120, - } - } - } - else: # cursor, windsurf, generic - return {"mcpServers": {"artemis": config_body}} - - -def _parse_json_lenient(text: str) -> dict | None: - """Parses JSON or JSONC (JSON with comments / trailing commas) safely. - - Returns ``None`` when the text cannot be parsed as a JSON object, so callers - can tell a genuinely empty ``{}`` file apart from a corrupt one. - """ - try: - data = json.loads(text) - return data if isinstance(data, dict) else None - except ValueError: - # Not strict JSON; fall through to the lenient JSONC cleanup below. - pass - # Remove // single-line comments - cleaned = re.sub(r"//.*", "", text) - # Remove /* ... */ multi-line comments - cleaned = re.sub(r"/\*.*?\*/", "", cleaned, flags=re.DOTALL) - # Remove trailing commas before } or ] - cleaned = re.sub(r",\s*([}\]])", r"\1", cleaned) - try: - data = json.loads(cleaned) - return data if isinstance(data, dict) else None - except Exception: - return None - - -def _merge_json_file( - file_path: Path, - server_name: str, - server_config: dict, - key_name: str | tuple[str, ...] = "mcpServers", - remove_paths: tuple[tuple[str, ...], ...] = (), - strict_parse: bool = False, -) -> bool: - """Merges server configuration into a target JSON file without overwriting existing servers. - - With ``strict_parse=True``, an existing non-empty file that cannot be parsed - aborts the merge instead of being replaced. Use this for files that hold - state beyond MCP config (e.g. ~/.claude.json), where a degraded rewrite - would destroy unrelated data. - """ - try: - file_path.parent.mkdir(parents=True, exist_ok=True) - data: dict = {} - if file_path.exists() and file_path.stat().st_size > 0: - raw_text = file_path.read_text(encoding="utf-8") - parsed = _parse_json_lenient(raw_text) - data = parsed or {} - if parsed is None and raw_text.strip(): - if strict_parse: - logger.warning( - f"Existing JSON in {file_path} could not be parsed; refusing to rewrite it. " - "Fix or remove the file, then re-run the install." - ) - return False - # Backup unparseable existing file to prevent accidental loss - backup_path = file_path.with_suffix(file_path.suffix + ".bak") - try: - backup_path.write_text(raw_text, encoding="utf-8") - logger.warning( - f"Existing JSON in {file_path} could not be parsed; backup created at {backup_path}" - ) - except OSError as exc: - logger.warning( - f"Existing JSON in {file_path} could not be parsed AND the backup to" - f" {backup_path} failed ({exc}); refusing to rewrite it." - " Fix or remove the file, then re-run the install." - ) - return False - - for remove_path in remove_paths: - parent = data - for part in remove_path[:-1]: - child = parent.get(part) - if not isinstance(child, dict): - parent = {} - break - parent = child - if parent: - parent.pop(remove_path[-1], None) - - key_path = (key_name,) if isinstance(key_name, str) else key_name - target = data - for part in key_path: - if not isinstance(target.get(part), dict): - target[part] = {} - target = target[part] - - target[server_name] = server_config - file_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") - return True - except Exception as e: - logger.warning(f"Could not update MCP config file {file_path}: {e}") - return False - - -def _codex_toml_block(server_config: dict) -> str: - """Renders a managed Codex MCP server block using TOML-compatible JSON strings.""" - command = json.dumps(str(server_config["command"]), ensure_ascii=False) - args = json.dumps(server_config.get("args", []), ensure_ascii=False) - cwd = json.dumps(str(server_config["cwd"]), ensure_ascii=False) - env = server_config.get("env", {}) - lines = [ - "# BEGIN ARTEMIS MCP CONFIG", - "[mcp_servers.artemis]", - f"command = {command}", - f"args = {args}", - f"cwd = {cwd}", - ] - for key in ("enabled", "required"): - if key in server_config: - lines.append(f"{key} = {'true' if server_config[key] else 'false'}") - if "startup_timeout_sec" in server_config: - lines.append(f"startup_timeout_sec = {int(server_config['startup_timeout_sec'])}") - if "enabled_tools" in server_config: - lines.append( - f"enabled_tools = {json.dumps(server_config['enabled_tools'], ensure_ascii=False)}" - ) - if env: - lines.extend(["", "[mcp_servers.artemis.env]"]) - for key, value in env.items(): - lines.append(f"{key} = {json.dumps(str(value), ensure_ascii=False)}") - lines.append("# END ARTEMIS MCP CONFIG") - return "\n".join(lines) + "\n" - - -def _merge_codex_toml(file_path: Path, server_config: dict) -> bool: - """Adds or updates Artemis in Codex config.toml while preserving unrelated settings.""" - try: - file_path.parent.mkdir(parents=True, exist_ok=True) - existing = file_path.read_text(encoding="utf-8") if file_path.exists() else "" - managed_block = _codex_toml_block(server_config) - begin_marker = "# BEGIN ARTEMIS MCP CONFIG" - end_marker = "# END ARTEMIS MCP CONFIG" - - if begin_marker in existing and end_marker in existing: - managed_pattern = re.compile( - rf"(?ms)^{re.escape(begin_marker)}\n.*?^{re.escape(end_marker)}\n?" - ) - updated = managed_pattern.sub(managed_block, existing, count=1) - else: - # Remove an older, unmanaged Artemis table before adding the managed block. - kept_lines: list[str] = [] - skipping_artemis = False - header_pattern = re.compile(r"^\s*\[{1,2}\s*(.+?)\s*\]{1,2}\s*(?:#.*)?$") - for line in existing.splitlines(keepends=True): - header_match = header_pattern.match(line.rstrip("\r\n")) - if header_match: - normalized = re.sub(r"[\s\"']", "", header_match.group(1)).lower() - skipping_artemis = normalized == "mcp_servers.artemis" or normalized.startswith( - "mcp_servers.artemis." - ) - if not skipping_artemis: - kept_lines.append(line) - preserved = "".join(kept_lines).rstrip() - updated = f"{preserved}\n\n{managed_block}" if preserved else managed_block - - # Never replace a user's config with invalid TOML. - tomllib.loads(updated) - if updated != existing: - file_path.write_text(updated, encoding="utf-8") - return True - except Exception as e: - if file_path.exists(): - try: - backup_path = file_path.with_suffix(file_path.suffix + ".bak") - backup_path.write_text(file_path.read_text(encoding="utf-8"), encoding="utf-8") - logger.warning( - f"Could not update {file_path}; backup created at {backup_path}: {e}" - ) - except (OSError, UnicodeDecodeError) as backup_exc: - logger.warning( - f"Could not update {file_path} ({e}); backup also failed: {backup_exc}" - ) - else: - logger.warning(f"Could not create Codex MCP config file {file_path}: {e}") - return False - - -def _remove_rules_block(file_path: Path) -> bool: - """Removes a previously injected managed ARTEMIS block from a rules file. - - Used to migrate clients that load both their main rules file and a - standalone rule file (e.g. Claude Code loads ~/.claude/CLAUDE.md AND - ~/.claude/rules/*.md), where keeping the injected block would duplicate - the rules in the model's context. If the file contains nothing but the - managed block, the file is deleted entirely. - """ - try: - if not file_path.exists(): - return True - begin_marker = "" - end_marker = "" - existing = file_path.read_text(encoding="utf-8") - if begin_marker not in existing or end_marker not in existing: - return True - prefix = existing.split(begin_marker)[0] - suffix = existing.split(end_marker)[1] - remainder = f"{prefix.rstrip()}\n\n{suffix.lstrip()}".strip() - if remainder: - file_path.write_text(remainder + "\n", encoding="utf-8") - else: - file_path.unlink() - return True - except Exception as e: - logger.warning(f"Could not remove rules block from {file_path}: {e}") - return False - - -def _inject_rules_block(file_path: Path, content: str) -> bool: - """Injects or updates a managed ARTEMIS block in a global rules file without overwriting user content.""" - try: - file_path.parent.mkdir(parents=True, exist_ok=True) - begin_marker = "" - end_marker = "" - new_block = f"{begin_marker}\n{content.strip()}\n{end_marker}\n" - - existing = "" - if file_path.exists(): - try: - existing = file_path.read_text(encoding="utf-8") - except Exception: - existing = "" - - if begin_marker in existing and end_marker in existing: - prefix = existing.split(begin_marker)[0] - suffix = existing.split(end_marker)[1] - updated = f"{prefix.rstrip()}\n\n{new_block}\n{suffix.lstrip()}".strip() + "\n" - else: - updated = f"{existing.rstrip()}\n\n{new_block}".strip() + "\n" - - if existing == updated: - return True - - file_path.write_text(updated, encoding="utf-8") - return True - except Exception as e: - logger.warning(f"Could not inject rules block into {file_path}: {e}") - return False - - -def _write_rule_file(file_path: Path, content: str) -> bool: - """Writes or overwrites a standalone rule file.""" - try: - file_path.parent.mkdir(parents=True, exist_ok=True) - new_text = content.strip() + "\n" - if file_path.exists(): - try: - if file_path.read_text(encoding="utf-8") == new_text: - return True - except (OSError, UnicodeDecodeError): - # Unreadable existing file: skip the no-op check and rewrite it. - pass - file_path.write_text(new_text, encoding="utf-8") - return True - except Exception as e: - logger.warning(f"Could not write rule file {file_path}: {e}") - return False - - -def _write_cursor_mdc(file_path: Path, content: str) -> bool: - """Writes a Cursor .mdc rule file with required YAML frontmatter.""" - try: - file_path.parent.mkdir(parents=True, exist_ok=True) - mdc_header = ( - "---\n" - "description: Artemis Mobile Testing Mindset & Rules\n" - "globs: **/*\n" - "alwaysApply: true\n" - "---\n\n" - ) - new_text = mdc_header + content.strip() + "\n" - if file_path.exists(): - try: - if file_path.read_text(encoding="utf-8") == new_text: - return True - except (OSError, UnicodeDecodeError): - # Unreadable existing file: skip the no-op check and rewrite it. - pass - file_path.write_text(new_text, encoding="utf-8") - return True - except Exception as e: - logger.warning(f"Could not write Cursor rule file {file_path}: {e}") - return False - - -def install_rules(client: str, project_root: str) -> list[str]: - """Auto-installs ARTEMIS testing rules globally into user IDE rule directories across any OS.""" - installed_paths: list[str] = [] - rules_src = Path(project_root) / "mcp_server" / "rules.md" - if not rules_src.exists(): - alt_src = Path(__file__).resolve().parents[4] / "mcp_server" / "rules.md" - if alt_src.exists(): - rules_src = alt_src - else: - logger.warning(f"Could not locate rules.md at {rules_src}") - return [] - - try: - raw_rules = rules_src.read_text(encoding="utf-8").strip() - except Exception as e: - logger.warning(f"Could not read rules file {rules_src}: {e}") - return [] - - targets = ( - [ - "antigravity", - "cursor", - "claude", - "windsurf", - "vscode", - "cline", - "roo", - "openclaw", - "codex", - ] - if client == "all" - else [client] - ) - - for target in targets: - if target in ("antigravity", "jetski"): - gemini_md = Path.home() / ".gemini" / "GEMINI.md" - global_rule = Path.home() / ".gemini" / "rules" / "artemis.md" - if _inject_rules_block(gemini_md, raw_rules): - installed_paths.append(str(gemini_md)) - if _write_rule_file(global_rule, raw_rules): - installed_paths.append(str(global_rule)) - elif target == "cursor": - cursor_rules_file = Path.home() / ".cursorrules" - global_mdc = Path.home() / ".cursor" / "rules" / "artemis.mdc" - if _inject_rules_block(cursor_rules_file, raw_rules): - installed_paths.append(str(cursor_rules_file)) - if _write_cursor_mdc(global_mdc, raw_rules): - installed_paths.append(str(global_mdc)) - elif target in ("claude", "claude_code"): - # Claude Code loads BOTH ~/.claude/CLAUDE.md and ~/.claude/rules/*.md - # into context every session, so install the rules in exactly one - # place (the standalone rule file) and migrate away any block a - # previous version injected into CLAUDE.md. - claude_md = Path.home() / ".claude" / "CLAUDE.md" - global_rule = Path.home() / ".claude" / "rules" / "artemis.md" - _remove_rules_block(claude_md) - if _write_rule_file(global_rule, raw_rules): - installed_paths.append(str(global_rule)) - elif target == "claude_desktop": - # Claude Desktop only reads ~/.claude/CLAUDE.md (not rules/*.md), so - # the rules block stays injected there for this target. - claude_md = Path.home() / ".claude" / "CLAUDE.md" - if _inject_rules_block(claude_md, raw_rules): - installed_paths.append(str(claude_md)) - elif target == "windsurf": - global_rule = Path.home() / ".codeium" / "windsurf" / "rules" / "artemis.md" - global_mem = Path.home() / ".codeium" / "windsurf" / "memories" / "global_rules.md" - if _inject_rules_block(global_mem, raw_rules): - installed_paths.append(str(global_mem)) - if _write_rule_file(global_rule, raw_rules): - installed_paths.append(str(global_rule)) - elif target == "vscode": - global_rule = Path.home() / ".vscode" / "rules" / "artemis.md" - user_rule = _get_vscode_user_dir() / "rules" / "artemis.md" - if _write_rule_file(global_rule, raw_rules): - installed_paths.append(str(global_rule)) - if _write_rule_file(user_rule, raw_rules): - installed_paths.append(str(user_rule)) - elif target == "cline": - clinerules = Path.home() / ".clinerules" - global_rule = Path.home() / ".cline" / "rules" / "artemis.md" - if _inject_rules_block(clinerules, raw_rules): - installed_paths.append(str(clinerules)) - if _write_rule_file(global_rule, raw_rules): - installed_paths.append(str(global_rule)) - elif target in ("roo", "roo_code"): - roorules = Path.home() / ".roorules" - global_rule = Path.home() / ".roo" / "rules" / "artemis.md" - if _inject_rules_block(roorules, raw_rules): - installed_paths.append(str(roorules)) - if _write_rule_file(global_rule, raw_rules): - installed_paths.append(str(global_rule)) - elif target == "openclaw": - openclaw_md = Path.home() / ".openclaw" / "OPENCLAW.md" - global_rule = Path.home() / ".openclaw" / "rules" / "artemis.md" - if _inject_rules_block(openclaw_md, raw_rules): - installed_paths.append(str(openclaw_md)) - if _write_rule_file(global_rule, raw_rules): - installed_paths.append(str(global_rule)) - elif target == "codex": - codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex")) - override_file = codex_home / "AGENTS.override.md" - agents_file = ( - override_file - if override_file.exists() and override_file.read_text(encoding="utf-8").strip() - else codex_home / "AGENTS.md" - ) - if _inject_rules_block(agents_file, raw_rules): - installed_paths.append(str(agents_file)) - - return installed_paths - - -def install_mcp_config(client: str, python_exe: str, project_root: str) -> list[str]: - """Auto-installs/merges ARTEMIS MCP configuration and testing rules into IDE config files across any OS.""" - installed_paths: list[str] = [] - targets = ( - [ - "antigravity", - "cursor", - "claude", - "windsurf", - "vscode", - "cline", - "roo", - "openclaw", - "codex", - ] - if client == "all" - else [client] - ) - - for target in targets: - snippet = _get_config_snippet(target, python_exe, project_root) - if target in ("antigravity", "jetski"): - current_server_cfg = _get_config_snippet("antigravity", python_exe, project_root)[ - "mcpServers" - ]["artemis"] - legacy_server_cfg = _get_config_snippet("jetski", python_exe, project_root)[ - "mcpServers" - ]["artemis"] - jetski_path = Path.home() / ".gemini" / "jetski" / "mcp_config.json" - antigravity_legacy_path = Path.home() / ".gemini" / "antigravity" / "mcp_config.json" - config_path = Path.home() / ".gemini" / "config" / "mcp_config.json" - if _merge_json_file(jetski_path, "artemis", legacy_server_cfg): - installed_paths.append(str(jetski_path)) - if _merge_json_file(antigravity_legacy_path, "artemis", current_server_cfg): - installed_paths.append(str(antigravity_legacy_path)) - if _merge_json_file(config_path, "artemis", current_server_cfg): - installed_paths.append(str(config_path)) - elif target in ("claude", "claude_code", "claude_desktop"): - server_cfg = snippet["mcpServers"]["artemis"] - if sys.platform == "darwin": - claude_path = ( - Path.home() - / "Library" - / "Application Support" - / "Claude" - / "claude_desktop_config.json" - ) - elif sys.platform == "win32": - appdata = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming") - claude_path = Path(appdata) / "Claude" / "claude_desktop_config.json" - else: - claude_path = Path.home() / ".config" / "Claude" / "claude_desktop_config.json" - if _merge_json_file(claude_path, "artemis", server_cfg): - installed_paths.append(str(claude_path)) - - # Also install to Claude Code CLI global config (~/.claude.json). - # This file holds Claude Code state well beyond MCP config, so a - # parse failure must abort the merge rather than rewrite the file. - claude_code_path = Path.home() / ".claude.json" - if _merge_json_file(claude_code_path, "artemis", server_cfg, strict_parse=True): - installed_paths.append(str(claude_code_path)) - elif target == "cursor": - server_cfg = snippet["mcpServers"]["artemis"] - cursor_path = Path.home() / ".cursor" / "mcp.json" - if _merge_json_file(cursor_path, "artemis", server_cfg): - installed_paths.append(str(cursor_path)) - elif target == "windsurf": - server_cfg = snippet["mcpServers"]["artemis"] - windsurf_path = Path.home() / ".codeium" / "windsurf" / "mcp_config.json" - if _merge_json_file(windsurf_path, "artemis", server_cfg): - installed_paths.append(str(windsurf_path)) - elif target == "vscode": - server_cfg = snippet["servers"]["artemis"] - vscode_path = _get_vscode_user_dir() / "mcp.json" - copilot_path = Path.home() / ".copilot" / "mcp-config.json" - if _merge_json_file(vscode_path, "artemis", server_cfg, key_name="servers"): - installed_paths.append(str(vscode_path)) - if _merge_json_file(copilot_path, "artemis", server_cfg, key_name="servers"): - installed_paths.append(str(copilot_path)) - elif target == "cline": - server_cfg = snippet["mcpServers"]["artemis"] - cline_paths = ( - Path.home() / ".cline" / "data" / "settings" / "cline_mcp_settings.json", - Path.home() / ".cline" / "mcp.json", - _get_vscode_user_dir() - / "globalStorage" - / "saoudrizwan.claude-dev" - / "settings" - / "cline_mcp_settings.json", - ) - for cline_path in cline_paths: - if _merge_json_file(cline_path, "artemis", server_cfg): - installed_paths.append(str(cline_path)) - elif target in ("roo", "roo_code"): - server_cfg = snippet["mcpServers"]["artemis"] - roo_settings_dir = ( - _get_vscode_user_dir() / "globalStorage" / "rooveterinaryinc.roo-cline" / "settings" - ) - roo_paths = ( - roo_settings_dir / "mcp_settings.json", - roo_settings_dir / "cline_mcp_settings.json", - Path(project_root) / ".roo" / "mcp.json", - ) - for roo_path in roo_paths: - if _merge_json_file(roo_path, "artemis", server_cfg): - installed_paths.append(str(roo_path)) - elif target == "openclaw": - server_cfg = snippet["mcp"]["servers"]["artemis"] - openclaw_path = Path.home() / ".openclaw" / "openclaw.json" - if _merge_json_file( - openclaw_path, - "artemis", - server_cfg, - key_name=("mcp", "servers"), - remove_paths=(("plugins", "artemis_mcp"),), - ): - installed_paths.append(str(openclaw_path)) - elif target == "codex": - server_cfg = snippet["mcp_servers"]["artemis"] - codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex")) - codex_path = codex_home / "config.toml" - if _merge_codex_toml(codex_path, server_cfg): - installed_paths.append(str(codex_path)) - - rules_paths = install_rules(client, project_root) - installed_paths.extend(rules_paths) - - unique_paths: list[str] = [] - for p in installed_paths: - if p not in unique_paths: - unique_paths.append(p) - return unique_paths def mcp_command( @@ -697,9 +42,13 @@ def mcp_command( typer.Option( "--type", "-t", - help="Type of MCP server to start: 'agent' (default, universal IDE mobile agent), 'adb' (raw adb), 'xml' (xml fuzzy search).", + help=( + "Device MCP server to start: 'adb' (default, raw adb), 'mock' (the adb " + "tool surface over an in-memory device, no adb and no hardware), " + "'xml' (xml fuzzy search over a trace store)." + ), ), - ] = "agent", + ] = "adb", transport: Annotated[ str, typer.Option( @@ -722,129 +71,38 @@ def mcp_command( help="Port number when running with SSE transport.", ), ] = 8001, - install_config: Annotated[ - str | None, - typer.Option( - "--install", - "-i", - help="Auto-install and merge ARTEMIS MCP configuration and testing rules into 'antigravity', 'claude', 'cursor', 'windsurf', 'vscode', 'cline', 'roo', 'openclaw', 'codex', or 'all'.", - ), - ] = None, - generate_config: Annotated[ - str | None, - typer.Option( - "--generate-config", - "-g", - help="Output ready-to-use MCP configuration for 'antigravity', 'cursor', 'claude', 'windsurf', 'vscode', 'cline', 'roo', 'openclaw', 'codex', or 'all'.", - ), - ] = None, ) -> None: - """Launch or configure the ARTEMIS Model Context Protocol (MCP) server.""" - project_root = env_utils.get_project_root() - python_exe = env_utils.resolve_python_executable(project_root) - - if install_config: - client = install_config.lower() - if client not in ( - "antigravity", - "jetski", - "claude", - "claude_code", - "claude_desktop", - "cursor", - "windsurf", - "vscode", - "cline", - "roo", - "roo_code", - "openclaw", - "codex", - "all", - ): - console.print( - f"[bold red]Unsupported install target: '{install_config}'. Use 'antigravity', 'claude', 'cursor', 'windsurf', 'vscode', 'cline', 'roo', 'openclaw', 'codex', or 'all'.[/bold red]" - ) - raise typer.Exit(1) - installed_paths = install_mcp_config(client, python_exe, project_root) - console.print( - "[bold green]✔ Successfully installed ARTEMIS MCP server configuration & rules to:[/bold green]" - ) - for path in installed_paths: - console.print(f" • [cyan]{path}[/cyan]") - console.print( - "\n[dim]Please restart or reload your IDE window to activate the Artemis MCP tools.[/dim]" - ) - raise typer.Exit(0) - - if generate_config: - client = generate_config.lower() - if client == "all": - all_configs = { - "antigravity (~/.gemini/jetski/mcp_config.json)": _get_config_snippet( - "antigravity", python_exe, project_root - ), - "cursor (.cursor/mcp.json)": _get_config_snippet( - "cursor", python_exe, project_root - ), - "claude (claude_desktop_config.json & ~/.claude.json)": _get_config_snippet( - "claude", python_exe, project_root - ), - "windsurf (~/.codeium/windsurf/mcp_config.json)": _get_config_snippet( - "windsurf", python_exe, project_root - ), - "vscode (mcp.json)": _get_config_snippet("vscode", python_exe, project_root), - "cline (cline_mcp_settings.json)": _get_config_snippet( - "cline", python_exe, project_root - ), - "roo (cline_mcp_settings.json)": _get_config_snippet( - "roo", python_exe, project_root - ), - "openclaw (openclaw.json)": _get_config_snippet( - "openclaw", python_exe, project_root - ), - "codex (~/.codex/config.toml)": _get_config_snippet( - "codex", python_exe, project_root - ), - } - json_str = json.dumps(all_configs, indent=2) - syntax_language = "json" - elif client == "codex": - server_cfg = _get_config_snippet("codex", python_exe, project_root)["mcp_servers"][ - "artemis" - ] - json_str = _codex_toml_block(server_cfg) - syntax_language = "toml" - else: - snippet = _get_config_snippet(client, python_exe, project_root) - json_str = json.dumps(snippet, indent=2) - syntax_language = "json" - - syntax = Syntax(json_str, syntax_language, theme="monokai", line_numbers=False) - console.print(f"[bold cyan]MCP Configuration for {client.upper()}:[/bold cyan]") - console.print(syntax) - raise typer.Exit(0) - + """Launch a device-facing ARTEMIS Model Context Protocol (MCP) server.""" threading.Thread(target=start_awake_service, daemon=True, name="artemis-awake-init").start() try: st = server_type.lower() - if st in ("agent", "mobile", "artemis", "default"): - logger.info(f"Starting Artemis Mobile Agent MCP Server over {transport}...") - if transport.lower() == "sse": - agent_mcp.run(transport="sse", host=host, port=port) - else: - agent_mcp.run(transport="stdio") - elif st == "adb": + if st == "adb": if transport.lower() == "sse": logger.info(f"Starting Artemis ADB MCP Server over {transport}...") adb_mcp.run(transport="sse", host=host, port=port) else: # stdio carries the MCP JSON-RPC stream: redirect logging and detach - # from the parent DataEngine before any output can corrupt it. + # from the parent before any output can corrupt it. from artemis.mcp.adb_server import configure_stdio_mode configure_stdio_mode() logger.info("Starting Artemis ADB MCP Server over stdio...") adb_mcp.run(transport="stdio") + elif st == "mock": + from artemis.mcp.mock_server import seed_mock_controllers + + # Same FastMCP object and the same tool set as 'adb'; only the device is + # in-memory, so contract tests exercise the py implementation, not a stub. + seed_mock_controllers() + if transport.lower() == "sse": + logger.info(f"Starting Artemis MOCK (device-free) MCP Server over {transport}...") + adb_mcp.run(transport="sse", host=host, port=port) + else: + from artemis.mcp.adb_server import configure_stdio_mode + + configure_stdio_mode() + logger.info("Starting Artemis MOCK (device-free) MCP Server over stdio...") + adb_mcp.run(transport="stdio") elif st == "xml": from artemis.mcp.xml_search_server import mcp as xml_mcp @@ -855,7 +113,7 @@ def mcp_command( xml_mcp.run(transport="stdio") else: logger.error( - f"Unsupported MCP server type: {server_type}. Use 'agent', 'adb', or 'xml'." + f"Unsupported MCP server type: {server_type}. Use 'adb', 'mock', or 'xml'." ) raise typer.Exit(1) finally: diff --git a/artemis/interfaces/cli/commands/run.py b/artemis/interfaces/cli/commands/run.py deleted file mode 100644 index 89dca25ab..000000000 --- a/artemis/interfaces/cli/commands/run.py +++ /dev/null @@ -1,463 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Task execution command (artemis run).""" - -import asyncio -import os -from typing import Annotated - -from langchain_core.callbacks.base import Callbacks -from artemis.config import checker_overrides_for_level, settings -from artemis.utils.startup_progress import publish_startup_progress -from third_party.mobile_use.main import ( - GoalArgument, - OutputDescriptionOption, - TestNameOption, - TracesPathOption, - VideoRecordingToolsOption, - ensure_video_recording_available, - new_default_config_builder, - run_automation, -) -from third_party.mobile_use.utils.cli_helpers import display_local_device_status -from third_party.mobile_use.utils.logger import get_logger -import signal -from rich.console import Console -from rich.panel import Panel -import typer - -logger = get_logger(__name__) - - -async def execute_task( - goal: str, - device_serial: str | None = None, - session_id: str | None = None, - locked_app_package: str | None = None, - test_name: str | None = None, - traces_output_path_str: str | None = None, - output_description: str | None = None, - graph_config_callbacks: Callbacks = [], - video_recording_tools_enabled: bool | None = None, - profile: str | None = None, - app_path: str | None = None, - enable_planner_validation: bool | None = None, - enable_committee: bool | None = None, - enable_checker: bool | None = None, - enable_step_summarizer: bool | None = None, - enable_outputter: bool | None = None, - force_output_synthesis: bool | None = None, - explorer_version: str | None = None, - explorer_flash_mode: str | None = None, - explorer_pro_mode: str | None = None, - verification_level: str | None = None, -) -> None: - """Executes a single mobile automation task end-to-end. - - Args: - goal: Target objective to achieve on the mobile device. - locked_app_package: Optional package name to constrain actions to. - test_name: Optional test identifier for trace directory naming. - traces_output_path_str: Destination path for recording traces. - output_description: Structured natural language output description. - graph_config_callbacks: LangGraph callback handlers. - video_recording_tools_enabled: Whether screen video analyzer is enabled. - profile: Execution profile ('flash' for fast reactive loop, 'pro' for full graph). - app_path: Optional local APK path to install prior to execution. - enable_planner_validation: Explicit override to enable/disable async planner validation. - enable_committee: Explicit override to enable/disable Multi-Agent Committee tool. - explorer_version: Override the fallback Explorer tier ('flash', 'pro', or 'ultra'). - explorer_flash_mode: Override the Explorer tier for the Flash execution profile. - explorer_pro_mode: Override the Explorer tier for the Pro execution profile. - verification_level: Coarse Checker preset ('off', 'final', 'checkpoints', - 'strict'); applied before the explicit ``enable_checker`` switch. - """ - effective_sid = ( - session_id or os.getenv("ARTEMIS_SESSION_ID") or os.getenv("ARTEMIS_CLOUD_SESSION_ID") - ) - if effective_sid: - os.environ["ARTEMIS_SESSION_ID"] = str(effective_sid) - if not os.environ.get("ARTEMIS_TASK_INGRESS"): - os.environ["ARTEMIS_TASK_INGRESS"] = "cli" - publish_startup_progress( - "configuration", - "Loading the run configuration", - session_id=str(effective_sid) if effective_sid else None, - ) - - config = new_default_config_builder() - - if video_recording_tools_enabled is not None: - config.with_video_recording_tools(enabled=video_recording_tools_enabled) - - if enable_planner_validation is not None: - config.with_planner_validation(enabled=enable_planner_validation) - - if enable_committee is not None: - config.with_committee(enabled=enable_committee) - - if verification_level is not None: - config.with_checker(**checker_overrides_for_level(verification_level)) - - if enable_checker is not None: - config.with_checker(enabled=enable_checker) - - if enable_step_summarizer is not None: - config.with_flash_step_summarizer(enabled=enable_step_summarizer) - - if enable_outputter is not None or force_output_synthesis is not None: - config.with_outputter( - enabled=enable_outputter if enable_outputter is not None else True, - force_synthesis=bool(force_output_synthesis), - ) - - if ( - explorer_version is not None - or explorer_flash_mode is not None - or explorer_pro_mode is not None - ): - config.with_explorer( - version=explorer_version, - flash_mode=explorer_flash_mode, - pro_mode=explorer_pro_mode, - ) - - target_serial = ( - device_serial or settings.ADB_DEVICE_SERIAL or os.environ.get("ADB_DEVICE_SERIAL") - ) - if not target_serial: - try: - from artemis.runtime import device_pool - - target_serial = device_pool.select_device() - except Exception: - target_serial = None - - if target_serial: - from artemis.context import DevicePlatform - - config.for_device(DevicePlatform.ANDROID, target_serial) - - if graph_config_callbacks: - config.with_graph_config_callbacks(graph_config_callbacks) - - await run_automation( - config, - goal, - session_id=effective_sid, - locked_app_package=locked_app_package, - test_name=test_name, - traces_output_path_str=traces_output_path_str, - output_description=output_description, - profile=profile, - app_path=app_path, - ) - - -def run_command( - goal: GoalArgument, - profile: Annotated[ - str | None, - typer.Option( - "--profile", - "-p", - help="Execution profile ('flash' for fast reactive, 'pro' for full graph).", - ), - ] = "pro", - locked_app_package: Annotated[ - str | None, - typer.Option( - "--locked-app", - "-a", - help="Optional Android package name to restrict the agent to.", - ), - ] = None, - test_name: TestNameOption = None, - traces_path: TracesPathOption = None, - output_description: OutputDescriptionOption = None, - with_video_recording_tools: VideoRecordingToolsOption = None, - app_path: Annotated[ - str | None, - typer.Option( - "--app-path", - help="Local APK path to install before starting the task.", - ), - ] = None, - enable_planner_validation: Annotated[ - bool | None, - typer.Option( - "--enable-planner-validation/--disable-planner-validation", - help="Enable or disable the advisory Planner review of task plan milestone edits (hint only, never rolled back).", - ), - ] = None, - enable_committee: Annotated[ - bool | None, - typer.Option( - "--enable-committee/--disable-committee", - help="Enable or disable Multi-Agent Committee council debate tool for Operator.", - ), - ] = None, - enable_checker: Annotated[ - bool | None, - typer.Option( - "--enable-checker/--disable-checker", - help="Enable or disable the Checker (plan checkpoint verification and exit final review).", - ), - ] = None, - enable_step_summarizer: Annotated[ - bool | None, - typer.Option( - "--enable-step-summarizer/--disable-step-summarizer", - help="Enable or disable Flash asynchronous objective visual step state summarizer.", - ), - ] = None, - enable_outputter: Annotated[ - bool | None, - typer.Option( - "--enable-outputter/--disable-outputter", - help="Enable or disable Outputter post-execution report and structured output synthesis.", - ), - ] = None, - force_output_synthesis: Annotated[ - bool | None, - typer.Option( - "--force-output-synthesis/--no-force-output-synthesis", - help="Force Outputter report synthesis even if no structured output schema is specified.", - ), - ] = None, - explorer_version: Annotated[ - str | None, - typer.Option( - "--explorer-version", - help=( - "Fallback Explorer tier when no profile knob applies: 'flash' (one-shot" - " detection), 'pro' (3-turn reasoning loop) or 'ultra' (8-turn loop with" - " zoom, OCR and image processing)." - ), - ), - ] = None, - explorer_flash_mode: Annotated[ - str | None, - typer.Option( - "--explorer-flash-mode", - help="Explorer tier behind ask_explorer under the Flash profile ('flash', 'pro', 'ultra').", - ), - ] = None, - explorer_pro_mode: Annotated[ - str | None, - typer.Option( - "--explorer-pro-mode", - help="Explorer tier behind ask_explorer under the Pro profile ('flash', 'pro', 'ultra').", - ), - ] = None, - verification_level: Annotated[ - str | None, - typer.Option( - "--verification-level", - help=( - "Checker preset for the Pro profile: 'off' (no audit), 'final' (exit review" - " only, the default), 'checkpoints' (every plan checkpoint + exit review)," - " 'strict' (checkpoints with a larger repair budget; a failed assert halts)." - ), - ), - ] = None, - device_serial: Annotated[ - str | None, - typer.Option( - "--device-serial", - "-s", - help="Target specific Android device by serial number (e.g. emulator-5554).", - ), - ] = None, - session_id: Annotated[ - str | None, - typer.Option( - "--session-id", - help="Canonical session UUID for trace and stream telemetry.", - ), - ] = None, - standalone: Annotated[ - bool, - typer.Option( - "--standalone", - help="Run in standalone embedded mode without auto-spawning the Artemis Daemon.", - ), - ] = False, -) -> None: - """Run an autonomous UI automation task on the connected Android device.""" - ensure_video_recording_available(with_video_recording_tools) - - console = Console() - - is_worker = ( - os.environ.get("ARTEMIS_TASK_WORKER") == "1" - or os.environ.get("ARTEMIS_DEVICE_QUEUE_TICKET") is not None - ) - is_standalone = standalone or os.environ.get("ARTEMIS_STANDALONE") == "1" - - # All platforms route through unified Artemis Daemon unless specifically configured as standalone - if not is_worker and not is_standalone: - try: - import uuid - from artemis.runtime import ( - ensure_daemon_running, - submit_task_to_daemon, - wait_for_daemon_task, - stop_task_on_daemon, - ) - - console.print("[dim]Connecting to Artemis Daemon scheduler...[/dim]") - is_running, base_url = ensure_daemon_running(timeout=8.0, wait_ready=True) - if is_running and base_url: - target_sid = session_id or str(uuid.uuid4()) - console.print( - f"[bold green]✓[/bold green] Artemis Daemon active at [cyan]{base_url}[/cyan]" - ) - resp = submit_task_to_daemon( - goal=goal, - profile=profile or "pro", - device_serial=device_serial, - expected_output=output_description, - enable_outputter=enable_outputter, - locked_app_package=locked_app_package, - app_path=app_path, - session_id=target_sid, - ingress="cli", - base_url=base_url, - ) - if resp and resp.get("tasks"): - console.print( - f"[bold green]✓[/bold green] Task scheduled in unified queue (Session: [cyan]{target_sid}[/cyan])" - ) - console.print(f"[dim]Live dashboard & replay: {base_url}[/dim]\n") - - def on_status(sess_info): - st = sess_info.get("status") - if st == "queued": - console.print( - "[yellow]⏳ Task queued in scheduler, waiting for device...[/yellow]" - ) - elif st == "running": - console.print("[green]▶ Task executing on mobile device...[/green]") - - try: - final_res = wait_for_daemon_task( - target_sid, - base_url=base_url, - timeout=1800.0, - on_status_update=on_status, - ) - final_st = final_res.get("status") - if final_st in ("completed", "success"): - console.print( - "\n[bold green]✅ Task completed successfully![/bold green]" - ) - return - else: - err = final_res.get("error") or final_res.get("explanation") or "" - console.print(f"\n[bold red]✖ Task {final_st}[/bold red]: {err}") - raise SystemExit(1) - except KeyboardInterrupt: - console.print("\n[yellow]Stopping task on Daemon...[/yellow]") - stop_task_on_daemon(target_sid, base_url=base_url) - console.print("[yellow]Task cancelled.[/yellow]") - raise SystemExit(130) - else: - console.print( - "[yellow]Warning: Could not enqueue task to Daemon. Falling back to local execution...[/yellow]" - ) - else: - console.print( - "[yellow]Warning: Artemis Daemon could not be started. Falling back to local execution...[/yellow]" - ) - except SystemExit: - raise - except Exception as exc: - logger.debug(f"Daemon dispatch error: {exc}") - console.print( - f"[yellow]Daemon routing notice: {exc}. Falling back to local execution...[/yellow]" - ) - - display_local_device_status(console, host=settings.ADB_HOST, port=settings.ADB_PORT) - - cancelled = False - original_sigterm = None - try: - if hasattr(signal, "SIGTERM"): - original_sigterm = signal.signal( - signal.SIGTERM, lambda s, f: (_ for _ in ()).throw(KeyboardInterrupt()) - ) - except (ValueError, OSError, RuntimeError): - # Not the main thread or platform does not support SIGTERM handlers; - # run proceeds without the graceful-cancel hook. - pass - - try: - asyncio.run( - execute_task( - goal=goal, - device_serial=device_serial, - session_id=session_id, - locked_app_package=locked_app_package, - test_name=test_name, - traces_output_path_str=traces_path, - output_description=output_description, - video_recording_tools_enabled=with_video_recording_tools, - profile=profile, - app_path=app_path, - enable_planner_validation=enable_planner_validation, - enable_committee=enable_committee, - enable_checker=enable_checker, - enable_step_summarizer=enable_step_summarizer, - enable_outputter=enable_outputter, - force_output_synthesis=force_output_synthesis, - explorer_version=explorer_version, - explorer_flash_mode=explorer_flash_mode, - explorer_pro_mode=explorer_pro_mode, - verification_level=verification_level, - ) - ) - except (KeyboardInterrupt, asyncio.CancelledError): - # KeyboardInterrupt: Ctrl+C / SIGTERM. CancelledError: the daemon asked - # this worker to stop through a cancel marker and the Agent cancelled - # its own task so the recording and trace could be finalized. - cancelled = True - except Exception as e: - err_msg = str(e) - if "API_KEY" in err_msg or "requires" in err_msg: - console.print() - console.print( - Panel( - f"[bold red]✖ Authentication Error:[/bold red] {err_msg}\n\n" - "💡 [bold cyan]Quick Fix:[/bold cyan] Run [bold green]artemis init[/bold green] to configure your API key in 10 seconds,\n" - "or add [bold]GEMINI_API_KEY=your_key[/bold] to [dim].env[/dim].", - title="Missing API Key", - expand=False, - ) - ) - console.print() - raise SystemExit(1) - else: - console.print(f"[bold red]Task execution failed:[/bold red] {e}") - raise - finally: - try: - if original_sigterm is not None and hasattr(signal, "SIGTERM"): - signal.signal(signal.SIGTERM, original_sigterm) - except (ValueError, OSError, RuntimeError): - # Best-effort restore of the previous handler on the way out. - pass - if cancelled: - raise SystemExit(130) diff --git a/artemis/interfaces/cli/commands/server.py b/artemis/interfaces/cli/commands/server.py deleted file mode 100644 index 96e5faa9d..000000000 --- a/artemis/interfaces/cli/commands/server.py +++ /dev/null @@ -1,151 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Server and cloud proxy commands (artemis server).""" - -import http.server -import os -import socketserver -import subprocess -import time -from typing import Annotated -import urllib.error -import urllib.request - -from artemis.interfaces.cli.commands.server_lifecycle import ( - restart_command, - status_command, - stop_command, -) -from third_party.mobile_use.utils.logger import get_logger -import typer - -logger = get_logger(__name__) -server_app = typer.Typer(help="Cloud proxy and server management commands.") - -server_app.command("restart", help="Restart running Artemis Web UI & server.")(restart_command) -server_app.command("stop", help="Stop running Artemis Web UI & server.")(stop_command) -server_app.command("status", help="Display Artemis Web UI & server status.")(status_command) - -cached_token = "" -token_expire_time = 0.0 - - -def get_identity_token() -> str: - """Fetch Google Cloud IAM identity token for authenticated requests.""" - global cached_token, token_expire_time - now = time.time() - if not cached_token or now >= token_expire_time: - try: - res = subprocess.check_output( - ["gcloud", "auth", "print-identity-token"], text=True - ).strip() - cached_token = res - token_expire_time = now + 1800 # cache 30 mins - except Exception as e: - logger.warning(f"Failed to get GCP identity token: {e}") - return "" - return cached_token - - -class CloudRunProxyHandler(http.server.BaseHTTPRequestHandler): - target_url: str = os.getenv("ARTEMIS_CLOUD_URL", "http://127.0.0.1:8000") - - def log_message(self, format, *args): - logger.info(f"[{self.log_date_time_string()}] {format % args}") - - def do_GET(self): - self.proxy_request("GET") - - def do_POST(self): - self.proxy_request("POST") - - def do_DELETE(self): - self.proxy_request("DELETE") - - def do_OPTIONS(self): - self.send_response(200) - self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") - self.send_header("Access-Control-Allow-Headers", "*") - self.end_headers() - - def proxy_request(self, method: str): - url = f"{self.target_url}{self.path}" - headers = {} - for k, v in self.headers.items(): - if k.lower() not in ("host", "authorization", "content-length"): - headers[k] = v - - token = get_identity_token() - if token: - headers["Authorization"] = f"Bearer {token}" - tenant_token = os.getenv("ARTEMIS_TENANT_TOKEN") - if tenant_token: - headers["X-Artemis-Token"] = tenant_token - - content_length = int(self.headers.get("Content-Length", 0)) - body = self.rfile.read(content_length) if content_length > 0 else None - - req = urllib.request.Request(url, data=body, headers=headers, method=method) - try: - with urllib.request.urlopen(req, timeout=30) as resp: - self.send_response(resp.status) - for k, v in resp.getheaders(): - if k.lower() not in ("transfer-encoding", "content-encoding", "content-length"): - self.send_header(k, v) - data = resp.read() - self.send_header("Content-Length", str(len(data))) - self.send_header("Access-Control-Allow-Origin", "*") - self.end_headers() - self.wfile.write(data) - except urllib.error.HTTPError as e: - self.send_response(e.code) - for k, v in e.headers.items(): - if k.lower() not in ("transfer-encoding", "content-encoding", "content-length"): - self.send_header(k, v) - data = e.read() - self.send_header("Content-Length", str(len(data))) - self.send_header("Access-Control-Allow-Origin", "*") - self.end_headers() - self.wfile.write(data) - except Exception as e: - self.send_response(500) - self.end_headers() - self.wfile.write(f"Proxy Error: {e}".encode()) - - -@server_app.command("web") -def web_proxy_command( - port: Annotated[int, typer.Option("--port", "-p", help="Local port to bind.")] = 8080, - target: Annotated[ - str, - typer.Option( - "--target", "-t", help="Target Artemis Cloud URL.", envvar="ARTEMIS_CLOUD_URL" - ), - ] = "http://127.0.0.1:8000", -) -> None: - """Start the local authenticated proxy for ARTEMIS Cloud Web Dashboard.""" - CloudRunProxyHandler.target_url = target - typer.secho( - f"🚀 Starting Artemis Cloud Web Proxy at http://localhost:{port}", fg=typer.colors.GREEN - ) - typer.secho(f"🔗 Forwarding authenticated requests to: {target}", fg=typer.colors.CYAN) - - socketserver.TCPServer.allow_reuse_address = True - with socketserver.TCPServer(("", port), CloudRunProxyHandler) as httpd: - try: - httpd.serve_forever() - except KeyboardInterrupt: - typer.secho("\n🛑 Stopping server.", fg=typer.colors.YELLOW) diff --git a/artemis/interfaces/cli/commands/server_lifecycle.py b/artemis/interfaces/cli/commands/server_lifecycle.py deleted file mode 100644 index 923fced65..000000000 --- a/artemis/interfaces/cli/commands/server_lifecycle.py +++ /dev/null @@ -1,328 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""CLI commands for Artemis server lifecycle management: restart, stop, and status.""" - -from __future__ import annotations - -import datetime -import time -from typing import Annotated -import webbrowser - -from rich.console import Console -from rich.panel import Panel -from rich.table import Table -import typer - -from artemis.interfaces.cli.commands.ui import ( - ensure_showcase_built, - load_session_reconciler, - ui_command, -) -from artemis.runtime.daemon_client import daemon_log_path, is_daemon_running, spawn_daemon -from artemis.runtime.server_lifecycle import ( - find_server_pids, - get_server_status, - stop_server, -) - -console = Console() - - -def _format_uptime(seconds: float | None) -> str: - """Format seconds into human-friendly duration (e.g. 1h 23m 45s).""" - if seconds is None: - return "Unknown" - sec = int(seconds) - hours, remainder = divmod(sec, 3600) - minutes, remainder_sec = divmod(remainder, 60) - parts = [] - if hours > 0: - parts.append(f"{hours}h") - if minutes > 0 or hours > 0: - parts.append(f"{minutes}m") - parts.append(f"{remainder_sec}s") - return " ".join(parts) - - -def restart_command( - host: Annotated[ - str, - typer.Option( - "--host", - "-H", - help=( - "Bind host address for the UI server. Defaults to loopback; for remote " - "access prefer a Tailscale/SSH tunnel over a wide bind." - ), - ), - ] = "127.0.0.1", - port: Annotated[ - int, - typer.Option("--port", "-p", help="Port to run the unified UI server on."), - ] = 8000, - open_browser: Annotated[ - bool, - typer.Option( - "--open/--no-open", - help="Automatically open the Showcase UI in default web browser.", - ), - ] = True, - force: Annotated[ - bool, - typer.Option( - "--force", - "-f", - help="Force kill previous server immediately without waiting for graceful exit.", - ), - ] = False, - reload: Annotated[ - bool, - typer.Option( - "--reload/--no-reload", - help="Enable uvicorn live auto-reload (for development).", - ), - ] = False, - daemon: Annotated[ - bool, - typer.Option( - "--daemon/--foreground", - "-d/-F", - help=( - "Run the new server as a detached background daemon (default) or attached " - "to the current terminal. The daemon is launched via 'python -m' so the " - "console-script executable is never pinned by a long-lived server " - "(which would block 'uv sync' reinstalls on Windows)." - ), - ), - ] = True, -) -> None: - """Restart the Artemis Web UI server, terminating any existing instance on the port.""" - console.print() - if reload and daemon: - # uvicorn auto-reload requires an attached terminal session. - console.print(" [dim]ℹ --reload requested: switching to foreground mode.[/dim]") - daemon = False - console.print( - Panel( - f"[bold cyan]🔄 Artemis Server Restart[/bold cyan]\n" - f"[dim]Inspecting port {port} and recycling any running Artemis instances...[/dim]", - border_style="cyan", - expand=False, - ) - ) - - # 1. Stop existing server - existing_pids = find_server_pids(port) - if existing_pids: - pid_str = ", ".join(map(str, existing_pids)) - console.print( - f" [yellow]🛑 Terminating running Artemis instance (PID: {pid_str})...[/yellow]" - ) - else: - console.print(f" [dim]ℹ No active Artemis server found on port {port}.[/dim]") - - load_session_reconciler() - success, msg, stopped_pids = stop_server(port=port, force=force, timeout=12.0) - if stopped_pids: - console.print(f" [green]✓ {msg}[/green]\n") - elif not success: - console.print(f" [red]⚠ {msg}[/red]\n") - else: - console.print(f" [green]✓ Port {port} is clear and ready.[/green]\n") - - # 2. Launch fresh server instance - local_url = f"http://localhost:{port}" - if daemon: - ensure_showcase_built(console) - console.print(" [cyan]🚀 Starting Artemis in detached background daemon mode...[/cyan]") - proc = spawn_daemon(host=host, port=port) - if not proc: - console.print(" [red]❌ Failed to spawn background daemon.[/red]\n") - raise typer.Exit(code=1) - - # Wait until the daemon answers HTTP before declaring success (cold - # start includes DB/trace initialization and can take tens of seconds). - ready = False - deadline = time.monotonic() + 60.0 - while time.monotonic() < deadline: - if is_daemon_running(host="127.0.0.1", port=port, timeout=0.5): - ready = True - break - if proc.poll() is not None: - # Parent exited: either it crashed, or it handed the socket off - # to a child worker. Give the worker a short grace period. - grace = time.monotonic() + 5.0 - while time.monotonic() < grace: - if is_daemon_running(host="127.0.0.1", port=port, timeout=0.5): - ready = True - break - time.sleep(0.3) - break - time.sleep(0.3) - - log_path = daemon_log_path(port) - if ready: - console.print( - f" [bold green]✓ Artemis server running in background (PID: {proc.pid})[/bold green]\n" - f" 🌐 [cyan]{local_url}[/cyan]\n" - f" 🛠️ [cyan]{local_url}/admin[/cyan]\n" - f" 📄 [dim]Logs: {log_path}[/dim]\n" - f" [dim]💡 Stop with [bold]artemis stop[/bold]; run in-terminal with [bold]artemis restart --foreground[/bold].[/dim]\n" - ) - if open_browser: - webbrowser.open(local_url) - else: - console.print( - f" [red]❌ Background daemon did not become ready on port {port}.[/red]\n" - f" 📄 [dim]Check logs: {log_path}[/dim]\n" - ) - raise typer.Exit(code=1) - return - - # Foreground mode (--foreground / --reload) - console.print( - " [cyan]🚀 Launching unified Artemis Showcase UI in current terminal...[/cyan]\n" - ) - ui_command(host=host, port=port, open_browser=open_browser, reload=reload) - - -def stop_command( - port: Annotated[ - int, - typer.Option("--port", "-p", help="Port of the Artemis server to stop."), - ] = 8000, - force: Annotated[ - bool, - typer.Option( - "--force", - "-f", - help="Force kill immediately without waiting for graceful shutdown.", - ), - ] = False, -) -> None: - """Stop any running Artemis Web UI / server instance on the given port.""" - console.print() - existing_pids = find_server_pids(port) - if existing_pids: - pid_str = ", ".join(map(str, existing_pids)) - console.print( - f" [yellow]🛑 Stopping Artemis server on port {port} (PID: {pid_str})...[/yellow]" - ) - else: - console.print(f" [dim]Scanning port {port}...[/dim]") - - load_session_reconciler() - success, msg, stopped_pids = stop_server(port=port, force=force, timeout=12.0) - if stopped_pids: - console.print( - Panel( - f"[bold green]✓ Artemis server stopped successfully.[/bold green]\n\n" - f"[dim]{msg}[/dim]", - title="🛑 Server Stopped", - border_style="green", - expand=False, - ) - ) - elif not success: - console.print( - Panel( - f"[bold red]⚠ Failed to fully stop server on port {port}[/bold red]\n\n{msg}", - title="⚠ Warning", - border_style="red", - expand=False, - ) - ) - raise typer.Exit(code=1) - else: - console.print( - Panel( - f"[dim]No active Artemis server found on port {port}. Port is free.[/dim]", - title="ℹ Status", - border_style="dim", - expand=False, - ) - ) - - -def status_command( - port: Annotated[ - int, - typer.Option("--port", "-p", help="Port to query for Artemis server status."), - ] = 8000, -) -> None: - """Check whether the Artemis server is currently running and display runtime metadata.""" - console.print() - status = get_server_status(port=port) - - if status["running"]: - pids_str = ( - ", ".join(map(str, status["pids"])) - if status["pids"] - else str(status["active_pid"] or "Unknown") - ) - uptime_str = _format_uptime(status["uptime_seconds"]) - metadata = status.get("metadata") or {} - - started_at_str = "Unknown" - if metadata.get("started_at"): - try: - dt = datetime.datetime.fromtimestamp(metadata["started_at"]) - started_at_str = dt.strftime("%Y-%m-%d %H:%M:%S") - except (TypeError, ValueError, OSError, OverflowError): - # Malformed or out-of-range timestamp in metadata: show "Unknown". - pass - - table = Table(show_header=False, box=None, padding=(0, 2)) - table.add_row( - "[bold cyan]Status[/bold cyan]", "[bold green]● ONLINE / RUNNING[/bold green]" - ) - table.add_row("[bold cyan]PID(s)[/bold cyan]", f"[yellow]{pids_str}[/yellow]") - table.add_row("[bold cyan]Port[/bold cyan]", f"[white]{port}[/white]") - table.add_row("[bold cyan]Uptime[/bold cyan]", f"[white]{uptime_str}[/white]") - table.add_row("[bold cyan]Started At[/bold cyan]", f"[dim]{started_at_str}[/dim]") - table.add_row("[bold cyan]Showcase UI[/bold cyan]", f"[cyan]{status['url']}[/cyan]") - table.add_row( - "[bold cyan]Admin Debug[/bold cyan]", f"[magenta]{status['admin_url']}[/magenta]" - ) - - if metadata.get("cwd"): - table.add_row("[bold cyan]Workspace[/bold cyan]", f"[dim]{metadata['cwd']}[/dim]") - - console.print( - Panel( - table, - title="🚀 Artemis Server Status", - border_style="green", - expand=False, - ) - ) - console.print( - " [dim]💡 To restart: [bold]artemis restart[/bold] | To stop: [bold]artemis stop[/bold][/dim]\n" - ) - else: - console.print( - Panel( - f"[dim]○ OFFLINE / STOPPED[/dim]\n\n" - f"Port [cyan]{port}[/cyan] is currently free.\n" - f"[dim]No active Artemis server process detected.[/dim]", - title="ℹ Artemis Server Status", - border_style="dim", - expand=False, - ) - ) - console.print( - " [dim]💡 To start: [bold]artemis ui[/bold] or [bold]./start.sh[/bold][/dim]\n" - ) diff --git a/artemis/interfaces/cli/commands/trace.py b/artemis/interfaces/cli/commands/trace.py deleted file mode 100644 index 2942ac1cc..000000000 --- a/artemis/interfaces/cli/commands/trace.py +++ /dev/null @@ -1,111 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Trace inspection and replay commands (artemis trace).""" - -import datetime -import json -from pathlib import Path -from typing import Annotated - -from artemis.config import settings -from third_party.mobile_use.utils.logger import get_logger -from rich.console import Console -from rich.table import Table -import typer - -logger = get_logger(__name__) -trace_app = typer.Typer(help="Inspect, list, and query task execution traces.") - - -@trace_app.command("list") -def list_traces( - traces_path: Annotated[ - Path | None, - typer.Option("--path", "-p", help="Custom traces directory path."), - ] = None, - limit: Annotated[int, typer.Option("--limit", "-l", help="Max sessions to list.")] = 20, -) -> None: - """List recorded execution trace sessions.""" - base_dir = traces_path or settings.TRACES_PATH - if not base_dir.exists(): - typer.secho(f"No traces found at: {base_dir}", fg=typer.colors.YELLOW) - return - - sessions = [d for d in base_dir.iterdir() if d.is_dir() and not d.name.startswith(".")] - sessions.sort(key=lambda x: x.stat().st_mtime, reverse=True) - - console = Console() - table = Table(title=f"Recorded Traces ({len(sessions)} total)") - table.add_column("Session Name", style="cyan") - table.add_column("Last Modified", style="green") - table.add_column("Artifacts", style="white") - - for s in sessions[:limit]: - artifacts = [] - if (s / "recording.mp4").exists() or (s / "recording.mkv").exists(): - artifacts.append("🎬 Video") - if (s / "steps.json").exists(): - artifacts.append("📋 Steps") - if (s / "notes").exists(): - artifacts.append("📝 Notes") - mtime = time_str = time_to_str(s.stat().st_mtime) - table.add_row(s.name, mtime, ", ".join(artifacts) or "Screenshots") - - console.print(table) - - -def time_to_str(ts: float) -> str: - return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") - - -@trace_app.command("view") -def view_trace( - session_name: Annotated[str, typer.Argument(help="Name of the trace session directory.")], - traces_path: Annotated[Path | None, typer.Option("--path", "-p")] = None, -) -> None: - """View step-by-step summary of a recorded trace session.""" - base_dir = traces_path or settings.TRACES_PATH - session_dir = base_dir / session_name - - if not session_dir.exists(): - typer.secho(f"Session '{session_name}' not found in {base_dir}", fg=typer.colors.RED) - raise typer.Exit(1) - - steps_file = session_dir / "steps.json" - console = Console() - - if steps_file.exists(): - try: - steps_data = json.loads(steps_file.read_text(encoding="utf-8")) - table = Table(title=f"Trace Steps: {session_name}") - table.add_column("Step", justify="right", style="cyan") - table.add_column("Action", style="green") - table.add_column("Reasoning / Motivation", style="white") - - for step in steps_data: - table.add_row( - str(step.get("step", "-")), - step.get("action", "-"), - step.get("motivation", step.get("thought", "-")), - ) - console.print(table) - return - except Exception as e: - logger.warning(f"Could not parse steps.json: {e}") - - # If steps.json not present, list files - typer.secho(f"Session files in {session_dir}:", fg=typer.colors.CYAN) - for f in session_dir.iterdir(): - typer.echo(f" - {f.name}") diff --git a/artemis/interfaces/cli/commands/ui.py b/artemis/interfaces/cli/commands/ui.py deleted file mode 100644 index 9caa0252d..000000000 --- a/artemis/interfaces/cli/commands/ui.py +++ /dev/null @@ -1,404 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""UI Launch and Web Console CLI Command (artemis ui).""" - -import logging -from pathlib import Path -import re -import shutil -import subprocess -import sys -import threading -import time -from typing import Annotated -import urllib.request -import webbrowser - -from rich.console import Console -from rich.markup import escape -from rich.panel import Panel -import typer - -logger = logging.getLogger(__name__) - - -def running_via_console_script_shim() -> bool: - """Return True when this CLI was launched through a console-script ``.exe`` shim. - - On Windows, ``uv``/pip install entry points as trampoline executables - (``Scripts/artemis.exe``) that stay resident for the lifetime of the CLI - process and hold a lock on their own file. A long-running foreground server - started through the shim therefore blocks ``uv sync`` from reinstalling the - package ("os error 32") until the server exits. Launching via - ``python -m artemis`` avoids the shim entirely. - """ - if sys.platform != "win32": - return False - argv0 = Path(sys.argv[0] or "") - return argv0.suffix.lower() == ".exe" and argv0.name.lower() != "python.exe" - - -def warn_if_console_script_shim(console: Console) -> None: - """Print a hint when a foreground server run will pin the console-script exe.""" - if running_via_console_script_shim(): - console.print( - " [dim]⚠ Running in foreground via the [bold]artemis.exe[/bold] launcher: package " - "syncs (uv sync / uv run) cannot replace it while this server is running.\n" - " Prefer [bold]artemis restart[/bold] (background daemon) or launch with " - "[bold]uv run python -m artemis ui[/bold] to avoid this.[/dim]\n" - ) - - -def _resolve_npm_executable(platform_name: str | None = None) -> str | None: - """Return a directly executable npm path for the current platform.""" - platform_name = platform_name or sys.platform - candidates = ("npm.cmd", "npm.exe", "npm") if platform_name == "win32" else ("npm",) - return next((path for candidate in candidates if (path := shutil.which(candidate))), None) - - -def _showcase_build_required(showcase_dir: Path) -> bool: - """Return whether the compiled Angular app is missing or older than its inputs.""" - base_dist = showcase_dir / "dist" - candidates = [ - base_dist / "frontend" / "browser" / "index.html", - base_dist / "browser" / "index.html", - base_dist / "frontend" / "index.html", - base_dist / "index.html", - ] - built_indexes = [path for path in candidates if path.exists()] - if not built_indexes: - return True - - build_time = max(path.stat().st_mtime for path in built_indexes) - input_paths = [showcase_dir / "package.json", showcase_dir / "angular.json"] - source_dir = showcase_dir / "src" - if source_dir.exists(): - input_paths.extend(path for path in source_dir.rglob("*") if path.is_file()) - return any(path.exists() and path.stat().st_mtime > build_time for path in input_paths) - - -def _npm_install_required(showcase_dir: Path) -> bool: - """Return whether ``node_modules`` is missing or older than the package manifests. - - npm rewrites ``node_modules/.package-lock.json`` on every successful - install, so its mtime is the "dependencies last synced" stamp. - """ - stamp = showcase_dir / "node_modules" / ".package-lock.json" - if not stamp.exists(): - return True - synced_at = stamp.stat().st_mtime - manifests = (showcase_dir / "package.json", showcase_dir / "package-lock.json") - return any(path.exists() and path.stat().st_mtime > synced_at for path in manifests) - - -_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") -_HEARTBEAT_INTERVAL_SECONDS = 15.0 - - -def _format_elapsed(seconds: float) -> str: - """``42s`` / ``3m 51s``.""" - whole = int(seconds) - if whole < 60: - return f"{whole}s" - return f"{whole // 60}m {whole % 60:02d}s" - - -def _run_build_step(console: Console, label: str, cmd: list[str], cwd: Path) -> None: - """Run one build step, relaying its output live and raising on failure. - - The child's stdout/stderr are forwarded line by line (ANSI stripped, - indented, dimmed) so npm summaries, ``ng build`` progress and any error - reach the terminal as they happen. On an interactive terminal a spinner - with the elapsed time keeps ticking through the long silent phases - (downloads, bundling); without a TTY a plain heartbeat line is printed - every 15 seconds instead so logs are not flooded with spinner frames. - """ - started = time.monotonic() - console.print(f" [bold]{label}[/bold]") - process = subprocess.Popen( - cmd, - cwd=cwd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - encoding="utf-8", - errors="replace", - ) - assert process.stdout is not None - - def relay_line(raw: str) -> None: - line = _ANSI_ESCAPE_RE.sub("", raw).rstrip() - if line.strip(): - console.print(f" {escape(line)}", style="dim", highlight=False) - - def stream_output(status=None) -> None: - last_heartbeat = started - for raw in process.stdout: - relay_line(raw) - now = time.monotonic() - if status is not None: - status.update(f"{label} … {_format_elapsed(now - started)}") - elif now - last_heartbeat >= _HEARTBEAT_INTERVAL_SECONDS: - console.print(f" still running… {_format_elapsed(now - started)}") - last_heartbeat = now - - if console.is_terminal: - with console.status(f"{label} … 0s", spinner="dots") as status: - ticker_stop = threading.Event() - - def tick() -> None: - while not ticker_stop.wait(1.0): - status.update(f"{label} … {_format_elapsed(time.monotonic() - started)}") - - ticker = threading.Thread(target=tick, daemon=True) - ticker.start() - try: - stream_output(status) - finally: - ticker_stop.set() - ticker.join(timeout=2.0) - else: - heartbeat_stop = threading.Event() - - def heartbeat() -> None: - while not heartbeat_stop.wait(_HEARTBEAT_INTERVAL_SECONDS): - console.print(f" still running… {_format_elapsed(time.monotonic() - started)}") - - beat = threading.Thread(target=heartbeat, daemon=True) - beat.start() - try: - stream_output() - finally: - heartbeat_stop.set() - beat.join(timeout=2.0) - - returncode = process.wait() - if returncode != 0: - raise subprocess.CalledProcessError(returncode, cmd) - - -def ensure_showcase_built(console: Console) -> None: - """Rebuild the Angular Showcase UI when its sources are newer than the dist build.""" - from artemis.config.paths import ROOT_DIR - from artemis.resources import get_bundled_showcase_dist - - showcase_dir = ROOT_DIR / "apps" / "showcase_ui" - # An installed wheel has no frontend source tree; its immutable build is - # prepared during packaging and must never trigger npm at runtime. - if not showcase_dir.is_dir() and get_bundled_showcase_dist() is not None: - return - if not _showcase_build_required(showcase_dir): - return - npm_executable = _resolve_npm_executable() - if not npm_executable: - return - console.print( - " [yellow]🎨 Showcase UI sources changed. Compiling Angular Showcase UI...[/yellow]" - ) - started = time.monotonic() - try: - if _npm_install_required(showcase_dir): - _run_build_step( - console, - "① npm install (dependencies changed)", - [npm_executable, "install", "--no-audit", "--no-fund", "--loglevel=warn"], - showcase_dir, - ) - _run_build_step(console, "② ng build", [npm_executable, "run", "build"], showcase_dir) - console.print( - f" [green]✓ Showcase UI built in {_format_elapsed(time.monotonic() - started)}." - "[/green]\n" - ) - except Exception as e: - console.print( - f" [red]⚠ Failed to auto-build Showcase UI: {e}[/red]\n" - " [dim]Build it by hand with: cd apps/showcase_ui && npm install && " - "npm run build[/dim]\n" - ) - - -def load_session_reconciler() -> None: - """Entry-layer assembly: arm stop_server()'s orphan-session cleanup. - - Importing the admin console session repository registers its reconciler - with ``artemis.runtime.server_lifecycle`` (inverted dependency: the base - runtime package never imports the application layer). Guarded so a broken - or absent admin console install can never block stopping a server. - """ - try: - import apps.admin_console.database.repositories.session_repository # noqa: F401 - except Exception as exc: # pylint: disable=broad-exception-caught - logger.debug("Admin console session reconciler not loaded: %s", exc, exc_info=True) - - -def _poll_and_open_browser(url: str, stop_event: threading.Event, timeout: float = 15.0) -> None: - """Poll the UI server until it responds, then launch the user's default browser.""" - start = time.time() - while not stop_event.is_set() and (time.time() - start < timeout): - try: - req = urllib.request.Request(url, headers={"User-Agent": "Artemis-HealthCheck/1.0"}) - with urllib.request.urlopen(req, timeout=1.5) as resp: - if resp.status == 200: - time.sleep(0.3) - webbrowser.open(url) - return - except Exception: - time.sleep(0.4) - - -def ui_command( - host: Annotated[ - str, - typer.Option( - "--host", - "-h", - help=( - "Bind host address for the UI server. Defaults to loopback; for remote " - "access prefer a Tailscale/SSH tunnel over a wide bind." - ), - ), - ] = "127.0.0.1", - port: Annotated[ - int, - typer.Option("--port", "-p", help="Port to run the unified UI server on."), - ] = 8000, - open_browser: Annotated[ - bool, - typer.Option( - "--open/--no-open", - help="Automatically open the Showcase UI in default web browser.", - ), - ] = True, - reload: Annotated[ - bool, - typer.Option( - "--reload/--no-reload", - help="Enable uvicorn live auto-reload (for development).", - ), - ] = False, - restart: Annotated[ - bool, - typer.Option( - "--restart", - "-r", - help="Restart server by terminating any existing instance on the port first.", - ), - ] = False, -) -> None: - """Launch the unified Artemis Showcase UI & Admin Console in one click.""" - console = Console() - local_url = f"http://localhost:{port}" - - from artemis.runtime.server_lifecycle import ( - find_server_pids, - is_port_in_use, - stop_server, - ) - - if restart: - console.print(f" [yellow]🔄 Restart requested. Recycling port {port}...[/yellow]") - load_session_reconciler() - stop_server(port=port, timeout=12.0) - elif is_port_in_use(port): - pids = find_server_pids(port) - pid_str = f" (PID: {', '.join(map(str, pids))})" if pids else "" - if sys.stdin.isatty(): - console.print( - f"\n [yellow]⚠ Port {port} is already in use by an active Artemis server{pid_str}.[/yellow]" - ) - console.print(" [dim]Choose an action:[/dim]") - console.print(" [bold]1[/bold] Open browser (default)") - console.print(" [bold]2[/bold] Restart server (stop existing and start here)") - console.print(" [bold]3[/bold] Cancel") - try: - choice = input(" Select [1-3, default 1]: ").strip() - except (KeyboardInterrupt, EOFError): - choice = "1" - if choice == "2": - console.print( - f"\n [yellow]🔄 Terminating previous Artemis instance on port {port}...[/yellow]" - ) - load_session_reconciler() - stop_server(port=port, timeout=12.0) - elif choice == "3": - return - else: - if open_browser: - webbrowser.open(local_url) - return - else: - console.print( - f"\n [yellow]⚠ Port {port} is already in use by Artemis{pid_str}. " - f"Use '[bold]artemis restart[/bold]' to restart.[/yellow]\n" - ) - if open_browser: - webbrowser.open(local_url) - return - - console.print() - msg = ( - f"[bold cyan]✨ Artemis Autonomous Mobile Agent UI[/bold cyan]\n\n" - f"📱 [bold green]Showcase UI & Onboarding:[/bold green] [cyan]{local_url}[/cyan]\n" - f"🛠️ [bold magenta]Admin Debug Console:[/bold magenta] [cyan]{local_url}/admin[/cyan]\n" - f"📖 [dim]API Documentation:[/dim] [dim]{local_url}/docs[/dim]\n\n" - + ( - "[dim]Press Ctrl+C to stop while idle; Ctrl+Break forces shutdown during a task.[/dim]" - if sys.platform == "win32" - else "[dim]Press Ctrl+C to stop the server.[/dim]" - ) - ) - console.print(Panel(msg, title="🚀 Web Server Active", border_style="cyan", expand=False)) - # Ensure the canonical writable .env exists. Installed wheels must never - # attempt to mutate their site-packages directory. - from artemis.config.paths import ROOT_DIR, get_env_file, is_source_checkout - - env_file = get_env_file() - env_example = ROOT_DIR / ".env.example" - if not env_file.exists(): - env_file.parent.mkdir(parents=True, exist_ok=True) - if is_source_checkout() and env_example.exists(): - try: - env_file.write_text(env_example.read_text(encoding="utf-8"), encoding="utf-8") - except (OSError, UnicodeError): - pass - else: - try: - env_file.touch() - except OSError: - pass - - ensure_showcase_built(console) - warn_if_console_script_shim(console) - - stop_event = threading.Event() - if open_browser: - t = threading.Thread( - target=_poll_and_open_browser, - args=(local_url, stop_event), - daemon=True, - ) - t.start() - - try: - # Intentional entry-layer lazy load: the CLI assembles the application - # here, and importing the FastAPI server at module import time would - # drag the whole admin console into every `artemis ` invocation. - from apps.admin_console.server import run_ui_server - - run_ui_server(host=host, port=port, reload=reload) - except KeyboardInterrupt: - console.print("\n[yellow]🛑 UI Server stopped.[/yellow]") - finally: - stop_event.set() diff --git a/artemis/interfaces/cli/main.py b/artemis/interfaces/cli/main.py index 629d6b2cb..94d15a25f 100644 --- a/artemis/interfaces/cli/main.py +++ b/artemis/interfaces/cli/main.py @@ -12,58 +12,38 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""ARTEMIS Unified Command Line Interface (CLI).""" +"""ARTEMIS Command Line Interface (CLI), contracted to the device surface (D18). + +Only the device-facing commands remain: ``mcp`` (device MCP servers), +``doctor`` (device/ADB/toolchain readiness) and ``helper`` (Accessibility +Helper APK management). The autonomous ``run``/``batch`` agent, the API-key +``init`` wizard, the web ``ui``/server lifecycle and trace/cloud commands now +live in the DSH host or have been removed. +""" from typing import Annotated from artemis._version import __version__ -from artemis.interfaces.cli.commands.batch import batch_command from artemis.interfaces.cli.commands.doctor import doctor_command from artemis.interfaces.cli.commands.helper import helper_app -from artemis.interfaces.cli.commands.init import init_command from artemis.interfaces.cli.commands.mcp import mcp_command -from artemis.interfaces.cli.commands.run import run_command -from artemis.interfaces.cli.commands.server import server_app -from artemis.interfaces.cli.commands.server_lifecycle import ( - restart_command, - status_command, - stop_command, -) -from artemis.interfaces.cli.commands.trace import trace_app -from artemis.interfaces.cli.commands.ui import ui_command -from third_party.mobile_use.utils.logger import get_logger from rich.console import Console from rich.panel import Panel import typer -logger = get_logger(__name__) - app = typer.Typer( name="artemis", - help="☕ Artemis: Autonomous Multimodal Android Agent & Testing Framework.", + help="ARTEMIS device-action core (DSH plugin).", add_completion=False, pretty_exceptions_enable=False, no_args_is_help=True, ) # Register subcommands -app.command(name="ui", help="Launch the unified Showcase UI & Admin Console in your browser.")( - ui_command -) -app.command(name="restart", help="Restart running Artemis Web UI & server.")(restart_command) -app.command(name="stop", help="Stop running Artemis Web UI & server.")(stop_command) -app.command(name="status", help="Display Artemis Web UI & server status.")(status_command) -app.command(name="run", help="Execute an autonomous task on a mobile device.")(run_command) -app.command(name="init", help="Interactive quickstart wizard to configure API keys & device.")( - init_command -) -app.command(name="doctor", help="Check system prerequisites, device status, and configuration.")( - doctor_command +app.command(name="doctor", help="Check device, ADB and toolchain readiness.")(doctor_command) +app.command(name="mcp", help="Start a device-facing Model Context Protocol (MCP) server.")( + mcp_command ) -app.command(name="batch", help="Execute a batch sequence of automation tasks.")(batch_command) -app.command(name="mcp", help="Start the Artemis Model Context Protocol (MCP) server.")(mcp_command) -app.add_typer(server_app, name="server", help="Cloud Run proxy and web dashboard server.") -app.add_typer(trace_app, name="trace", help="Inspect and query execution traces.") app.add_typer( helper_app, name="helper", help="Manage the Accessibility Helper APK on attached devices." ) @@ -74,9 +54,8 @@ def version_callback(value: bool): console = Console() console.print( Panel( - f"[bold cyan]Artemis Agent Platform[/bold cyan] v{__version__}\n" - "[dim]Autonomous Multimodal Mobile AI Engine[/dim]", - title="☕ Artemis", + f"[bold cyan]ARTEMIS device-action core[/bold cyan] v{__version__}", + title="ARTEMIS", expand=False, ) ) @@ -96,7 +75,7 @@ def main_callback( ), ] = False, ): - """ARTEMIS Autonomous Mobile Agent CLI.""" + """ARTEMIS device-action CLI.""" pass diff --git a/artemis/interfaces/sdk/__init__.py b/artemis/interfaces/sdk/__init__.py deleted file mode 100644 index 60cc9af08..000000000 --- a/artemis/interfaces/sdk/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Python SDK for ARTEMIS.""" - -from artemis.interfaces.sdk.client import ArtemisClient, ConcurrencyMode -from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, Task -from artemis_client import Capabilities, Device, TaskHandle, TaskResult - -__all__ = [ - "ArtemisClient", - "Capabilities", - "ConcurrencyMode", - "Device", - "StreamEvent", - "StreamEventType", - "Task", - "TaskHandle", - "TaskResult", -] diff --git a/artemis/interfaces/sdk/client.py b/artemis/interfaces/sdk/client.py deleted file mode 100644 index 9eb979a2f..000000000 --- a/artemis/interfaces/sdk/client.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 - -"""Compatibility import for the standalone remote Artemis client. - -The implementation lives in the zero-dependency ``artemis-client`` package. -Keeping this module lets existing full-runtime installations migrate without -maintaining a second client execution path. -""" - -from artemis.runtime import ConcurrencyMode -from artemis_client import ArtemisClient - -__all__ = ["ArtemisClient", "ConcurrencyMode"] diff --git a/artemis/interfaces/sdk/task.py b/artemis/interfaces/sdk/task.py deleted file mode 100644 index bc25a15e2..000000000 --- a/artemis/interfaces/sdk/task.py +++ /dev/null @@ -1,72 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import time -from typing import Any, Literal -from pydantic import BaseModel, Field - - -class StreamEventType: - THOUGHT = "thought" - ACTION = "action" - OBSERVATION = "observation" - STEP_START = "step_start" - STEP_END = "step_end" - STATUS = "status" - ERROR = "error" - - -class StreamEvent(BaseModel): - """Real-time intermediate progress event emitted during task execution.""" - - event_type: str = Field( - ..., description="Type of event: thought, action, observation, status, etc." - ) - step_number: int = Field(default=0, description="1-indexed execution step") - payload: dict[str, Any] = Field(default_factory=dict, description="Event data dictionary") - timestamp: float = Field(default_factory=time.time, description="Epoch timestamp") - - -class Task(BaseModel): - """Configures a single autonomous mobile automation request.""" - - goal: str = Field(..., description="High-level goal description") - profile: Literal["flash", "pro"] = Field(default="pro", description="Execution profile") - device_id: str = Field(default="default-device", description="Target device serial") - device_serial: str | None = Field( - default=None, description="Target device serial (alias for device_id)" - ) - concurrency_mode: Literal["global", "per_device"] | None = Field( - default=None, - description="Concurrency mode: 'global' (1 across all devices) or 'per_device' (1 per device)", - ) - locked_package: str | None = Field(default=None, description="Package lock restriction") - max_turns: int = Field(default=30, description="Turn cutoff limit") - - def model_post_init(self, __context: Any) -> None: - if self.device_serial and self.device_id == "default-device": - self.device_id = self.device_serial - elif self.device_id != "default-device" and not self.device_serial: - self.device_serial = self.device_id - - -class TaskResult(BaseModel): - """Final structured outcome of a task execution.""" - - trace_id: str - status: str - turns: int - output: Any = None - error: str | None = None - device_id: str | None = None diff --git a/artemis/llm/router.py b/artemis/llm/router.py index f2e6b42b6..d4a29d9ea 100644 --- a/artemis/llm/router.py +++ b/artemis/llm/router.py @@ -39,6 +39,7 @@ class ModelProvider(StrEnum): GEMINI = "google" VERTEX_AI = "vertexai" OPENAI = "openai" + OPENAI_RESPONSES = "openai_responses" ANTHROPIC = "anthropic" OPENROUTER = "openrouter" XAI = "xai" @@ -67,6 +68,8 @@ def from_string(cls, val: Any) -> "ModelProvider": "vertexai": cls.VERTEX_AI, "vertex": cls.VERTEX_AI, "openai": cls.OPENAI, + "openairesponses": cls.OPENAI_RESPONSES, + "responses": cls.OPENAI_RESPONSES, "anthropic": cls.ANTHROPIC, "claude": cls.ANTHROPIC, "openrouter": cls.OPENROUTER, @@ -280,7 +283,7 @@ def create_model(cls, endpoint: ModelEndpoint) -> BaseChatModel: } return ChatVertexAI(**{k: v for k, v in kwargs.items() if v is not None}) - elif provider == ModelProvider.OPENAI: + elif provider in (ModelProvider.OPENAI, ModelProvider.OPENAI_RESPONSES): from langchain_openai import ChatOpenAI api_key = ( @@ -301,6 +304,9 @@ def create_model(cls, endpoint: ModelEndpoint) -> BaseChatModel: } if endpoint.reasoning_effort: kwargs["reasoning_effort"] = endpoint.reasoning_effort + if provider == ModelProvider.OPENAI_RESPONSES: + # OpenAI's newer Responses API (langchain-openai >= 1.0). + kwargs["use_responses_api"] = True return ChatOpenAI(**{k: v for k, v in kwargs.items() if v is not None}) elif provider == ModelProvider.ANTHROPIC: diff --git a/artemis/main.py b/artemis/main.py deleted file mode 100644 index f3434597f..000000000 --- a/artemis/main.py +++ /dev/null @@ -1,58 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Backward compatibility entrypoint for artemis CLI.""" - -import sys - -import typer - -from artemis.interfaces.cli.main import app - -# Tests replace `app`; command discovery still needs the real Typer app. -from artemis.interfaces.cli.main import app as _typer_app - - -def _root_tokens() -> frozenset[str]: - """Command names and root options registered on the Typer app.""" - group = typer.main.get_command(_typer_app) - tokens: set[str] = {"-h"} - # Typer may vendor its own click, so use getattr instead of isinstance. - tokens.update(getattr(group, "commands", {}) or {}) - tokens.update(group.get_help_option_names(group.context_class(group))) - for param in group.params: - tokens.update(getattr(param, "opts", ())) - tokens.update(getattr(param, "secondary_opts", ())) - return frozenset(tokens) - - -def normalize_argv(argv: list[str]) -> list[str]: - """Insert ``run`` when the first argument is not a known root command/flag. - - Keeps ``python -m artemis.main "some goal"`` working as ``run "some goal"``. - """ - if len(argv) > 1 and argv[1] not in _root_tokens(): - return [argv[0], "run", *argv[1:]] - return list(argv) - - -def cli(): - # If invoked directly as python -m artemis.main without subcommand 'run', - # check if first argument is a goal rather than a subcommand - sys.argv[:] = normalize_argv(sys.argv) - app() - - -if __name__ == "__main__": - cli() diff --git a/artemis/mcp/action_server.py b/artemis/mcp/action_server.py deleted file mode 100644 index be32b95c9..000000000 --- a/artemis/mcp/action_server.py +++ /dev/null @@ -1,202 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""The unified device action MCP server. - -``build_action_server`` turns an actuator into a ``FastMCP`` server that registers -**only** the tools the actuator implements, plus its extension tools -- so -``list_tools()`` is clean by construction and an absent optional tool is invisible to -every consumer. - -Return protocol: every device action returns a ``CallToolResult`` whose -``structuredContent`` is an ``ActionResult`` -- ``ok``/``code`` carry the real status -(killing the historical ``"Error" in text`` sniffing) while ``content`` keeps the -historical human-readable message. ``isError`` is reserved for protocol-level -failures; a device refusing an action is ``isError=False, ok=False`` so it reaches the -model as an observation, not an exception. - -Exceptions raised by the actuator are wrapped here with the historical -``"Error during X: {e}"`` wording the agents' transcripts already contain. -""" - -import asyncio -from typing import Annotated, Any - -from mcp.server.fastmcp import FastMCP -from mcp.types import CallToolResult, ImageContent, TextContent - -from artemis.mcp.action_manifest import validate_actuator -from artemis.mcp.action_specs import ( - exception_prefix, - make_wire_handler, - wire_dialects, -) -from artemis.mcp.action_types import ActionCode, ActionResult, ObserveResult -from artemis.mcp.actuators.base import Actuator -from artemis.mcp.observation import observe as observe_impl -from third_party.mobile_use.utils.logger import get_logger - -logger = get_logger(__name__) - -__all__ = ["build_action_server"] - - -def _wrap(res: ActionResult) -> CallToolResult: - return CallToolResult( - content=[TextContent(type="text", text=res.message)], - structuredContent=res.model_dump(mode="json"), - isError=False, - ) - - -def _wrap_exception(action: str, exc: Exception) -> CallToolResult: - return _wrap( - ActionResult.failure(action, f"{exception_prefix(action)}: {exc}", detail=repr(exc)) - ) - - -def build_action_server(actuator: Actuator, name: str = "artemis_actions") -> FastMCP: - """Builds a FastMCP server exposing exactly what ``actuator`` implements. - - Raises ``ActuatorContractError`` when the actuator violates the manifest, so a - misconfigured backend fails before serving anything. - """ - validate_actuator(actuator) - caps = actuator.capabilities() - mcp = FastMCP(name) - - # --- Device actions (registered only when implemented) --------------------------- - # Declarations and argument conversion both come from the canonical manifest - # (artemis/mcp/action_specs.py); this server contributes only the transport - # wrapping, so the served schema cannot drift from the manifest. - - for spec in wire_dialects(): - if spec.name in caps: - mcp.add_tool( - make_wire_handler(spec, actuator, _wrap, _wrap_exception), - name=spec.name, - description=spec.wire.description, - ) - - # --- Internal observation tools (never declared to an LLM) ----------------------- - - if "observe_screen" in caps: - - @mcp.tool() - async def observe_screen( - settle_ms: int = 400, include_image: bool = False - ) -> Annotated[CallToolResult, ObserveResult]: - """Capture and index the current screen (adapter-internal).""" - try: - obs, img_bytes = await observe_impl( - getattr(actuator, "ctx", None), - actuator.controller, - settle_ms=settle_ms, - ) - content: list[Any] = [TextContent(type="text", text=obs.message)] - if include_image and img_bytes: - import base64 as _b64 - - content.append( - ImageContent( - type="image", - data=_b64.b64encode(img_bytes).decode("utf-8"), - mimeType="image/jpeg", - ) - ) - return CallToolResult( - content=content, - structuredContent=obs.model_dump(mode="json"), - isError=False, - ) - except Exception as e: - obs = ObserveResult( - ok=False, - code=ActionCode.DEVICE_ERROR, - message=f"Error during observe_screen: {e}", - hierarchy_ok=False, - ) - return CallToolResult( - content=[TextContent(type="text", text=obs.message)], - structuredContent=obs.model_dump(mode="json"), - isError=False, - ) - - # Timeouts on the two observation tools run INSIDE the tool, around the actuator - # call only. They must never be expressed as a transport-level read timeout - # (``read_timeout_seconds``) or a caller-side ``asyncio.wait_for``: both produce a - # late response to an abandoned request, which crashes the mcp-1.29 client - # receive loop (``_handle_response`` sends into the request's already-closed - # response stream) and bricks the in-memory session for every later caller. - # A server-side timeout yields a normal error payload and no late response. - - if "take_screenshot" in caps: - - @mcp.tool() - async def take_screenshot(timeout_ms: int | None = None) -> CallToolResult: - """Return the current screen as base64 (adapter-internal, no persistence).""" - try: - coro = actuator.take_screenshot() - if timeout_ms: - coro = asyncio.wait_for(coro, timeout=timeout_ms / 1000.0) - b64 = await coro - payload: dict[str, Any] = {"ok": True, "image_b64": b64} - except TimeoutError: - payload = {"ok": False, "error": f"screenshot timed out after {timeout_ms}ms"} - except Exception as e: - payload = {"ok": False, "error": str(e)} - return CallToolResult( - content=[ - TextContent(type="text", text="screenshot" if payload["ok"] else str(payload)) - ], - structuredContent=payload, - isError=False, - ) - - if "get_ui_hierarchy" in caps: - - @mcp.tool() - async def get_ui_hierarchy(timeout_ms: int | None = None) -> CallToolResult: - """Return the raw UI element hierarchy (adapter-internal).""" - try: - coro = actuator.get_ui_elements() - if timeout_ms: - coro = asyncio.wait_for(coro, timeout=timeout_ms / 1000.0) - elements = await coro - payload: dict[str, Any] = {"ok": True, "elements": elements} - except TimeoutError: - payload = { - "ok": False, - "error": f"ui hierarchy fetch timed out after {timeout_ms}ms", - } - except Exception as e: - payload = {"ok": False, "error": str(e)} - return CallToolResult( - content=[ - TextContent(type="text", text="ui_hierarchy" if payload["ok"] else str(payload)) - ], - structuredContent=payload, - isError=False, - ) - - # --- Backend extension tools ----------------------------------------------------- - - for ext in actuator.extensions(): - mcp.add_tool(ext.handler, name=ext.name, description=ext.description) - - logger.info( - f"Action server '{name}' built with {len(caps)} device actions and" - f" {len(actuator.extensions())} extension tools." - ) - return mcp diff --git a/artemis/mcp/action_types.py b/artemis/mcp/action_types.py index c1462cfeb..e7959130e 100644 --- a/artemis/mcp/action_types.py +++ b/artemis/mcp/action_types.py @@ -69,7 +69,12 @@ def failure( class ObserveResult(BaseModel): - """Post-action observation: persisted screenshot plus the parsed element list.""" + """Post-action observation: persisted screenshot plus the parsed element list. + + Restored verbatim from main for the mcp_server provider (G72 route 4): the + smart stack's observe/replay path consumes it; the new 15-tool action face + does not. + """ ok: bool code: ActionCode = ActionCode.OK diff --git a/artemis/mcp/actuators/__init__.py b/artemis/mcp/actuators/__init__.py index cf2393ba8..7f6ad0f56 100644 --- a/artemis/mcp/actuators/__init__.py +++ b/artemis/mcp/actuators/__init__.py @@ -14,8 +14,7 @@ """Actuator backends: pluggable implementations of the device action contract.""" -from artemis.mcp.actuators.base import Actuator from artemis.mcp.actuators.adb import AdbActuator from artemis.mcp.actuators.mock import MockActuator -__all__ = ["Actuator", "AdbActuator", "MockActuator"] +__all__ = ["AdbActuator", "MockActuator"] diff --git a/artemis/mcp/actuators/adb.py b/artemis/mcp/actuators/adb.py index 4580e436b..4a8098ffc 100644 --- a/artemis/mcp/actuators/adb.py +++ b/artemis/mcp/actuators/adb.py @@ -284,8 +284,10 @@ async def press_key(self, key: str) -> ActionResult: return ActionResult.success("press_key", f"Pressed key '{key}'.") async def manage_app(self, action: str, app_name: str) -> ActionResult: - # Imported lazily: launch_app pulls in tool wrappers that are costly at import. - from artemis.tools.mobile.launch_app import find_package + # Deterministic package resolution lives in the vendored device layer; + # the old artemis.tools launch tool was a LangChain tool that pulled in + # the whole smart stack (D18: NL name resolution is the host LLM's job). + from third_party.mobile_use.tools.mobile.launch_app import find_package from third_party.mobile_use.utils.app_launch_utils import launch_app_with_retries res = find_package(self.ctx, app_name, use_fallback=False) diff --git a/artemis/mcp/adb_server.py b/artemis/mcp/adb_server.py index 14da4d5c5..1f90c6dc8 100644 --- a/artemis/mcp/adb_server.py +++ b/artemis/mcp/adb_server.py @@ -13,6 +13,7 @@ # limitations under the License. from collections.abc import Callable +import asyncio import json import logging import os @@ -462,6 +463,89 @@ async def take_screenshot(ctx: Context) -> str: return f"Error: {e}" +@_tool() +async def compare_ui_frames( + before_b64: str, after_b64: str, action: str = "tap", coordinates: list[int] | None = None +) -> str: + """Reports whether the screen visibly changed between two captured frames. + + Pure computation on base64 frames the caller already holds -- nothing is + captured here, so the caller keeps ownership of when to look. The verdict + rules (status/nav bar masking, the full-screen change ratio, the widened ROI + check for taps, and assuming "changed" when a frame cannot be decoded) are + the same ones the Validator uses, from artemis.utils.image_diff. + + Returns a JSON string such as '{"changed": true}', or 'Error: ...'. + """ + import base64 + + from artemis.utils.image_diff import check_ui_change + + try: + before = base64.b64decode(before_b64) + after = base64.b64decode(after_b64) + except Exception as e: + return f"Error: frame decode failed: {e}" + + action_item: dict[str, Any] = {"action": action} + if coordinates and len(coordinates) == 2: + action_item["coordinates"] = [int(coordinates[0]), int(coordinates[1])] + + try: + changed = bool(check_ui_change(before, after, action_item)) + except Exception as e: + logger.exception("Failed to compare UI frames") + return f"Error: {e}" + + return json.dumps({"changed": changed}) + + +@_tool() +async def list_devices(include_lock_state: bool = False) -> str: + """Lists attached Android devices and their readiness, touching none of them. + + Read-only discovery for callers that schedule work across machines (the plugin's device pool and + submission admission, G3/G28/G64). Enumeration is the runtime pool's own, so snapshot caching + and timeout behaviour stay in one place. + + The result keeps **"could not ask adb"** distinct from **"adb answered: no devices"**: + `enumerated` is false only when the question itself failed. Collapsing those two would turn a + transient adb blip into an empty fleet, which is why `try_list_devices_async` exists upstream. + + `include_lock_state` defaults to **false** because the answer costs 1-2 `dumpsys` calls per + device (Keyguard is not part of `adb devices`); ask for it only when you gate a dispatch on it. + Each device then carries `is_locked`: true locked, false unlocked, **null unknown** -- and + unknown is treated as not-admissible by the submission gate, so it must stay distinguishable + from false. Without the flag the key is simply absent, meaning "not asked", never "unlocked". + + Returns a JSON string such as '{"enumerated": true, "devices": [{"serial": "emulator-5554", + "state": "device", "is_busy": false, "is_locked": null}]}'. + """ + from artemis.runtime.device_pool import device_pool + + try: + statuses = await device_pool.try_list_devices_async() + except Exception as e: + logger.warning(f"Device enumeration failed: {e}") + return json.dumps({"enumerated": False, "devices": []}) + + if statuses is None: + return json.dumps({"enumerated": False, "devices": []}) + + entries = [status.to_dict() for status in statuses] + if include_lock_state: + from artemis.core.diagnostics.probes.adb_probe import AdbDeviceProbe + + probe = AdbDeviceProbe() + locks = await asyncio.gather( + *(probe.device_lock_state(entry["serial"]) for entry in entries), + return_exceptions=True, + ) + for entry, lock in zip(entries, locks, strict=True): + entry["is_locked"] = None if isinstance(lock, BaseException) else lock + return json.dumps({"enumerated": True, "devices": entries}) + + @_tool() async def get_ui_hierarchy(ctx: Context) -> str: """Retrieves the current UI elements hierarchy from the device.""" diff --git a/artemis/mcp/mock_server.py b/artemis/mcp/mock_server.py new file mode 100644 index 000000000..7e81f929b --- /dev/null +++ b/artemis/mcp/mock_server.py @@ -0,0 +1,57 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Device-free action surface: the real ``--type adb`` tools over an in-memory device. + +One function stands between those 14 tools and a machine with no adb server: +``adb_server._get_controller`` opens ``AdbClient(...)``, calls ``device_list()`` and raises when +nothing is attached. Its two cache short-circuits run *before* that I/O, so seeding the cache +with a controller bound to :class:`~artemis.mcp.actuators.mock.MockActuator` makes the whole +surface run offline **without touching the existing server**. + +Seeding has to cover both short-circuits. Setting only the global controller is not enough: the +app loads ``.env`` itself, so ``ADB_DEVICE_SERIAL`` is normally populated and the lookup takes the +``_CONTROLLERS[serial]`` path. ``seed_mock_controllers`` therefore pins +``ARTEMIS_DEVICE_ID`` -- which is consulted *first* -- to :data:`MOCK_SERIAL` and registers that +key, so every call resolves deterministically instead of silently falling through to real adb. +""" + +from __future__ import annotations + +import os + +from artemis.mcp import adb_server +from artemis.mcp.actuators.mock import MockActuator + +__all__ = ["MOCK_SERIAL", "seed_mock_controllers"] + +MOCK_SERIAL = "mock-device" + + +def seed_mock_controllers(width: int = 1080, height: int = 2400) -> MockActuator: + """Install an in-memory controller in ``adb_server``'s caches and return the actuator. + + ``ARTEMIS_DEVICE_ID`` wins over ``ADB_DEVICE_SERIAL`` in ``_get_controller``'s lookup, so + pinning it is what makes the mock surface independent of whatever ``.env`` says. + """ + actuator = MockActuator(width=width, height=height) + controller = actuator.controller + os.environ["ARTEMIS_DEVICE_ID"] = MOCK_SERIAL + adb_server._GLOBAL_CONTROLLER = controller # noqa: SLF001 + adb_server._CONTROLLERS[MOCK_SERIAL] = controller # noqa: SLF001 - documented seam for this server + serial = os.environ.get("ADB_DEVICE_SERIAL") + if serial: + # A tool call that passes the serial explicitly must land on the mock too. + adb_server._CONTROLLERS[serial] = controller # noqa: SLF001 + return actuator diff --git a/artemis/memory/chunking.py b/artemis/memory/chunking.py index c0c91ea07..161b72aed 100644 --- a/artemis/memory/chunking.py +++ b/artemis/memory/chunking.py @@ -323,17 +323,17 @@ def __init__( def _get_llm(self): if self._llm is None: - from artemis.services.llm import get_google_llm + from artemis.services.llm import get_default_deployment_llm - self._llm = get_google_llm(model_name=self._model_name, temperature=0.0) + self._llm = get_default_deployment_llm(self._model_name, temperature=0.0) return self._llm def _get_fallback_llm(self): if self._fallback_llm is None and self._fallback_model_name: - from artemis.services.llm import get_google_llm + from artemis.services.llm import get_default_deployment_llm - self._fallback_llm = get_google_llm( - model_name=self._fallback_model_name, temperature=0.0 + self._fallback_llm = get_default_deployment_llm( + self._fallback_model_name, temperature=0.0 ) return self._fallback_llm diff --git a/artemis/resources/__init__.py b/artemis/resources/__init__.py index e883b1f48..c94e44f73 100644 --- a/artemis/resources/__init__.py +++ b/artemis/resources/__init__.py @@ -12,13 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Access to immutable defaults and web assets shipped with Artemis. +"""Access to immutable defaults shipped with Artemis. -Python wheels are installed as ordinary directories by pip, so callers that -need to hand a path to another library (for example FastAPI's ``FileResponse``) -can safely use the paths returned here. Source checkouts continue to prefer -their editable ``config`` and ``apps/showcase_ui/dist`` trees; these resources -are the installation fallback. +Python wheels are installed as ordinary directories by pip, so callers that need +to hand a path to another library can safely use the paths returned here. """ from importlib.resources import files @@ -37,10 +34,4 @@ def get_bundled_config_path(filename: str) -> Path | None: return candidate if candidate.is_file() else None -def get_bundled_showcase_dist() -> Path | None: - """Return the bundled Angular browser build when it is complete.""" - candidate = _resource_path("showcase_ui") - return candidate if (candidate / "index.html").is_file() else None - - -__all__ = ["get_bundled_config_path", "get_bundled_showcase_dist"] +__all__ = ["get_bundled_config_path"] diff --git a/artemis/resources/config/artemis.jsonc b/artemis/resources/config/artemis.jsonc index 517d73840..56c46556c 100644 --- a/artemis/resources/config/artemis.jsonc +++ b/artemis/resources/config/artemis.jsonc @@ -18,38 +18,7 @@ }, // ------------------------------------------------------------------------------ - // 2. Ready-to-Use Presets (Switch easily in code or CLI) - // ------------------------------------------------------------------------------ - "presets": { - "gemini-flagship": { - "provider": "google", - "model": "gemini-3.8-flash", - "fallback": { "provider": "google", "model": "gemini-3.7-flash" } - }, - "gemini-flash": { - "provider": "google", - "model": "gemini-2.5-flash", - "fallback": { "provider": "google", "model": "gemini-2.0-flash" } - }, - "openai-gpt4o": { - "provider": "openai", - "model": "gpt-4o", - "fallback": { "provider": "openai", "model": "gpt-4o-mini" } - }, - "cost-saving": { - "provider": "google", - "model": "gemini-3.5-flash-lite", - "fallback": { "provider": "google", "model": "gemini-3.1-flash-lite" } - }, - "local-ollama": { - "provider": "openai", - "model": "llama3.2-vision", - "fallback": { "provider": "openai", "model": "llama3.2-vision" } - } - }, - - // ------------------------------------------------------------------------------ - // 3. Agent & Sub-Agent Node Customizations (Optional - Inherits from 'default') + // 2. Agent & Sub-Agent Node Customizations (Optional - Inherits from 'default') // ------------------------------------------------------------------------------ "nodes": { // 🧠 Planner: High-level goal decomposition and strategic step planning @@ -129,11 +98,11 @@ }, // ------------------------------------------------------------------------------ - // 4. Runtime Mode & Agent Capabilities Configuration (Dual-Engine: Flash vs. Pro) + // 3. Runtime Mode & Agent Capabilities Configuration (Dual-Engine: Flash vs. Pro) // ------------------------------------------------------------------------------ "agent": { // ========================================================================== - // ⚡ 4.1 FLASH Profile Options (Reactive Single-Agent Lightweight Loop) + // ⚡ 3.1 FLASH Profile Options (Reactive Single-Agent Lightweight Loop) // ========================================================================== // Best for: Simple, fast, deterministic tasks; runs unbounded by default. // Execution: Observe-Think-Act single LLM reactive loop without graph overhead. @@ -150,7 +119,7 @@ } }, // ========================================================================== - // 🚀 4.2 PRO Profile Options (Deep Multi-Agent Graph Closed-Loop) + // 🚀 3.2 PRO Profile Options (Deep Multi-Agent Graph Closed-Loop) // ========================================================================== // Best for: Complex, exploratory, dynamic multi-branch tasks. // Execution: LangGraph multi-node graph with a living task plan, a pre-execution safety net (single actions) / fast-action bursts (multi-action turns), @@ -190,7 +159,7 @@ } }, // ========================================================================== - // 🌐 4.3 Shared Capabilities & Global Overrides + // 🌐 3.3 Shared Capabilities & Global Overrides // ========================================================================== // Shared step memory and history compression // Transcript ledger + layered compression for the Pro operator. diff --git a/artemis/resources/showcase_ui/favicon.ico b/artemis/resources/showcase_ui/favicon.ico deleted file mode 100644 index df9d973c4..000000000 Binary files a/artemis/resources/showcase_ui/favicon.ico and /dev/null differ diff --git a/artemis/resources/showcase_ui/index.html b/artemis/resources/showcase_ui/index.html deleted file mode 100644 index 1a249eab3..000000000 --- a/artemis/resources/showcase_ui/index.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - artemis - - - - - - - - - - - - - - - - diff --git a/artemis/resources/showcase_ui/logo.png b/artemis/resources/showcase_ui/logo.png deleted file mode 100644 index 2d96e1400..000000000 Binary files a/artemis/resources/showcase_ui/logo.png and /dev/null differ diff --git a/artemis/resources/showcase_ui/logo.svg b/artemis/resources/showcase_ui/logo.svg deleted file mode 100644 index 53a9a18bf..000000000 --- a/artemis/resources/showcase_ui/logo.svg +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/artemis/resources/showcase_ui/main-BGAFACUZ.js b/artemis/resources/showcase_ui/main-BGAFACUZ.js deleted file mode 100644 index 8ae6a1db6..000000000 --- a/artemis/resources/showcase_ui/main-BGAFACUZ.js +++ /dev/null @@ -1,18 +0,0 @@ -var aO=Object.defineProperty,sO=Object.defineProperties;var cO=Object.getOwnPropertyDescriptors;var vm=Object.getOwnPropertySymbols;var lO=Object.prototype.hasOwnProperty,dO=Object.prototype.propertyIsEnumerable;var ym=(n,t,e)=>t in n?aO(n,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):n[t]=e,S=(n,t)=>{for(var e in t||={})lO.call(t,e)&&ym(n,e,t[e]);if(vm)for(var e of vm(t))dO.call(t,e)&&ym(n,e,t[e]);return n},F=(n,t)=>sO(n,cO(t));var be=(n,t,e)=>new Promise((o,i)=>{var r=c=>{try{s(e.next(c))}catch(p){i(p)}},a=c=>{try{s(e.throw(c))}catch(p){i(p)}},s=c=>c.done?o(c.value):Promise.resolve(c.value).then(r,a);s((e=e.apply(n,t)).next())});var mt=null,Ds=!1,$o=1,uO=null,nt=Symbol("SIGNAL");function Y(n){let t=mt;return mt=n,t}function Is(){return mt}var po={version:0,lastCleanEpoch:0,dirty:!1,producers:void 0,producersTail:void 0,consumers:void 0,consumersTail:void 0,recomputing:!1,consumerAllowSignalWrites:!1,consumerIsAlwaysLive:!1,kind:"unknown",producerMustRecompute:()=>!1,producerRecomputeValue:()=>{},consumerMarkedDirty:()=>{},consumerOnSignalRead:()=>{}};function Ho(n){if(Ds)throw new Error("");if(mt===null)return;mt.consumerOnSignalRead(n);let t=mt.producersTail;if(t!==void 0&&t.producer===n)return;let e,o=mt.recomputing;if(o&&(e=t!==void 0?t.nextProducer:mt.producers,e!==void 0&&e.producer===n)){mt.producersTail=e,e.lastReadVersion=n.version,e.knownValidAtEpoch=$o;return}let i=n.consumersTail;if(i!==void 0&&i.consumer===mt&&(!o||i.knownValidAtEpoch===$o))return;let r=$i(mt),a={producer:n,consumer:mt,nextProducer:e,prevConsumer:void 0,knownValidAtEpoch:$o,lastReadVersion:n.version,nextConsumer:void 0};mt.producersTail=a,t!==void 0?t.nextProducer=a:mt.producers=a,r&&km(n,a)}function xm(){$o++}function Uo(n){if(!($i(n)&&!n.dirty)&&!(!n.dirty&&n.lastCleanEpoch===$o)){if(!n.producerMustRecompute(n)&&!Wr(n)){ji(n);return}n.producerRecomputeValue(n),ji(n)}}function eu(n){if(n.consumers===void 0)return;let t=Ds;Ds=!0;try{for(let e=n.consumers;e!==void 0;e=e.nextConsumer){let o=e.consumer;o.dirty||gO(o)}}finally{Ds=t}}function tu(){return mt?.consumerAllowSignalWrites!==!1}function gO(n){n.dirty=!0,eu(n),n.consumerMarkedDirty?.(n)}function ji(n){n.dirty=!1,n.lastCleanEpoch=$o}function fo(n){return n&&wm(n),Y(n)}function wm(n){if(n.producersTail?.knownValidAtEpoch===$o){let t=n.producers;for(;t!==void 0;)t.knownValidAtEpoch=null,t=t.nextProducer}n.producersTail=void 0,n.recomputing=!0}function Go(n,t){Y(t),n&&Sm(n)}function Sm(n){n.recomputing=!1;let t=n.producersTail,e=t!==void 0?t.nextProducer:n.producers;if(e!==void 0){if($i(n))do e=nu(e);while(e!==void 0);t!==void 0?t.nextProducer=void 0:n.producers=void 0}}function Wr(n){for(let t=n.producers;t!==void 0;t=t.nextProducer){let e=t.producer,o=t.lastReadVersion;if(o!==e.version||(Uo(e),o!==e.version))return!0}return!1}function qo(n){if($i(n)){let t=n.producers;for(;t!==void 0;)t=nu(t)}n.producers=void 0,n.producersTail=void 0,n.consumers=void 0,n.consumersTail=void 0}function km(n,t){let e=n.consumersTail,o=$i(n);if(e!==void 0?(t.nextConsumer=e.nextConsumer,e.nextConsumer=t):(t.nextConsumer=void 0,n.consumers=t),t.prevConsumer=e,n.consumersTail=t,!o)for(let i=n.producers;i!==void 0;i=i.nextProducer)km(i.producer,i)}function nu(n){let t=n.producer,e=n.nextProducer,o=n.nextConsumer,i=n.prevConsumer;if(n.nextConsumer=void 0,n.prevConsumer=void 0,o!==void 0?o.prevConsumer=i:t.consumersTail=i,i!==void 0)i.nextConsumer=o;else if(t.consumers=o,!$i(t)){let r=t.producers;for(;r!==void 0;)r=nu(r)}return e}function $i(n){return n.consumerIsAlwaysLive||n.consumers!==void 0}function Kr(n){uO?.(n)}function Yr(n,t){return Object.is(n,t)}function Ts(n,t){let e=Object.create(pO);e.computation=n,t!==void 0&&(e.equal=t);let o=()=>{if(Uo(e),Ho(e),e.value===Cn)throw e.error;return e.value};return o[nt]=e,Kr(e),o}var zo=Symbol("UNSET"),Bo=Symbol("COMPUTING"),Cn=Symbol("ERRORED"),pO=F(S({},po),{value:zo,dirty:!0,error:null,equal:Yr,kind:"computed",producerMustRecompute(n){return n.value===zo||n.value===Bo},producerRecomputeValue(n){if(n.value===Bo)throw new Error("");let t=n.value;n.value=Bo;let e=fo(n),o,i=!1;try{o=n.computation(),Y(null),i=t!==zo&&t!==Cn&&o!==Cn&&n.equal(t,o)}catch(r){o=Cn,n.error=r}finally{Go(n,e)}if(i){n.value=t;return}n.value=o,n.version++}});function fO(){throw new Error}var Em=fO;function Dm(n){Em(n)}function ou(n){Em=n}var mO=null;function iu(n,t){let e=Object.create(Ns);e.value=n,t!==void 0&&(e.equal=t);let o=()=>Im(e);return o[nt]=e,Kr(e),[o,a=>Wo(e,a),a=>As(e,a)]}function Im(n){return Ho(n),n.value}function Wo(n,t){tu()||Dm(n),n.equal(n.value,t)||(n.value=t,_O(n))}function As(n,t){tu()||Dm(n),Wo(n,t(n.value))}var Ns=F(S({},po),{equal:Yr,value:void 0,kind:"signal"});function _O(n){n.version++,xm(),eu(n),mO?.(n)}var ru=F(S({},po),{consumerIsAlwaysLive:!0,consumerAllowSignalWrites:!0,dirty:!0,kind:"effect"});function au(n){if(n.dirty=!1,n.version>0&&!Wr(n))return;n.version++;let t=fo(n);try{n.cleanup(),n.fn()}finally{Go(n,t)}}var su;function Rs(){return su}function Mn(n){let t=su;return su=n,t}var Tm=Symbol("NotFound");function zi(n){return n===Tm||n?.name==="\u0275NotFound"}function cu(n,t,e){let o=Object.create(hO);o.source=n,o.computation=t,e!=null&&(o.equal=e);let r=()=>{if(Uo(o),Ho(o),o.value===Cn)throw o.error;return o.value};return r[nt]=o,Kr(o),r}function lu(n,t){Uo(n),Wo(n,t),ji(n)}function Am(n,t){if(Uo(n),n.value===Cn)throw n.error;As(n,t),ji(n)}var hO=F(S({},po),{value:zo,dirty:!0,error:null,equal:Yr,kind:"linkedSignal",producerMustRecompute(n){return n.value===zo||n.value===Bo},producerRecomputeValue(n){if(n.value===Bo)throw new Error("");let t=n.value;n.value=Bo;let e=fo(n),o,i=!1;try{let r=n.source(),a=t!==zo&&t!==Cn,s=a?{source:n.sourceValue,value:t}:void 0;o=n.computation(r,s),n.sourceValue=r,Y(null),i=a&&o!==Cn&&n.equal(t,o)}catch(r){o=Cn,n.error=r}finally{Go(n,e)}if(i){n.value=t;return}n.value=o,n.version++}});function Nm(n){let t=Y(null);try{return n()}finally{Y(t)}}function ae(n){return typeof n=="function"}function Bi(n){let e=n(o=>{Error.call(o),o.stack=new Error().stack});return e.prototype=Object.create(Error.prototype),e.prototype.constructor=e,e}var Fs=Bi(n=>function(e){n(this),this.message=e?`${e.length} errors occurred during unsubscription: -${e.map((o,i)=>`${i+1}) ${o.toString()}`).join(` - `)}`:"",this.name="UnsubscriptionError",this.errors=e});function Zr(n,t){if(n){let e=n.indexOf(t);0<=e&&n.splice(e,1)}}var Be=class n{constructor(t){this.initialTeardown=t,this.closed=!1,this._parentage=null,this._finalizers=null}unsubscribe(){let t;if(!this.closed){this.closed=!0;let{_parentage:e}=this;if(e)if(this._parentage=null,Array.isArray(e))for(let r of e)r.remove(this);else e.remove(this);let{initialTeardown:o}=this;if(ae(o))try{o()}catch(r){t=r instanceof Fs?r.errors:[r]}let{_finalizers:i}=this;if(i){this._finalizers=null;for(let r of i)try{Rm(r)}catch(a){t=t??[],a instanceof Fs?t=[...t,...a.errors]:t.push(a)}}if(t)throw new Fs(t)}}add(t){var e;if(t&&t!==this)if(this.closed)Rm(t);else{if(t instanceof n){if(t.closed||t._hasParent(this))return;t._addParent(this)}(this._finalizers=(e=this._finalizers)!==null&&e!==void 0?e:[]).push(t)}}_hasParent(t){let{_parentage:e}=this;return e===t||Array.isArray(e)&&e.includes(t)}_addParent(t){let{_parentage:e}=this;this._parentage=Array.isArray(e)?(e.push(t),e):e?[e,t]:t}_removeParent(t){let{_parentage:e}=this;e===t?this._parentage=null:Array.isArray(e)&&Zr(e,t)}remove(t){let{_finalizers:e}=this;e&&Zr(e,t),t instanceof n&&t._removeParent(this)}};Be.EMPTY=(()=>{let n=new Be;return n.closed=!0,n})();var du=Be.EMPTY;function Ls(n){return n instanceof Be||n&&"closed"in n&&ae(n.remove)&&ae(n.add)&&ae(n.unsubscribe)}function Rm(n){ae(n)?n():n.unsubscribe()}var en={onUnhandledError:null,onStoppedNotification:null,Promise:void 0,useDeprecatedSynchronousErrorHandling:!1,useDeprecatedNextContext:!1};var Hi={setTimeout(n,t,...e){let{delegate:o}=Hi;return o?.setTimeout?o.setTimeout(n,t,...e):setTimeout(n,t,...e)},clearTimeout(n){let{delegate:t}=Hi;return(t?.clearTimeout||clearTimeout)(n)},delegate:void 0};function Vs(n){Hi.setTimeout(()=>{let{onUnhandledError:t}=en;if(t)t(n);else throw n})}function Qr(){}var Fm=uu("C",void 0,void 0);function Lm(n){return uu("E",void 0,n)}function Vm(n){return uu("N",n,void 0)}function uu(n,t,e){return{kind:n,value:t,error:e}}var Ko=null;function Ui(n){if(en.useDeprecatedSynchronousErrorHandling){let t=!Ko;if(t&&(Ko={errorThrown:!1,error:null}),n(),t){let{errorThrown:e,error:o}=Ko;if(Ko=null,e)throw o}}else n()}function jm(n){en.useDeprecatedSynchronousErrorHandling&&Ko&&(Ko.errorThrown=!0,Ko.error=n)}var Yo=class extends Be{constructor(t){super(),this.isStopped=!1,t?(this.destination=t,Ls(t)&&t.add(this)):this.destination=bO}static create(t,e,o){return new zn(t,e,o)}next(t){this.isStopped?pu(Vm(t),this):this._next(t)}error(t){this.isStopped?pu(Lm(t),this):(this.isStopped=!0,this._error(t))}complete(){this.isStopped?pu(Fm,this):(this.isStopped=!0,this._complete())}unsubscribe(){this.closed||(this.isStopped=!0,super.unsubscribe(),this.destination=null)}_next(t){this.destination.next(t)}_error(t){try{this.destination.error(t)}finally{this.unsubscribe()}}_complete(){try{this.destination.complete()}finally{this.unsubscribe()}}},CO=Function.prototype.bind;function gu(n,t){return CO.call(n,t)}var fu=class{constructor(t){this.partialObserver=t}next(t){let{partialObserver:e}=this;if(e.next)try{e.next(t)}catch(o){js(o)}}error(t){let{partialObserver:e}=this;if(e.error)try{e.error(t)}catch(o){js(o)}else js(t)}complete(){let{partialObserver:t}=this;if(t.complete)try{t.complete()}catch(e){js(e)}}},zn=class extends Yo{constructor(t,e,o){super();let i;if(ae(t)||!t)i={next:t??void 0,error:e??void 0,complete:o??void 0};else{let r;this&&en.useDeprecatedNextContext?(r=Object.create(t),r.unsubscribe=()=>this.unsubscribe(),i={next:t.next&&gu(t.next,r),error:t.error&&gu(t.error,r),complete:t.complete&&gu(t.complete,r)}):i=t}this.destination=new fu(i)}};function js(n){en.useDeprecatedSynchronousErrorHandling?jm(n):Vs(n)}function MO(n){throw n}function pu(n,t){let{onStoppedNotification:e}=en;e&&Hi.setTimeout(()=>e(n,t))}var bO={closed:!0,next:Qr,error:MO,complete:Qr};var Gi=typeof Symbol=="function"&&Symbol.observable||"@@observable";function tn(n){return n}function $s(...n){return mu(n)}function mu(n){return n.length===0?tn:n.length===1?n[0]:function(e){return n.reduce((o,i)=>i(o),e)}}var ie=class n{constructor(t){t&&(this._subscribe=t)}lift(t){let e=new n;return e.source=this,e.operator=t,e}subscribe(t,e,o){let i=OO(t)?t:new zn(t,e,o);return Ui(()=>{let{operator:r,source:a}=this;i.add(r?r.call(i,a):a?this._subscribe(i):this._trySubscribe(i))}),i}_trySubscribe(t){try{return this._subscribe(t)}catch(e){t.error(e)}}forEach(t,e){return e=$m(e),new e((o,i)=>{let r=new zn({next:a=>{try{t(a)}catch(s){i(s),r.unsubscribe()}},error:i,complete:o});this.subscribe(r)})}_subscribe(t){var e;return(e=this.source)===null||e===void 0?void 0:e.subscribe(t)}[Gi](){return this}pipe(...t){return mu(t)(this)}toPromise(t){return t=$m(t),new t((e,o)=>{let i;this.subscribe(r=>i=r,r=>o(r),()=>e(i))})}};ie.create=n=>new ie(n);function $m(n){var t;return(t=n??en.Promise)!==null&&t!==void 0?t:Promise}function PO(n){return n&&ae(n.next)&&ae(n.error)&&ae(n.complete)}function OO(n){return n&&n instanceof Yo||PO(n)&&Ls(n)}function vO(n){return ae(n?.lift)}function fe(n){return t=>{if(vO(t))return t.lift(function(e){try{return n(e,this)}catch(o){this.error(o)}});throw new TypeError("Unable to lift unknown Observable type")}}function _e(n,t,e,o,i){return new _u(n,t,e,o,i)}var _u=class extends Yo{constructor(t,e,o,i,r,a){super(t),this.onFinalize=r,this.shouldUnsubscribe=a,this._next=e?function(s){try{e(s)}catch(c){t.error(c)}}:super._next,this._error=i?function(s){try{i(s)}catch(c){t.error(c)}finally{this.unsubscribe()}}:super._error,this._complete=o?function(){try{o()}catch(s){t.error(s)}finally{this.unsubscribe()}}:super._complete}unsubscribe(){var t;if(!this.shouldUnsubscribe||this.shouldUnsubscribe()){let{closed:e}=this;super.unsubscribe(),!e&&((t=this.onFinalize)===null||t===void 0||t.call(this))}}};var zm=Bi(n=>function(){n(this),this.name="ObjectUnsubscribedError",this.message="object unsubscribed"});var ye=class extends ie{constructor(){super(),this.closed=!1,this.currentObservers=null,this.observers=[],this.isStopped=!1,this.hasError=!1,this.thrownError=null}lift(t){let e=new zs(this,this);return e.operator=t,e}_throwIfClosed(){if(this.closed)throw new zm}next(t){Ui(()=>{if(this._throwIfClosed(),!this.isStopped){this.currentObservers||(this.currentObservers=Array.from(this.observers));for(let e of this.currentObservers)e.next(t)}})}error(t){Ui(()=>{if(this._throwIfClosed(),!this.isStopped){this.hasError=this.isStopped=!0,this.thrownError=t;let{observers:e}=this;for(;e.length;)e.shift().error(t)}})}complete(){Ui(()=>{if(this._throwIfClosed(),!this.isStopped){this.isStopped=!0;let{observers:t}=this;for(;t.length;)t.shift().complete()}})}unsubscribe(){this.isStopped=this.closed=!0,this.observers=this.currentObservers=null}get observed(){var t;return((t=this.observers)===null||t===void 0?void 0:t.length)>0}_trySubscribe(t){return this._throwIfClosed(),super._trySubscribe(t)}_subscribe(t){return this._throwIfClosed(),this._checkFinalizedStatuses(t),this._innerSubscribe(t)}_innerSubscribe(t){let{hasError:e,isStopped:o,observers:i}=this;return e||o?du:(this.currentObservers=null,i.push(t),new Be(()=>{this.currentObservers=null,Zr(i,t)}))}_checkFinalizedStatuses(t){let{hasError:e,thrownError:o,isStopped:i}=this;e?t.error(o):i&&t.complete()}asObservable(){let t=new ie;return t.source=this,t}};ye.create=(n,t)=>new zs(n,t);var zs=class extends ye{constructor(t,e){super(),this.destination=t,this.source=e}next(t){var e,o;(o=(e=this.destination)===null||e===void 0?void 0:e.next)===null||o===void 0||o.call(e,t)}error(t){var e,o;(o=(e=this.destination)===null||e===void 0?void 0:e.error)===null||o===void 0||o.call(e,t)}complete(){var t,e;(e=(t=this.destination)===null||t===void 0?void 0:t.complete)===null||e===void 0||e.call(t)}_subscribe(t){var e,o;return(o=(e=this.source)===null||e===void 0?void 0:e.subscribe(t))!==null&&o!==void 0?o:du}};var st=class extends ye{constructor(t){super(),this._value=t}get value(){return this.getValue()}_subscribe(t){let e=super._subscribe(t);return!e.closed&&t.next(this._value),e}getValue(){let{hasError:t,thrownError:e,_value:o}=this;if(t)throw e;return this._throwIfClosed(),o}next(t){super.next(this._value=t)}};var hu={now(){return(hu.delegate||Date).now()},delegate:void 0};var Bs=class extends ye{constructor(t=1/0,e=1/0,o=hu){super(),this._bufferSize=t,this._windowTime=e,this._timestampProvider=o,this._buffer=[],this._infiniteTimeWindow=!0,this._infiniteTimeWindow=e===1/0,this._bufferSize=Math.max(1,t),this._windowTime=Math.max(1,e)}next(t){let{isStopped:e,_buffer:o,_infiniteTimeWindow:i,_timestampProvider:r,_windowTime:a}=this;e||(o.push(t),!i&&o.push(r.now()+a)),this._trimBuffer(),super.next(t)}_subscribe(t){this._throwIfClosed(),this._trimBuffer();let e=this._innerSubscribe(t),{_infiniteTimeWindow:o,_buffer:i}=this,r=i.slice();for(let a=0;an.complete());function Bm(n){return n&&ae(n.schedule)}function Hm(n){return n[n.length-1]}function Hs(n){return ae(Hm(n))?n.pop():void 0}function mo(n){return Bm(Hm(n))?n.pop():void 0}function Gm(n,t,e,o){function i(r){return r instanceof e?r:new e(function(a){a(r)})}return new(e||(e=Promise))(function(r,a){function s(f){try{p(o.next(f))}catch(_){a(_)}}function c(f){try{p(o.throw(f))}catch(_){a(_)}}function p(f){f.done?r(f.value):i(f.value).then(s,c)}p((o=o.apply(n,t||[])).next())})}function Um(n){var t=typeof Symbol=="function"&&Symbol.iterator,e=t&&n[t],o=0;if(e)return e.call(n);if(n&&typeof n.length=="number")return{next:function(){return n&&o>=n.length&&(n=void 0),{value:n&&n[o++],done:!n}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")}function Zo(n){return this instanceof Zo?(this.v=n,this):new Zo(n)}function qm(n,t,e){if(!Symbol.asyncIterator)throw new TypeError("Symbol.asyncIterator is not defined.");var o=e.apply(n,t||[]),i,r=[];return i=Object.create((typeof AsyncIterator=="function"?AsyncIterator:Object).prototype),s("next"),s("throw"),s("return",a),i[Symbol.asyncIterator]=function(){return this},i;function a(h){return function(P){return Promise.resolve(P).then(h,_)}}function s(h,P){o[h]&&(i[h]=function(T){return new Promise(function(A,N){r.push([h,T,A,N])>1||c(h,T)})},P&&(i[h]=P(i[h])))}function c(h,P){try{p(o[h](P))}catch(T){C(r[0][3],T)}}function p(h){h.value instanceof Zo?Promise.resolve(h.value.v).then(f,_):C(r[0][2],h)}function f(h){c("next",h)}function _(h){c("throw",h)}function C(h,P){h(P),r.shift(),r.length&&c(r[0][0],r[0][1])}}function Wm(n){if(!Symbol.asyncIterator)throw new TypeError("Symbol.asyncIterator is not defined.");var t=n[Symbol.asyncIterator],e;return t?t.call(n):(n=typeof Um=="function"?Um(n):n[Symbol.iterator](),e={},o("next"),o("throw"),o("return"),e[Symbol.asyncIterator]=function(){return this},e);function o(r){e[r]=n[r]&&function(a){return new Promise(function(s,c){a=n[r](a),i(s,c,a.done,a.value)})}}function i(r,a,s,c){Promise.resolve(c).then(function(p){r({value:p,done:s})},a)}}var Us=(n=>n&&typeof n.length=="number"&&typeof n!="function");function Gs(n){return ae(n?.then)}function qs(n){return ae(n[Gi])}function Ws(n){return Symbol.asyncIterator&&ae(n?.[Symbol.asyncIterator])}function Ks(n){return new TypeError(`You provided ${n!==null&&typeof n=="object"?"an invalid object":`'${n}'`} where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.`)}function yO(){return typeof Symbol!="function"||!Symbol.iterator?"@@iterator":Symbol.iterator}var Ys=yO();function Zs(n){return ae(n?.[Ys])}function Qs(n){return qm(this,arguments,function*(){let e=n.getReader();try{for(;;){let{value:o,done:i}=yield Zo(e.read());if(i)return yield Zo(void 0);yield yield Zo(o)}}finally{e.releaseLock()}})}function Js(n){return ae(n?.getReader)}function Re(n){if(n instanceof ie)return n;if(n!=null){if(qs(n))return xO(n);if(Us(n))return wO(n);if(Gs(n))return SO(n);if(Ws(n))return Km(n);if(Zs(n))return kO(n);if(Js(n))return EO(n)}throw Ks(n)}function xO(n){return new ie(t=>{let e=n[Gi]();if(ae(e.subscribe))return e.subscribe(t);throw new TypeError("Provided object does not correctly implement Symbol.observable")})}function wO(n){return new ie(t=>{for(let e=0;e{n.then(e=>{t.closed||(t.next(e),t.complete())},e=>t.error(e)).then(null,Vs)})}function kO(n){return new ie(t=>{for(let e of n)if(t.next(e),t.closed)return;t.complete()})}function Km(n){return new ie(t=>{DO(n,t).catch(e=>t.error(e))})}function EO(n){return Km(Qs(n))}function DO(n,t){var e,o,i,r;return Gm(this,void 0,void 0,function*(){try{for(e=Wm(n);o=yield e.next(),!o.done;){let a=o.value;if(t.next(a),t.closed)return}}catch(a){i={error:a}}finally{try{o&&!o.done&&(r=e.return)&&(yield r.call(e))}finally{if(i)throw i.error}}t.complete()})}function Ot(n,t,e,o=0,i=!1){let r=t.schedule(function(){e(),i?n.add(this.schedule(null,o)):this.unsubscribe()},o);if(n.add(r),!i)return r}function Xs(n,t=0){return fe((e,o)=>{e.subscribe(_e(o,i=>Ot(o,n,()=>o.next(i),t),()=>Ot(o,n,()=>o.complete(),t),i=>Ot(o,n,()=>o.error(i),t)))})}function ec(n,t=0){return fe((e,o)=>{o.add(n.schedule(()=>e.subscribe(o),t))})}function Ym(n,t){return Re(n).pipe(ec(t),Xs(t))}function Zm(n,t){return Re(n).pipe(ec(t),Xs(t))}function Qm(n,t){return new ie(e=>{let o=0;return t.schedule(function(){o===n.length?e.complete():(e.next(n[o++]),e.closed||this.schedule())})})}function Jm(n,t){return new ie(e=>{let o;return Ot(e,t,()=>{o=n[Ys](),Ot(e,t,()=>{let i,r;try{({value:i,done:r}=o.next())}catch(a){e.error(a);return}r?e.complete():e.next(i)},0,!0)}),()=>ae(o?.return)&&o.return()})}function tc(n,t){if(!n)throw new Error("Iterable cannot be null");return new ie(e=>{Ot(e,t,()=>{let o=n[Symbol.asyncIterator]();Ot(e,t,()=>{o.next().then(i=>{i.done?e.complete():e.next(i.value)})},0,!0)})})}function Xm(n,t){return tc(Qs(n),t)}function e_(n,t){if(n!=null){if(qs(n))return Ym(n,t);if(Us(n))return Qm(n,t);if(Gs(n))return Zm(n,t);if(Ws(n))return tc(n,t);if(Zs(n))return Jm(n,t);if(Js(n))return Xm(n,t)}throw Ks(n)}function Fe(n,t){return t?e_(n,t):Re(n)}function se(...n){let t=mo(n);return Fe(n,t)}function Cu(n,t){let e=ae(n)?n:()=>n,o=i=>i.error(e());return new ie(t?i=>t.schedule(o,0,i):o)}function nc(n){return!!n&&(n instanceof ie||ae(n.lift)&&ae(n.subscribe))}var Qo=Bi(n=>function(){n(this),this.name="EmptyError",this.message="no elements in sequence"});function Pe(n,t){return fe((e,o)=>{let i=0;e.subscribe(_e(o,r=>{o.next(n.call(t,r,i++))}))})}var{isArray:IO}=Array;function TO(n,t){return IO(t)?n(...t):n(t)}function oc(n){return Pe(t=>TO(n,t))}var{isArray:AO}=Array,{getPrototypeOf:NO,prototype:RO,keys:FO}=Object;function ic(n){if(n.length===1){let t=n[0];if(AO(t))return{args:t,keys:null};if(LO(t)){let e=FO(t);return{args:e.map(o=>t[o]),keys:e}}}return{args:n,keys:null}}function LO(n){return n&&typeof n=="object"&&NO(n)===RO}function rc(n,t){return n.reduce((e,o,i)=>(e[o]=t[i],e),{})}function Mu(...n){let t=mo(n),e=Hs(n),{args:o,keys:i}=ic(n);if(o.length===0)return Fe([],t);let r=new ie(VO(o,t,i?a=>rc(i,a):tn));return e?r.pipe(oc(e)):r}function VO(n,t,e=tn){return o=>{t_(t,()=>{let{length:i}=n,r=new Array(i),a=i,s=i;for(let c=0;c{let p=Fe(n[c],t),f=!1;p.subscribe(_e(o,_=>{r[c]=_,f||(f=!0,s--),s||o.next(e(r.slice()))},()=>{--a||o.complete()}))},o)},o)}}function t_(n,t,e){n?Ot(e,n,t):t()}function n_(n,t,e,o,i,r,a,s){let c=[],p=0,f=0,_=!1,C=()=>{_&&!c.length&&!p&&t.complete()},h=T=>p{r&&t.next(T),p++;let A=!1;Re(e(T,f++)).subscribe(_e(t,N=>{i?.(N),r?h(N):t.next(N)},()=>{A=!0},void 0,()=>{if(A)try{for(p--;c.length&&pP(N)):P(N)}C()}catch(N){t.error(N)}}))};return n.subscribe(_e(t,h,()=>{_=!0,C()})),()=>{s?.()}}function ut(n,t,e=1/0){return ae(t)?ut((o,i)=>Pe((r,a)=>t(o,r,i,a))(Re(n(o,i))),e):(typeof t=="number"&&(e=t),fe((o,i)=>n_(o,i,n,e)))}function qi(n=1/0){return ut(tn,n)}function o_(){return qi(1)}function Wi(...n){return o_()(Fe(n,mo(n)))}function Jr(n){return new ie(t=>{Re(n()).subscribe(t)})}function bu(...n){let t=Hs(n),{args:e,keys:o}=ic(n),i=new ie(r=>{let{length:a}=e;if(!a){r.complete();return}let s=new Array(a),c=a,p=a;for(let f=0;f{_||(_=!0,p--),s[f]=C},()=>c--,void 0,()=>{(!c||!_)&&(p||r.next(o?rc(o,s):s),r.complete())}))}});return t?i.pipe(oc(t)):i}function Bt(n,t){return fe((e,o)=>{let i=0;e.subscribe(_e(o,r=>n.call(t,r,i++)&&o.next(r)))})}function Ki(n){return fe((t,e)=>{let o=null,i=!1,r;o=t.subscribe(_e(e,void 0,void 0,a=>{r=Re(n(a,Ki(n)(t))),o?(o.unsubscribe(),o=null,r.subscribe(e)):i=!0})),i&&(o.unsubscribe(),o=null,r.subscribe(e))})}function Jo(n,t){return ae(t)?ut(n,t,1):ut(n,1)}function i_(n){return fe((t,e)=>{let o=!1;t.subscribe(_e(e,i=>{o=!0,e.next(i)},()=>{o||e.next(n),e.complete()}))})}function Bn(n){return n<=0?()=>ct:fe((t,e)=>{let o=0;t.subscribe(_e(e,i=>{++o<=n&&(e.next(i),n<=o&&e.complete())}))})}function r_(n=jO){return fe((t,e)=>{let o=!1;t.subscribe(_e(e,i=>{o=!0,e.next(i)},()=>o?e.complete():e.error(n())))})}function jO(){return new Qo}function _o(n){return fe((t,e)=>{try{t.subscribe(e)}finally{e.add(n)}})}function Hn(n,t){let e=arguments.length>=2;return o=>o.pipe(n?Bt((i,r)=>n(i,r,o)):tn,Bn(1),e?i_(t):r_(()=>new Qo))}function ac(n){return n<=0?()=>ct:fe((t,e)=>{let o=[];t.subscribe(_e(e,i=>{o.push(i),n{for(let i of o)e.next(i);e.complete()},void 0,()=>{o=null}))})}function a_(n={}){let{connector:t=()=>new ye,resetOnError:e=!0,resetOnComplete:o=!0,resetOnRefCountZero:i=!0}=n;return r=>{let a,s,c,p=0,f=!1,_=!1,C=()=>{s?.unsubscribe(),s=void 0},h=()=>{C(),a=c=void 0,f=_=!1},P=()=>{let T=a;h(),T?.unsubscribe()};return fe((T,A)=>{p++,!_&&!f&&C();let N=c=c??t();A.add(()=>{p--,p===0&&!_&&!f&&(s=Pu(P,i))}),N.subscribe(A),!a&&p>0&&(a=new zn({next:E=>N.next(E),error:E=>{_=!0,C(),s=Pu(h,e,E),N.error(E)},complete:()=>{f=!0,C(),s=Pu(h,o),N.complete()}}),Re(T).subscribe(a))})(r)}}function Pu(n,t,...e){if(t===!0){n();return}if(t===!1)return;let o=new zn({next:()=>{o.unsubscribe(),n()}});return Re(t(...e)).subscribe(o)}function Ou(n,t,e){let o,i=!1;return n&&typeof n=="object"?{bufferSize:o=1/0,windowTime:t=1/0,refCount:i=!1,scheduler:e}=n:o=n??1/0,a_({connector:()=>new Bs(o,t,e),resetOnError:!0,resetOnComplete:!1,resetOnRefCountZero:i})}function vu(...n){let t=mo(n);return fe((e,o)=>{(t?Wi(n,e,t):Wi(n,e)).subscribe(o)})}function vt(n,t){return fe((e,o)=>{let i=null,r=0,a=!1,s=()=>a&&!i&&o.complete();e.subscribe(_e(o,c=>{i?.unsubscribe();let p=0,f=r++;Re(n(c,f)).subscribe(i=_e(o,_=>o.next(t?t(c,_,f,p++):_),()=>{i=null,s()}))},()=>{a=!0,s()}))})}function Xr(n){return fe((t,e)=>{Re(n).subscribe(_e(e,()=>e.complete(),Qr)),!e.closed&&t.subscribe(e)})}function xe(n,t,e){let o=ae(n)||t||e?{next:n,error:t,complete:e}:n;return o?fe((i,r)=>{var a;(a=o.subscribe)===null||a===void 0||a.call(o);let s=!0;i.subscribe(_e(r,c=>{var p;(p=o.next)===null||p===void 0||p.call(o,c),r.next(c)},()=>{var c;s=!1,(c=o.complete)===null||c===void 0||c.call(o),r.complete()},c=>{var p;s=!1,(p=o.error)===null||p===void 0||p.call(o,c),r.error(c)},()=>{var c,p;s&&((c=o.unsubscribe)===null||c===void 0||c.call(o)),(p=o.finalize)===null||p===void 0||p.call(o)}))}):tn}var pc="https://angular.dev/best-practices/security#preventing-cross-site-scripting-xss",z=class extends Error{code;constructor(t,e){super(Wn(t,e)),this.code=t}};function $O(n){return`NG0${Math.abs(n)}`}function Wn(n,t){return`${$O(n)}${t?": "+t:""}`}function he(n){for(let t in n)if(n[t]===he)return t;throw Error("")}function g_(n,t){for(let e in t)Object.hasOwn(t,e)&&!Object.hasOwn(n,e)&&(n[e]=t[e])}function aa(n){if(typeof n=="string")return n;if(Array.isArray(n))return`[${n.map(aa).join(", ")}]`;if(n==null)return""+n;let t=n.overriddenName||n.name;if(t)return`${t}`;let e=n.toString();if(e==null)return""+e;let o=e.indexOf(` -`);return o>=0?e.slice(0,o):e}function fc(n,t){return n?t?`${n} ${t}`:n:t||""}var zO=he({__forward_ref__:he});function on(n){return n.__forward_ref__=on,n}function lt(n){return Lu(n)?n():n}function Lu(n){return typeof n=="function"&&Object.hasOwn(n,zO)&&n.__forward_ref__===on}function te(n){return{token:n.token,providedIn:n.providedIn||null,factory:n.factory,value:void 0}}function Pn(n){return{providers:n.providers||[],imports:n.imports||[]}}function sa(n){return BO(n,mc)}function Vu(n){return sa(n)!==null}function BO(n,t){return Object.hasOwn(n,t)&&n[t]||null}function HO(n){let t=n?.[mc]??null;return t||null}function xu(n){return n&&Object.hasOwn(n,cc)?n[cc]:null}var mc=he({\u0275prov:he}),cc=he({\u0275inj:he}),B=class{_desc;ngMetadataName="InjectionToken";\u0275prov;constructor(t,e){this._desc=t,this.\u0275prov=void 0,typeof e=="number"?this.__NG_ELEMENT_ID__=e:e!==void 0&&(this.\u0275prov=te({token:this,providedIn:e.providedIn||"root",factory:e.factory}))}get multi(){return this}toString(){return`InjectionToken ${this._desc}`}};function ju(n){return n&&!!n.\u0275providers}var ca=he({\u0275cmp:he}),la=he({\u0275dir:he}),$u=he({\u0275pipe:he}),zu=he({\u0275mod:he}),na=he({\u0275fac:he}),oi=he({__NG_ELEMENT_ID__:he}),s_=he({__NG_ENV_ID__:he});function p_(n){return _c(n,"@NgModule"),n[zu]||null}function ii(n){return _c(n,"@Component"),n[ca]||null}function Bu(n){return _c(n,"@Directive"),n[la]||null}function f_(n){return _c(n,"@Pipe"),n[$u]||null}function _c(n,t){if(n==null)throw new z(-919,!1)}function rn(n){return typeof n=="string"?n:n==null?"":String(n)}var m_=he({ngErrorCode:he}),UO=he({ngErrorMessage:he}),GO=he({ngTokenPath:he});function Hu(n,t){return __("",-200,t)}function hc(n,t){throw new z(-201,!1)}function __(n,t,e){let o=new z(t,n);return o[m_]=t,o[UO]=n,e&&(o[GO]=e),o}function qO(n){return n[m_]}var wu;function h_(){return wu}function _t(n){let t=wu;return wu=n,t}function Uu(n,t,e){let o=sa(n);if(o&&o.providedIn=="root")return o.value===void 0?o.value=o.factory():o.value;if(e&8)return null;if(t!==void 0)return t;hc(n,"")}var an=globalThis;var WO={},Xo=WO,KO="__NG_DI_FLAG__",Su=class{injector;constructor(t){this.injector=t}retrieve(t,e){let o=ei(e)||0;try{return this.injector.get(t,o&8?null:Xo,o)}catch(i){if(zi(i))return i;throw i}}};function YO(n,t=0){let e=Rs();if(e===void 0)throw new z(-203,!1);if(e===null)return Uu(n,void 0,t);{let o=ZO(t),i=e.retrieve(n,o);if(zi(i)){if(o.optional)return null;throw i}return i}}function Q(n,t=0){return(h_()||YO)(lt(n),t)}function D(n,t){return Q(n,ei(t))}function ei(n){return typeof n>"u"||typeof n=="number"?n:0|(n.optional&&8)|(n.host&&1)|(n.self&&2)|(n.skipSelf&&4)}function ZO(n){return{optional:!!(n&8),host:!!(n&1),self:!!(n&2),skipSelf:!!(n&4)}}function ku(n){let t=[];for(let e=0;eArray.isArray(e)?Cc(e,t):t(e))}function Gu(n,t,e){t>=n.length?n.push(e):n.splice(t,0,e)}function da(n,t){return t>=n.length-1?n.pop():n.splice(t,1)[0]}function b_(n,t,e,o){let i=n.length;if(i==t)n.push(e,o);else if(i===1)n.push(o,n[0]),n[0]=e;else{for(i--,n.push(n[i-1],n[i]);i>t;){let r=i-2;n[i]=n[r],i--}n[t]=e,n[t+1]=o}}function Mc(n,t,e){let o=Ji(n,t);return o>=0?n[o|1]=e:(o=~o,b_(n,o,t,e)),o}function bc(n,t){let e=Ji(n,t);if(e>=0)return n[e|1]}function Ji(n,t){return JO(n,t,1)}function JO(n,t,e){let o=0,i=n.length>>e;for(;i!==o;){let r=o+(i-o>>1),a=n[r<t?i=r:o=r+1}return~(i<{e.push(a)};return Cc(t,a=>{let s=a;lc(s,r,[],o)&&(i||=[],i.push(s))}),i!==void 0&&O_(i,r),e}function O_(n,t){for(let e=0;e{t(r,o)})}}function lc(n,t,e,o){if(n=lt(n),!n)return!1;let i=null,r=xu(n),a=!r&&ii(n);if(!r&&!a){let c=n.ngModule;if(r=xu(c),r)i=c;else return!1}else{if(a&&!a.standalone)return!1;i=n}let s=o.has(i);if(a){if(s)return!1;if(o.add(i),a.dependencies){let c=typeof a.dependencies=="function"?a.dependencies():a.dependencies;for(let p of c)lc(p,t,e,o)}}else if(r){if(r.imports!=null&&!s){o.add(i);let p;Cc(r.imports,f=>{lc(f,t,e,o)&&(p||=[],p.push(f))}),p!==void 0&&O_(p,t)}if(!s){let p=Co(i)||(()=>new i);t({provide:i,useFactory:p,deps:ht},i),t({provide:qu,useValue:i,multi:!0},i),t({provide:ri,useValue:()=>Q(i),multi:!0},i)}let c=r.providers;if(c!=null&&!s){let p=n;Ku(c,f=>{t(f,p)})}}else return!1;return i!==n&&n.providers!==void 0}function Ku(n,t){for(let e of n)ju(e)&&(e=e.\u0275providers),Array.isArray(e)?Ku(e,t):t(e)}var XO=he({provide:String,useValue:he});function v_(n){return n!==null&&typeof n=="object"&&XO in n}function ev(n){return!!(n&&n.useExisting)}function tv(n){return!!(n&&n.useFactory)}function ti(n){return typeof n=="function"}function y_(n){return!!n.useClass}var ga=new B(""),sc={},c_={},yu;function pa(){return yu===void 0&&(yu=new Zi),yu}var Le=class{},ni=class extends Le{parent;source;scopes;records=new Map;_ngOnDestroyHooks=new Set;_onDestroyHooks=[];get destroyed(){return this._destroyed}_destroyed=!1;injectorDefTypes;constructor(t,e,o,i){super(),this.parent=e,this.source=o,this.scopes=i,Du(t,a=>this.processProvider(a)),this.records.set(ua,Yi(void 0,this)),i.has("environment")&&this.records.set(Le,Yi(void 0,this));let r=this.records.get(ga);r!=null&&typeof r.value=="string"&&this.scopes.add(r.value),this.injectorDefTypes=new Set(this.get(qu,ht,{self:!0}))}retrieve(t,e){let o=ei(e)||0;try{return this.get(t,Xo,o)}catch(i){if(zi(i))return i;throw i}}destroy(){ea(this),this._destroyed=!0;let t=Y(null);try{for(let o of this._ngOnDestroyHooks)o.ngOnDestroy();let e=this._onDestroyHooks;this._onDestroyHooks=[];for(let o of e)o()}finally{this.records.clear(),this._ngOnDestroyHooks.clear(),this.injectorDefTypes.clear(),Y(t)}}onDestroy(t){return ea(this),this._onDestroyHooks.push(t),()=>this.removeOnDestroy(t)}runInContext(t){ea(this);let e=Mn(this),o=_t(void 0),i;try{return t()}finally{Mn(e),_t(o)}}get(t,e=Xo,o){if(ea(this),Object.hasOwn(t,s_))return t[s_](this);let i=ei(o),r,a=Mn(this),s=_t(void 0);try{if(!(i&4)){let p=this.records.get(t);if(p===void 0){let f=av(t)&&sa(t);f&&this.injectableDefInScope(f)?p=Yi(Eu(t),sc):p=null,this.records.set(t,p)}if(p!=null)return this.hydrate(t,p,i)}let c=i&2?pa():this.parent;return e=i&8&&e===Xo?null:e,c.get(t,e)}catch(c){let p=qO(c);throw p===-200||p===-201?new z(p,null):c}finally{_t(s),Mn(a)}}resolveInjectorInitializers(){let t=Y(null),e=Mn(this),o=_t(void 0),i;try{let r=this.get(ri,ht,{self:!0});for(let a of r)a()}finally{Mn(e),_t(o),Y(t)}}toString(){return"R3Injector[...]"}processProvider(t){t=lt(t);let e=ti(t)?t:lt(t&&t.provide),o=ov(t);if(!ti(t)&&t.multi===!0){let i=this.records.get(e);i||(i=Yi(void 0,sc,!0),i.factory=()=>ku(i.multi),this.records.set(e,i)),e=t,i.multi.push(t)}this.records.set(e,o)}hydrate(t,e,o){let i=Y(null);try{if(e.value===c_)throw Hu("");return e.value===sc&&(e.value=c_,e.value=e.factory(void 0,o)),typeof e.value=="object"&&e.value&&rv(e.value)&&this._ngOnDestroyHooks.add(e.value),e.value}finally{Y(i)}}injectableDefInScope(t){if(!t.providedIn)return!1;let e=lt(t.providedIn);return typeof e=="string"?e==="any"||this.scopes.has(e):this.injectorDefTypes.has(e)}removeOnDestroy(t){let e=this._onDestroyHooks.indexOf(t);e!==-1&&this._onDestroyHooks.splice(e,1)}};function Eu(n){let t=sa(n),e=t!==null?t.factory:Co(n);if(e!==null)return e;if(n instanceof B)throw new z(-204,!1);if(n instanceof Function)return nv(n);throw new z(-204,!1)}function nv(n){if(n.length>0)throw new z(-204,!1);let e=HO(n);return e!==null?()=>e.factory(n):()=>new n}function ov(n){if(v_(n))return Yi(void 0,n.useValue);{let t=Yu(n);return Yi(t,sc)}}function Yu(n,t,e){let o;if(ti(n)){let i=lt(n);return Co(i)||Eu(i)}else if(v_(n))o=()=>lt(n.useValue);else if(tv(n))o=()=>n.useFactory(...ku(n.deps||[]));else if(ev(n))o=(i,r)=>Q(lt(n.useExisting),r!==void 0&&r&8?8:void 0);else{let i=lt(n&&(n.useClass||n.provide));if(iv(n))o=()=>new i(...ku(n.deps));else return Co(i)||Eu(i)}return o}function ea(n){if(n.destroyed)throw new z(-205,!1)}function Yi(n,t,e=!1){return{factory:n,value:t,multi:e?[]:void 0}}function iv(n){return!!n.deps}function rv(n){return n!==null&&typeof n=="object"&&typeof n.ngOnDestroy=="function"}function av(n){return typeof n=="function"||typeof n=="object"&&n.ngMetadataName==="InjectionToken"}function Du(n,t){for(let e of n)Array.isArray(e)?Du(e,t):e&&ju(e)?Du(e.\u0275providers,t):t(e)}function et(n,t){let e;n instanceof ni?(ea(n),e=n):e=new Su(n);let o,i=Mn(e),r=_t(void 0);try{return t()}finally{Mn(i),_t(r)}}function x_(){return h_()!==void 0||Rs()!=null}var sn=0,K=1,ee=2,Xe=3,Ht=4,yt=5,Xi=6,er=7,ze=8,On=9,cn=10,we=11,tr=12,Zu=13,Po=14,xt=15,Oo=16,ai=17,vn=18,yn=19,Qu=20,Un=21,Pc=22,Gn=23,Tt=24,si=25,xn=26,He=27,w_=1,Ju=6,ci=7,fa=8,li=9,Ve=10;function Kn(n){return Array.isArray(n)&&typeof n[w_]=="object"}function Ut(n){return Array.isArray(n)&&n[w_]===!0}function Xu(n){return(n.flags&4)!==0}function Yn(n){return n.componentOffset>-1}function Oc(n){return(n.flags&1)===1}function wn(n){return!!n.template}function nr(n){return(n[ee]&512)!==0}function di(n){return(n[ee]&256)===256}var Je=(function(n){return n[n.NONE=0]="NONE",n[n.HTML=1]="HTML",n[n.STYLE=2]="STYLE",n[n.SCRIPT=3]="SCRIPT",n[n.URL=4]="URL",n[n.RESOURCE_URL=5]="RESOURCE_URL",n[n.ATTRIBUTE_NO_BINDING=6]="ATTRIBUTE_NO_BINDING",n})(Je||{}),ta,Qi="svg",vc="math",S_="",l_="*",Iu=()=>Object.create(null);function sv(){return ta||(ta=Iu(),ho(Je.HTML,void 0,[["iframe",["srcdoc"]],["*",["innerHTML","outerHTML"]]]),ho(Je.STYLE,void 0,[["*",["style"]]]),ho(Je.URL,void 0,[["*",["formAction"]],["area",["href"]],["a",["href","xlink:href"]],["form",["action"]],["img",["src"]],["video",["src"]]]),ho(Je.URL,vc,[["*",["href","xlink:href"]]]),ho(Je.RESOURCE_URL,void 0,[["base",["href"]],["embed",["src"]],["frame",["src"]],["iframe",["src"]],["link",["href"]],["object",["codebase","data"]]]),ho(Je.URL,Qi,[["a",["href","xlink:href"]]]),ho(Je.ATTRIBUTE_NO_BINDING,Qi,[["animate",["attributeName","values","to","from"]],["set",["to","attributeName"]],["animateMotion",["attributeName"]],["animateTransform",["attributeName"]]]),ho(Je.ATTRIBUTE_NO_BINDING,void 0,[["unknown",["attributeName","values","to","from","sandbox","allow","allowFullscreen","referrerPolicy","csp","fetchPriority","credentialless"]],["iframe",["sandbox","allow","allowFullscreen","referrerPolicy","csp","fetchPriority","credentialless"]]]),ta)}function ho(n,t,e){let o=t??S_;for(let[i,r]of e){let a=i.toLowerCase();for(let s of r){let c=s.toLowerCase(),p=ta[c]??=Iu(),f=p[o]??=Iu();f[a]=n}}}function k_(n,t,e){let i=sv()[t.toLowerCase()];if(!i)return Je.NONE;let r=n.toLowerCase(),a;if(e){let s=i[e];s&&(a=s[r]??s[l_])}if(a===void 0){let s=i[S_];s&&(a=s[r]??s[l_])}return a??Je.NONE}function dt(n){for(;Array.isArray(n);)n=n[sn];return n}function eg(n,t){return dt(t[n])}function At(n,t){return dt(t[n.index])}function yc(n,t){return n.data[t]}function xc(n,t){return n[t]}function tg(n,t,e,o){e>=n.data.length&&(n.data[e]=null,n.blueprint[e]=null),t[e]=o}function Gt(n,t){let e=t[n];return Kn(e)?e:e[sn]}function E_(n){return(n[ee]&4)===4}function wc(n){return(n[ee]&128)===128}function D_(n){return Ut(n[Xe])}function Sn(n,t){return t==null?null:n[t]}function ng(n){n[ai]=0}function og(n){n[ee]&1024||(n[ee]|=1024,wc(n)&&ui(n))}function I_(n,t){for(;n>0;)t=t[Po],n--;return t}function ma(n){return!!(n[ee]&9216||n[Tt]?.dirty)}function Sc(n){n[cn].changeDetectionScheduler?.notify(8),n[ee]&64&&(n[ee]|=1024),ma(n)&&ui(n)}function ui(n){n[cn].changeDetectionScheduler?.notify(0);let t=qn(n);for(;t!==null&&!(t[ee]&8192||(t[ee]|=8192,!wc(t)));)t=qn(t)}function kc(n,t){if(di(n))throw new z(911,!1);n[Un]===null&&(n[Un]=[]),n[Un].push(t)}function T_(n,t){if(n[Un]===null)return;let e=n[Un].indexOf(t);e!==-1&&n[Un].splice(e,1)}function qn(n){let t=n[Xe];return Ut(t)?t[Xe]:t}function ig(n){return n[er]??=[]}function rg(n){return n.cleanup??=[]}function A_(n,t,e,o){let i=ig(t);i.push(e),n.firstCreatePass&&rg(n).push(o,i.length-1)}var ce={lFrame:W_(null),bindingsEnabled:!0,skipHydrationRootTNode:null};var Tu=!1;function N_(){return ce.lFrame.elementDepthCount}function R_(){ce.lFrame.elementDepthCount++}function ag(){ce.lFrame.elementDepthCount--}function F_(){return ce.bindingsEnabled}function L_(){return ce.skipHydrationRootTNode!==null}function sg(n){return ce.skipHydrationRootTNode===n}function cg(){ce.skipHydrationRootTNode=null}function Z(){return ce.lFrame.lView}function Ne(){return ce.lFrame.tView}function v(n){return ce.lFrame.contextLView=n,n[ze]}function y(n){return ce.lFrame.contextLView=null,n}function tt(){let n=lg();for(;n!==null&&n.type===64;)n=n.parent;return n}function lg(){return ce.lFrame.currentTNode}function V_(){let n=ce.lFrame,t=n.currentTNode;return n.isParent?t:t.parent}function or(n,t){let e=ce.lFrame;e.currentTNode=n,e.isParent=t}function dg(){return ce.lFrame.isParent}function j_(){ce.lFrame.isParent=!1}function $_(){return ce.lFrame.contextLView}function ug(){return Tu}function oa(n){let t=Tu;return Tu=n,t}function ir(){let n=ce.lFrame,t=n.bindingRootIndex;return t===-1&&(t=n.bindingRootIndex=n.tView.bindingStartIndex),t}function gg(){return ce.lFrame.bindingIndex}function z_(n){return ce.lFrame.bindingIndex=n}function Zn(){return ce.lFrame.bindingIndex++}function _a(n){let t=ce.lFrame,e=t.bindingIndex;return t.bindingIndex=t.bindingIndex+n,e}function B_(){return ce.lFrame.inI18n}function H_(n,t){let e=ce.lFrame;e.bindingIndex=e.bindingRootIndex=n,Ec(t)}function U_(){return ce.lFrame.currentDirectiveIndex}function Ec(n){ce.lFrame.currentDirectiveIndex=n}function G_(n){let t=ce.lFrame.currentDirectiveIndex;return t===-1?null:n[t]}function pg(){return ce.lFrame.currentQueryIndex}function Dc(n){ce.lFrame.currentQueryIndex=n}function cv(n){let t=n[K];return t.type===2?t.declTNode:t.type===1?n[yt]:null}function fg(n,t,e){if(e&4){let i=t,r=n;for(;i=i.parent,i===null&&!(e&1);)if(i=cv(r),i===null||(r=r[Po],i.type&10))break;if(i===null)return!1;t=i,n=r}let o=ce.lFrame=q_();return o.currentTNode=t,o.lView=n,!0}function Ic(n){let t=q_(),e=n[K];ce.lFrame=t,t.currentTNode=e.firstChild,t.lView=n,t.tView=e,t.contextLView=n,t.bindingIndex=e.bindingStartIndex,t.inI18n=!1}function q_(){let n=ce.lFrame,t=n===null?null:n.child;return t===null?W_(n):t}function W_(n){let t={currentTNode:null,isParent:!0,lView:null,tView:null,selectedIndex:-1,contextLView:null,elementDepthCount:0,currentNamespace:null,currentDirectiveIndex:-1,bindingRootIndex:-1,bindingIndex:-1,currentQueryIndex:0,parent:n,child:null,inI18n:!1};return n!==null&&(n.child=t),t}function K_(){let n=ce.lFrame;return ce.lFrame=n.parent,n.currentTNode=null,n.lView=null,n}var mg=K_;function Tc(){let n=K_();n.isParent=!0,n.tView=null,n.selectedIndex=-1,n.contextLView=null,n.elementDepthCount=0,n.currentDirectiveIndex=-1,n.currentNamespace=null,n.bindingRootIndex=-1,n.bindingIndex=-1,n.currentQueryIndex=0}function Y_(n){return(ce.lFrame.contextLView=I_(n,ce.lFrame.contextLView))[ze]}function qt(){return ce.lFrame.selectedIndex}function vo(n){ce.lFrame.selectedIndex=n}function gi(){let n=ce.lFrame;return yc(n.tView,n.selectedIndex)}function yo(){ce.lFrame.currentNamespace=Qi}function rr(){lv()}function lv(){ce.lFrame.currentNamespace=null}function _g(){return ce.lFrame.currentNamespace}var Z_=!0;function Ac(){return Z_}function Nc(n){Z_=n}function Au(n,t=null,e=null,o){let i=hg(n,t,e,o);return i.resolveInjectorInitializers(),i}function hg(n,t=null,e=null,o,i=new Set){let r=[e||ht,P_(n)],a;return new ni(r,t||pa(),a||null,i)}var Ct=class n{static THROW_IF_NOT_FOUND=Xo;static NULL=new Zi;static create(t,e){if(Array.isArray(t))return Au({name:""},e,t,"");{let o=t.name??"";return Au({name:o},t.parent,t.providers,o)}}static \u0275prov=te({token:n,providedIn:"any",factory:()=>Q(ua)});static __NG_ELEMENT_ID__=-1},Ue=new B(""),Ae=class{static __NG_ELEMENT_ID__=dv;static __NG_ENV_ID__=t=>t},dc=class extends Ae{_lView;constructor(t){super(),this._lView=t}get destroyed(){return di(this._lView)}onDestroy(t){let e=this._lView;return kc(e,t),()=>T_(e,t)}};function dv(){return new dc(Z())}var Cg=!1,Q_=new B(""),kn=(()=>{class n{taskId=0;pendingTasks=new Set;destroyed=!1;pendingTask=new st(!1);debugTaskTracker=D(Q_,{optional:!0});get hasPendingTasks(){return this.destroyed?!1:this.pendingTask.value}get hasPendingTasksObservable(){return this.destroyed?new ie(e=>{e.next(!1),e.complete()}):this.pendingTask}add(){!this.hasPendingTasks&&!this.destroyed&&this.pendingTask.next(!0);let e=this.taskId++;return this.pendingTasks.add(e),this.debugTaskTracker?.add(e),e}has(e){return this.pendingTasks.has(e)}remove(e){this.pendingTasks.delete(e),this.debugTaskTracker?.remove(e),this.pendingTasks.size===0&&this.hasPendingTasks&&this.pendingTask.next(!1)}ngOnDestroy(){this.pendingTasks.clear(),this.hasPendingTasks&&this.pendingTask.next(!1),this.destroyed=!0,this.pendingTask.unsubscribe()}static \u0275prov=te({token:n,providedIn:"root",factory:()=>new n})}return n})(),Nu=class extends ye{__isAsync;destroyRef=void 0;pendingTasks=void 0;constructor(t=!1){super(),this.__isAsync=t,x_()&&(this.destroyRef=D(Ae,{optional:!0})??void 0,this.pendingTasks=D(kn,{optional:!0})??void 0)}emit(t){let e=Y(null);try{super.next(t)}finally{Y(e)}}subscribe(t,e,o){let i=t,r=e||(()=>null),a=o;if(t&&typeof t=="object"){let c=t;i=c.next?.bind(c),r=c.error?.bind(c),a=c.complete?.bind(c)}this.__isAsync&&(r=this.wrapInTimeout(r),i&&(i=this.wrapInTimeout(i)),a&&(a=this.wrapInTimeout(a)));let s=super.subscribe({next:i,error:r,complete:a});return t instanceof Be&&t.add(s),s}wrapInTimeout(t){return e=>{let o=this.pendingTasks?.add();setTimeout(()=>{try{t(e)}finally{o!==void 0&&this.pendingTasks?.remove(o)}})}}},Ke=Nu;function uc(...n){}function Mg(n){let t,e;function o(){n=uc;try{e!==void 0&&typeof cancelAnimationFrame=="function"&&cancelAnimationFrame(e),t!==void 0&&clearTimeout(t)}catch(i){}}return t=setTimeout(()=>{n(),o()}),typeof requestAnimationFrame=="function"&&(e=requestAnimationFrame(()=>{n(),o()})),()=>o()}function J_(n){return queueMicrotask(()=>n()),()=>{n=uc}}var bg="isAngularZone",ia=bg+"_ID",uv=0,ge=class n{hasPendingMacrotasks=!1;hasPendingMicrotasks=!1;isStable=!0;onUnstable=new Ke(!1);onMicrotaskEmpty=new Ke(!1);onStable=new Ke(!1);onError=new Ke(!1);constructor(t){let{enableLongStackTrace:e=!1,shouldCoalesceEventChangeDetection:o=!1,shouldCoalesceRunChangeDetection:i=!1,scheduleInRootZone:r=Cg}=t;if(typeof Zone>"u")throw new z(908,!1);Zone.assertZonePatched();let a=this;a._nesting=0,a._outer=a._inner=Zone.current,Zone.TaskTrackingZoneSpec&&(a._inner=a._inner.fork(new Zone.TaskTrackingZoneSpec)),e&&Zone.longStackTraceZoneSpec&&(a._inner=a._inner.fork(Zone.longStackTraceZoneSpec)),a.shouldCoalesceEventChangeDetection=!i&&o,a.shouldCoalesceRunChangeDetection=i,a.callbackScheduled=!1,a.scheduleInRootZone=r,fv(a)}static isInAngularZone(){return typeof Zone<"u"&&Zone.current.get(bg)===!0}static assertInAngularZone(){if(!n.isInAngularZone())throw new z(909,!1)}static assertNotInAngularZone(){if(n.isInAngularZone())throw new z(909,!1)}run(t,e,o){return this._inner.run(t,e,o)}runTask(t,e,o,i){let r=this._inner,a=r.scheduleEventTask("NgZoneEvent: "+i,t,gv,uc,uc);try{return r.runTask(a,e,o)}finally{r.cancelTask(a)}}runGuarded(t,e,o){return this._inner.runGuarded(t,e,o)}runOutsideAngular(t){return this._outer.run(t)}},gv={};function Pg(n){if(n._nesting==0&&!n.hasPendingMicrotasks&&!n.isStable)try{n._nesting++,n.onMicrotaskEmpty.emit(null)}finally{if(n._nesting--,!n.hasPendingMicrotasks)try{n.runOutsideAngular(()=>n.onStable.emit(null))}finally{n.isStable=!0}}}function pv(n){if(n.isCheckStableRunning||n.callbackScheduled)return;n.callbackScheduled=!0;function t(){Mg(()=>{n.callbackScheduled=!1,Ru(n),n.isCheckStableRunning=!0,Pg(n),n.isCheckStableRunning=!1})}n.scheduleInRootZone?Zone.root.run(()=>{t()}):n._outer.run(()=>{t()}),Ru(n)}function fv(n){let t=()=>{pv(n)},e=uv++;n._inner=n._inner.fork({name:"angular",properties:{[bg]:!0,[ia]:e,[ia+e]:!0},onInvokeTask:(o,i,r,a,s,c)=>{if(mv(c))return o.invokeTask(r,a,s,c);try{return d_(n),o.invokeTask(r,a,s,c)}finally{(n.shouldCoalesceEventChangeDetection&&a.type==="eventTask"||n.shouldCoalesceRunChangeDetection)&&t(),u_(n)}},onInvoke:(o,i,r,a,s,c,p)=>{try{return d_(n),o.invoke(r,a,s,c,p)}finally{n.shouldCoalesceRunChangeDetection&&!n.callbackScheduled&&!_v(c)&&t(),u_(n)}},onHasTask:(o,i,r,a)=>{o.hasTask(r,a),i===r&&(a.change=="microTask"?(n._hasPendingMicrotasks=a.microTask,Ru(n),Pg(n)):a.change=="macroTask"&&(n.hasPendingMacrotasks=a.macroTask))},onHandleError:(o,i,r,a)=>(o.handleError(r,a),n.runOutsideAngular(()=>n.onError.emit(a)),!1)})}function Ru(n){n._hasPendingMicrotasks||(n.shouldCoalesceEventChangeDetection||n.shouldCoalesceRunChangeDetection)&&n.callbackScheduled===!0?n.hasPendingMicrotasks=!0:n.hasPendingMicrotasks=!1}function d_(n){n._nesting++,n.isStable&&(n.isStable=!1,n.onUnstable.emit(null))}function u_(n){n._nesting--,Pg(n)}var ra=class{hasPendingMicrotasks=!1;hasPendingMacrotasks=!1;isStable=!0;onUnstable=new Ke;onMicrotaskEmpty=new Ke;onStable=new Ke;onError=new Ke;run(t,e,o){return t.apply(e,o)}runGuarded(t,e,o){return t.apply(e,o)}runOutsideAngular(t){return t()}runTask(t,e,o,i){return t.apply(e,o)}};function mv(n){return X_(n,"__ignore_ng_zone__")}function _v(n){return X_(n,"__scheduler_tick__")}function X_(n,t){return!Array.isArray(n)||n.length!==1?!1:n[0]?.data?.[t]===!0}var nn=class{_console=console;handleError(t){this._console.error("ERROR",t)}},Nt=new B("",{factory:()=>{let n=D(ge),t=D(Le),e;return o=>{n.runOutsideAngular(()=>{t.destroyed&&!e?setTimeout(()=>{throw o}):(e??=t.get(nn),e.handleError(o))})}}}),eh={provide:ri,useValue:()=>{let n=D(nn,{optional:!0})},multi:!0};function x(n,t){let[e,o,i]=iu(n,t?.equal),r=e,a=r[nt];return r.set=o,r.update=i,r.asReadonly=Rc.bind(r),r}function Rc(){let n=this[nt];if(n.readonlyFn===void 0){let t=()=>this();t[nt]=n,n.readonlyFn=t}return n.readonlyFn}var ha=new B("",{factory:()=>hv}),hv="ng";var Fc=new B(""),Ca=new B("",{providedIn:"platform",factory:()=>"unknown"});var Ma=new B("",{factory:()=>D(Ue).body?.querySelector("[ngCspNonce]")?.getAttribute("ngCspNonce")||null});var ba=(()=>{class n{view;node;constructor(e,o){this.view=e,this.node=o}static __NG_ELEMENT_ID__=Cv}return n})();function Cv(){return new ba(Z(),tt())}var bn=class{},ar=new B("",{factory:()=>!0});var Lc=new B(""),Vc=(()=>{class n{static \u0275prov=te({token:n,providedIn:"root",factory:()=>new Fu})}return n})(),Fu=class{dirtyEffectCount=0;queues=new Map;add(t){this.enqueue(t),this.schedule(t)}schedule(t){t.dirty&&this.dirtyEffectCount++}remove(t){let e=t.zone,o=this.queues.get(e);o.has(t)&&(o.delete(t),t.dirty&&this.dirtyEffectCount--)}enqueue(t){let e=t.zone;this.queues.has(e)||this.queues.set(e,new Set);let o=this.queues.get(e);o.has(t)||o.add(t)}flush(){for(;this.dirtyEffectCount>0;){let t=!1;for(let[e,o]of this.queues)e===null?t||=this.flushQueue(o):t||=e.run(()=>this.flushQueue(o));t||(this.dirtyEffectCount=0)}}flushQueue(t){let e=!1;for(let o of t)o.dirty&&(this.dirtyEffectCount--,e=!0,o.run());return e}},gc=class{[nt];constructor(t){this[nt]=t}destroy(){this[nt].destroy()}};function Ye(n,t){let e=t?.injector??D(Ct),o=t?.manualCleanup!==!0?e.get(Ae):null,i,r=e.get(ba,null,{optional:!0}),a=e.get(bn);return r!==null?(i=Pv(r.view,a,n),o instanceof dc&&o._lView===r.view&&(o=null)):i=Ov(n,e.get(Vc),a),i.injector=e,o!==null&&(i.onDestroyFns=[o.onDestroy(()=>i.destroy())]),new gc(i)}var th=F(S({},ru),{cleanupFns:void 0,zone:null,onDestroyFns:null,run(){let n=oa(!1);try{au(this)}finally{oa(n)}},cleanup(){if(!this.cleanupFns?.length)return;let n=Y(null);try{for(;this.cleanupFns.length;)this.cleanupFns.pop()()}finally{this.cleanupFns=[],Y(n)}}}),Mv=F(S({},th),{consumerMarkedDirty(){this.scheduler.schedule(this),this.notifier.notify(12)},destroy(){if(qo(this),this.onDestroyFns!==null)for(let n of this.onDestroyFns)n();this.cleanup(),this.scheduler.remove(this)}}),bv=F(S({},th),{consumerMarkedDirty(){this.view[ee]|=8192,ui(this.view),this.notifier.notify(13)},destroy(){if(qo(this),this.onDestroyFns!==null)for(let n of this.onDestroyFns)n();this.cleanup(),this.view[Gn]?.delete(this)}});function Pv(n,t,e){let o=Object.create(bv);return o.view=n,o.zone=typeof Zone<"u"?Zone.current:null,o.notifier=t,o.fn=nh(o,e),n[Gn]??=new Set,n[Gn].add(o),o.consumerMarkedDirty(o),o}function Ov(n,t,e){let o=Object.create(Mv);return o.fn=nh(o,n),o.scheduler=t,o.notifier=e,o.zone=typeof Zone<"u"?Zone.current:null,o.scheduler.add(o),o.notifier.notify(12),o}function nh(n,t){return()=>{t(e=>(n.cleanupFns??=[]).push(e))}}function Pa(n){return typeof n=="function"&&n[nt]!==void 0}function jc(n){return Pa(n)&&typeof n.set=="function"}var Oa=(()=>{class n{internalPendingTasks=D(kn);scheduler=D(bn);errorHandler=D(Nt);add(){let e=this.internalPendingTasks.add();return()=>{this.internalPendingTasks.has(e)&&(this.scheduler.notify(11),this.internalPendingTasks.remove(e))}}run(e){let o=this.add();try{e().catch(this.errorHandler).finally(o)}catch(i){this.errorHandler(i),o()}}static \u0275prov=te({token:n,providedIn:"root",factory:()=>new n})}return n})();function Na(n){return{toString:n}.toString()}var pe=(function(n){return n[n.TemplateCreateStart=0]="TemplateCreateStart",n[n.TemplateCreateEnd=1]="TemplateCreateEnd",n[n.TemplateUpdateStart=2]="TemplateUpdateStart",n[n.TemplateUpdateEnd=3]="TemplateUpdateEnd",n[n.LifecycleHookStart=4]="LifecycleHookStart",n[n.LifecycleHookEnd=5]="LifecycleHookEnd",n[n.OutputStart=6]="OutputStart",n[n.OutputEnd=7]="OutputEnd",n[n.BootstrapApplicationStart=8]="BootstrapApplicationStart",n[n.BootstrapApplicationEnd=9]="BootstrapApplicationEnd",n[n.BootstrapComponentStart=10]="BootstrapComponentStart",n[n.BootstrapComponentEnd=11]="BootstrapComponentEnd",n[n.ChangeDetectionStart=12]="ChangeDetectionStart",n[n.ChangeDetectionEnd=13]="ChangeDetectionEnd",n[n.ChangeDetectionSyncStart=14]="ChangeDetectionSyncStart",n[n.ChangeDetectionSyncEnd=15]="ChangeDetectionSyncEnd",n[n.AfterRenderHooksStart=16]="AfterRenderHooksStart",n[n.AfterRenderHooksEnd=17]="AfterRenderHooksEnd",n[n.ComponentStart=18]="ComponentStart",n[n.ComponentEnd=19]="ComponentEnd",n[n.DeferBlockStateStart=20]="DeferBlockStateStart",n[n.DeferBlockStateEnd=21]="DeferBlockStateEnd",n[n.DynamicComponentStart=22]="DynamicComponentStart",n[n.DynamicComponentEnd=23]="DynamicComponentEnd",n[n.HostBindingsUpdateStart=24]="HostBindingsUpdateStart",n[n.HostBindingsUpdateEnd=25]="HostBindingsUpdateEnd",n})(pe||{}),Kc=class{previousValue;currentValue;firstChange;constructor(t,e,o){this.previousValue=t,this.currentValue=e,this.firstChange=o}isFirstChange(){return this.firstChange}};function Hh(n,t,e,o){t!==null?t.applyValueToInputSignal(t,o):n[e]=o}var Uh=null,In=(()=>{Uh=oh;let n=()=>oh;return n.ngInherit=!0,n})();function Iv(){return Uh}function oh(n){return n.type.prototype.ngOnChanges&&(n.setInput=Av),Tv}function Tv(){let n=Gh(this),t=n?.current;if(t){let e=n.previous;if(e===Mo)n.previous=t;else for(let o in t)e[o]=t[o];n.current=null,this.ngOnChanges(t)}}function Av(n,t,e,o,i){let r=this.declaredInputs[o],a=Gh(n)||Nv(n,{previous:Mo,current:null}),s=a.current||(a.current={}),c=a.previous,p=c[r];s[r]=new Kc(p&&p.currentValue,e,c===Mo),Hh(n,t,i,e)}var Ag="__ngSimpleChanges__";function Gh(n){return Object.hasOwn(n,Ag)&&n[Ag]||null}function Nv(n,t){return n[Ag]=t}var ih=[];var Ce=function(n,t=null,e){for(let o=0;o=o)break}else t[c]<0&&(n[ai]+=65536),(s>14>16&&(n[ee]&3)===t&&(n[ee]+=16384,rh(s,r)):rh(s,r)}var cr=-1,mi=class{factory;name;injectImpl;resolving=!1;canSeeViewProviders;multi;componentProviders;index;providerFactory;constructor(t,e,o,i){this.factory=t,this.name=i,this.canSeeViewProviders=e,this.injectImpl=o}};function Vv(n){return(n.flags&8)!==0}function jv(n){return(n.flags&16)!==0}function $v(n,t,e){let o=0;for(;ot){a=r-1;break}}}for(;r>16}function Zc(n,t){let e=Bv(n),o=t;for(;e>0;)o=o[Po],e--;return o}var Ng=!0;function Qc(n){let t=Ng;return Ng=n,t}var Hv=256,Yh=Hv-1,Zh=5,Uv=0,En={};function Gv(n,t,e){let o;typeof e=="string"?o=e.charCodeAt(0)||0:Object.hasOwn(e,oi)&&(o=e[oi]),o==null&&(o=e[oi]=Uv++);let i=o&Yh,r=1<>Zh)]|=r}function Jc(n,t){let e=Qh(n,t);if(e!==-1)return e;let o=t[K];o.firstCreatePass&&(n.injectorIndex=t.length,vg(o.data,n),vg(t,null),vg(o.blueprint,null));let i=dp(n,t),r=n.injectorIndex;if(Kh(i)){let a=Yc(i),s=Zc(i,t),c=s[K].data;for(let p=0;p<8;p++)t[r+p]=s[a+p]|c[a+p]}return t[r+8]=i,r}function vg(n,t){n.push(0,0,0,0,0,0,0,0,t)}function Qh(n,t){return n.injectorIndex===-1||n.parent&&n.parent.injectorIndex===n.injectorIndex||t[n.injectorIndex+8]===null?-1:n.injectorIndex}function dp(n,t){if(n.parent&&n.parent.injectorIndex!==-1)return n.parent.injectorIndex;let e=0,o=null,i=t;for(;i!==null;){if(o=nC(i),o===null)return cr;if(e++,i=i[Po],o.injectorIndex!==-1)return o.injectorIndex|e<<16}return cr}function Rg(n,t,e){Gv(n,t,e)}function qv(n,t){if(t==="class")return n.classes;if(t==="style")return n.styles;let e=n.attrs;if(e){let o=e.length,i=0;for(;i>20,_=o?s:s+f,C=i?s+f:p;for(let h=_;h=c&&P.type===e)return h}if(i){let h=a[c];if(h&&wn(h)&&h.type===e)return c}return null}function Sa(n,t,e,o,i){let r=n[e],a=t.data;if(r instanceof mi){let s=r;if(s.resolving)throw Hu("");let c=Qc(s.canSeeViewProviders);s.resolving=!0;let p=a[e].type||a[e],f,_=s.injectImpl?_t(s.injectImpl):null,C=fg(n,o,0);try{r=n[e]=s.factory(void 0,i,a,n,o),t.firstCreatePass&&e>=o.directiveStart&&Rv(e,a[e],t)}finally{_!==null&&_t(_),Qc(c),s.resolving=!1,mg()}}return r}function Kv(n){if(typeof n=="string")return n.charCodeAt(0)||0;let t=Object.hasOwn(n,oi)?n[oi]:void 0;return typeof t=="number"?t>=0?t&Yh:Yv:t}function sh(n,t,e){let o=1<>Zh)]&o)}function ch(n,t){return!(n&2)&&!(n&1&&t)}var xo=class{_tNode;_lView;constructor(t,e){this._tNode=t,this._lView=e}get(t,e,o){return eC(this._tNode,this._lView,t,ei(o),e)}};function Yv(){return new xo(tt(),Z())}function ko(n){return Na(()=>{let t=n.prototype.constructor,e=t[na]||Fg(t),o=Object.prototype,i=Object.getPrototypeOf(n.prototype).constructor;for(;i&&i!==o;){let r=i[na]||Fg(i);if(r&&r!==e)return r;i=Object.getPrototypeOf(i)}return r=>new r})}function Fg(n){return Lu(n)?()=>{let t=Fg(lt(n));return t&&t()}:Co(n)}function Zv(n,t,e,o,i){let r=n,a=t;for(;r!==null&&a!==null&&a[ee]&2048&&!nr(a);){let s=tC(r,a,e,o|2,En);if(s!==En)return s;let c=r.parent;if(!c){let p=a[Qu];if(p){let f=p.get(e,En,o&-5);if(f!==En)return f}c=nC(a),a=a[Po]}r=c}return i}function nC(n){let t=n[K],e=t.type;return e===2?t.declTNode:e===1?n[yt]:null}function Ra(n){return qv(tt(),n)}function Oe(n){return{token:n.token,providedIn:n.autoProvided===!1?null:"root",factory:n.factory,value:void 0}}function Qv(){return pr(tt(),Z())}function pr(n,t){return new Mt(At(n,t))}var Mt=(()=>{class n{nativeElement;constructor(e){this.nativeElement=e}static __NG_ELEMENT_ID__=Qv}return n})();function Jv(n){return n instanceof Mt?n.nativeElement:n}function Xv(){return this._results[Symbol.iterator]()}var Xc=class{_emitDistinctChangesOnly;dirty=!0;_onDirty=void 0;_results=[];_changesDetected=!1;_changes=void 0;length=0;first=void 0;last=void 0;get changes(){return this._changes??=new ye}constructor(t=!1){this._emitDistinctChangesOnly=t}get(t){return this._results[t]}map(t){return this._results.map(t)}filter(t){return this._results.filter(t)}find(t){return this._results.find(t)}reduce(t,e){return this._results.reduce(t,e)}forEach(t){this._results.forEach(t)}some(t){return this._results.some(t)}toArray(){return this._results.slice()}toString(){return this._results.toString()}reset(t,e){this.dirty=!1;let o=M_(t);(this._changesDetected=!C_(this._results,o,e))&&(this._results=o,this.length=o.length,this.last=o[this.length-1],this.first=o[0])}notifyOnChanges(){this._changes!==void 0&&(this._changesDetected||!this._emitDistinctChangesOnly)&&this._changes.next(this)}onDirty(t){this._onDirty=t}setDirty(){this.dirty=!0,this._onDirty?.()}destroy(){this._changes!==void 0&&(this._changes.complete(),this._changes.unsubscribe())}[Symbol.iterator]=Xv};function oC(n){return(n.flags&128)===128}var up=(function(n){return n[n.OnPush=0]="OnPush",n[n.Eager=1]="Eager",n[n.Default=1]="Default",n})(up||{}),iC=new Map,ey=0;function ty(){return ey++}function ny(n){iC.set(n[yn],n)}function Lg(n){iC.delete(n[yn])}var lh="__ngContext__";function dr(n,t){Kn(t)?(n[lh]=t[yn],ny(t)):n[lh]=t}function rC(n){return sC(n[tr])}function aC(n){return sC(n[Ht])}function sC(n){for(;n!==null&&!Ut(n);)n=n[Ht];return n}var Vg;function gp(n){Vg=n}function pp(){if(Vg!==void 0)return Vg;if(typeof document<"u")return document;throw new z(210,!1)}var cC="r";var lC="di";var dC=!1,uC=new B("",{factory:()=>dC});var dh=new WeakMap;function oy(n,t){if(n==null||typeof n!="object")return;let e=dh.get(n);e||(e=new WeakSet,dh.set(n,e)),e.add(t)}var iy=(n,t,e,o)=>{};function ry(n,t,e,o){iy(n,t,e,o)}function fp(n){return(n.flags&32)===32}var ay=()=>null;function gC(n,t,e=!1){return ay(n,t,e)}function pC(n,t){let e=n.contentQueries;if(e!==null){let o=Y(null);try{for(let i=0;in,createScript:n=>n,createScriptURL:n=>n})}catch(n){}return $c}function fl(n){return cy()?.createHTML(n)||n}var zc;function mC(){if(zc===void 0&&(zc=null,an.trustedTypes))try{zc=an.trustedTypes.createPolicy("angular#unsafe-bypass",{createHTML:n=>n,createScript:n=>n,createScriptURL:n=>n})}catch(n){}return zc}function uh(n){return mC()?.createHTML(n)||n}function gh(n){return mC()?.createScriptURL(n)||n}var el=class{changingThisBreaksApplicationSecurity;constructor(t){this.changingThisBreaksApplicationSecurity=t}toString(){return`SafeValue must use [property]=binding: ${this.changingThisBreaksApplicationSecurity} (see ${pc})`}};function _i(n){return n instanceof el?n.changingThisBreaksApplicationSecurity:n}function ml(n,t){let e=_C(n);if(e!=null&&e!==t){if(e==="ResourceURL"&&t==="URL")return!0;throw new Error(`Required a safe ${t}, got a ${e} (see ${pc})`)}return e===t}function _C(n){return n instanceof el&&n.getTypeName()||null}function ly(n){let t=new zg(n);return dy()?new $g(t):t}var $g=class{inertDocumentHelper;constructor(t){this.inertDocumentHelper=t}getInertBodyElement(t){t=""+t;try{let e=new window.DOMParser().parseFromString(fl(t),"text/html").body;return e===null?this.inertDocumentHelper.getInertBodyElement(t):(e.firstChild?.remove(),e)}catch(e){return null}}},zg=class{defaultDoc;inertDocument;constructor(t){this.defaultDoc=t,this.inertDocument=this.defaultDoc.implementation.createHTMLDocument("sanitization-inert")}getInertBodyElement(t){let e=this.inertDocument.createElement("template");return e.innerHTML=fl(t),e}};function dy(){try{return!!new window.DOMParser().parseFromString(fl(""),"text/html")}catch(n){return!1}}var uy=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:\/?#]*(?:[\/?#]|$))/i;function mp(n){return n=String(n),n.match(uy)?n:"unsafe:"+n}function Qn(n){let t=Object.create(null);for(let e of n.split(","))t[e]=!0;return t}function Fa(...n){let t=Object.create(null);for(let e of n)for(let o in e)Object.hasOwn(e,o)&&(t[o]=!0);return t}var hC=Qn("area,br,col,hr,img,wbr"),CC=Qn("colgroup,dd,dt,li,p,tbody,td,tfoot,th,thead,tr"),MC=Qn("rp,rt"),gy=Fa(MC,CC),py=Fa(CC,Qn("address,article,aside,blockquote,caption,center,del,details,dialog,dir,div,dl,figure,figcaption,footer,h1,h2,h3,h4,h5,h6,header,hgroup,hr,ins,main,map,menu,nav,ol,pre,section,summary,table,ul")),fy=Fa(MC,Qn("a,abbr,acronym,audio,b,bdi,bdo,big,br,cite,code,del,dfn,em,font,i,img,ins,kbd,label,map,mark,picture,q,ruby,rp,rt,s,samp,small,source,span,strike,strong,sub,sup,time,track,tt,u,var,video")),ph=Fa(hC,py,fy,gy),bC=Qn("background,cite,href,itemtype,longdesc,poster,src,xlink:href"),my=Qn("abbr,accesskey,align,alt,autoplay,axis,bgcolor,border,cellpadding,cellspacing,class,clear,color,cols,colspan,compact,controls,coords,datetime,default,dir,download,face,headers,height,hidden,hreflang,hspace,ismap,itemscope,itemprop,kind,label,lang,language,loop,media,muted,nohref,nowrap,open,preload,rel,rev,role,rows,rowspan,rules,scope,scrolling,shape,size,sizes,span,srclang,srcset,start,summary,tabindex,target,title,translate,type,usemap,valign,value,vspace,width"),_y=Qn("aria-activedescendant,aria-atomic,aria-autocomplete,aria-busy,aria-checked,aria-colcount,aria-colindex,aria-colspan,aria-controls,aria-current,aria-describedby,aria-details,aria-disabled,aria-dropeffect,aria-errormessage,aria-expanded,aria-flowto,aria-grabbed,aria-haspopup,aria-hidden,aria-invalid,aria-keyshortcuts,aria-label,aria-labelledby,aria-level,aria-live,aria-modal,aria-multiline,aria-multiselectable,aria-orientation,aria-owns,aria-placeholder,aria-posinset,aria-pressed,aria-readonly,aria-relevant,aria-required,aria-roledescription,aria-rowcount,aria-rowindex,aria-rowspan,aria-selected,aria-setsize,aria-sort,aria-valuemax,aria-valuemin,aria-valuenow,aria-valuetext"),hy=Fa(bC,my,_y),Cy=Qn("script,style,template"),Bg=class{sanitizedSomething=!1;buf=[];sanitizeChildren(t){let e=t.firstChild,o=!0,i=[];for(;e;){if(e.nodeType===Node.ELEMENT_NODE?o=this.startElement(e):e.nodeType===Node.TEXT_NODE?this.chars(e.nodeValue):this.sanitizedSomething=!0,o&&e.firstChild){i.push(e),e=Py(e);continue}for(;e;){e.nodeType===Node.ELEMENT_NODE&&this.endElement(e);let r=by(e);if(r){e=r;break}e=i.pop()}}return this.buf.join("")}startElement(t){let e=fh(t).toLowerCase();if(!Object.hasOwn(ph,e))return this.sanitizedSomething=!0,!Object.hasOwn(Cy,e);this.buf.push("<"),this.buf.push(e);let o=t.attributes;for(let i=0;i"),!0}endElement(t){let e=fh(t).toLowerCase();Object.hasOwn(ph,e)&&!Object.hasOwn(hC,e)&&(this.buf.push(""))}chars(t){this.buf.push(mh(t))}};function My(n,t){return(n.compareDocumentPosition(t)&Node.DOCUMENT_POSITION_CONTAINED_BY)!==Node.DOCUMENT_POSITION_CONTAINED_BY}function by(n){let t=n.nextSibling;if(t&&n!==t.previousSibling)throw PC(t);return t}function Py(n){let t=n.firstChild;if(t&&My(n,t))throw PC(t);return t}function fh(n){let t=n.nodeName;return typeof t=="string"?t:"FORM"}function PC(n){return new Error(`Failed to sanitize html because the element is clobbered: ${n.outerHTML}`)}var Oy=/[\uD800-\uDBFF][\uDC00-\uDFFF]/g,vy=/([^\#-~ |!])/g;function mh(n){return n.replace(/&/g,"&").replace(Oy,function(t){let e=t.charCodeAt(0),o=t.charCodeAt(1);return"&#"+((e-55296)*1024+(o-56320)+65536)+";"}).replace(vy,function(t){return"&#"+t.charCodeAt(0)+";"}).replace(//g,">")}var Bc;function OC(n,t){let e=null;try{Bc=Bc||ly(n);let o=t?String(t):"";e=Bc.getInertBodyElement(o);let i=5,r=o;do{if(i===0)throw new Error("Failed to sanitize html because the input is unstable");i--,o=r,r=e.innerHTML,e=Bc.getInertBodyElement(o)}while(o!==r);let s=new Bg().sanitizeChildren(_h(e)||e);return fl(s)}finally{if(e){let o=_h(e)||e;for(;o.firstChild;)o.firstChild.remove()}}}function _h(n){return"content"in n&&yy(n)?n.content:null}function yy(n){return n.nodeType===Node.ELEMENT_NODE&&n.nodeName==="TEMPLATE"}function xy(n,t){return n.createText(t)}function wy(n,t,e){n.setValue(t,e)}function vC(n,t,e){return n.createElement(t,e)}function pi(n,t,e,o,i){n.insertBefore(t,e,o,i)}function yC(n,t,e){n.appendChild(t,e)}function hh(n,t,e,o,i){o!==null?pi(n,t,e,o,i):yC(n,t,e)}function xC(n,t,e,o){n.removeChild(null,t,e,o)}function Sy(n,t,e){n.setAttribute(t,"style",e)}function ky(n,t,e){e===""?n.removeAttribute(t,"class"):n.setAttribute(t,"class",e)}function wC(n,t,e){let{mergedAttrs:o,classes:i,styles:r}=e;o!==null&&$v(n,t,o),i!==null&&ky(n,t,i),r!==null&&Sy(n,t,r)}function Ey(n,t=!0){if(n[0]!=":")return[null,n];let e=n.indexOf(":",1);if(e===-1){if(t)throw new Error(`Unsupported format "${n}" expecting ":namespace:name"`);return[null,n]}return[n.slice(1,e),n.slice(e+1)]}function Pi(n,t,e){if(t!==void 0&&e!==void 0&&kC(t,e)!==Je.HTML)return n;let o=hp();return o?uh(o.sanitize(Je.HTML,n)||""):ml(n,"HTML")?uh(_i(n)):OC(pp(),rn(n))}function kt(n){let t=hp();return t?t.sanitize(Je.URL,n)||"":ml(n,"URL")?_i(n):mp(rn(n))}function SC(n){let t=hp();if(t)return gh(t.sanitize(Je.RESOURCE_URL,n)||"");if(ml(n,"ResourceURL"))return gh(_i(n));throw new z(904,!1)}function Dy(n,t){switch(kC(n,t)){case Je.RESOURCE_URL:return SC;case Je.URL:return kt;default:return null}}function _p(n,t,e){return Dy(t,e)?.(n)??n}function hp(){let n=Z();return n&&n[cn].sanitizer}function kC(n,t){let[e,o]=Iy(n);return k_(o,t,e)}function Iy(n){n=n.toLowerCase();let t=Ey(n,!1);if(t[0])return t;let o=qt()===-1?null:gi(),i=o?.namespace;if(n==="#host"&&o?.type===2){let r=At(o,Z());if(r.tagName&&(n=r.tagName.toLowerCase()),i==null){let a=r.namespaceURI;i=a&&sy[a]}}return[i,n]}function _l(n){return n.ownerDocument.defaultView}function hl(n){return n.ownerDocument}function Ty(n){return n instanceof Function?n():n}function Ay(n,t,e){let o=n.length;for(;;){let i=n.indexOf(t,e);if(i===-1)return i;if(i===0||n.charCodeAt(i-1)<=32){let r=t.length;if(i+r===o||n.charCodeAt(i+r)<=32)return i}e=i+1}}var EC="ng-template";function Ny(n,t,e,o){let i=0;if(o){for(;i-1){let r;for(;++ir?_="":_=i[f+1].toLowerCase(),o&2&&p!==_){if(ln(o))return!1;a=!0}}}}return ln(o)||a}function ln(n){return(n&1)===0}function Ly(n,t,e,o){if(t===null)return-1;let i=0;if(o||!e){let r=!1;for(;i-1)for(e++;e0?'="'+s+'"':"")+"]"}else o&8?i+="."+a:o&4&&(i+=" "+a);else i!==""&&!ln(a)&&(t+=Ch(r,i),i=""),o=a,r=r||!ln(o);e++}return i!==""&&(t+=Ch(r,i)),t}function By(n){return n.map(zy).join(",")}function Hy(n){let t=[],e=[],o=1,i=2;for(;o=0;s--){let{el:c,declarationView:p}=o[s],f=c.parentNode;c===t?(o.splice(s,1),ya.add(c),c.dispatchEvent(new CustomEvent("animationend",{detail:{cancel:!0}}))):r&&c===r?(o.splice(s,1),c.dispatchEvent(new CustomEvent("animationend",{detail:{cancel:!0}})),c.parentNode?.removeChild(c)):f&&i&&f!==i&&(a===null||p===null||a===p)&&(o.splice(s,1),c.dispatchEvent(new CustomEvent("animationend",{detail:{cancel:!0}})),c.parentNode?.removeChild(c))}}function qy(n,t,e){let o=DC(e),i=Hg.get(n);i?i.some(r=>r.el===t)||i.push({el:t,declarationView:o}):Hg.set(n,[{el:t,declarationView:o}])}var Cl=(function(n){return n[n.CHANGE_DETECTION=0]="CHANGE_DETECTION",n[n.AFTER_NEXT_RENDER=1]="AFTER_NEXT_RENDER",n})(Cl||{}),Tn=new B(""),Mh=new Set;function An(n){Mh.has(n)||(Mh.add(n),performance?.mark?.("mark_feature_usage",{detail:{feature:n}}))}var bp=(()=>{class n{impl=null;execute(){this.impl?.execute()}static \u0275prov=te({token:n,providedIn:"root",factory:()=>new n})}return n})(),IC=[0,1,2,3],TC=(()=>{class n{ngZone=D(ge);scheduler=D(bn);errorHandler=D(nn,{optional:!0});sequences=new Set;deferredRegistrations=new Set;executing=!1;constructor(){D(Tn,{optional:!0})}execute(){let e=this.sequences.size>0;e&&Ce(pe.AfterRenderHooksStart),this.executing=!0;for(let o of IC)for(let i of this.sequences)if(!(i.erroredOrDestroyed||!i.hooks[o]))try{i.pipelinedValue=this.ngZone.runOutsideAngular(()=>this.maybeTrace(()=>{let r=i.hooks[o];return r(i.pipelinedValue)},i.snapshot))}catch(r){i.erroredOrDestroyed=!0,this.errorHandler?.handleError(r)}this.executing=!1;for(let o of this.sequences)o.afterRun(),o.once&&(this.sequences.delete(o),o.destroy());for(let o of this.deferredRegistrations)this.sequences.add(o);this.deferredRegistrations.size>0&&this.scheduler.notify(7),this.deferredRegistrations.clear(),e&&Ce(pe.AfterRenderHooksEnd)}register(e){let{view:o}=e;o!==void 0?((o[si]??=[]).push(e),ui(o),o[ee]|=8192):this.executing?this.deferredRegistrations.add(e):this.addSequence(e)}addSequence(e){this.sequences.add(e),this.scheduler.notify(7)}unregister(e){this.executing&&this.sequences.has(e)?(e.erroredOrDestroyed=!0,e.pipelinedValue=void 0,e.once=!0):(this.sequences.delete(e),this.deferredRegistrations.delete(e))}maybeTrace(e,o){return o?o.run(Cl.AFTER_NEXT_RENDER,e):e()}static \u0275prov=te({token:n,providedIn:"root",factory:()=>new n})}return n})(),tl=class{impl;hooks;view;once;snapshot;erroredOrDestroyed=!1;pipelinedValue=void 0;unregisterOnDestroy;constructor(t,e,o,i,r,a=null){this.impl=t,this.hooks=e,this.view=o,this.once=i,this.snapshot=a,this.unregisterOnDestroy=r?.onDestroy(()=>this.destroy())}afterRun(){this.erroredOrDestroyed=!1,this.pipelinedValue=void 0,this.snapshot?.dispose(),this.snapshot=null}destroy(){this.impl.unregister(this),this.unregisterOnDestroy?.();let t=this.view?.[si];t&&(this.view[si]=t.filter(e=>e!==this))}};function La(n,t){let e=t?.injector??D(Ct);return An("NgAfterNextRender"),Ky(n,e,t,!0)}function Wy(n){return n instanceof Function?[void 0,void 0,n,void 0]:[n.earlyRead,n.write,n.mixedReadWrite,n.read]}function Ky(n,t,e,o){let i=t.get(bp);i.impl??=t.get(TC);let r=t.get(Tn,null,{optional:!0}),a=e?.manualCleanup!==!0?t.get(Ae):null,s=t.get(ba,null,{optional:!0}),c=new tl(i.impl,Wy(n),s?.view,o,a,r?.snapshot(null));return i.impl.register(c),c}var Pp=new B("",{factory:()=>{let n=D(Le),t=new Set;return n.onDestroy(()=>t.clear()),{queue:t,isScheduled:!1,scheduler:null,injector:n}}});function AC(n,t,e){let o=n.get(Pp);if(Array.isArray(t))for(let i of t)o.queue.add(i),e?.detachedLeaveAnimationFns?.push(i);else o.queue.add(t),e?.detachedLeaveAnimationFns?.push(t);o.scheduler&&o.scheduler(n)}function Yy(n,t){let e=n.get(Pp);if(Array.isArray(t))for(let o of t)e.queue.delete(o);else e.queue.delete(t)}function Zy(n,t){let e=n.get(Pp);if(t.detachedLeaveAnimationFns){for(let o of t.detachedLeaveAnimationFns)e.queue.delete(o);t.detachedLeaveAnimationFns=void 0}}function Qy(n,t){for(let[e,o]of t)AC(n,o.animateFns)}function bh(n,t,e,o){let i=n?.[xn]?.enter;t!==null&&i&&i.has(e.index)&&Qy(o,i)}function Ph(n,t,e,o){try{e.get(ua)}catch(a){return o(!1)}let i=n?.[xn];i?.enter?.has(t.index)&&Yy(e,i.enter.get(t.index).animateFns);let r=Jy(n,t,i);if(r.size===0){let a=!1;if(n){let s=[];Ml(n,t,s),a=s.length>0}if(!a)return o(!1)}n&&wo.add(n[yn]),AC(e,()=>Xy(n,t,i||void 0,r,o),i||void 0)}function Jy(n,t,e){let o=new Map,i=e?.leave;if(i&&i.has(t.index)&&o.set(t.index,i.get(t.index)),n&&i)for(let[r,a]of i){if(o.has(r))continue;let c=n[K].data[r].parent;for(;c;){if(c===t){o.set(r,a);break}c=c.parent}}return o}function Xy(n,t,e,o,i){let r=[];if(e&&e.leave)for(let[a]of o){if(!e.leave.has(a))continue;let s=e.leave.get(a);for(let c of s.animateFns){let{promise:p}=c();r.push(p)}e.detachedLeaveAnimationFns=void 0}if(n&&Ml(n,t,r),r.length>0){let a=e||n?.[xn];if(a){let s=a.running;s&&r.push(s),a.running=Promise.allSettled(r),tx(n,a.running,i)}else Promise.allSettled(r).then(()=>{n&&wo.delete(n[yn]),i(!0)})}else n&&wo.delete(n[yn]),i(!1)}function Ml(n,t,e){if(t.type&12){let i=n[t.index];if(Ut(i))for(let r=Ve;r{n[xn]?.running===t&&(n[xn].running=void 0,wo.delete(n[yn])),e(!0)})}function sr(n,t,e,o,i,r,a,s){if(i!=null){let c,p=!1;Ut(i)?c=i:Kn(i)&&(p=!0,i=i[sn]);let f=dt(i);n===0&&o!==null?(bh(s,o,r,e),a==null?yC(t,o,f):pi(t,o,f,a||null,!0)):n===1&&o!==null?(bh(s,o,r,e),pi(t,o,f,a||null,!0),Gy(r,f,s)):n===2?(s?.[xn]?.leave?.has(r.index)&&qy(r,f,s),ya.delete(f),Ph(s,r,e,_=>{if(ya.has(f)){ya.delete(f);return}xC(t,f,p,_)})):n===3&&(ya.delete(f),Ph(s,r,e,()=>{t.destroyNode(f)})),c!=null&&fx(t,n,e,c,r,o,a)}}function nx(n,t){NC(n,t),t[sn]=null,t[yt]=null}function ox(n,t,e,o,i,r){o[sn]=i,o[yt]=t,Pl(n,o,e,1,i,r)}function NC(n,t){t[cn].changeDetectionScheduler?.notify(9),Pl(n,t,t[we],2,null,null)}function ix(n){let t=n[tr];if(!t)return yg(n[K],n);for(;t;){let e=null;if(Kn(t))e=t[tr];else{let o=t[Ve];o&&(e=o)}if(!e){for(;t&&!t[Ht]&&t!==n;)Kn(t)&&yg(t[K],t),t=t[Xe];t===null&&(t=n),Kn(t)&&yg(t[K],t),e=t&&t[Ht]}t=e}}function Op(n,t){let e=n[li],o=e.indexOf(t);e.splice(o,1)}function bl(n,t){if(di(t))return;let e=t[we];e.destroyNode&&Pl(n,t,e,3,null,null),ix(t)}function yg(n,t){if(di(t))return;let e=Y(null);try{t[ee]&=-129,t[ee]|=256,t[Tt]&&qo(t[Tt]),ax(n,t),rx(n,t),t[K].type===1&&t[we].destroy();let o=t[Oo];if(o!==null&&Ut(t[Xe])){o!==t[Xe]&&Op(o,t);let i=t[vn];i!==null&&i.detachView(n)}Lg(t)}finally{Y(e)}}function rx(n,t){let e=n.cleanup,o=t[er];if(e!==null)for(let a=0;a=0?o[s]():o[-s].unsubscribe(),a+=2}else{let s=o[e[a+1]];e[a].call(s)}o!==null&&(t[er]=null);let i=t[Un];if(i!==null){t[Un]=null;for(let a=0;aHe&&VC(n,t,He,!1);let s=a?pe.TemplateUpdateStart:pe.TemplateCreateStart;Ce(s,i,e),e(o,i)}finally{vo(r);let s=a?pe.TemplateUpdateEnd:pe.TemplateCreateEnd;Ce(s,i,e)}}function $C(n,t,e){yx(n,t,e),(e.flags&64)===64&&xx(n,t,e)}function kp(n,t,e=At){let o=t.localNames;if(o!==null){let i=t.index+1;for(let r=0;rnull;function Ox(n){return n==="class"?"className":n==="for"?"htmlFor":n==="formaction"?"formAction":n==="innerHtml"?"innerHTML":n==="readonly"?"readOnly":n==="tabindex"?"tabIndex":n}function zC(n,t,e,o,i,r){let a=t[K];if(Ep(n,a,t,e,o)){Yn(n)&&vx(t,n.index);return}n.type&3&&(e=Ox(e)),BC(n,t,e,o,i,r)}function BC(n,t,e,o,i,r){if(n.type&3){let a=At(n,t);o=r!=null?r(o,n.value||"",e):o,i.setProperty(a,e,o)}else n.type&12}function vx(n,t){let e=Gt(t,n);e[ee]&16||(e[ee]|=64)}function yx(n,t,e){let o=e.directiveStart,i=e.directiveEnd;Yn(e)&&Cx(t,e,n.data[o+e.componentOffset]),n.firstCreatePass||Jc(e,t);let r=e.initialInputs;for(let a=o;a=s&&h<=c){let P=t.data[h],T=_[C+1];hi(P,e[h],T,r),p=!0}else if(h>c)break}}return a!==null&&Object.hasOwn(o.inputs,i)&&(hi(o,e[a],i,r),p=!0),p}function Ax(n,t){let e=Gt(t,n),o=e[K];Nx(o,e);let i=e[sn];i!==null&&e[Xi]===null&&(e[Xi]=gC(i,e[On])),Ce(pe.ComponentStart);try{Dp(o,e,e[ze])}finally{Ce(pe.ComponentEnd,e[ze])}}function Nx(n,t){for(let e=t.length;e{ui(n.lView)},consumerOnSignalRead(){this.lView[Tt]=this}});function $x(n){let t=n[Tt]??Object.create(zx);return t.lView=n,t}var zx=F(S({},po),{consumerIsAlwaysLive:!0,kind:"template",consumerMarkedDirty:n=>{let t=qn(n.lView);for(;t&&!KC(t[K]);)t=qn(t);t&&og(t)},consumerOnSignalRead(){this.lView[Tt]=this}});function KC(n){return n.type!==2}function YC(n){if(n[Gn]===null)return;let t=!0;for(;t;){let e=!1;for(let o of n[Gn])if(o.dirty&&(e=!0,o.zone===null||Zone.current===o.zone?o.run():o.zone.run(()=>o.run()),n[Gn]===null))return;t=e&&!!(n[ee]&8192)}}var Bx=100;function ZC(n,t=0){let o=n[cn].rendererFactory,i=!1;i||o.begin?.();try{Hx(n,t)}finally{i||o.end?.()}}function Hx(n,t){let e=ug();try{oa(!0),Gg(n,t);let o=0;for(;ma(n);){if(o===Bx)throw new z(103,!1);o++,Gg(n,1)}}finally{oa(e)}}function Ux(n,t,e,o){if(di(t))return;let i=t[ee],r=!1,a=!1;Ic(t);let s=!0,c=null,p=null;r||(KC(n)?(p=Fx(t),c=fo(p)):Is()===null?(s=!1,p=$x(t),c=fo(p)):t[Tt]&&(qo(t[Tt]),t[Tt]=null));try{ng(t),z_(n.bindingStartIndex),e!==null&&jC(n,t,e,2,o);let f=(i&3)===3;if(!r)if(f){let h=n.preOrderCheckHooks;h!==null&&Uc(t,h,null)}else{let h=n.preOrderHooks;h!==null&&Gc(t,h,0,null),Og(t,0)}if(a||Gx(t),YC(t),QC(t,0),n.contentQueries!==null&&pC(n,t),!r)if(f){let h=n.contentCheckHooks;h!==null&&Uc(t,h)}else{let h=n.contentHooks;h!==null&&Gc(t,h,1),Og(t,1)}Wx(n,t);let _=n.components;_!==null&&XC(t,_,0);let C=n.viewQuery;if(C!==null&&jg(2,C,o),!r)if(f){let h=n.viewCheckHooks;h!==null&&Uc(t,h)}else{let h=n.viewHooks;h!==null&&Gc(t,h,2),Og(t,2)}if(n.firstUpdatePass===!0&&(n.firstUpdatePass=!1),t[Pc]){for(let h of t[Pc])h();t[Pc]=null}r||(qC(t),t[ee]&=-73)}catch(f){throw r||ui(t),f}finally{p!==null&&(Go(p,c),s&&Vx(p)),Tc()}}function QC(n,t){for(let e=rC(n);e!==null;e=aC(e))for(let o=Ve;o0&&(n[e-1][Ht]=o[Ht]);let r=da(n,Ve+t);nx(o[K],o);let a=r[vn];a!==null&&a.detachView(r[K]),o[Xe]=null,o[Ht]=null,o[ee]&=-129}return o}function Kx(n,t,e,o){let i=Ve+o,r=e.length;o>0&&(e[i-1][Ht]=t),o-1&&(Da(t,o),da(e,o))}this._attachedToViewContainer=!1}bl(this._lView[K],this._lView)}onDestroy(t){kc(this._lView,t)}markForCheck(){Ip(this._cdRefInjectingView||this._lView,4)}detach(){this._lView[ee]&=-129}reattach(){Sc(this._lView),this._lView[ee]|=128}detectChanges(){this._lView[ee]|=1024,ZC(this._lView)}checkNoChanges(){}attachToViewContainerRef(){if(this._appRef)throw new z(902,!1);this._attachedToViewContainer=!0}detachFromAppRef(){this._appRef=null;let t=nr(this._lView),e=this._lView[Oo];e!==null&&!t&&Op(e,this._lView),NC(this._lView[K],this._lView)}attachToAppRef(t){if(this._attachedToViewContainer)throw new z(902,!1);this._appRef=t;let e=nr(this._lView),o=this._lView[Oo];o!==null&&!e&&oM(o,this._lView),Sc(this._lView)}};var ur=(()=>{class n{_declarationLView;_declarationTContainer;elementRef;static __NG_ELEMENT_ID__=Yx;constructor(e,o,i){this._declarationLView=e,this._declarationTContainer=o,this.elementRef=i}get ssrId(){return this._declarationTContainer.tView?.ssrId||null}createEmbeddedView(e,o){return this.createEmbeddedViewImpl(e,o)}createEmbeddedViewImpl(e,o,i){let r=vl(this._declarationLView,this._declarationTContainer,e,{embeddedViewInjector:o,dehydratedView:i});return new So(r)}}return n})();function Yx(){return Tp(tt(),Z())}function Tp(n,t){return n.type&4?new ur(t,n,pr(n,t)):null}function xl(n,t,e,o,i){let r=n.data[t];if(r===null)r=Zx(n,t,e,o,i),B_()&&(r.flags|=32);else if(r.type&64){r.type=e,r.value=o,r.attrs=i;let a=V_();r.injectorIndex=a===null?-1:a.injectorIndex}return or(r,!0),r}function Zx(n,t,e,o,i){let r=lg(),a=dg(),s=a?r:r&&r.parent,c=n.data[t]=Jx(n,s,e,t,o,i);return Qx(n,c,r,a),c}function Qx(n,t,e,o){n.firstChild===null&&(n.firstChild=t),e!==null&&(o?e.child==null&&t.parent!==null&&(e.child=t):e.next===null&&(e.next=t,t.prev=e))}function Jx(n,t,e,o,i,r){let a=t?t.injectorIndex:-1,s=0;return L_()&&(s|=128),{type:e,index:o,insertBeforeIndex:null,injectorIndex:a,directiveStart:-1,directiveEnd:-1,directiveStylingLast:-1,componentOffset:-1,controlDirectiveIndex:-1,customControlIndex:-1,propertyBindings:null,flags:s,providerIndexes:0,value:i,namespace:_g(),attrs:r,mergedAttrs:null,localNames:null,initialInputs:null,inputs:null,hostDirectiveInputs:null,outputs:null,hostDirectiveOutputs:null,directiveToIndex:null,tView:null,next:null,prev:null,projectionNext:null,child:null,parent:t,projection:null,styles:null,stylesWithoutHost:null,residualStyles:void 0,classes:null,classesWithoutHost:null,residualClasses:void 0,classBindings:0,styleBindings:0}}function Xx(n){let t=n[Ju]??[],o=n[Xe][we],i=[];for(let r of t)r.data[lC]!==void 0?i.push(r):e1(r,o);n[Ju]=i}function e1(n,t){let e=0,o=n.firstChild;if(o){let i=n.data[cC];for(;enull,n1=()=>null;function qg(n,t){return t1(n,t)}function iM(n,t,e){return n1(n,t,e)}var rM=class{},Ci=class{},wt=class{destroyNode=null;static __NG_ELEMENT_ID__=()=>o1()};function o1(){let n=Z(),t=tt(),e=Gt(t.index,n);return(Kn(e)?e:n)[we]}var aM=(()=>{class n{static \u0275prov=te({token:n,providedIn:"root",factory:()=>null})}return n})();function sM(n){return n.debugInfo?.className||n.type.name||null}var Wc={},nl=class{injector;parentInjector;constructor(t,e){this.injector=t,this.parentInjector=e}get(t,e,o){let i=this.injector.get(t,Wc,o);return i!==Wc||e===Wc?i:this.parentInjector.get(t,e,o)}};function Ap(n,t,e){return n[t]=e}function i1(n,t){return n[t]}function St(n,t,e){if(e===ot)return!1;let o=n[t];return Object.is(o,e)?!1:(n[t]=e,!0)}function Np(n,t,e,o){let i=St(n,t,e);return St(n,t+1,o)||i}function r1(n,t,e,o,i){let r=Np(n,t,e,o);return St(n,t+2,i)||r}function fi(n,t,e){return function o(i){let r=o.__ngNativeEl__;r!==void 0&&oy(i,r);let a=Yn(n)?Gt(n.index,t):t;Ip(a,5);let s=t[ze],c=vh(t,s,e,i),p=o.__ngNextListenerFn__;for(;p;)c=vh(t,s,p,i)&&c,p=p.__ngNextListenerFn__;return c}}function vh(n,t,e,o){let i=Y(null);try{return Ce(pe.OutputStart,t,e),e(o)!==!1}catch(r){return Ix(n,r),!1}finally{Ce(pe.OutputEnd,t,e),Y(i)}}function Rp(n,t,e,o,i,r,a,s){let c=Oc(n),p=!1,f=null;if(!o&&c&&(f=s1(t,e,r,n.index)),f!==null){let _=f.__ngLastListenerFn__||f;_.__ngNextListenerFn__=a,f.__ngLastListenerFn__=a,p=!0}else{let _=At(n,e),C=o?o(_):_;ry(e,C,r,s),o||(s.__ngNativeEl__=_);let h=i.listen(C,r,s);if(!a1(r)){let P=o?T=>o(dt(T[n.index])):n.index;cM(P,t,e,r,s,h,!1)}}return p}function a1(n){return n.startsWith("animation")||n.startsWith("transition")}function s1(n,t,e,o){let i=n.cleanup;if(i!=null)for(let r=0;rc?s[c]:null}typeof a=="string"&&(r+=2)}return null}function cM(n,t,e,o,i,r,a){let s=t.firstCreatePass?rg(t):null,c=ig(e),p=c.length;c.push(i,r),s&&s.push(o,n,p,(p+1)*(a?-1:1))}function yh(n,t,e,o,i){let r=null,a=null,s=null,c=!1,p=n.directiveToIndex.get(e.type);if(typeof p=="number"?r=p:[r,a,s]=p,a!==null&&s!==null&&n.hostDirectiveOutputs&&Object.hasOwn(n.hostDirectiveOutputs,o)){let f=n.hostDirectiveOutputs[o];for(let _=0;_=a&&C<=s)c=!0,ol(n,t,C,f[_+1],o,i);else if(C>s)break}}return Object.hasOwn(e.outputs,o)&&(c=!0,ol(n,t,r,o,o,i)),c}function ol(n,t,e,o,i,r){let a=t[e],s=t[K],p=s.data[e].outputs[o],_=a[p].subscribe(r);cM(n.index,s,t,i,r,_,!0)}function Rt(){c1()}function c1(){let n=Z(),t=Ne(),e=tt();if(t.firstCreatePass&&d1(t,e),e.controlDirectiveIndex===-1)return;An("NgSignalForms");let o=n[e.controlDirectiveIndex];t.data[e.controlDirectiveIndex].controlDef.create(o,new il(n,t,e))}function Ft(){l1()}function l1(){let n=Z(),t=Ne(),e=gi();if(e.controlDirectiveIndex===-1)return;let o=t.data[e.controlDirectiveIndex].controlDef,i=n[e.controlDirectiveIndex];o.update(i,new il(n,t,e))}var il=class{lView;tView;tNode;hasPassThrough;constructor(t,e,o){this.lView=t,this.tView=e,this.tNode=o,this.hasPassThrough=!!(o.flags&4096)}get customControl(){return this.tNode.customControlIndex!==-1?this.lView[this.tNode.customControlIndex]:void 0}get nativeElement(){return At(this.tNode,this.lView)}get descriptor(){return`<${this.tNode.value}>`}listenToCustomControlOutput(t,e){let o=this.tView.data[this.tNode.customControlIndex];yh(this.tNode,this.lView,o,t,fi(this.tNode,this.lView,e))}listenToCustomControlModel(t){let e=this.tNode.flags&1024?"valueChange":"checkedChange",o=this.tView.data[this.tNode.customControlIndex];yh(this.tNode,this.lView,o,e,fi(this.tNode,this.lView,t))}listenToDom(t,e){Rp(this.tNode,this.tView,this.lView,void 0,this.lView[we],t,e,fi(this.tNode,this.lView,e))}setInputOnDirectives(t,e){let o=this.tNode.inputs?.[t],i=this.tNode.hostDirectiveInputs?.[t];if(!o&&!i)return!1;let r=!1;if(o)for(let a of o){if(a===this.tNode.controlDirectiveIndex)continue;let s=this.tView.data[a],c=this.lView[a];hi(s,c,t,e),r=!0}if(i)for(let a=0;a0;){let i=o.shift();if(typeof i!="function"){for(let a in i.inputs)e[i.inputs[a]]=!0;let r=xh(i.directive);r!==null&&o.push(...r);continue}for(let r of i()){if(typeof r=="function")continue;if(r.inputs)for(let s=0;s1){t.flags|=4096;return}u1(n,t)}function u1(n,t){for(let e=t.directiveStart;e{let r=t.hostDirectiveInputs[o],a=t.hostDirectiveOutputs[o+"Change"];if(!r||!a)return!1;for(let s=0;s=h&&c<=P)return t.flags|=i,t.customControlIndex=C,!0}}}return!1};if(e("value",1024)||e("checked",2048))return}}function wh(n,t){return g1(n,t)&&p1(n,t+"Change")}function g1(n,t){return t in n.inputs}function p1(n,t){return t in n.outputs}var Wg=Symbol("BINDING");var Oi=new B("");function rl(n,t,e){let o=e?n.styles:null,i=e?n.classes:null,r=0;if(t!==null)for(let a=0;a0&&(e.directiveToIndex=new Map);for(let C=0;C0;){let e=n[--t];if(typeof e=="number"&&e<0)return e}return 0}function O1(n,t,e){if(e){if(t.exportAs)for(let o=0;o{let[e,o,i]=n[t],r={propName:e,templateName:t,isSignal:(o&Ol.SignalBased)!==0};return i&&(r.transform=i),r})}function S1(n){return Object.keys(n).map(t=>({propName:n[t],templateName:t}))}function k1(n,t,e){let o=t instanceof Le?t:t?.injector;return o&&n.getStandaloneInjector!==null&&(o=n.getStandaloneInjector(o)||o),o?new nl(e,o):e}function E1(n){let t=n.get(Ci,null);if(t===null)throw new z(407,!1);let e=n.get(aM,null),o=n.get(bn,null),i=n.get(Tn,null,{optional:!0});return{rendererFactory:t,sanitizer:e,changeDetectionScheduler:o,ngReflect:!1,tracingService:i}}function D1(n,t,e){let o=pM(n);return vC(t,o,o==="svg"?Qi:o==="math"?vc:e)}function I1(n){if((n&&"localName"in n&&typeof n.localName=="string"?n.localName:n?.tagName)?.toLowerCase()==="script")throw new z(905,!1)}function pM(n){return(n.selectors[0][0]||"div").toLowerCase()}var Ia=class{componentDef;ngModule;selector;componentType;ngContentSelectors;isBoundToModule;cachedInputs=null;cachedOutputs=null;get inputs(){return this.cachedInputs??=w1(this.componentDef.inputs),this.cachedInputs}get outputs(){return this.cachedOutputs??=S1(this.componentDef.outputs),this.cachedOutputs}constructor(t,e){this.componentDef=t,this.ngModule=e,this.componentType=t.type,this.selector=By(t.selectors),this.ngContentSelectors=t.ngContentSelectors??[],this.isBoundToModule=!!e}create(t,e,o,i,r,a,s){Ce(pe.DynamicComponentStart);let c=Y(null);try{let p=this.componentDef,f=k1(p,i||this.ngModule,t),_=E1(f),C=_.tracingService;return C&&C.componentCreate?C.componentCreate(sM(p),()=>this.createComponentRef(_,f,e,o,r,a,s)):this.createComponentRef(_,f,e,o,r,a,s)}finally{Y(c)}}createComponentRef(t,e,o,i,r,a,s){let c=this.componentDef,p=T1(i,c,a,r),f=t.rendererFactory.createRenderer(null,c),_=i?Mx(f,i,c.encapsulation,e):D1(c,f,s??null);I1(_);let C=e.get(Oi,null),h=A1(_,()=>e.get(Ue,null)??pp());C&&C.addHost(h);let P=a?.some(Dh)||r?.some(N=>typeof N!="function"&&N.bindings.some(Dh)),T=wp(null,p,null,512|FC(c),null,null,t,f,e,null,gC(_,e,!0));C&&gM&&h instanceof ShadowRoot&&kc(T,()=>{C.removeHost(h)}),T[He]=_,Ic(T);let A=null;try{let N=dM(He,T,2,"#host",()=>p.directiveRegistry,!0,0);wC(f,_,N),dr(_,T),$C(p,T,N),fC(p,N,T),uM(p,N),o!==void 0&&R1(N,this.ngContentSelectors,o),A=Gt(N.index,T),T[ze]=A[ze],Dp(p,T,null)}catch(N){throw A!==null&&Lg(A),Lg(T),N}finally{Ce(pe.DynamicComponentEnd),Tc()}return new al(this.componentType,T,!!P)}};function T1(n,t,e,o){let i=n?["ng-version","22.1.4"]:Hy(t.selectors[0]),r=null,a=null,s=0;if(e)for(let f of e)s+=f[Wg].requiredVars,f.create&&(f.targetIdx=0,(r??=[]).push(f)),f.update&&(f.targetIdx=0,(a??=[]).push(f));if(o)for(let f=0;f{if(e&1&&n)for(let o of n)o.create();if(e&2&&t)for(let o of t)o.update()}}function Dh(n){let t=n[Wg].kind;return t==="input"||t==="twoWay"}var al=class extends rM{_rootLView;_hasInputBindings;instance;hostView;changeDetectorRef;componentType;location;previousInputValues=null;_tNode;constructor(t,e,o){super(),this._rootLView=e,this._hasInputBindings=o,this._tNode=yc(e[K],He),this.location=pr(this._tNode,e),this.instance=Gt(this._tNode.index,e)[ze],this.hostView=this.changeDetectorRef=new So(e,void 0),this.componentType=t}setInput(t,e){this._hasInputBindings;let o=this._tNode;if(this.previousInputValues??=new Map,this.previousInputValues.has(t)&&Object.is(this.previousInputValues.get(t),e))return;let i=this._rootLView,r=Ep(o,i[K],i,t,e);this.previousInputValues.set(t,e);let a=Gt(o.index,i);Ip(a,1)}get injector(){return new xo(this._tNode,this._rootLView)}destroy(){this.hostView.destroy()}onDestroy(t){this.hostView.onDestroy(t)}};function R1(n,t,e){let o=n.projection=[];for(let i=0;i{class n{static __NG_ELEMENT_ID__=F1}return n})();function F1(){let n=tt();return fM(n,Z())}var Kg=class n extends vi{_lContainer;_hostTNode;_hostLView;constructor(t,e,o){super(),this._lContainer=t,this._hostTNode=e,this._hostLView=o}get element(){return pr(this._hostTNode,this._hostLView)}get injector(){return new xo(this._hostTNode,this._hostLView)}get parentInjector(){let t=dp(this._hostTNode,this._hostLView);if(Kh(t)){let e=Zc(t,this._hostLView),o=Yc(t),i=e[K].data[o+8];return new xo(i,e)}else return new xo(null,this._hostLView)}clear(){for(;this.length>0;)this.remove(this.length-1)}get(t){let e=Ih(this._lContainer);return e!==null&&e[t]||null}get length(){return this._lContainer.length-Ve}createEmbeddedView(t,e,o){let i,r;typeof o=="number"?i=o:o!=null&&(i=o.index,r=o.injector);let a=qg(this._lContainer,t.ssrId),s=t.createEmbeddedViewImpl(e||{},r,a);return this.insertImpl(s,i,ka(this._hostTNode,a)),s}createComponent(t,e,o,i,r,a,s){let c,p=e||{};c=p.index,o=p.injector,i=p.projectableNodes,r=p.environmentInjector||p.ngModuleRef,a=p.directives,s=p.bindings;let f=new Ia(ii(t)),_=o||this.parentInjector;if(!r&&f.ngModule==null){let N=this.parentInjector.get(Le,null);N&&(r=N)}let C=ii(f.componentType??{}),h=qg(this._lContainer,C?.id??null),P=h?.firstChild??null,T=f.create(_,i,P,r,a,s,this._getHostElementNamespace());return this.insertImpl(T.hostView,c,ka(this._hostTNode,h)),T}_getHostElementNamespace(){if(this._hostTNode.type&2){let t=this._hostTNode.parent??this._hostLView[yt];return t!==null&&t.type&2&&typeof t.value=="string"&&t.value.toLowerCase()==="foreignobject"?null:t?.namespace??null}return this._hostTNode.namespace}insert(t,e){return this.insertImpl(t,e,!0)}insertImpl(t,e,o){let i=t._lView;if(D_(i)){let s=this.indexOf(t);if(s!==-1)this.detach(s);else{let c=i[Xe],p=new n(c,c[yt],c[Xe]);p.detach(p.indexOf(t))}}let r=this._adjustIndex(e),a=this._lContainer;return yl(a,i,r,o),t.attachToViewContainerRef(),Gu(xg(a),r,t),t}move(t,e){return this.insert(t,e)}indexOf(t){let e=Ih(this._lContainer);return e!==null?e.indexOf(t):-1}remove(t){let e=this._adjustIndex(t,-1),o=Da(this._lContainer,e);o&&(da(xg(this._lContainer),e),bl(o[K],o))}detach(t){let e=this._adjustIndex(t,-1),o=Da(this._lContainer,e);return o&&da(xg(this._lContainer),e)!=null?new So(o):null}_adjustIndex(t,e=0){return t??this.length+e}};function Ih(n){return n[fa]}function xg(n){return n[fa]||(n[fa]=[])}function fM(n,t){let e,o=t[n.index];return Ut(o)?e=o:(e=eM(o,t,null,n),t[n.index]=e,Sp(t,e)),V1(e,t,n,o),new Kg(e,n,t)}function L1(n,t){let e=n[we],o=e.createComment(""),i=At(t,n),r=e.parentNode(i);return pi(e,r,o,e.nextSibling(i),!1),o}var V1=z1,j1=()=>!1;function $1(n,t,e){return j1(n,t,e)}function z1(n,t,e,o){if(n[ci])return;let i;e.type&8?i=dt(o):i=L1(t,e),n[ci]=i}var Yg=class n{queryList;matches=null;constructor(t){this.queryList=t}clone(){return new n(this.queryList)}setDirty(){this.queryList.setDirty()}},Zg=class n{queries;constructor(t=[]){this.queries=t}createEmbeddedView(t){let e=t.queries;if(e!==null){let o=t.contentQueries!==null?t.contentQueries[0]:e.length,i=[];for(let r=0;r0)o.push(a[s/2]);else{let p=r[s+1],f=t[-c];for(let _=Ve;_t.trim())}function hM(n,t,e){n.queries===null&&(n.queries=new Qg),n.queries.track(new Jg(t,e))}function Z1(n,t){let e=n.contentQueries||(n.contentQueries=[]),o=e.length?e[e.length-1]:-1;t!==o&&e.push(n.queries.length-1,t)}function Fp(n,t){return n.queries.getByIndex(t)}function Q1(n,t){let e=n[K],o=Fp(e,t);return o.crossesNgTemplate?Xg(e,n,t,[]):mM(e,n,o,t)}function Eo(n){return!!n&&typeof n.then=="function"}function Lp(n){return!!n&&typeof n.subscribe=="function"}var Mi=class{},wl=class{};var cl=class extends Mi{ngModuleType;_parent;_bootstrapComponents=[];_r3Injector;instance;destroyCbs=[];constructor(t,e,o,i=!0){super(),this.ngModuleType=t,this._parent=e;let r=p_(t);this._bootstrapComponents=Ty(r.bootstrap),this._r3Injector=hg(t,e,[{provide:Mi,useValue:this},...o],aa(t),new Set(["environment"])),i&&this.resolveInjectorInitializers()}resolveInjectorInitializers(){this._r3Injector.resolveInjectorInitializers(),this.instance=this._r3Injector.get(this.ngModuleType)}get injector(){return this._r3Injector}destroy(){let t=this._r3Injector;!t.destroyed&&t.destroy(),this.destroyCbs.forEach(e=>e()),this.destroyCbs=null}onDestroy(t){this.destroyCbs.push(t)}},ll=class extends wl{moduleType;constructor(t){super(),this.moduleType=t}create(t){return new cl(this.moduleType,t,[])}};var Ta=class extends Mi{injector;instance=null;constructor(t){super();let e=new ni([...t.providers,{provide:Mi,useValue:this}],t.parent||pa(),t.debugName,new Set(["environment"]));this.injector=e,t.runEnvironmentInitializers&&e.resolveInjectorInitializers()}destroy(){this.injector.destroy()}onDestroy(t){this.injector.onDestroy(t)}};function Va(n,t,e=null){return new Ta({providers:n,parent:t,debugName:e,runEnvironmentInitializers:!0}).injector}var J1=(()=>{class n{_injector;cachedInjectors=new Map;constructor(e){this._injector=e}getOrCreateStandaloneInjector(e){if(!e.standalone)return null;if(!this.cachedInjectors.has(e)){let o=Wu(!1,e.type),i=o.length>0?Va([o],this._injector,""):null;this.cachedInjectors.set(e,i)}return this.cachedInjectors.get(e)}ngOnDestroy(){try{for(let e of this.cachedInjectors.values())e!==null&&e.destroy()}finally{this.cachedInjectors.clear()}}static \u0275prov=te({token:n,providedIn:"environment",factory:()=>new n(Q(Le))})}return n})();function Ge(n){return Na(()=>{let t=CM(n),e=F(S({},t),{decls:n.decls,vars:n.vars,template:n.template,consts:n.consts||null,ngContentSelectors:n.ngContentSelectors,onPush:n.changeDetection!==up.Eager,directiveDefs:null,pipeDefs:null,dependencies:t.standalone&&n.dependencies||null,getStandaloneInjector:t.standalone?i=>i.get(J1).getOrCreateStandaloneInjector(e):null,getExternalStyles:null,signals:n.signals??!1,data:n.data||{},encapsulation:n.encapsulation||un.Emulated,styles:n.styles||ht,_:null,schemas:n.schemas||null,tView:null,id:""});t.standalone&&An("NgStandalone"),MM(e);let o=n.dependencies;return e.directiveDefs=Th(o,X1),e.pipeDefs=Th(o,f_),e.id=nw(e),e})}function X1(n){return ii(n)||Bu(n)}function Jn(n){return Na(()=>({type:n.type,bootstrap:n.bootstrap||ht,declarations:n.declarations||ht,imports:n.imports||ht,exports:n.exports||ht,transitiveCompileScopes:null,schemas:n.schemas||null,id:n.id||null}))}function ew(n,t){if(n==null)return Mo;let e={};for(let o in n)if(Object.hasOwn(n,o)){let i=n[o],r,a,s,c;Array.isArray(i)?(s=i[0],r=i[1],a=i[2]??r,c=i[3]||null):(r=i,a=i,s=Ol.None,c=null),e[r]=[o,s,c],t[r]=a}return e}function tw(n){if(n==null)return Mo;let t={};for(let e in n)Object.hasOwn(n,e)&&(t[n[e]]=e);return t}function qe(n){return Na(()=>{let t=CM(n);return MM(t),t})}function Sl(n){return{type:n.type,name:n.name,factory:null,pure:n.pure!==!1,standalone:n.standalone??!0,onDestroy:n.type.prototype.ngOnDestroy||null}}function CM(n){let t={};return{type:n.type,providersResolver:null,viewProvidersResolver:null,factory:null,hostBindings:n.hostBindings||null,hostVars:n.hostVars||0,hostAttrs:n.hostAttrs||null,contentQueries:n.contentQueries||null,declaredInputs:t,inputConfig:n.inputs||Mo,exportAs:n.exportAs||null,standalone:n.standalone??!0,signals:n.signals===!0,selectors:n.selectors||ht,viewQuery:n.viewQuery||null,features:n.features||null,setInput:null,resolveHostDirectives:null,hostDirectives:null,controlDef:null,signalFormsInputPresence:null,inputs:ew(n.inputs,t),outputs:tw(n.outputs),debugInfo:null}}function MM(n){n.features?.forEach(t=>t(n))}function Th(n,t){return n?()=>{let e=typeof n=="function"?n():n,o=[];for(let i of e){let r=t(i);r!==null&&o.push(r)}return o}:null}function nw(n){let t=0,e=typeof n.consts=="function"?"":n.consts,o=[n.selectors,n.ngContentSelectors,n.hostVars,n.hostAttrs,e,n.vars,n.decls,n.encapsulation,n.standalone,n.signals,n.exportAs,JSON.stringify(n.inputs),JSON.stringify(n.outputs),Object.getOwnPropertyNames(n.type.prototype),!!n.contentQueries,!!n.viewQuery];for(let r of o.join("|"))t=Math.imul(31,t)+r.charCodeAt(0)<<0;return t+=2147483648,"c"+t}var bM=new B("");var Vp=(()=>{class n{resolve;reject;initialized=!1;done=!1;donePromise=new Promise((e,o)=>{this.resolve=e,this.reject=o});appInits=D(bM,{optional:!0})??[];injector=D(Ct);constructor(){}runInitializers(){if(this.initialized)return;let e=[];for(let i of this.appInits){let r=et(this.injector,i);if(Eo(r))e.push(r);else if(Lp(r)){let a=new Promise((s,c)=>{r.subscribe({complete:s,error:c})});e.push(a)}}let o=()=>{this.done=!0,this.resolve()};Promise.all(e).then(()=>{o()}).catch(i=>{this.reject(i)}),e.length===0&&o(),this.initialized=!0}static \u0275fac=function(o){return new(o||n)};static \u0275prov=Oe({token:n,factory:n.\u0275fac})}return n})();function jp(n){return t=>{t.controlDef={create:(e,o)=>{e?.\u0275ngControlCreate(o)},update:(e,o)=>{e?.\u0275ngControlUpdate?.(o)},passThroughInput:n}}}function ow(n){return Object.getPrototypeOf(n.prototype).constructor}function gn(n){let t=ow(n.type),e=!0,o=[n];for(;t&&t!==Function.prototype&&t!==Object.prototype;){let i,r=Object.hasOwn(t,ca)?t[ca]:void 0,a=Object.hasOwn(t,la)?t[la]:void 0;if(wn(n))i=r??a;else{if(r)throw new z(903,!1);i=a}if(i){if(e){o.push(i);let c=n;c.inputs=wg(n.inputs),c.declaredInputs=wg(n.declaredInputs),c.outputs=wg(n.outputs);let p=i.hostBindings;p&&cw(n,p);let f=i.viewQuery,_=i.contentQueries;if(f&&aw(n,f),_&&sw(n,_),iw(n,i),g_(n.outputs,i.outputs),wn(i)&&i.data.animation){let C=n.data;C.animation=(C.animation||[]).concat(i.data.animation)}}let s=i.features;if(s)for(let c=0;c=0;o--){let i=n[o];i.hostVars=t+=i.hostVars,i.hostAttrs=lr(i.hostAttrs,e=lr(e,i.hostAttrs))}}function wg(n){return n===Mo?{}:n===ht?[]:n}function aw(n,t){let e=n.viewQuery;e?n.viewQuery=(o,i)=>{t(o,i),e(o,i)}:n.viewQuery=t}function sw(n,t){let e=n.contentQueries;e?n.contentQueries=(o,i,r)=>{t(o,i,r),e(o,i,r)}:n.contentQueries=t}function cw(n,t){let e=n.hostBindings;e?n.hostBindings=(o,i)=>{t(o,i),e(o,i)}:n.hostBindings=t}function lw(n,t,e,o,i,r,a,s){if(e.firstCreatePass){n.mergedAttrs=lr(n.mergedAttrs,n.attrs);let f=n.tView=xp(2,n,i,r,a,e.directiveRegistry,e.pipeRegistry,null,e.schemas,e.consts,null);e.queries!==null&&(e.queries.template(e,n),f.queries=e.queries.embeddedTView(n))}s&&(n.flags|=s),or(n,!1);let c=dw(e,t,n,o);Ac()&&vp(e,t,c,n),dr(c,t);let p=eM(c,t,c,n);t[o+He]=p,Sp(t,p),$1(p,n,t)}function dl(n,t,e,o,i,r,a,s,c,p,f){let _=e+He,C;if(t.firstCreatePass){if(C=xl(t,_,4,a||null,s||null),p!=null){let h=Sn(t.consts,p);C.localNames=[];for(let P=0;P{class n{log(e){console.log(e)}warn(e){console.warn(e)}static \u0275fac=function(o){return new(o||n)};static \u0275prov=te({token:n,factory:n.\u0275fac,providedIn:"platform"})}return n})();var zp=new B("");var kl=new B("");function PM(){ou(()=>{let n="";throw new z(600,n)})}var gw=10;var Xn=(()=>{class n{_runningTick=!1;_destroyed=!1;_destroyListeners=[];_views=[];internalErrorHandler=D(Nt);afterRenderManager=D(bp);zonelessEnabled=D(ar);rootEffectScheduler=D(Vc);dirtyFlags=0;tracingSnapshot=null;allTestViews=new Set;autoDetectTestViews=new Set;includeAllTestViews=!1;afterTick=new ye;get allViews(){return[...(this.includeAllTestViews?this.allTestViews:this.autoDetectTestViews).keys(),...this._views]}get destroyed(){return this._destroyed}componentTypes=[];components=[];internalPendingTask=D(kn);get isStable(){return this.internalPendingTask.hasPendingTasksObservable.pipe(Pe(e=>!e))}constructor(){D(Tn,{optional:!0})}whenStable(){let e;return new Promise(o=>{e=this.isStable.subscribe({next:i=>{i&&o()}})}).finally(()=>{e.unsubscribe()})}_injector=D(Le);_rendererFactory=null;get injector(){return this._injector}bootstrap(e,o){return this.bootstrapImpl(e,o)}bootstrapImpl(e,o,i=Ct.NULL){return this._injector.get(ge).run(()=>{if(Ce(pe.BootstrapComponentStart),!this._injector.get(Vp).done){let N="";throw new z(405,N)}let s=ii(e),c=this._injector.get(Mi),p=new Ia(s,c);this.componentTypes.push(e);let{hostElement:f,directives:_,bindings:C}=pw(o),h=f||p.selector,P=p.create(i,[],h,c.injector,_,C),T=P.location.nativeElement,A=P.injector.get(zp,null);return A?.registerApplication(T),P.onDestroy(()=>{this.detachView(P.hostView),wa(this.components,P),A?.unregisterApplication(T)}),this._loadComponent(P),Ce(pe.BootstrapComponentEnd,P),P})}tick(){this.zonelessEnabled||(this.dirtyFlags|=1),this._tick()}_tick(){Ce(pe.ChangeDetectionStart),this.tracingSnapshot!==null?this.tracingSnapshot.run(Cl.CHANGE_DETECTION,this.tickImpl):this.tickImpl()}tickImpl=()=>{if(this._runningTick)throw Ce(pe.ChangeDetectionEnd),new z(101,!1);let e=Y(null);try{this._runningTick=!0,this.synchronize()}finally{this._runningTick=!1,this.tracingSnapshot?.dispose(),this.tracingSnapshot=null,Y(e),this.afterTick.next(),Ce(pe.ChangeDetectionEnd)}};synchronize(){this._rendererFactory===null&&!this._injector.destroyed&&(this._rendererFactory=this._injector.get(Ci,null,{optional:!0}));let e=0;for(;this.dirtyFlags!==0&&e++ma(e))){this.dirtyFlags|=2;return}else this.dirtyFlags&=-8}attachView(e){let o=e;this._views.push(o),o.attachToAppRef(this)}detachView(e){let o=e;wa(this._views,o),o.detachFromAppRef()}_loadComponent(e){this.attachView(e.hostView);try{this.tick()}catch(i){this.internalErrorHandler(i)}this.components.push(e),this._injector.get(kl,[]).forEach(i=>i(e))}ngOnDestroy(){if(!this._destroyed)try{this._destroyListeners.forEach(e=>e()),this._views.slice().forEach(e=>e.destroy())}finally{this._destroyed=!0,this._views=[],this._destroyListeners=[]}}onDestroy(e){return this._destroyListeners.push(e),()=>wa(this._destroyListeners,e)}destroy(){if(this._destroyed)throw new z(406,!1);let e=this._injector;e.destroy&&!e.destroyed&&e.destroy()}get viewCount(){return this._views.length}static \u0275fac=function(o){return new(o||n)};static \u0275prov=Oe({token:n,factory:n.\u0275fac})}return n})();function pw(n){return n===void 0||typeof n=="string"||n instanceof Element?{hostElement:n}:n}function wa(n,t){let e=n.indexOf(t);e>-1&&n.splice(e,1)}function Wt(n,t,e,o){let i=Z(),r=Zn();if(St(i,r,t)){let a=Ne(),s=gi();kx(s,i,n,t,e,o)}return Wt}function De(){return Z()[xt][ze]}var ep=class{destroy(t){}updateValue(t,e){}swap(t,e){let o=Math.min(t,e),i=Math.max(t,e),r=this.detach(i);if(i-o>1){let a=this.detach(o);this.attach(o,r),this.attach(i,a)}else this.attach(o,r)}move(t,e){this.attach(e,this.detach(t))}};function Sg(n,t,e,o,i){return n===e&&Object.is(t,o)?1:Object.is(i(n,t),i(e,o))?-1:0}function fw(n,t,e,o){let i,r,a=0,s=n.length-1,c=void 0;if(Array.isArray(t)){Y(o);let p=t.length-1;for(Y(null);a<=s&&a<=p;){let f=n.at(a),_=t[a],C=Sg(a,f,a,_,e);if(C!==0){C<0&&n.updateValue(a,_),a++;continue}let h=n.at(s),P=t[p],T=Sg(s,h,p,P,e);if(T!==0){T<0&&n.updateValue(s,P),s--,p--;continue}let A=e(a,f),N=e(s,h),E=e(a,_);if(Object.is(E,N)){let U=e(p,P);Object.is(U,A)?(n.swap(a,s),n.updateValue(s,P),p--,s--):n.move(s,a),n.updateValue(a,_),a++;continue}if(i??=new ul,r??=Nh(n,a,s,e),tp(n,i,a,E))n.updateValue(a,_),a++,s++;else if(r.has(E))i.set(A,n.detach(a)),s--;else{let U=n.create(a,t[a]);n.attach(a,U),a++,s++}}for(;a<=p;)Ah(n,i,e,a,t[a]),a++}else if(t!=null){Y(o);let p=t[Symbol.iterator]();Y(null);let f=p.next();for(;!f.done&&a<=s;){let _=n.at(a),C=f.value,h=Sg(a,_,a,C,e);if(h!==0)h<0&&n.updateValue(a,C),a++,f=p.next();else{i??=new ul,r??=Nh(n,a,s,e);let P=e(a,C);if(tp(n,i,a,P))n.updateValue(a,C),a++,s++,f=p.next();else if(!r.has(P))n.attach(a,n.create(a,C)),a++,s++,f=p.next();else{let T=e(a,_);i.set(T,n.detach(a)),s--}}}for(;!f.done;)Ah(n,i,e,n.length,f.value),f=p.next()}for(;a<=s;)n.destroy(n.detach(s--));i?.forEach(p=>{n.destroy(p)})}function tp(n,t,e,o){return t!==void 0&&t.has(o)?(n.attach(e,t.get(o)),t.delete(o),!0):!1}function Ah(n,t,e,o,i){if(tp(n,t,o,e(o,i)))n.updateValue(o,i);else{let r=n.create(o,i);n.attach(o,r)}}function Nh(n,t,e,o){let i=new Set;for(let r=t;r<=e;r++)i.add(o(r,n.at(r)));return i}var ul=class{kvMap=new Map;_vMap=void 0;has(t){return this.kvMap.has(t)}delete(t){if(!this.has(t))return!1;let e=this.kvMap.get(t);return this._vMap!==void 0&&this._vMap.has(e)?(this.kvMap.set(t,this._vMap.get(e)),this._vMap.delete(e)):this.kvMap.delete(t),!0}get(t){return this.kvMap.get(t)}set(t,e){if(this.kvMap.has(t)){let o=this.kvMap.get(t);this._vMap===void 0&&(this._vMap=new Map);let i=this._vMap;for(;i.has(o);)o=i.get(o);i.set(o,e)}else this.kvMap.set(t,e)}forEach(t){for(let[e,o]of this.kvMap)if(t(o,e),this._vMap!==void 0){let i=this._vMap;for(;i.has(o);)o=i.get(o),t(o,e)}}};function M(n,t,e,o,i,r,a,s){An("NgControlFlow");let c=Z(),p=Ne(),f=Sn(p.consts,r);return dl(c,p,n,t,e,o,i,f,256,a,s),Bp}function Bp(n,t,e,o,i,r,a,s){An("NgControlFlow");let c=Z(),p=Ne(),f=Sn(p.consts,r);return dl(c,p,n,t,e,o,i,f,512,a,s),Bp}function b(n,t){An("NgControlFlow");let e=Z(),o=Zn(),i=e[o]!==ot?e[o]:-1,r=i!==-1?gl(e,He+i):void 0,a=0;if(St(e,o,n)){let s=Y(null);try{if(r!==void 0&&nM(r,a),n!==-1){let c=He+n,p=gl(e,c),f=rp(e[K],c),_=iM(p,f,e),C=vl(e,f,t,{dehydratedView:_});yl(p,C,a,ka(f,_))}}finally{Y(s)}}else if(r!==void 0){let s=tM(r,a);s!==void 0&&(s[ze]=t)}}var np=class{lContainer;$implicit;$index;constructor(t,e,o){this.lContainer=t,this.$implicit=e,this.$index=o}get $count(){return this.lContainer.length-Ve}};function Do(n){return n}function Ie(n,t){return t}var op=class{hasEmptyBlock;trackByFn;liveCollection;constructor(t,e,o){this.hasEmptyBlock=t,this.trackByFn=e,this.liveCollection=o}};function G(n,t,e,o,i,r,a,s,c,p,f,_,C){An("NgControlFlow");let h=Z(),P=Ne(),T=c!==void 0,A=Z(),N=s?a.bind(A[xt][ze]):a,E=new op(T,N);A[He+n]=E,dl(h,P,n+1,t,e,o,i,Sn(P.consts,r),256),T&&dl(h,P,n+2,c,p,f,_,Sn(P.consts,C),512)}var ip=class extends ep{lContainer;hostLView;templateTNode;operationsCounter=void 0;needsIndexUpdate=!1;constructor(t,e,o){super(),this.lContainer=t,this.hostLView=e,this.templateTNode=o}get length(){return this.lContainer.length-Ve}at(t){return this.getLView(t)[ze].$implicit}attach(t,e){let o=e[Xi];this.needsIndexUpdate||=t!==this.length,yl(this.lContainer,e,t,ka(this.templateTNode,o)),mw(this.lContainer,t)}detach(t){return this.needsIndexUpdate||=t!==this.length-1,_w(this.lContainer,t),hw(this.lContainer,t)}create(t,e){let o=qg(this.lContainer,this.templateTNode.tView.ssrId);return vl(this.hostLView,this.templateTNode,new np(this.lContainer,e,t),{dehydratedView:o})}destroy(t){bl(t[K],t)}updateValue(t,e){this.getLView(t)[ze].$implicit=e}reset(){this.needsIndexUpdate=!1}updateIndexes(){if(this.needsIndexUpdate)for(let t=0;t0){let r=o[On];Zy(r,i),wo.delete(o[yn]),i.detachedLeaveAnimationFns=void 0}}function _w(n,t){if(n.length<=Ve)return;let e=Ve+t,o=n[e],i=o?o[xn]:void 0;i&&i.leave&&i.leave.size>0&&(i.detachedLeaveAnimationFns=[])}function hw(n,t){return Da(n,t)}function Cw(n,t){return tM(n,t)}function rp(n,t){return yc(n,t)}function R(n,t,e){let o=Z(),i=Zn();if(St(o,i,t)){let r=Ne(),a=gi();zC(a,o,n,t,o[we],e)}return R}function ap(n,t,e,o,i){Ep(t,n,e,i?"class":"style",o)}function l(n,t,e,o){let i=Z(),r=i[K],a=n+He,s=r.firstCreatePass?dM(a,i,2,t,Sx,F_(),e,o):r.data[a];if(Yn(s)){let c=i[cn].tracingService;if(c&&c.componentCreate){let p=r.data[s.directiveStart+s.componentOffset];return c.componentCreate(sM(p),()=>(Rh(n,t,i,s,o),l))}}return Rh(n,t,i,s,o),l}function Rh(n,t,e,o,i){if(HC(o,e,n,t,OM),Oc(o)){let r=e[K];$C(r,e,o),fC(r,o,e)}i!=null&&kp(e,o)}function d(){let n=Ne(),t=tt(),e=UC(t);return n.firstCreatePass&&uM(n,e),sg(e)&&cg(),ag(),e.classesWithoutHost!=null&&Vv(e)&&ap(n,e,Z(),e.classesWithoutHost,!0),e.stylesWithoutHost!=null&&jv(e)&&ap(n,e,Z(),e.stylesWithoutHost,!1),d}function $(n,t,e,o){return l(n,t,e,o),d(),$}function j(n,t,e,o){let i=Z(),r=i[K],a=n+He,s=r.firstCreatePass?y1(a,r,2,t,e,o):r.data[a];return HC(s,i,n,t,OM),o!=null&&kp(i,s),j}function H(){let n=tt(),t=UC(n);return sg(t)&&cg(),ag(),H}function yi(n,t,e,o){return j(n,t,e,o),H(),yi}var OM=(n,t,e,o,i)=>(Nc(!0),vC(t[we],o,_g()));function L(){return Z()}function je(n,t,e){let o=Z(),i=Zn();if(St(o,i,t)){let r=Ne(),a=gi();BC(a,o,n,t,o[we],e)}return je}var va=void 0;function Mw(n){let t=Math.floor(Math.abs(n)),e=n.toString().replace(/^[^.]*\.?/,"").length;return t===1&&e===0?1:5}var bw=["en",[["a","p"],["AM","PM"]],[["AM","PM"]],[["S","M","T","W","T","F","S"],["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],["Su","Mo","Tu","We","Th","Fr","Sa"]],va,[["J","F","M","A","M","J","J","A","S","O","N","D"],["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],["January","February","March","April","May","June","July","August","September","October","November","December"]],va,[["B","A"],["BC","AD"],["Before Christ","Anno Domini"]],0,[6,0],["M/d/yy","MMM d, y","MMMM d, y","EEEE, MMMM d, y"],["h:mm\u202Fa","h:mm:ss\u202Fa","h:mm:ss\u202Fa z","h:mm:ss\u202Fa zzzz"],["{1}, {0}",va,va,va],[".",",",";","%","+","-","E","\xD7","\u2030","\u221E","NaN",":"],["#,##0.###","#,##0%","\xA4#,##0.00","#E0"],"USD","$","US Dollar",{},"ltr",Mw],kg=Object.create(null);function Lt(n){let t=Pw(n),e=Fh(t);if(e)return e;let o=t.split("-")[0];if(e=Fh(o),e)return e;if(o==="en")return bw;throw new z(701,!1)}function Fh(n){if(!(n in kg)){let t=an.ng&&an.ng.common&&an.ng.common.locales&&an.ng.common.locales[n];return t!==void 0&&(kg[n]=t),t}return kg[n]}var Ze={LocaleId:0,DayPeriodsFormat:1,DayPeriodsStandalone:2,DaysFormat:3,DaysStandalone:4,MonthsFormat:5,MonthsStandalone:6,Eras:7,FirstDayOfWeek:8,WeekendRange:9,DateFormat:10,TimeFormat:11,DateTimeFormat:12,NumberSymbols:13,NumberFormats:14,CurrencyCode:15,CurrencySymbol:16,CurrencyName:17,Currencies:18,Directionality:19,PluralCase:20,ExtraData:21};function Pw(n){return n.toLowerCase().replace(/_/g,"-")}var ja="en-US";var Ow=ja;function vM(n){typeof n=="string"&&(Ow=n.toLowerCase().replace(/_/g,"-"))}function k(n,t,e){let o=Z(),i=Ne(),r=tt();return yM(i,o,o[we],r,n,t,e),k}function ue(n,t,e){let o=Z(),i=Ne(),r=tt();return(r.type&3||e)&&Rp(r,i,o,e,o[we],n,t,fi(r,o,t)),ue}function yM(n,t,e,o,i,r,a){let s=!0,c=null;if((o.type&3||a)&&(c??=fi(o,t,r),Rp(o,n,t,a,e,i,r,c)&&(s=!1)),s){let p=o.outputs?.[i],f=o.hostDirectiveOutputs?.[i];if(f&&f.length)for(let _=0;_>17&32767}function vw(n){return(n&2)==2}function yw(n,t){return n&131071|t<<17}function sp(n){return n|2}function gr(n){return(n&131068)>>2}function Eg(n,t){return n&-131069|t<<2}function xw(n){return(n&1)===1}function cp(n){return n|1}function ww(n,t,e,o,i,r){let a=r?t.classBindings:t.styleBindings,s=bi(a),c=gr(a);n[o]=e;let p=!1,f;if(Array.isArray(e)){let _=e;f=_[1],(f===null||Ji(_,f)>0)&&(p=!0)}else f=e;if(i)if(c!==0){let C=bi(n[s+1]);n[o+1]=Hc(C,s),C!==0&&(n[C+1]=Eg(n[C+1],o)),n[s+1]=yw(n[s+1],o)}else n[o+1]=Hc(s,0),s!==0&&(n[s+1]=Eg(n[s+1],o)),s=o;else n[o+1]=Hc(c,0),s===0?s=o:n[c+1]=Eg(n[c+1],o),c=o;p&&(n[o+1]=sp(n[o+1])),Lh(n,f,o,!0),Lh(n,f,o,!1),Sw(t,f,n,o,r),a=Hc(s,c),r?t.classBindings=a:t.styleBindings=a}function Sw(n,t,e,o,i){let r=i?n.residualClasses:n.residualStyles;r!=null&&typeof t=="string"&&Ji(r,t)>=0&&(e[o+1]=cp(e[o+1]))}function Lh(n,t,e,o){let i=n[e+1],r=t===null,a=o?bi(i):gr(i),s=!1;for(;a!==0&&(s===!1||r);){let c=n[a],p=n[a+1];kw(c,t)&&(s=!0,n[a+1]=o?cp(p):sp(p)),a=o?bi(p):gr(p)}s&&(n[e+1]=o?sp(i):cp(i))}function kw(n,t){return n===null||t==null||(Array.isArray(n)?n[1]:n)===t?!0:Array.isArray(n)&&typeof t=="string"?Ji(n,t)>=0:!1}var dn={textEnd:0,key:0,keyEnd:0,value:0,valueEnd:0};function Ew(n){return n.substring(dn.key,dn.keyEnd)}function Dw(n){return Iw(n),xM(n,wM(n,0,dn.textEnd))}function xM(n,t){let e=dn.textEnd;return e===t?-1:(t=dn.keyEnd=Tw(n,dn.key=t,e),wM(n,t,e))}function Iw(n){dn.key=0,dn.keyEnd=0,dn.value=0,dn.valueEnd=0,dn.textEnd=n.length}function wM(n,t,e){for(;t32;)t++;return t}function Se(n,t,e){return SM(n,t,e,!1),Se}function w(n,t){return SM(n,t,null,!0),w}function Dl(n){Nw($w,Aw,n,!0)}function Aw(n,t){for(let e=Dw(t);e>=0;e=xM(t,e))Mc(n,Ew(t),!0)}function SM(n,t,e,o){let i=Z(),r=Ne(),a=_a(2);if(r.firstUpdatePass&&EM(r,n,a,o),t!==ot&&St(i,a,t)){let s=r.data[qt()];DM(r,s,i,i[we],n,i[a+1]=Bw(t,e),o,a)}}function Nw(n,t,e,o){let i=Ne(),r=_a(2);i.firstUpdatePass&&EM(i,null,r,o);let a=Z();if(e!==ot&&St(a,r,e)){let s=i.data[qt()];if(IM(s,o)&&!kM(i,r)){let c=o?s.classesWithoutHost:s.stylesWithoutHost;c!==null&&(e=fc(c,e||"")),ap(i,s,a,e,o)}else zw(i,s,a,a[we],a[r+1],a[r+1]=jw(n,t,e),o,r)}}function kM(n,t){return t>=n.expandoStartIndex}function EM(n,t,e,o){let i=n.data;if(i[e+1]===null){let r=i[qt()],a=kM(n,e);IM(r,o)&&t===null&&!a&&(t=!1),t=Rw(i,r,t,o),ww(i,r,t,e,a,o)}}function Rw(n,t,e,o){let i=G_(n),r=o?t.residualClasses:t.residualStyles;if(i===null)(o?t.classBindings:t.styleBindings)===0&&(e=Dg(null,n,t,e,o),e=Aa(e,t.attrs,o),r=null);else{let a=t.directiveStylingLast;if(a===-1||n[a]!==i)if(e=Dg(i,n,t,e,o),r===null){let c=Fw(n,t,o);c!==void 0&&Array.isArray(c)&&(c=Dg(null,n,t,c[1],o),c=Aa(c,t.attrs,o),Lw(n,t,o,c))}else r=Vw(n,t,o)}return r!==void 0&&(o?t.residualClasses=r:t.residualStyles=r),e}function Fw(n,t,e){let o=e?t.classBindings:t.styleBindings;if(gr(o)!==0)return n[bi(o)]}function Lw(n,t,e,o){let i=e?t.classBindings:t.styleBindings;n[bi(i)]=o}function Vw(n,t,e){let o,i=t.directiveEnd;for(let r=1+t.directiveStylingLast;r0;){let c=n[i],p=Array.isArray(c),f=p?c[1]:c,_=f===null,C=e[i+1];C===ot&&(C=_?ht:void 0);let h=_?bc(C,o):f===o?C:void 0;if(p&&!pl(h)&&(h=bc(c,o)),pl(h)&&(s=h,a))return s;let P=n[i+1];i=a?bi(P):gr(P)}if(t!==null){let c=r?t.residualClasses:t.residualStyles;c!=null&&(s=bc(c,o))}return s}function pl(n){return n!==void 0}function Bw(n,t){return n==null||n===""||(typeof t=="string"?n=_i(n)+t:typeof n=="object"&&(n=aa(_i(n)))),n}function IM(n,t){return(n.flags&(t?8:16))!==0}function u(n,t=""){let e=Z(),o=Ne(),i=n+He,r=o.firstCreatePass?xl(o,i,1,t,null):o.data[i],a=Hw(o,e,r,t);e[i]=a,Ac()&&vp(o,e,a,r),or(r,!1)}var Hw=(n,t,e,o)=>(Nc(!0),xy(t[we],o));function Uw(n,t,e,o=""){return St(n,Zn(),e)?t+rn(e)+o:ot}function Gw(n,t,e,o,i,r=""){let a=gg(),s=Np(n,a,e,i);return _a(2),s?t+rn(e)+o+rn(i)+r:ot}function qw(n,t,e,o,i,r,a,s=""){let c=gg(),p=r1(n,c,e,i,a);return _a(3),p?t+rn(e)+o+rn(i)+r+rn(a)+s:ot}function O(n){return V("",n),O}function V(n,t,e){let o=Z(),i=Uw(o,n,t,e);return i!==ot&&Hp(o,qt(),i),V}function We(n,t,e,o,i){let r=Z(),a=Gw(r,n,t,e,o,i);return a!==ot&&Hp(r,qt(),a),We}function mr(n,t,e,o,i,r,a){let s=Z(),c=qw(s,n,t,e,o,i,r,a);return c!==ot&&Hp(s,qt(),c),mr}function Hp(n,t,e){let o=eg(t,n);wy(n[we],o,e)}function _r(n,t,e){jc(t)&&(t=t());let o=Z(),i=Zn();if(St(o,i,t)){let r=Ne(),a=gi();zC(a,o,n,t,o[we],e)}return _r}function $a(n,t){let e=jc(n);return e&&n.set(t),e}function hr(n,t){let e=Z(),o=Ne(),i=tt();return yM(o,e,e[we],i,n,t),hr}function xi(n){return St(Z(),Zn(),n)?rn(n):ot}function jh(n,t,e){let o=Ne();o.firstCreatePass&&TM(t,o.data,o.blueprint,wn(n),e)}function TM(n,t,e,o,i){if(n=lt(n),Array.isArray(n))for(let r=0;r>20;if(ti(n)||!n.multi){let h=new mi(p,i,le,null),P=Tg(c,t,i?f:f+C,_);P===-1?(Rg(Jc(s,a),r,c),Ig(r,n,t.length),t.push(c),s.directiveStart++,s.directiveEnd++,i&&(s.providerIndexes+=1048576),e.push(h),a.push(h)):(e[P]=h,a[P]=h)}else{let h=Tg(c,t,f+C,_),P=Tg(c,t,f,f+C),T=h>=0&&e[h],A=P>=0&&e[P];if(i&&!A||!i&&!T){Rg(Jc(s,a),r,c);let N=Yw(i?Kw:Ww,e.length,i,o,p,n);!i&&A&&(e[P].providerFactory=N),Ig(r,n,t.length,0),t.push(c),s.directiveStart++,s.directiveEnd++,i&&(s.providerIndexes+=1048576),e.push(N),a.push(N)}else{let N=AM(e[i?P:h],p,!i&&o);Ig(r,n,h>-1?h:P,N)}!i&&o&&A&&e[P].componentProviders++}}}function Ig(n,t,e,o){let i=ti(t),r=y_(t);if(i||r){let c=(r?lt(t.useClass):t).prototype.ngOnDestroy;if(c){let p=n.destroyHooks||(n.destroyHooks=[]);if(!i&&t.multi){let f=p.indexOf(e);f===-1?p.push(e,[o,c]):p[f+1].push(o,c)}else p.push(e,c)}}}function AM(n,t,e){return e&&n.componentProviders++,n.multi.push(t)-1}function Tg(n,t,e,o){for(let i=e;i{e.providersResolver=(o,i)=>jh(o,i?i(n):n,!1),t&&(e.viewProvidersResolver=(o,i)=>jh(o,i?i(t):t,!0))}}function za(n,t){let e=ir()+n,o=Z();return o[e]===ot?Ap(o,e,t()):i1(o,e)}function Up(n,t,e){return RM(Z(),ir(),n,t,e)}function Gp(n,t,e,o){return FM(Z(),ir(),n,t,e,o)}function NM(n,t){let e=n[t];return e===ot?void 0:e}function RM(n,t,e,o,i,r){let a=t+e;return St(n,a,i)?Ap(n,a+1,r?o.call(r,i):o(i)):NM(n,a+1)}function FM(n,t,e,o,i,r,a){let s=t+e;return Np(n,s,i,r)?Ap(n,s+2,a?o.call(a,i,r):o(i,r)):NM(n,s+2)}function it(n,t){let e=Ne(),o,i=n+He;e.firstCreatePass?(o=Zw(t,e.pipeRegistry),e.data[i]=o,o.onDestroy&&(e.destroyHooks??=[]).push(i,o.onDestroy)):o=e.data[i];let r=o.factory||(o.factory=Co(o.type,!0)),a,s=_t(le);try{let c=Qc(!1),p=r();return Qc(c),tg(e,Z(),i,p),p}finally{_t(s)}}function Zw(n,t){if(t)for(let e=t.length-1;e>=0;e--){let o=t[e];if(n===o.name)return o}}function wi(n,t,e){let o=n+He,i=Z(),r=xc(i,o);return LM(i,o)?RM(i,ir(),t,r.transform,e,r):r.transform(e)}function Et(n,t,e,o){let i=n+He,r=Z(),a=xc(r,i);return LM(r,i)?FM(r,ir(),t,a.transform,e,o,a):a.transform(e,o)}function LM(n,t){return n[K].data[t].pure}var VM=(()=>{class n{applicationErrorHandler=D(Nt);appRef=D(Xn);taskService=D(kn);ngZone=D(ge);zonelessEnabled=D(ar);tracing=D(Tn,{optional:!0});zoneIsDefined=typeof Zone<"u"&&!!Zone.root.run;schedulerTickApplyArgs=[{data:{__scheduler_tick__:!0}}];subscriptions=new Be;angularZoneId=this.zoneIsDefined?this.ngZone._inner?.get(ia):null;scheduleInRootZone=!this.zonelessEnabled&&this.zoneIsDefined&&(D(Lc,{optional:!0})??!1);cancelScheduledCallback=null;useMicrotaskScheduler=!1;runningTick=!1;pendingRenderTaskId=null;constructor(){this.subscriptions.add(this.appRef.afterTick.subscribe(()=>{let e=this.taskService.add();if(!this.runningTick&&(this.cleanup(),!this.zonelessEnabled||this.appRef.includeAllTestViews)){this.taskService.remove(e);return}this.switchToMicrotaskScheduler(),this.taskService.remove(e)})),this.subscriptions.add(this.ngZone.onUnstable.subscribe(()=>{this.runningTick||this.cleanup()}))}switchToMicrotaskScheduler(){this.ngZone.runOutsideAngular(()=>{let e=this.taskService.add();this.useMicrotaskScheduler=!0,queueMicrotask(()=>{this.useMicrotaskScheduler=!1,this.taskService.remove(e)})})}notify(e){if(!this.zonelessEnabled&&e===5)return;switch(e){case 0:case 2:{this.appRef.dirtyFlags|=2;break}case 3:case 4:case 5:case 1:{this.appRef.dirtyFlags|=4;break}case 6:{this.appRef.dirtyFlags|=2;break}case 12:{this.appRef.dirtyFlags|=16;break}case 13:{this.appRef.dirtyFlags|=2;break}case 11:break;default:this.appRef.dirtyFlags|=8}if(this.appRef.tracingSnapshot=this.tracing?.snapshot(this.appRef.tracingSnapshot)??null,!this.shouldScheduleTick())return;let o=this.useMicrotaskScheduler?J_:Mg;this.pendingRenderTaskId=this.taskService.add(),this.scheduleInRootZone?this.cancelScheduledCallback=Zone.root.run(()=>o(()=>this.tick())):this.cancelScheduledCallback=this.ngZone.runOutsideAngular(()=>o(()=>this.tick()))}shouldScheduleTick(){return!(this.appRef.destroyed||this.pendingRenderTaskId!==null||this.runningTick||this.appRef._runningTick||!this.zonelessEnabled&&this.zoneIsDefined&&Zone.current.get(ia+this.angularZoneId))}tick(){if(this.runningTick||this.appRef.destroyed)return;if(this.appRef.dirtyFlags===0){this.cleanup();return}!this.zonelessEnabled&&this.appRef.dirtyFlags&7&&(this.appRef.dirtyFlags|=1);let e=this.taskService.add();try{this.ngZone.run(()=>{this.runningTick=!0,this.appRef._tick()},void 0,this.schedulerTickApplyArgs)}catch(o){this.applicationErrorHandler(o)}finally{this.taskService.remove(e),this.cleanup()}}ngOnDestroy(){this.subscriptions.unsubscribe(),this.cleanup()}cleanup(){if(this.runningTick=!1,this.cancelScheduledCallback?.(),this.cancelScheduledCallback=null,this.pendingRenderTaskId!==null){let e=this.pendingRenderTaskId;this.pendingRenderTaskId=null,this.taskService.remove(e)}}static \u0275fac=function(o){return new(o||n)};static \u0275prov=Oe({token:n,factory:n.\u0275fac})}return n})();function jM(){return[{provide:bn,useExisting:VM},{provide:ge,useClass:ra},{provide:ar,useValue:!0}]}var qp=(()=>{class n{compileModuleSync(e){return new ll(e)}compileModuleAsync(e){return Promise.resolve(this.compileModuleSync(e))}clearCache(){}clearCacheFor(e){}getModuleId(e){}static \u0275fac=function(o){return new(o||n)};static \u0275prov=Oe({token:n,factory:n.\u0275fac})}return n})();function Qw(){return typeof $localize<"u"&&$localize.locale||ja}var Ba=new B("",{factory:()=>D(Ba,{optional:!0,skipSelf:!0})||Qw()});function I(n,t){return Ts(n,t?.equal)}function de(n){return Nm(n)}var $M=class n extends Error{_brand;constructor(t){super(t)}static IDLE=new n("IDLE");static LOADING=new n("LOADING")},Jw=n=>n;function Wp(n,t){if(typeof n=="function"){let e=cu(n,Jw,t?.equal);return zM(e,t?.debugName,t?.set)}else{let e=cu(n.source,n.computation,n.equal);return zM(e,n.debugName,n.set)}}function zM(n,t,e){let o=n[nt],i=n;if(e!==void 0){let r=a=>lu(o,a);i.set=a=>e(a,r),i.update=a=>e(a(de(n)),r)}else i.set=r=>lu(o,r),i.update=r=>Am(o,r);return i.asReadonly=Rc.bind(n),i}var UM=Symbol("InputSignalNode#UNSET"),fS=F(S({},Ns),{transformFn:void 0,applyValueToInputSignal(n,t){Wo(n,t)}});function GM(n,t){let e=Object.create(fS);e.value=n,e.transformFn=t?.transform;function o(){if(Ho(e),e.value===UM){let i=null;throw new z(-950,i)}return e.value}return o[nt]=e,o}var Tl=class{attributeName;constructor(t){this.attributeName=t}__NG_ELEMENT_ID__=()=>Ra(this.attributeName);toString(){return`HostAttributeToken ${this.attributeName}`}};function Yp(n){return mS(n)?n.default:n}function mS(n){return n&&typeof n=="object"&&"default"in n}function BM(n,t){return GM(n,t)}function _S(n){return GM(UM,n)}var Al=(BM.required=_S,BM);var hS=1e4;var EU=hS-1e3;var oo=(()=>{class n{static __NG_ELEMENT_ID__=CS}return n})();function CS(n){return MS(tt(),Z(),(n&16)===16)}function MS(n,t,e){if(Yn(n)&&!e){let o=Gt(n.index,t);return new So(o,o)}else if(n.type&175){let o=t[xt];return new So(o,t)}return null}var bS=(()=>{class n{zone=D(ge);changeDetectionScheduler=D(bn);applicationRef=D(Xn);applicationErrorHandler=D(Nt);_onMicrotaskEmptySubscription;initialize(){this._onMicrotaskEmptySubscription||(this._onMicrotaskEmptySubscription=this.zone.onMicrotaskEmpty.subscribe({next:()=>{this.changeDetectionScheduler.runningTick||this.zone.run(()=>{try{this.applicationRef.dirtyFlags|=1,this.applicationRef._tick()}catch(e){this.applicationErrorHandler(e)}})}}))}ngOnDestroy(){this._onMicrotaskEmptySubscription?.unsubscribe()}static \u0275fac=function(o){return new(o||n)};static \u0275prov=Oe({token:n,factory:n.\u0275fac})}return n})(),PS=new B("",{factory:()=>!1});function OS({ngZoneFactory:n,scheduleInRootZone:t}){return n??=()=>new ge(F(S({},WM()),{scheduleInRootZone:t})),[{provide:ar,useValue:!1},{provide:ge,useFactory:n},{provide:ri,multi:!0,useFactory:()=>{let e=D(bS,{optional:!0});return()=>e.initialize()}},{provide:ri,multi:!0,useFactory:()=>{let e=D(vS);return()=>{e.initialize()}}},{provide:Lc,useValue:t??Cg}]}function qM(n){let t=n?.scheduleInRootZone,e=OS({ngZoneFactory:()=>{let o=WM(n);return o.scheduleInRootZone=t,o.shouldCoalesceEventChangeDetection&&An("NgZone_CoalesceEvent"),new ge(o)},scheduleInRootZone:t});return bo([{provide:PS,useValue:!0},e])}function WM(n){return{enableLongStackTrace:!1,shouldCoalesceEventChangeDetection:n?.eventCoalescing??!1,shouldCoalesceRunChangeDetection:n?.runCoalescing??!1}}var vS=(()=>{class n{subscription=new Be;initialized=!1;zone=D(ge);pendingTasks=D(kn);initialize(){if(this.initialized)return;this.initialized=!0;let e=null;!this.zone.isStable&&!this.zone.hasPendingMacrotasks&&!this.zone.hasPendingMicrotasks&&(e=this.pendingTasks.add()),this.zone.runOutsideAngular(()=>{this.subscription.add(this.zone.onStable.subscribe(()=>{ge.assertNotInAngularZone(),queueMicrotask(()=>{e!==null&&!this.zone.hasPendingMacrotasks&&!this.zone.hasPendingMicrotasks&&(this.pendingTasks.remove(e),e=null)})}))}),this.subscription.add(this.zone.onUnstable.subscribe(()=>{ge.assertInAngularZone(),e??=this.pendingTasks.add()}))}ngOnDestroy(){this.subscription.unsubscribe()}static \u0275fac=function(o){return new(o||n)};static \u0275prov=Oe({token:n,factory:n.\u0275fac})}return n})();var Kp=new B(""),yS=new B("");function Ha(n){return!n.moduleRef}function xS(n){let t=Ha(n)?n.r3Injector:n.moduleRef.injector,e=t.get(ge);return e.run(()=>{Ha(n)?n.r3Injector.resolveInjectorInitializers():n.moduleRef.resolveInjectorInitializers();let o=t.get(Nt),i;if(e.runOutsideAngular(()=>{i=e.onError.subscribe({next:o})}),Ha(n)){let r=()=>t.destroy(),a=n.platformInjector.get(Kp);a.add(r),t.onDestroy(()=>{i.unsubscribe(),a.delete(r)})}else{let r=()=>n.moduleRef.destroy(),a=n.platformInjector.get(Kp);a.add(r),n.moduleRef.onDestroy(()=>{wa(n.allPlatformModules,n.moduleRef),i.unsubscribe(),a.delete(r)})}return SS(o,e,()=>{let r=t.get(kn),a=r.add(),s=t.get(Vp);return s.runInitializers(),s.donePromise.then(()=>{let c=t.get(Ba,ja);if(vM(c||ja),!t.get(yS,!0))return Ha(n)?t.get(Xn):(n.allPlatformModules.push(n.moduleRef),n.moduleRef);if(Ha(n)){let f=t.get(Xn);return n.rootComponent!==void 0&&f.bootstrap(n.rootComponent),f}else return wS?.(n.moduleRef,n.allPlatformModules),n.moduleRef}).finally(()=>{r.remove(a)})})})}var wS;function SS(n,t,e){try{let o=e();return Eo(o)?o.catch(i=>{throw t.runOutsideAngular(()=>n(i)),i}):o}catch(o){throw t.runOutsideAngular(()=>n(o)),o}}var Il=null;function kS(n=[],t){return Ct.create({name:t,providers:[{provide:ga,useValue:"platform"},{provide:Kp,useValue:new Set([()=>Il=null])},...n]})}function ES(n=[]){if(Il)return Il;let t=kS(n);return Il=t,PM(),DS(t),t}function DS(n){let t=n.get(Fc,null);et(n,()=>{t?.forEach(e=>e())})}function KM(n){let{rootComponent:t,appProviders:e,platformProviders:o,platformRef:i}=n;Ce(pe.BootstrapApplicationStart);try{let r=i?.injector??ES(o),a=[jM(),eh,...e||[]],s=new Ta({providers:a,parent:r,debugName:"",runEnvironmentInitializers:!1});return xS({r3Injector:s.injector,platformInjector:r,rootComponent:t})}catch(r){return Promise.reject(r)}finally{Ce(pe.BootstrapApplicationEnd)}}function Si(n){return typeof n=="boolean"?n:n!=null&&n!=="false"}var YM=null;function Kt(){return YM}function Zp(n){YM??=n}var Ua=class{},Cr=(()=>{class n{historyGo(e){throw new Error("")}static \u0275fac=function(o){return new(o||n)};static \u0275prov=te({token:n,factory:()=>D(ZM),providedIn:"platform"})}return n})();var ZM=(()=>{class n extends Cr{_location;_history;_doc=D(Ue);constructor(){super(),this._location=window.location,this._history=window.history}getBaseHrefFromDOM(){return Kt().getBaseHref(this._doc)}onPopState(e){let o=Kt().getGlobalEventTarget(this._doc,"window");return o.addEventListener("popstate",e,!1),()=>o.removeEventListener("popstate",e)}onHashChange(e){let o=Kt().getGlobalEventTarget(this._doc,"window");return o.addEventListener("hashchange",e,!1),()=>o.removeEventListener("hashchange",e)}get href(){return this._location.href}get protocol(){return this._location.protocol}get hostname(){return this._location.hostname}get port(){return this._location.port}get pathname(){return this._location.pathname}get search(){return this._location.search}get hash(){return this._location.hash}set pathname(e){this._location.pathname=e}pushState(e,o,i){this._history.pushState(e,o,i)}replaceState(e,o,i){this._history.replaceState(e,o,i)}forward(){this._history.forward()}back(){this._history.back()}historyGo(e=0){this._history.go(e)}getState(){return this._history.state}static \u0275fac=function(o){return new(o||n)};static \u0275prov=te({token:n,factory:()=>new n,providedIn:"platform"})}return n})();function XM(n,t){return n?t?n.endsWith("/")?t.startsWith("/")?n+t.slice(1):n+t:t.startsWith("/")?n+t:`${n}/${t}`:n:t}function QM(n){let t=n.search(/#|\?|$/);return n[t-1]==="/"?n.slice(0,t-1)+n.slice(t):n}function Ao(n){return n&&n[0]!=="?"?`?${n}`:n}var Mr=(()=>{class n{historyGo(e){throw new Error("")}static \u0275fac=function(o){return new(o||n)};static \u0275prov=te({token:n,factory:()=>D(TS),providedIn:"root"})}return n})(),IS=new B(""),TS=(()=>{class n extends Mr{_platformLocation;_baseHref;_removeListenerFns=[];constructor(e,o){super(),this._platformLocation=e,this._baseHref=o??this._platformLocation.getBaseHrefFromDOM()??D(Ue).location?.origin??""}ngOnDestroy(){for(;this._removeListenerFns.length;)this._removeListenerFns.pop()()}onPopState(e){this._removeListenerFns.push(this._platformLocation.onPopState(e),this._platformLocation.onHashChange(e))}getBaseHref(){return this._baseHref}prepareExternalUrl(e){return XM(this._baseHref,e)}path(e=!1){let o=this._platformLocation.pathname+Ao(this._platformLocation.search),i=this._platformLocation.hash;return i&&e?`${o}${i}`:o}pushState(e,o,i,r){let a=this.prepareExternalUrl(i+Ao(r));this._platformLocation.pushState(e,o,a)}replaceState(e,o,i,r){let a=this.prepareExternalUrl(i+Ao(r));this._platformLocation.replaceState(e,o,a)}forward(){this._platformLocation.forward()}back(){this._platformLocation.back()}getState(){return this._platformLocation.getState()}historyGo(e=0){this._platformLocation.historyGo?.(e)}static \u0275fac=function(o){return new(o||n)(Q(Cr),Q(IS,8))};static \u0275prov=te({token:n,factory:n.\u0275fac,providedIn:"root"})}return n})();var br=(()=>{class n{_subject=new ye;_basePath;_locationStrategy;_urlChangeListeners=[];_urlChangeSubscription=null;constructor(e){this._locationStrategy=e;let o=this._locationStrategy.getBaseHref();this._basePath=RS(QM(JM(o))),this._locationStrategy.onPopState(i=>{this._subject.next({url:this.path(!0),pop:!0,state:i.state,type:i.type})})}ngOnDestroy(){this._urlChangeSubscription?.unsubscribe(),this._urlChangeListeners=[]}path(e=!1){return this.normalize(this._locationStrategy.path(e))}getState(){return this._locationStrategy.getState()}isCurrentPathEqualTo(e,o=""){return this.path()==this.normalize(e+Ao(o))}normalize(e){return n.stripTrailingSlash(NS(this._basePath,JM(e)))}prepareExternalUrl(e){return e&&e[0]!=="/"&&(e="/"+e),this._locationStrategy.prepareExternalUrl(e)}go(e,o="",i=null){this._locationStrategy.pushState(i,"",e,o),this._notifyUrlChangeListeners(this.prepareExternalUrl(e+Ao(o)),i)}replaceState(e,o="",i=null){this._locationStrategy.replaceState(i,"",e,o),this._notifyUrlChangeListeners(this.prepareExternalUrl(e+Ao(o)),i)}forward(){this._locationStrategy.forward()}back(){this._locationStrategy.back()}historyGo(e=0){this._locationStrategy.historyGo?.(e)}onUrlChange(e){return this._urlChangeListeners.push(e),this._urlChangeSubscription??=this.subscribe(o=>{this._notifyUrlChangeListeners(o.url,o.state)}),()=>{let o=this._urlChangeListeners.indexOf(e);this._urlChangeListeners.splice(o,1),this._urlChangeListeners.length===0&&(this._urlChangeSubscription?.unsubscribe(),this._urlChangeSubscription=null)}}_notifyUrlChangeListeners(e="",o){this._urlChangeListeners.forEach(i=>i(e,o))}subscribe(e,o,i){return this._subject.subscribe({next:e,error:o??void 0,complete:i??void 0})}static normalizeQueryParams=Ao;static joinWithSlash=XM;static stripTrailingSlash=QM;static \u0275fac=function(o){return new(o||n)(Q(Mr))};static \u0275prov=te({token:n,factory:()=>AS(),providedIn:"root"})}return n})();function AS(){return new br(Q(Mr))}function NS(n,t){if(!n||!t.startsWith(n))return t;let e=t.substring(n.length);return e===""||["/",";","?","#"].includes(e[0])?e:t}function JM(n){return n.replace(/\/index\.html$/,"")}function RS(n){if(new RegExp("^(https?:)?//").test(n)){let[,e]=n.split(/\/\/[^\/]+/);return e}return n}var gt=(function(n){return n[n.Format=0]="Format",n[n.Standalone=1]="Standalone",n})(gt||{}),ve=(function(n){return n[n.Narrow=0]="Narrow",n[n.Abbreviated=1]="Abbreviated",n[n.Wide=2]="Wide",n[n.Short=3]="Short",n})(ve||{}),Dt=(function(n){return n[n.Short=0]="Short",n[n.Medium=1]="Medium",n[n.Long=2]="Long",n[n.Full=3]="Full",n})(Dt||{}),ro={Decimal:0,Group:1,List:2,PercentSign:3,PlusSign:4,MinusSign:5,Exponential:6,SuperscriptingExponent:7,PerMille:8,Infinity:9,NaN:10,TimeSeparator:11,CurrencyDecimal:12,CurrencyGroup:13};function n0(n){return Lt(n)[Ze.LocaleId]}function o0(n,t,e){let o=Lt(n),i=[o[Ze.DayPeriodsFormat],o[Ze.DayPeriodsStandalone]],r=Yt(i,t);return Yt(r,e)}function i0(n,t,e){let o=Lt(n),i=[o[Ze.DaysFormat],o[Ze.DaysStandalone]],r=Yt(i,t);return Yt(r,e)}function r0(n,t,e){let o=Lt(n),i=[o[Ze.MonthsFormat],o[Ze.MonthsStandalone]],r=Yt(i,t);return Yt(r,e)}function a0(n,t){let o=Lt(n)[Ze.Eras];return Yt(o,t)}function Ga(n,t){let e=Lt(n);return Yt(e[Ze.DateFormat],t)}function qa(n,t){let e=Lt(n);return Yt(e[Ze.TimeFormat],t)}function Wa(n,t){let o=Lt(n)[Ze.DateTimeFormat];return Yt(o,t)}function Ka(n,t){let e=Lt(n),o=e[Ze.NumberSymbols][t];if(typeof o>"u"){if(t===ro.CurrencyDecimal)return e[Ze.NumberSymbols][ro.Decimal];if(t===ro.CurrencyGroup)return e[Ze.NumberSymbols][ro.Group]}return o}function s0(n){if(!n[Ze.ExtraData])throw new z(2303,!1)}function c0(n){let t=Lt(n);return s0(t),(t[Ze.ExtraData][2]||[]).map(o=>typeof o=="string"?Qp(o):[Qp(o[0]),Qp(o[1])])}function l0(n,t,e){let o=Lt(n);s0(o);let i=[o[Ze.ExtraData][0],o[Ze.ExtraData][1]],r=Yt(i,t)||[];return Yt(r,e)||[]}function Yt(n,t){for(let e=t;e>-1;e--)if(typeof n[e]<"u")return n[e];throw new z(2304,!1)}function Qp(n){let[t,e]=n.split(":");return{hours:+t,minutes:+e}}var FS=/^(\d{4,})-?(\d\d)-?(\d\d)(?:T(\d\d)(?::?(\d\d)(?::?(\d\d)(?:\.(\d+))?)?)?(Z|([+-])(\d\d):?(\d\d))?)?$/,Nl=Object.create(null),LS=/((?:[^BEGHLMOSWYZabcdhmswyz']+)|(?:'(?:[^']|'')*')|(?:G{1,5}|y{1,4}|Y{1,4}|M{1,5}|L{1,5}|w{1,2}|W{1}|d{1,2}|E{1,6}|c{1,6}|a{1,5}|b{1,5}|B{1,5}|h{1,2}|H{1,2}|m{1,2}|s{1,2}|S{1,3}|z{1,4}|Z{1,5}|O{1,4}))([\s\S]*)/,VS=256;function d0(n,t,e,o){let i=KS(n);jS(t),t=io(e,t)||t;let a=[],s;for(;t;)if(s=LS.exec(t),s){a=a.concat(s.slice(1));let f=a.pop();if(!f)break;t=f}else{a.push(t);break}let c=i.getTimezoneOffset();o&&(c=g0(o,c),i=WS(i,o));let p="";return a.forEach(f=>{let _=GS(f);p+=_?_(i,e,c):f==="''"?"'":f.replace(/(^'|'$)/g,"").replace(/''/g,"'")}),p}function jS(n){if(n.length>VS)throw new z(2300,!1)}function jl(n,t,e){let o=new Date(0);return o.setFullYear(n,t,e),o.setHours(0,0,0),o}function io(n,t){let e=n0(n);if(Nl[e]??=Object.create(null),Nl[e][t])return Nl[e][t];let o="";switch(t){case"shortDate":o=Ga(n,Dt.Short);break;case"mediumDate":o=Ga(n,Dt.Medium);break;case"longDate":o=Ga(n,Dt.Long);break;case"fullDate":o=Ga(n,Dt.Full);break;case"shortTime":o=qa(n,Dt.Short);break;case"mediumTime":o=qa(n,Dt.Medium);break;case"longTime":o=qa(n,Dt.Long);break;case"fullTime":o=qa(n,Dt.Full);break;case"short":let i=io(n,"shortTime"),r=io(n,"shortDate");o=Rl(Wa(n,Dt.Short),[i,r]);break;case"medium":let a=io(n,"mediumTime"),s=io(n,"mediumDate");o=Rl(Wa(n,Dt.Medium),[a,s]);break;case"long":let c=io(n,"longTime"),p=io(n,"longDate");o=Rl(Wa(n,Dt.Long),[c,p]);break;case"full":let f=io(n,"fullTime"),_=io(n,"fullDate");o=Rl(Wa(n,Dt.Full),[f,_]);break}return o&&(Nl[e][t]=o),o}function Rl(n,t){return t&&(n=n.replace(/\{([^}]+)}/g,function(e,o){return Object.hasOwn(t,o)?t[o]:e})),n}function pn(n,t,e="-",o,i){let r="";(n<0||i&&n<=0)&&(i?n=-n+1:(n=-n,r=e));let a=String(n);for(;a.length0||s>-e)&&(s+=e),n===3)s===0&&e===-12&&(s=12);else if(n===6)return $S(s,t);let c=Ka(a,ro.MinusSign);return pn(s,t,c,o,i)}}function zS(n,t){switch(n){case 0:return t.getFullYear();case 1:return t.getMonth();case 2:return t.getDate();case 3:return t.getHours();case 4:return t.getMinutes();case 5:return t.getSeconds();case 6:return t.getMilliseconds();case 7:return t.getDay();default:throw new z(2301,!1)}}function Te(n,t,e=gt.Format,o=!1){return function(i,r){return BS(i,r,n,t,e,o)}}function BS(n,t,e,o,i,r){switch(e){case 2:return r0(t,i,o)[n.getMonth()];case 1:return i0(t,i,o)[n.getDay()];case 0:let a=n.getHours(),s=n.getMinutes();if(r){let p=c0(t),f=l0(t,i,o),_=p.findIndex(C=>{if(Array.isArray(C)){let[h,P]=C,T=a>=h.hours&&s>=h.minutes,A=a0?Math.floor(i/60):Math.ceil(i/60);switch(n){case 0:return(i>=0?"+":"")+pn(a,2,r)+pn(Math.abs(i%60),2,r);case 1:return"GMT"+(i>=0?"+":"")+pn(a,1,r);case 2:return"GMT"+(i>=0?"+":"")+pn(a,2,r)+":"+pn(Math.abs(i%60),2,r);case 3:return o===0?"Z":(i>=0?"+":"")+pn(a,2,r)+":"+pn(Math.abs(i%60),2,r);default:throw new z(2310,!1)}}}var HS=0,Vl=4;function US(n){let t=jl(n,HS,1).getDay();return jl(n,0,1+(t<=Vl?Vl:Vl+7)-t)}function u0(n){let t=n.getDay(),e=t===0?-3:Vl-t;return jl(n.getFullYear(),n.getMonth(),n.getDate()+e)}function Jp(n,t=!1){return function(e,o){let i;if(t){let r=new Date(e.getFullYear(),e.getMonth(),1).getDay()-1,a=e.getDate();i=1+Math.floor((a+r)/7)}else{let r=u0(e),a=US(r.getFullYear()),s=r.getTime()-a.getTime();i=1+Math.round(s/6048e5)}return pn(i,n,Ka(o,ro.MinusSign))}}function Ll(n,t=!1){return function(e,o){let r=u0(e).getFullYear();return pn(r,n,Ka(o,ro.MinusSign),t)}}var Xp=Object.create(null);function GS(n){if(Xp[n])return Xp[n];let t;switch(n){case"G":case"GG":case"GGG":t=Te(3,ve.Abbreviated);break;case"GGGG":t=Te(3,ve.Wide);break;case"GGGGG":t=Te(3,ve.Narrow);break;case"y":t=Qe(0,1,0,!1,!0);break;case"yy":t=Qe(0,2,0,!0,!0);break;case"yyy":t=Qe(0,3,0,!1,!0);break;case"yyyy":t=Qe(0,4,0,!1,!0);break;case"Y":t=Ll(1);break;case"YY":t=Ll(2,!0);break;case"YYY":t=Ll(3);break;case"YYYY":t=Ll(4);break;case"M":case"L":t=Qe(1,1,1);break;case"MM":case"LL":t=Qe(1,2,1);break;case"MMM":t=Te(2,ve.Abbreviated);break;case"MMMM":t=Te(2,ve.Wide);break;case"MMMMM":t=Te(2,ve.Narrow);break;case"LLL":t=Te(2,ve.Abbreviated,gt.Standalone);break;case"LLLL":t=Te(2,ve.Wide,gt.Standalone);break;case"LLLLL":t=Te(2,ve.Narrow,gt.Standalone);break;case"w":t=Jp(1);break;case"ww":t=Jp(2);break;case"W":t=Jp(1,!0);break;case"d":t=Qe(2,1);break;case"dd":t=Qe(2,2);break;case"c":case"cc":t=Qe(7,1);break;case"ccc":t=Te(1,ve.Abbreviated,gt.Standalone);break;case"cccc":t=Te(1,ve.Wide,gt.Standalone);break;case"ccccc":t=Te(1,ve.Narrow,gt.Standalone);break;case"cccccc":t=Te(1,ve.Short,gt.Standalone);break;case"E":case"EE":case"EEE":t=Te(1,ve.Abbreviated);break;case"EEEE":t=Te(1,ve.Wide);break;case"EEEEE":t=Te(1,ve.Narrow);break;case"EEEEEE":t=Te(1,ve.Short);break;case"a":case"aa":case"aaa":t=Te(0,ve.Abbreviated);break;case"aaaa":t=Te(0,ve.Wide);break;case"aaaaa":t=Te(0,ve.Narrow);break;case"b":case"bb":case"bbb":t=Te(0,ve.Abbreviated,gt.Standalone,!0);break;case"bbbb":t=Te(0,ve.Wide,gt.Standalone,!0);break;case"bbbbb":t=Te(0,ve.Narrow,gt.Standalone,!0);break;case"B":case"BB":case"BBB":t=Te(0,ve.Abbreviated,gt.Format,!0);break;case"BBBB":t=Te(0,ve.Wide,gt.Format,!0);break;case"BBBBB":t=Te(0,ve.Narrow,gt.Format,!0);break;case"h":t=Qe(3,1,-12);break;case"hh":t=Qe(3,2,-12);break;case"H":t=Qe(3,1);break;case"HH":t=Qe(3,2);break;case"m":t=Qe(4,1);break;case"mm":t=Qe(4,2);break;case"s":t=Qe(5,1);break;case"ss":t=Qe(5,2);break;case"S":t=Qe(6,1);break;case"SS":t=Qe(6,2);break;case"SSS":t=Qe(6,3);break;case"Z":case"ZZ":case"ZZZ":t=Fl(0);break;case"ZZZZZ":t=Fl(3);break;case"O":case"OO":case"OOO":case"z":case"zz":case"zzz":t=Fl(1);break;case"OOOO":case"ZZZZ":case"zzzz":t=Fl(2);break;default:return null}return Xp[n]=t,t}function g0(n,t){n=n.replace(/:/g,"");let e=Date.parse("Jan 01, 1970 00:00:00 "+n)/6e4;return isNaN(e)?t:e}function qS(n,t){return n=new Date(n.getTime()),n.setMinutes(n.getMinutes()+t),n}function WS(n,t,e){let i=n.getTimezoneOffset(),r=g0(t,i);return qS(n,-1*(r-i))}function KS(n){if(e0(n))return n;if(typeof n=="number"&&!isNaN(n))return new Date(n);if(typeof n=="string"){if(n=n.trim(),/^(\d{4}(-\d{1,2}(-\d{1,2})?)?)$/.test(n)){let[i,r=1,a=1]=n.split("-").map(s=>+s);return jl(i,r-1,a)}let e=parseFloat(n);if(!isNaN(n-e))return new Date(e);let o;if(o=n.match(FS))return YS(o)}let t=new Date(n);if(!e0(t))throw new z(2311,!1);return t}function YS(n){let t=new Date(0),e=0,o=0,i=n[8]?t.setUTCFullYear:t.setFullYear,r=n[8]?t.setUTCHours:t.setHours;n[9]&&(e=Number(n[9]+n[10]),o=Number(n[9]+n[11])),i.call(t,Number(n[1]),Number(n[2])-1,Number(n[3]));let a=Number(n[4]||0)-e,s=Number(n[5]||0)-o,c=Number(n[6]||0),p=Math.floor(parseFloat("0."+(n[7]||0))*1e3);return r.call(t,a,s,c,p),t}function e0(n){return n instanceof Date&&!isNaN(n.valueOf())}var ef=/\s+/,t0=[],ki=(()=>{class n{_ngEl;_renderer;initialClasses=t0;rawClass;stateMap=new Map;constructor(e,o){this._ngEl=e,this._renderer=o}set klass(e){this.initialClasses=e!=null?e.trim().split(ef):t0}set ngClass(e){this.rawClass=typeof e=="string"?e.trim().split(ef):e}ngDoCheck(){for(let o of this.initialClasses)this._updateState(o,!0);let e=this.rawClass;if(Array.isArray(e)||e instanceof Set)for(let o of e)this._updateState(o,!0);else if(e!=null)for(let o of Object.keys(e))this._updateState(o,!!e[o]);this._applyStateDiff()}_updateState(e,o){let i=this.stateMap.get(e);i!==void 0?(i.enabled!==o&&(i.changed=!0,i.enabled=o),i.touched=!0):this.stateMap.set(e,{enabled:o,changed:!0,touched:!0})}_applyStateDiff(){for(let e of this.stateMap){let o=e[0],i=e[1];i.changed?(this._toggleClass(o,i.enabled),i.changed=!1):i.touched||(i.enabled&&this._toggleClass(o,!1),this.stateMap.delete(o)),i.touched=!1}}_toggleClass(e,o){e=e.trim(),e.length>0&&e.split(ef).forEach(i=>{o?this._renderer.addClass(this._ngEl.nativeElement,i):this._renderer.removeClass(this._ngEl.nativeElement,i)})}static \u0275fac=function(o){return new(o||n)(le(Mt),le(wt))};static \u0275dir=qe({type:n,selectors:[["","ngClass",""]],inputs:{klass:[0,"class","klass"],ngClass:"ngClass"}})}return n})();function p0(n,t){return new z(2100,!1)}var Ya=(()=>{class n{transform(e){return e==null?null:(ZS(n,e),e.toUpperCase())}static \u0275fac=function(o){return new(o||n)};static \u0275pipe=Sl({name:"uppercase",type:n,pure:!0})}return n})();function ZS(n,t){if(typeof t!="string")throw p0(n,t)}var QS="mediumDate",f0=new B(""),m0=new B(""),Ei=(()=>{class n{locale;defaultTimezone;defaultOptions;constructor(e,o,i){this.locale=e,this.defaultTimezone=o,this.defaultOptions=i}transform(e,o,i,r){if(e==null||e===""||e!==e)return null;try{let a=o??this.defaultOptions?.dateFormat??QS,s=i??this.defaultOptions?.timezone??this.defaultTimezone??void 0;return d0(e,a,r||this.locale,s)}catch(a){throw p0(n,a.message)}}static \u0275fac=function(o){return new(o||n)(le(Ba,16),le(f0,24),le(m0,24))};static \u0275pipe=Sl({name:"date",type:n,pure:!0})}return n})();var No=(()=>{class n{static \u0275fac=function(o){return new(o||n)};static \u0275mod=Jn({type:n});static \u0275inj=Pn({})}return n})();function Za(n,t){t=encodeURIComponent(t);for(let e of n.split(";")){let o=e.indexOf("="),[i,r]=o==-1?[e,""]:[e.slice(0,o),e.slice(o+1)];if(i.trim()!==t)continue;let a=r;try{a=decodeURIComponent(r)}catch(s){}return a.length>1&&a[0]==='"'&&a[a.length-1]==='"'&&(a=a.slice(1,-1)),a}return null}var JS=(()=>{class n{build(){return new XMLHttpRequest}static \u0275fac=function(o){return new(o||n)};static \u0275prov=Oe({token:n,factory:n.\u0275fac})}return n})(),tf=(()=>{class n{static \u0275fac=function(o){return new(o||n)};static \u0275prov=te({token:n,factory:function(o){let i=null;return o?i=new(o||n):i=Q(JS),i},providedIn:"root"})}return n})();var _0="browser";var Qa=class{_doc;constructor(t){this._doc=t}manager},zl=(()=>{class n extends Qa{constructor(e){super(e)}supports(e){return!0}addEventListener(e,o,i,r){return e.addEventListener(o,i,r),()=>this.removeEventListener(e,o,i,r)}removeEventListener(e,o,i,r){return e.removeEventListener(o,i,r)}static \u0275fac=function(o){return new(o||n)(Q(Ue))};static \u0275prov=te({token:n,factory:n.\u0275fac})}return n})(),Ul=new B(""),af=(()=>{class n{_zone;_plugins;_eventNameToPlugin=new Map;constructor(e,o){this._zone=o,e.forEach(a=>{a.manager=this});let i=e.filter(a=>!(a instanceof zl));this._plugins=i.slice().reverse();let r=e.find(a=>a instanceof zl);r&&this._plugins.push(r)}addEventListener(e,o,i,r){return this._findPluginFor(o).addEventListener(e,o,i,r)}getZone(){return this._zone}_findPluginFor(e){let o=this._eventNameToPlugin.get(e);if(o)return o;if(o=this._plugins.find(r=>r.supports(e)),!o)throw new z(-5101,!1);return this._eventNameToPlugin.set(e,o),o}static \u0275fac=function(o){return new(o||n)(Q(Ul),Q(ge))};static \u0275prov=te({token:n,factory:n.\u0275fac})}return n})(),nf="ng-app-id";function h0(n){for(let t of n)t.remove()}function C0(n,t){let e=t.createElement("style");return e.textContent=n,e}function ek(n,t,e,o){let i=n.head?.querySelectorAll(`style[${nf}="${t}"],link[${nf}="${t}"]`);if(!i||i.length===0)return!1;for(let r of i)r.removeAttribute(nf),r instanceof HTMLLinkElement?o.set(r.href.slice(r.href.lastIndexOf("/")+1),{usage:0,elements:[r]}):r.textContent&&e.set(r.textContent,{usage:0,elements:[r]});return!0}function rf(n,t){let e=t.createElement("link");return e.setAttribute("rel","stylesheet"),e.setAttribute("href",n),e}var sf=(()=>{class n{doc;appId;nonce;inline=new Map;external=new Map;hosts=new Set;constructor(e,o,i,r={}){this.doc=e,this.appId=o,this.nonce=i,ek(e,o,this.inline,this.external)&&this.hosts.add(e.head)}addStyles(e,o){for(let i of e)this.addUsage(i,this.inline,C0);o?.forEach(i=>this.addUsage(i,this.external,rf))}removeStyles(e,o){for(let i of e)this.removeUsage(i,this.inline);o?.forEach(i=>this.removeUsage(i,this.external))}addUsage(e,o,i){let r=o.get(e);r?r.usage++:o.set(e,{usage:1,elements:[...this.hosts].map(a=>this.addElement(a,i(e,this.doc)))})}removeUsage(e,o){let i=o.get(e);i&&(i.usage--,i.usage<=0&&(h0(i.elements),o.delete(e)))}ngOnDestroy(){for(let[,{elements:e}]of[...this.inline,...this.external])h0(e);this.hosts.clear()}addHost(e){if(!this.hosts.has(e)){this.hosts.add(e);for(let[o,{elements:i}]of this.inline)i.push(this.addElement(e,C0(o,this.doc)));for(let[o,{elements:i}]of this.external)i.push(this.addElement(e,rf(o,this.doc)))}}removeHost(e){this.hosts.delete(e);for(let o of[...this.inline.values(),...this.external.values()]){let i=[];for(let r of o.elements)r.parentNode===e?r.remove():i.push(r);o.elements=i}}addElement(e,o){return this.nonce&&o.setAttribute("nonce",this.nonce),e.appendChild(o)}static \u0275fac=function(o){return new(o||n)(Q(Ue),Q(ha),Q(Ma,8),Q(Ca))};static \u0275prov=te({token:n,factory:n.\u0275fac})}return n})(),of={svg:"http://www.w3.org/2000/svg",xhtml:"http://www.w3.org/1999/xhtml",xlink:"http://www.w3.org/1999/xlink",xml:"http://www.w3.org/XML/1998/namespace",xmlns:"http://www.w3.org/2000/xmlns/",math:"http://www.w3.org/1998/Math/MathML"},cf=/%COMP%/g;var b0="%COMP%",tk=`_nghost-${b0}`,nk=`_ngcontent-${b0}`,ok=!0,ik=new B("",{factory:()=>ok}),rk=new B("");function ak(n){return nk.replace(cf,n)}function sk(n){return tk.replace(cf,n)}function P0(n,t){return t.map(e=>e.replace(cf,n))}var lf=(()=>{class n{eventManager;sharedStylesHost;appId;removeStylesOnCompDestroy;doc;ngZone;nonce;tracingService;rendererByCompId=new Map;defaultRenderer;cssVarNamespace;constructor(e,o,i,r,a,s,c=null,p=null,f=null){this.eventManager=e,this.sharedStylesHost=o,this.appId=i,this.removeStylesOnCompDestroy=r,this.doc=a,this.ngZone=s,this.nonce=c,this.tracingService=p,this.cssVarNamespace=f??"",this.defaultRenderer=new Ja(e,a,s,this.tracingService,this.cssVarNamespace)}createRenderer(e,o){if(!e||!o)return this.defaultRenderer;let i=this.getOrCreateRenderer(e,o);return i instanceof Hl?i.applyToHost(e):i instanceof Xa&&i.applyStyles(),i}getOrCreateRenderer(e,o){let i=this.rendererByCompId,r=i.get(o.id);if(!r){let a=this.doc,s=this.ngZone,c=this.eventManager,p=this.sharedStylesHost,f=this.removeStylesOnCompDestroy,_=this.tracingService;switch(o.encapsulation){case un.Emulated:r=new Hl(c,p,o,this.appId,f,a,s,_,this.cssVarNamespace);break;case un.ShadowDom:return new Bl(c,e,o,a,s,this.nonce,_,this.cssVarNamespace,p);case un.ExperimentalIsolatedShadowDom:return new Bl(c,e,o,a,s,this.nonce,_,this.cssVarNamespace);default:r=new Xa(c,p,o,f,a,s,_,this.cssVarNamespace);break}i.set(o.id,r)}return r}ngOnDestroy(){this.rendererByCompId.clear()}componentReplaced(e){this.rendererByCompId.delete(e)}static \u0275fac=function(o){return new(o||n)(Q(af),Q(Oi),Q(ha),Q(ik),Q(Ue),Q(ge),Q(Ma),Q(Tn,8),Q(rk,8))};static \u0275prov=te({token:n,factory:n.\u0275fac})}return n})(),Ja=class{eventManager;doc;ngZone;tracingService;cssVarNamespace;data=Object.create(null);throwOnSyntheticProps=!0;constructor(t,e,o,i,r=""){this.eventManager=t,this.doc=e,this.ngZone=o,this.tracingService=i,this.cssVarNamespace=r}destroy(){}destroyNode=null;createElement(t,e){return e?this.doc.createElementNS(of[e]||e,t):this.doc.createElement(t)}createComment(t){return this.doc.createComment(t)}createText(t){return this.doc.createTextNode(t)}appendChild(t,e){(M0(t)?t.content:t).appendChild(e)}insertBefore(t,e,o){if(t){let i=M0(t)?t.content:t;if(o!=null&&o.parentNode!==i)throw new z(-5106,!1);i.insertBefore(e,o)}}removeChild(t,e){e.remove()}selectRootElement(t,e){let o=typeof t=="string"?this.doc.querySelector(t):t;if(!o)throw new z(-5104,!1);return e||(o.textContent=""),o}parentNode(t){return t.parentNode}nextSibling(t){return t.nextSibling}setAttribute(t,e,o,i){if(i){e=i+":"+e;let r=of[i];r?t.setAttributeNS(r,e,o):t.setAttribute(e,o)}else t.setAttribute(e,o)}removeAttribute(t,e,o){if(o){let i=of[o];i?t.removeAttributeNS(i,e):t.removeAttribute(`${o}:${e}`)}else t.removeAttribute(e)}addClass(t,e){t.classList.add(e)}removeClass(t,e){t.classList.remove(e)}setStyle(t,e,o,i){let r=e.startsWith("--");r&&(e=e.replace("%NS%",this.cssVarNamespace)),r||i&(Dn.DashCase|Dn.Important)?t.style.setProperty(e,o,i&Dn.Important?"important":""):t.style[e]=o}removeStyle(t,e,o){let i=e.startsWith("--");i&&(e=e.replace("%NS%",this.cssVarNamespace)),i||o&Dn.DashCase?t.style.removeProperty(e):t.style[e]=""}setProperty(t,e,o){t!=null&&(t[e]=o)}setValue(t,e){t.nodeValue=e}listen(t,e,o,i){if(typeof t=="string"&&(t=Kt().getGlobalEventTarget(this.doc,t),!t))throw new z(-5102,!1);let r=this.decoratePreventDefault(o);return this.tracingService?.wrapEventListener&&(r=this.tracingService.wrapEventListener(t,e,r)),this.eventManager.addEventListener(t,e,r,i)}decoratePreventDefault(t){return e=>{if(e==="__ngUnwrap__")return t;t(e)===!1&&e.preventDefault()}}};function M0(n){return n.tagName==="TEMPLATE"&&n.content!==void 0}var Bl=class extends Ja{hostEl;sharedStylesHost;shadowRoot;constructor(t,e,o,i,r,a,s,c,p){super(t,i,r,s,c),this.hostEl=e,this.sharedStylesHost=p,this.shadowRoot=e.attachShadow({mode:"open"}),this.sharedStylesHost&&this.sharedStylesHost.addHost(this.shadowRoot);let f=o.styles;f=P0(o.id,f).map(C=>C.replace(/%NS%/g,c));for(let C of f){let h=document.createElement("style");a&&h.setAttribute("nonce",a),h.textContent=C,this.shadowRoot.appendChild(h)}let _=o.getExternalStyles?.();if(_)for(let C of _){let h=rf(C,i);a&&h.setAttribute("nonce",a),this.shadowRoot.appendChild(h)}}nodeOrShadowRoot(t){return t===this.hostEl?this.shadowRoot:t}appendChild(t,e){return super.appendChild(this.nodeOrShadowRoot(t),e)}insertBefore(t,e,o){return super.insertBefore(this.nodeOrShadowRoot(t),e,o)}removeChild(t,e){return super.removeChild(null,e)}parentNode(t){return this.nodeOrShadowRoot(super.parentNode(this.nodeOrShadowRoot(t)))}destroy(){this.sharedStylesHost&&this.sharedStylesHost.removeHost(this.shadowRoot)}},Xa=class extends Ja{sharedStylesHost;removeStylesOnCompDestroy;styles;styleUrls;constructor(t,e,o,i,r,a,s,c,p){super(t,r,a,s,c),this.sharedStylesHost=e,this.removeStylesOnCompDestroy=i;let f=o.styles,_=p?P0(p,f):f;this.styles=_.map(C=>C.replace(/%NS%/g,c)),this.styleUrls=o.getExternalStyles?.(p)}applyStyles(){this.sharedStylesHost.addStyles(this.styles,this.styleUrls)}destroy(){this.removeStylesOnCompDestroy&&wo.size===0&&this.sharedStylesHost.removeStyles(this.styles,this.styleUrls)}},Hl=class extends Xa{contentAttr;hostAttr;constructor(t,e,o,i,r,a,s,c,p){let f=i+"-"+o.id;super(t,e,o,r,a,s,c,p,f),this.contentAttr=ak(f),this.hostAttr=sk(f)}applyToHost(t){this.applyStyles(),this.setAttribute(t,this.hostAttr,"")}createElement(t,e){let o=super.createElement(t,e);return super.setAttribute(o,this.contentAttr,""),o}};var Gl=class n extends Ua{supportsDOMEvents=!0;static makeCurrent(){Zp(new n)}onAndCancel(t,e,o,i){return t.addEventListener(e,o,i),()=>{t.removeEventListener(e,o,i)}}dispatchEvent(t,e){t.dispatchEvent(e)}remove(t){t.remove()}createElement(t,e){return e=e||this.getDefaultDocument(),e.createElement(t)}createHtmlDocument(){return document.implementation.createHTMLDocument("fakeTitle")}getDefaultDocument(){return document}isElementNode(t){return t.nodeType===Node.ELEMENT_NODE}isShadowRoot(t){return t instanceof DocumentFragment}getGlobalEventTarget(t,e){return e==="window"?window:e==="document"?t:e==="body"?t.body:null}getBaseHref(t){let e=ck();return e==null?null:lk(e)}resetBaseElement(){es=null}getUserAgent(){return window.navigator.userAgent}getCookie(t){return Za(document.cookie,t)}},es=null;function ck(){return es=es||document.head.querySelector("base"),es?es.getAttribute("href"):null}function lk(n){return new URL(n,document.baseURI).pathname}var O0=["alt","control","meta","shift"],dk={"\b":"Backspace"," ":"Tab","\x7F":"Delete","\x1B":"Escape",Del:"Delete",Esc:"Escape",Left:"ArrowLeft",Right:"ArrowRight",Up:"ArrowUp",Down:"ArrowDown",Menu:"ContextMenu",Scroll:"ScrollLock",Win:"OS"},uk={alt:n=>n.altKey,control:n=>n.ctrlKey,meta:n=>n.metaKey,shift:n=>n.shiftKey},v0=(()=>{class n extends Qa{constructor(e){super(e)}supports(e){return n.parseEventName(e)!=null}addEventListener(e,o,i,r){let a=n.parseEventName(o),s=n.eventCallback(a.fullKey,i,this.manager.getZone());return this.manager.getZone().runOutsideAngular(()=>Kt().onAndCancel(e,a.domEventName,s,r))}static parseEventName(e){let o=e.toLowerCase().split("."),i=o.shift();if(o.length===0||!(i==="keydown"||i==="keyup"))return null;let r=n._normalizeKey(o.pop()),a="",s=o.indexOf("code");if(s>-1&&(o.splice(s,1),a="code."),O0.forEach(p=>{let f=o.indexOf(p);f>-1&&(o.splice(f,1),a+=p+".")}),a+=r,o.length!=0||r.length===0)return null;let c={};return c.domEventName=i,c.fullKey=a,c}static matchEventFullKeyCode(e,o){let i=dk[e.key]||e.key,r="";return o.indexOf("code.")>-1&&(i=e.code,r="code."),i==null||!i?!1:(i=i.toLowerCase(),i===" "?i="space":i==="."&&(i="dot"),O0.forEach(a=>{if(a!==i){let s=uk[a];s(e)&&(r+=a+".")}}),r+=i,r===o)}static eventCallback(e,o,i){return r=>{n.matchEventFullKeyCode(r,e)&&i.runGuarded(()=>o(r))}}static _normalizeKey(e){return e==="esc"?"escape":e}static \u0275fac=function(o){return new(o||n)(Q(Ue))};static \u0275prov=te({token:n,factory:n.\u0275fac})}return n})();function df(n,t,e){return be(this,null,function*(){let o=S({rootComponent:n},gk(t,e));return KM(o)})}function gk(n,t){return{platformRef:t?.platformRef,appProviders:[...hk,...n?.providers??[]],platformProviders:_k}}function pk(){Gl.makeCurrent()}function fk(){return new nn}function mk(){return gp(document),document}var _k=[{provide:Ca,useValue:_0},{provide:Fc,useValue:pk,multi:!0},{provide:Ue,useFactory:mk}];var hk=[{provide:ga,useValue:"root"},{provide:nn,useFactory:fk},{provide:Ul,useClass:zl,multi:!0},{provide:Ul,useClass:v0,multi:!0},lf,{provide:Oi,useClass:sf},{provide:sf,useExisting:Oi},af,{provide:Ci,useExisting:lf},[]];var Fn=class n{headers;normalizedNames=new Map;lazyInit;lazyUpdate=null;constructor(t){t?typeof t=="string"?this.lazyInit=()=>{this.headers=new Map,t.split(` -`).forEach(e=>{let o=e.indexOf(":");if(o>0){let i=e.slice(0,o),r=e.slice(o+1).trim();this.addHeaderEntry(i,r)}})}:typeof Headers<"u"&&t instanceof Headers?(this.headers=new Map,t.forEach((e,o)=>{this.addHeaderEntry(o,e)})):this.lazyInit=()=>{this.headers=new Map,Object.entries(t).forEach(([e,o])=>{this.setHeaderEntries(e,o)})}:this.headers=new Map}has(t){return this.init(),this.headers.has(t.toLowerCase())}get(t){this.init();let e=this.headers.get(t.toLowerCase());return e&&e.length>0?e[0]:null}keys(){return this.init(),Array.from(this.normalizedNames.values())}getAll(t){return this.init(),this.headers.get(t.toLowerCase())||null}append(t,e){return this.clone({name:t,value:e,op:"a"})}set(t,e){return this.clone({name:t,value:e,op:"s"})}delete(t,e){return this.clone({name:t,value:e,op:"d"})}maybeSetNormalizedName(t,e){this.normalizedNames.has(e)||this.normalizedNames.set(e,t)}init(){this.lazyInit&&(this.lazyInit instanceof n?this.copyFrom(this.lazyInit):this.lazyInit(),this.lazyInit=null,this.lazyUpdate&&(this.lazyUpdate.forEach(t=>this.applyUpdate(t)),this.lazyUpdate=null))}copyFrom(t){t.init();for(let[e,o]of t.headers.entries())this.headers.set(e,o),this.normalizedNames.set(e,t.normalizedNames.get(e))}clone(t){let e=new n;return e.lazyInit=this.lazyInit&&this.lazyInit instanceof n?this.lazyInit:this,e.lazyUpdate=(this.lazyUpdate||[]).concat([t]),e}applyUpdate(t){let e=t.name.toLowerCase();switch(t.op){case"a":case"s":let o=t.value;if(typeof o=="string"&&(o=[o]),o.length===0)return;this.maybeSetNormalizedName(t.name,e);let i=t.op==="a"?(this.headers.get(e)||[]).slice():[];i.push(...o),this.headers.set(e,i);break;case"d":let r=t.value;if(r===void 0)this.headers.delete(e),this.normalizedNames.delete(e);else{let a=Array.isArray(r)?r:[r],s=this.headers.get(e);if(!s)return;s=s.filter(c=>a.indexOf(c)===-1),s.length===0?(this.headers.delete(e),this.normalizedNames.delete(e)):this.headers.set(e,s)}break}}addHeaderEntry(t,e){let o=t.toLowerCase();this.maybeSetNormalizedName(t,o),this.headers.has(o)?this.headers.get(o).push(e):this.headers.set(o,[e])}setHeaderEntries(t,e){let o=(Array.isArray(e)?e:[e]).map(r=>r.toString()),i=t.toLowerCase();this.headers.set(i,o),this.maybeSetNormalizedName(t,i)}forEach(t){this.init(),Array.from(this.normalizedNames.keys()).forEach(e=>t(this.normalizedNames.get(e),this.headers.get(e)))}};var Wl=class{map=new Map;set(t,e){return this.map.set(t,e),this}get(t){return this.map.has(t)||this.map.set(t,t.defaultValue()),this.map.get(t)}delete(t){return this.map.delete(t),this}has(t){return this.map.has(t)}keys(){return this.map.keys()}},Kl=class{encodeKey(t){return y0(t)}encodeValue(t){return y0(t)}decodeKey(t){return decodeURIComponent(t)}decodeValue(t){return decodeURIComponent(t)}};function Ck(n,t){let e=new Map;return n.length>0&&n.replace(/^\?/,"").split("&").forEach(i=>{let r=i.indexOf("="),[a,s]=r==-1?[t.decodeKey(i),""]:[t.decodeKey(i.slice(0,r)),t.decodeValue(i.slice(r+1))],c=e.get(a)||[];c.push(s),e.set(a,c)}),e}var Mk=/%(\d[a-f0-9])/gi,bk={40:"@","3A":":",24:"$","2C":",","3B":";","3D":"=","3F":"?","2F":"/"};function y0(n){return encodeURIComponent(n).replace(Mk,(t,e)=>bk[e]??t)}function ql(n){return`${n}`}var ao=class n{map;encoder;updates=null;cloneFrom=null;constructor(t={}){if(this.encoder=t.encoder||new Kl,t.fromString){if(t.fromObject)throw new z(2805,!1);this.map=Ck(t.fromString,this.encoder)}else t.fromObject?(this.map=new Map,Object.keys(t.fromObject).forEach(e=>{let o=t.fromObject[e],i=Array.isArray(o)?o.map(ql):[ql(o)];this.map.set(e,i)})):this.map=null}has(t){return this.init(),this.map.has(t)}get(t){this.init();let e=this.map.get(t);return e?e[0]:null}getAll(t){return this.init(),this.map.get(t)||null}keys(){return this.init(),Array.from(this.map.keys())}append(t,e){return this.clone({param:t,value:e,op:"a"})}appendAll(t){let e=[];return Object.keys(t).forEach(o=>{let i=t[o];Array.isArray(i)?i.forEach(r=>{e.push({param:o,value:r,op:"a"})}):e.push({param:o,value:i,op:"a"})}),this.clone(e)}set(t,e){return this.clone({param:t,value:e,op:"s"})}delete(t,e){return this.clone({param:t,value:e,op:"d"})}toString(){return this.init(),this.keys().map(t=>{let e=this.encoder.encodeKey(t);return this.map.get(t).map(o=>e+"="+this.encoder.encodeValue(o)).join("&")}).filter(t=>t!=="").join("&")}clone(t){let e=new n({encoder:this.encoder});return e.cloneFrom=this.cloneFrom||this,e.updates=(this.updates||[]).concat(t),e}init(){if(this.map===null&&(this.map=new Map),this.cloneFrom!==null){this.cloneFrom.init();for(let[t,e]of this.cloneFrom.map.entries())this.map.set(t,e);this.updates.forEach(t=>{switch(t.op){case"a":case"s":let e=t.op==="a"?(this.map.get(t.param)||[]).slice():[];e.push(ql(t.value)),this.map.set(t.param,e);break;case"d":if(t.value!==void 0){let o=(this.map.get(t.param)||[]).slice(),i=o.indexOf(ql(t.value));i!==-1&&o.splice(i,1),o.length>0?this.map.set(t.param,o):this.map.delete(t.param)}else{this.map.delete(t.param);break}}}),this.cloneFrom=this.updates=null}}};function Pk(n){switch(n){case"DELETE":case"GET":case"HEAD":case"OPTIONS":case"JSONP":return!1;default:return!0}}function x0(n){return typeof ArrayBuffer<"u"&&n instanceof ArrayBuffer}function w0(n){return typeof Blob<"u"&&n instanceof Blob}function S0(n){return typeof FormData<"u"&&n instanceof FormData}function Ok(n){return typeof URLSearchParams<"u"&&n instanceof URLSearchParams}var ts="Content-Type",Yl="Accept",D0="text/plain",I0="application/json",T0=`${I0}, ${D0}, */*`,Pr=class n{url;body=null;headers;context;reportProgress=!1;reportUploadProgress=!1;reportDownloadProgress=!1;withCredentials=!1;credentials;keepalive=!1;cache;priority;mode;redirect;referrer;integrity;referrerPolicy;responseType="json";method;params;urlWithParams;transferCache;timeout;constructor(t,e,o,i){this.url=e,this.method=t.toUpperCase();let r;if(Pk(this.method)||i?(this.body=o!==void 0?o:null,r=i):r=o,r){if(this.reportProgress=!!r.reportProgress,this.reportUploadProgress=!!r.reportUploadProgress,this.reportDownloadProgress=!!r.reportDownloadProgress,this.withCredentials=!!r.withCredentials,this.keepalive=!!r.keepalive,r.responseType&&(this.responseType=r.responseType),r.headers&&(this.headers=r.headers),r.context&&(this.context=r.context),r.params&&(this.params=r.params),r.priority&&(this.priority=r.priority),r.cache&&(this.cache=r.cache),r.credentials&&(this.credentials=r.credentials),typeof r.timeout=="number"){if(r.timeout<1||!Number.isInteger(r.timeout))throw new z(2822,"");this.timeout=r.timeout}r.mode&&(this.mode=r.mode),r.redirect&&(this.redirect=r.redirect),r.integrity&&(this.integrity=r.integrity),r.referrer!==void 0&&(this.referrer=r.referrer),r.referrerPolicy&&(this.referrerPolicy=r.referrerPolicy),this.transferCache=r.transferCache}if(this.headers??=new Fn,this.context??=new Wl,!this.params)this.params=new ao,this.urlWithParams=e;else{let a=this.params.toString();if(a.length===0)this.urlWithParams=e;else{let s=e,c="",p=e.indexOf("#");p!==-1&&(c=e.substring(p),s=e.substring(0,p));let f=s.indexOf("?"),_=f===-1?"?":fMe.set(Ee,t.setHeaders[Ee]),J)),t.setParams&&(X=Object.keys(t.setParams).reduce((Me,Ee)=>Me.set(Ee,t.setParams[Ee]),X)),new n(e,o,A,{params:X,headers:J,context:W,reportProgress:E,reportUploadProgress:U,reportDownloadProgress:ne,responseType:i,withCredentials:N,transferCache:P,keepalive:r,cache:s,priority:a,timeout:T,mode:c,redirect:p,credentials:f,referrer:_,integrity:C,referrerPolicy:h})}},Rn=(function(n){return n[n.Sent=0]="Sent",n[n.UploadProgress=1]="UploadProgress",n[n.ResponseHeader=2]="ResponseHeader",n[n.DownloadProgress=3]="DownloadProgress",n[n.Response=4]="Response",n[n.User=5]="User",n})(Rn||{}),Or=class{headers;status;statusText;url;ok;type;redirected;responseType;constructor(t,e=200,o="OK"){this.headers=t.headers||new Fn,this.status=t.status!==void 0?t.status:e,this.statusText=t.statusText||o,this.url=t.url||null,this.redirected=t.redirected,this.responseType=t.responseType,this.ok=this.status>=200&&this.status<300}},ns=class n extends Or{constructor(t={}){super(t)}type=Rn.ResponseHeader;clone(t={}){return new n({headers:t.headers||this.headers,status:t.status!==void 0?t.status:this.status,statusText:t.statusText||this.statusText,url:t.url||this.url||void 0})}},vr=class n extends Or{body;constructor(t={}){super(t),this.body=t.body!==void 0?t.body:null}type=Rn.Response;clone(t={}){return new n({body:t.body!==void 0?t.body:this.body,headers:t.headers||this.headers,status:t.status!==void 0?t.status:this.status,statusText:t.statusText||this.statusText,url:t.url||this.url||void 0,redirected:t.redirected??this.redirected,responseType:t.responseType??this.responseType})}},Nn=class extends Or{name="HttpErrorResponse";message;error;ok=!1;constructor(t){super(t,0,"Unknown Error"),this.status>=200&&this.status<300?this.message=`Http failure during parsing for ${t.url||"(unknown url)"}`:this.message=`Http failure response for ${t.url||"(unknown url)"}: ${t.status} ${t.statusText}`,this.error=t.error||null}},A0=200,vk=204;var yk=/^\)\]\}',?\n/,Dq=1024*1024,N0=new B("",{factory:()=>null}),Zl=(()=>{class n{fetchImpl=D(gf,{optional:!0})?.fetch??((...e)=>globalThis.fetch(...e));ngZone=D(ge);destroyRef=D(Ae);maxResponseSize=D(N0);handle(e){return new ie(o=>{let i=new AbortController,r=!1,a={next:c=>{c.type===Rn.Response&&(r=!0),o.next(c)},error:c=>{r=!0,o.error(c)},complete:()=>{r=!0,o.complete()}};this.doRequest(e,i.signal,a).then(pf,c=>a.error(new Nn({error:c})));let s;return e.timeout&&(s=this.ngZone.runOutsideAngular(()=>setTimeout(()=>{i.signal.aborted||i.abort(new DOMException("signal timed out","TimeoutError"))},e.timeout))),()=>{s!==void 0&&clearTimeout(s),!r&&!i.signal.aborted&&i.abort()}})}doRequest(e,o,i){return be(this,null,function*(){let r=this.createRequestInit(e),a;try{let A=this.ngZone.runOutsideAngular(()=>this.fetchImpl(e.urlWithParams,S({signal:o},r)));xk(A),i.next({type:Rn.Sent}),a=yield A}catch(A){i.error(new Nn({error:A,status:A.status??0,statusText:A.statusText,url:e.urlWithParams,headers:A.headers}));return}let s=new Fn(a.headers),c=a.statusText,p=a.url||e.urlWithParams,f=a.status,_=null,C=e.reportProgress||e.reportDownloadProgress;if(C&&i.next(new ns({headers:s,status:f,statusText:c,url:p})),a.body){let A=a.headers.get(ts)??"",N=a.headers.get("content-length"),E=N!==null?Number(N):NaN;this.maxResponseSize!==null&&Number.isFinite(E)&&E>this.maxResponseSize&&(yield a.body.cancel(),k0(this.maxResponseSize));let U=[],ne=a.body.getReader(),J=0,X,W,Me=typeof Zone<"u"&&Zone.current,Ee=!1;if(yield this.ngZone.runOutsideAngular(()=>be(this,null,function*(){for(;;){if(this.destroyRef.destroyed){yield ne.cancel(),Ee=!0;break}let{done:Jt,value:Xt}=yield ne.read();if(Jt)break;if(U.push(Xt),J+=Xt.length,this.maxResponseSize!==null&&J>this.maxResponseSize&&(yield ne.cancel(),k0(this.maxResponseSize)),C){W=e.responseType==="text"?(W??"")+(X??=E0(A)).decode(Xt,{stream:!0}):void 0;let $t=()=>i.next({type:Rn.DownloadProgress,total:Number.isFinite(E)?E:void 0,loaded:J,partialText:W});Me?Me.run($t):$t()}}})),Ee){i.complete();return}let ft=this.concatChunks(U,J);try{_=this.parseBody(e,ft,A,f)}catch(Jt){i.error(new Nn({error:Jt,headers:new Fn(a.headers),status:a.status,statusText:a.statusText,url:a.url||e.urlWithParams}));return}}f===0&&(f=_?A0:0);let h=f>=200&&f<300,P=a.redirected,T=a.type;h?(i.next(new vr({body:_,headers:s,status:f,statusText:c,url:p,redirected:P,responseType:T})),i.complete()):i.error(new Nn({error:_,headers:s,status:f,statusText:c,url:p,redirected:P,responseType:T}))})}parseBody(e,o,i,r){switch(e.responseType){case"json":let a=new TextDecoder().decode(o).replace(yk,"");if(a==="")return null;try{return JSON.parse(a)}catch(s){if(r<200||r>=300)return a;throw s}case"text":return E0(i).decode(o);case"blob":return new Blob([o],{type:i});case"arraybuffer":return o.buffer}}createRequestInit(e){if(e.reportUploadProgress)throw new z(2824,!1);let o={},i;if(i=e.credentials,e.withCredentials&&(i="include"),e.headers.forEach((r,a)=>o[r]=a.join(",")),e.headers.has(Yl)||(o[Yl]=T0),!e.headers.has(ts)){let r=e.detectContentTypeHeader();r!==null&&(o[ts]=r)}return{body:e.serializeBody(),method:e.method,headers:o,credentials:i,keepalive:e.keepalive,cache:e.cache,priority:e.priority,mode:e.mode,redirect:e.redirect,referrer:e.referrer,integrity:e.integrity,referrerPolicy:e.referrerPolicy}}concatChunks(e,o){let i=new Uint8Array(o),r=0;for(let a of e)i.set(a,r),r+=a.length;return i}static \u0275fac=function(o){return new(o||n)};static \u0275prov=Oe({token:n,factory:n.\u0275fac})}return n})(),gf=class{};function pf(){}function xk(n){n.then(pf,pf)}function k0(n){throw new z(-2825,!1)}var wk=/charset=\s*["']?([^;"'\s]+)["']?/i;function E0(n){let t=n.match(wk);if(t!==null)try{return new TextDecoder(t[1])}catch(e){}return new TextDecoder}var Sk=new B("",{factory:()=>!0}),kk="XSRF-TOKEN",Ek=new B("",{factory:()=>kk}),Dk="X-XSRF-TOKEN",Ik=new B("",{factory:()=>Dk}),Tk=(()=>{class n{cookieName=D(Ek);doc=D(Ue);lastCookieString="";lastToken=null;parseCount=0;getToken(){let e=this.doc.cookie||"";return e!==this.lastCookieString&&(this.parseCount++,this.lastToken=Za(e,this.cookieName),this.lastCookieString=e),this.lastToken}static \u0275fac=function(o){return new(o||n)};static \u0275prov=Oe({token:n,factory:n.\u0275fac})}return n})(),R0=(()=>{class n{static \u0275fac=function(o){return new(o||n)};static \u0275prov=te({token:n,factory:function(o){let i=null;return o?i=new(o||n):i=Q(Tk),i},providedIn:"root"})}return n})();function F0(n,t){if(!D(Sk)||n.method==="GET"||n.method==="HEAD")return t(n);try{let i=D(Cr).href,{origin:r}=new URL(i),{origin:a}=new URL(n.url,r);if(r!==a)return t(n)}catch(i){return t(n)}let e=D(R0).getToken(),o=D(Ik);return e!=null&&!n.headers.has(o)&&(n=n.clone({headers:n.headers.set(o,e)})),t(n)}function Ak(n,t){return t(n)}function Nk(n,t,e){return(o,i)=>et(e,()=>t(o,r=>n(r,i)))}var L0=new B("",{factory:()=>[F0]}),V0=new B(""),j0=new B("",{factory:()=>!0});var Jl=(()=>{class n{static \u0275fac=function(o){return new(o||n)};static \u0275prov=te({token:n,factory:function(o){let i=null;return o?i=new(o||n):i=Q(Zl),i},providedIn:"root"})}return n})();var Ql=(()=>{class n{backend;injector;chain=null;pendingTasks=D(Oa);contributeToStability=D(j0);constructor(e,o){this.backend=e,this.injector=o}handle(e){if(this.chain===null){let i=this.injector.get(Xl,null,{skipSelf:!0}),r=i!==null&&this.backend===i,a=this.injector.get(V0,[],r?{self:!0}:void 0),s=Array.from(new Set([...this.injector.get(L0),...a]));this.chain=s.reduceRight((c,p)=>Nk(c,p,this.injector),Ak)}let o=this.chain;if(this.contributeToStability){let i=this.pendingTasks.add();return de(()=>o(e,r=>this.backend.handle(r))).pipe(_o(i))}else return de(()=>o(e,i=>this.backend.handle(i)))}static \u0275fac=function(o){return new(o||n)(Q(Jl),Q(Le))};static \u0275prov=te({token:n,factory:n.\u0275fac,providedIn:"root"})}return n})(),Xl=(()=>{class n{static \u0275fac=function(o){return new(o||n)};static \u0275prov=te({token:n,factory:function(o){let i=null;return o?i=new(o||n):i=Q(Ql),i},providedIn:"root"})}return n})();function uf(n,t){return S({body:t},n)}var Ro=(()=>{class n{handler;constructor(e){this.handler=e}request(e,o,i={}){let r;if(e instanceof Pr)r=e;else{let c;i.headers instanceof Fn?c=i.headers:c=new Fn(i.headers);let p;i.params&&(i.params instanceof ao?p=i.params:p=new ao({fromObject:i.params})),r=new Pr(e,o,i.body!==void 0?i.body:null,{headers:c,context:i.context,params:p,reportProgress:i.reportProgress,reportUploadProgress:i.reportUploadProgress,reportDownloadProgress:i.reportDownloadProgress,responseType:i.responseType||"json",withCredentials:i.withCredentials,transferCache:i.transferCache,keepalive:i.keepalive,priority:i.priority,cache:i.cache,mode:i.mode,redirect:i.redirect,credentials:i.credentials,referrer:i.referrer,referrerPolicy:i.referrerPolicy,integrity:i.integrity,timeout:i.timeout})}let a=se(r).pipe(Jo(c=>this.handler.handle(c)));if(e instanceof Pr||i.observe==="events")return a;let s=a.pipe(Bt(c=>c instanceof vr));switch(i.observe||"body"){case"body":switch(r.responseType){case"arraybuffer":return s.pipe(Pe(c=>{if(c.body!==null&&!(c.body instanceof ArrayBuffer))throw new z(2806,!1);return c.body}));case"blob":return s.pipe(Pe(c=>{if(c.body!==null&&!(c.body instanceof Blob))throw new z(2807,!1);return c.body}));case"text":return s.pipe(Pe(c=>{if(c.body!==null&&typeof c.body!="string")throw new z(2808,!1);return c.body}));default:return s.pipe(Pe(c=>c.body))}case"response":return s;default:throw new z(2809,!1)}}delete(e,o={}){return this.request("DELETE",e,o)}get(e,o={}){return this.request("GET",e,o)}head(e,o={}){return this.request("HEAD",e,o)}jsonp(e,o){return this.request("JSONP",e,{params:new ao().append(o,"JSONP_CALLBACK"),observe:"body",responseType:"json"})}options(e,o={}){return this.request("OPTIONS",e,o)}patch(e,o,i={}){return this.request("PATCH",e,uf(i,o))}post(e,o,i={}){return this.request("POST",e,uf(i,o))}put(e,o,i={}){return this.request("PUT",e,uf(i,o))}static \u0275fac=function(o){return new(o||n)(Q(Xl))};static \u0275prov=te({token:n,factory:n.\u0275fac,providedIn:"root"})}return n})();var Rk=/^\)\]\}',?\n/;var ff=(()=>{class n{xhrFactory;tracingService=D(Tn,{optional:!0});constructor(e){this.xhrFactory=e}maybePropagateTrace(e){return this.tracingService?.propagate?this.tracingService.propagate(e):e}handle(e){if(e.method==="JSONP")throw new z(-2800,!1);let o=this.xhrFactory;return se(null).pipe(vt(()=>new ie(r=>{let a=o.build();if(a.open(e.method,e.urlWithParams),e.withCredentials&&(a.withCredentials=!0),e.headers.forEach((E,U)=>a.setRequestHeader(E,U.join(","))),e.headers.has(Yl)||a.setRequestHeader(Yl,T0),!e.headers.has(ts)){let E=e.detectContentTypeHeader();E!==null&&a.setRequestHeader(ts,E)}if(e.timeout&&(a.timeout=e.timeout),e.responseType){let E=e.responseType.toLowerCase();a.responseType=E!=="json"?E:"text"}let s=e.serializeBody(),c=null,p=()=>{if(c!==null)return c;let E=a.statusText||"OK",U=new Fn(a.getAllResponseHeaders()),ne=a.responseURL||e.url;return c=new ns({headers:U,status:a.status,statusText:E,url:ne}),c},f=this.maybePropagateTrace(()=>{let{headers:E,status:U,statusText:ne,url:J}=p(),X=null;U!==vk&&(X=typeof a.response>"u"?a.responseText:a.response),U===0&&(U=X?A0:0);let W=U>=200&&U<300;if(e.responseType==="json"&&typeof X=="string"){let Me=X;X=X.replace(Rk,"");try{X=X!==""?JSON.parse(X):null}catch(Ee){X=Me,W&&(W=!1,X={error:Ee,text:X})}}W?(r.next(new vr({body:X,headers:E,status:U,statusText:ne,url:J||void 0})),r.complete()):r.error(new Nn({error:X,headers:E,status:U,statusText:ne,url:J||void 0}))}),_=this.maybePropagateTrace(E=>{let{url:U}=p(),ne=new Nn({error:E,status:a.status||0,statusText:a.statusText||"Unknown Error",url:U||void 0});r.error(ne)}),C=_;e.timeout&&(C=this.maybePropagateTrace(E=>{let{url:U}=p(),ne=new Nn({error:new DOMException("Request timed out","TimeoutError"),status:a.status||0,statusText:a.statusText||"Request timeout",url:U||void 0});r.error(ne)}));let h=!1,P=this.maybePropagateTrace(E=>{h||(r.next(p()),h=!0);let U={type:Rn.DownloadProgress,loaded:E.loaded};E.lengthComputable&&(U.total=E.total),e.responseType==="text"&&a.responseText&&(U.partialText=a.responseText),r.next(U)}),T=this.maybePropagateTrace(E=>{let U={type:Rn.UploadProgress,loaded:E.loaded};E.lengthComputable&&(U.total=E.total),r.next(U)});a.addEventListener("load",f),a.addEventListener("error",_),a.addEventListener("timeout",C),a.addEventListener("abort",_);let A=e.reportProgress||e.reportUploadProgress,N=e.reportProgress||e.reportDownloadProgress;return N&&a.addEventListener("progress",P),A&&s!==null&&a.upload&&a.upload.addEventListener("progress",T),a.send(s),r.next({type:Rn.Sent}),()=>{a.removeEventListener("error",_),a.removeEventListener("abort",_),a.removeEventListener("load",f),a.removeEventListener("timeout",C),N&&a.removeEventListener("progress",P),A&&s!==null&&a.upload&&a.upload.removeEventListener("progress",T),a.readyState!==a.DONE&&a.abort()}})))}static \u0275fac=function(o){return new(o||n)(Q(tf))};static \u0275prov=te({token:n,factory:n.\u0275fac,providedIn:"root"})}return n})(),mf=(function(n){return n[n.Interceptors=0]="Interceptors",n[n.LegacyInterceptors=1]="LegacyInterceptors",n[n.CustomXsrfConfiguration=2]="CustomXsrfConfiguration",n[n.NoXsrfProtection=3]="NoXsrfProtection",n[n.JsonpSupport=4]="JsonpSupport",n[n.RequestsMadeViaParent=5]="RequestsMadeViaParent",n[n.Fetch=6]="Fetch",n[n.Xhr=7]="Xhr",n})(mf||{});function Fk(n,t){return{\u0275kind:n,\u0275providers:t}}function _f(...n){let t=[Ro,Zl,Ql,{provide:Xl,useExisting:Ql},{provide:Jl,useFactory:()=>D(Zl)},{provide:L0,useValue:F0,multi:!0}];for(let e of n)t.push(...e.\u0275providers);return bo(t)}function hf(){return Fk(mf.Xhr,[ff,{provide:Jl,useExisting:ff}])}var $0=(()=>{class n{_doc;constructor(e){this._doc=e}getTitle(){return this._doc.title}setTitle(e){this._doc.title=e||""}static \u0275fac=function(o){return new(o||n)(Q(Ue))};static \u0275prov=te({token:n,factory:n.\u0275fac,providedIn:"root"})}return n})();var re="primary",_s=Symbol("RouteTitle"),Of=class{params;constructor(t){this.params=t||{}}has(t){return Object.hasOwn(this.params,t)}get(t){if(this.has(t)){let e=this.params[t];return Array.isArray(e)?e[0]:e}return null}getAll(t){if(this.has(t)){let e=this.params[t];return Array.isArray(e)?e:[e]}return[]}get keys(){return Object.keys(this.params)}};function Ii(n){return new Of(n)}function Cf(n,t,e){for(let o=0;on.length||e.pathMatch==="full"&&(t.hasChildren()||o.lengthn.length||e.pathMatch==="full"&&t.hasChildren()&&e.path!=="**")return null;let s={};return!Cf(r,n.slice(0,r.length),s)||!Cf(a,n.slice(n.length-a.length),s)?null:{consumed:n,posParams:s}}function rd(n){return new Promise((t,e)=>{n.pipe(Hn()).subscribe({next:o=>t(o),error:o=>e(o)})})}function jk(n,t){if(n.length!==t.length)return!1;for(let e=0;eo[r]===i)}else return n===t}function $k(n){return n.length>0?n[n.length-1]:null}function Ni(n){return nc(n)?n:Eo(n)?Fe(Promise.resolve(n)):se(n)}function Y0(n){return nc(n)?rd(n):Promise.resolve(n)}var zk={exact:Q0,subset:J0},Z0={exact:Bk,subset:Hk,ignored:()=>!0},Vf={paths:"exact",fragment:"ignored",matrixParams:"ignored",queryParams:"exact"},Sr={paths:"subset",fragment:"ignored",matrixParams:"ignored",queryParams:"subset"};function jf(n,t,e){let o=n instanceof bt?n:t.parseUrl(n);return I(()=>yf(t.lastSuccessfulNavigation()?.finalUrl??new bt,o,S(S({},Sr),e)))}function yf(n,t,e){return zk[e.paths](n.root,t.root,e.matrixParams)&&Z0[e.queryParams](n.queryParams,t.queryParams)&&!(e.fragment==="exact"&&n.fragment!==t.fragment)}function Bk(n,t){return Ln(n,t)}function Q0(n,t,e){if(!Di(n.segments,t.segments)||!nd(n.segments,t.segments,e)||n.numberOfChildren!==t.numberOfChildren)return!1;for(let o in t.children)if(!n.children[o]||!Q0(n.children[o],t.children[o],e))return!1;return!0}function Hk(n,t){return Object.keys(t).length<=Object.keys(n).length&&Object.keys(t).every(e=>K0(n[e],t[e]))}function J0(n,t,e){return X0(n,t,t.segments,e)}function X0(n,t,e,o){if(n.segments.length>e.length){let i=n.segments.slice(0,e.length);return!(!Di(i,e)||t.hasChildren()||!nd(i,e,o))}else if(n.segments.length===e.length){if(!Di(n.segments,e)||!nd(n.segments,e,o))return!1;for(let i in t.children)if(!n.children[i]||!J0(n.children[i],t.children[i],o))return!1;return!0}else{let i=e.slice(0,n.segments.length),r=e.slice(n.segments.length);return!Di(n.segments,i)||!nd(n.segments,i,o)||!n.children[re]?!1:X0(n.children[re],t,r,o)}}function nd(n,t,e){return t.every((o,i)=>Z0[e](n[i].parameters,o.parameters))}var bt=class{root;queryParams;fragment;_queryParamMap;constructor(t=new me([],{}),e={},o=null){this.root=t,this.queryParams=e,this.fragment=o}get queryParamMap(){return this._queryParamMap??=Ii(this.queryParams),this._queryParamMap}toString(){return qk.serialize(this)}},me=class{segments;children;parent=null;constructor(t,e){this.segments=t,this.children=e,Object.values(e).forEach(o=>o.parent=this)}hasChildren(){return this.numberOfChildren>0}get numberOfChildren(){return Object.keys(this.children).length}toString(){return od(this)}},Fo=class{path;parameters;_parameterMap;constructor(t,e){this.path=t,this.parameters=e}get parameterMap(){return this._parameterMap??=Ii(this.parameters),this._parameterMap}toString(){return tb(this)}};function Uk(n,t){return Di(n,t)&&n.every((e,o)=>Ln(e.parameters,t[o].parameters))}function Di(n,t){return n.length!==t.length?!1:n.every((e,o)=>e.path===t[o].path)}function Gk(n,t){let e=[];return Object.entries(n.children).forEach(([o,i])=>{o===re&&(e=e.concat(t(i,o)))}),Object.entries(n.children).forEach(([o,i])=>{o!==re&&(e=e.concat(t(i,o)))}),e}var Ar=(()=>{class n{static \u0275fac=function(o){return new(o||n)};static \u0275prov=Oe({token:n,factory:()=>new Lo})}return n})(),Lo=class{parse(t){let e=new wf(t);return new bt(e.parseRootSegment(),e.parseQueryParams(),e.parseFragment())}serialize(t){let e=`/${os(t.root,!0)}`,o=Yk(t.queryParams),i=typeof t.fragment=="string"?`#${Wk(t.fragment)}`:"";return`${e}${o}${i}`}},qk=new Lo;function od(n){return n.segments.map(t=>tb(t)).join("/")}function os(n,t){if(!n.hasChildren())return od(n);if(t){let e=n.children[re]?os(n.children[re],!1):"",o=[];return Object.entries(n.children).forEach(([i,r])=>{i!==re&&o.push(`${i}:${os(r,!1)}`)}),o.length>0?`${e}(${o.join("//")})`:e}else{let e=Gk(n,(o,i)=>i===re?[os(n.children[re],!1)]:[`${i}:${os(o,!1)}`]);return Object.keys(n.children).length===1&&n.children[re]!=null?`${od(n)}/${e[0]}`:`${od(n)}/(${e.join("//")})`}}function eb(n){return encodeURIComponent(n).replace(/%40/g,"@").replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",")}function ed(n){return eb(n).replace(/%3B/gi,";")}function Wk(n){return encodeURI(n)}function xf(n){return eb(n).replace(/\(/g,"%28").replace(/\)/g,"%29").replace(/%26/gi,"&")}function id(n){return decodeURIComponent(n)}function z0(n){return id(n.replace(/\+/g,"%20"))}function tb(n){return`${xf(n.path)}${Kk(n.parameters)}`}function Kk(n){return Object.entries(n).map(([t,e])=>`;${xf(t)}=${xf(e)}`).join("")}function Yk(n){let t=Object.entries(n).map(([e,o])=>Array.isArray(o)?o.map(i=>`${ed(e)}=${ed(i)}`).join("&"):`${ed(e)}=${ed(o)}`).filter(e=>e);return t.length?`?${t.join("&")}`:""}var Zk=/^[^\/()?;#]+/;function Mf(n){let t=n.match(Zk);return t?t[0]:""}var Qk=/^[^\/()?;=#]+/;function Jk(n){let t=n.match(Qk);return t?t[0]:""}var Xk=/^[^=?&#]+/;function e2(n){let t=n.match(Xk);return t?t[0]:""}var t2=/^[^&#]+/;function n2(n){let t=n.match(t2);return t?t[0]:""}var wf=class{url;remaining;constructor(t){this.url=t,this.remaining=t}parseRootSegment(){for(;this.consumeOptional("/"););return this.remaining===""||this.peekStartsWith("?")||this.peekStartsWith("#")?new me([],{}):new me([],this.parseChildren())}parseQueryParams(){let t={};if(this.consumeOptional("?"))do this.parseQueryParam(t);while(this.consumeOptional("&"));return t}parseFragment(){return this.consumeOptional("#")?decodeURIComponent(this.remaining):null}parseChildren(t=0){if(t>50)throw new z(4010,!1);if(this.remaining==="")return{};this.consumeOptional("/");let e=[];for(this.peekStartsWith("(")||e.push(this.parseSegment());this.peekStartsWith("/")&&!this.peekStartsWith("//")&&!this.peekStartsWith("/(");)this.capture("/"),e.push(this.parseSegment());let o={};this.peekStartsWith("/(")&&(this.capture("/"),o=this.parseParens(!0,t));let i={};return this.peekStartsWith("(")&&(i=this.parseParens(!1,t)),(e.length>0||Object.keys(o).length>0)&&(i[re]=new me(e,o)),i}parseSegment(){let t=Mf(this.remaining);if(t===""&&this.peekStartsWith(";"))throw new z(4009,!1);return this.capture(t),new Fo(id(t),this.parseMatrixParams())}parseMatrixParams(){let t={};for(;this.consumeOptional(";");)this.parseParam(t);return t}parseParam(t){let e=Jk(this.remaining);if(!e)return;this.capture(e);let o="";if(this.consumeOptional("=")){let i=Mf(this.remaining);i&&(o=i,this.capture(o))}t[id(e)]=id(o)}parseQueryParam(t){let e=e2(this.remaining);if(!e)return;this.capture(e);let o="";if(this.consumeOptional("=")){let a=n2(this.remaining);a&&(o=a,this.capture(o))}let i=z0(e),r=z0(o);if(Object.hasOwn(t,i)){let a=t[i];Array.isArray(a)||(a=[a],t[i]=a),a.push(r)}else t[i]=r}parseParens(t,e){let o=Object.create(null);for(this.capture("(");!this.consumeOptional(")")&&this.remaining.length>0;){let i=Mf(this.remaining),r=this.remaining[i.length];if(r!=="/"&&r!==")"&&r!==";")throw new z(4010,!1);let a;i.indexOf(":")>-1?(a=i.slice(0,i.indexOf(":")),this.capture(a),this.capture(":")):t&&(a=re);let s=this.parseChildren(e+1);o[a??re]=Object.keys(s).length===1&&s[re]?s[re]:new me([],s),this.consumeOptional("//")}return o}peekStartsWith(t){return this.remaining.startsWith(t)}consumeOptional(t){return this.peekStartsWith(t)?(this.remaining=this.remaining.substring(t.length),!0):!1}capture(t){if(!this.consumeOptional(t))throw new z(4011,!1)}};function nb(n){return n.segments.length>0?new me([],{[re]:n}):n}function ob(n){let t=Object.create(null);for(let[o,i]of Object.entries(n.children)){let r=ob(i);if(o===re&&r.segments.length===0&&r.hasChildren())for(let[a,s]of Object.entries(r.children))t[a]=s;else(r.segments.length>0||r.hasChildren())&&(t[o]=r)}let e=new me(n.segments,t);return o2(e)}function o2(n){if(n.numberOfChildren===1&&n.children[re]){let t=n.children[re];return new me(n.segments.concat(t.segments),t.children)}return n}function Vo(n){return n instanceof bt}function ib(n,t,e=null,o=null,i=new Lo){let r=rb(n);return ab(r,t,e,o,i)}function rb(n){let t;function e(r){let a={};for(let c of r.children){let p=e(c);a[c.outlet]=p}let s=new me(r.url,a);return r===n&&(t=s),s}let o=e(n.root),i=nb(o);return t??i}function ab(n,t,e,o,i){let r=n;for(;r.parent;)r=r.parent;if(t.length===0)return bf(r,r,r,e,o,i);let a=i2(t);if(a.toRoot())return bf(r,r,new me([],{}),e,o,i);let s=r2(a,r,n),c=s.processChildren?rs(s.segmentGroup,s.index,a.commands):cb(s.segmentGroup,s.index,a.commands);return bf(r,s.segmentGroup,c,e,o,i)}function ad(n){return typeof n=="object"&&n!=null&&!n.outlets&&!n.segmentPath}function cs(n){return typeof n=="object"&&n!=null&&n.outlets}function B0(n,t,e){n||="\u0275";let o=new bt;return o.queryParams={[n]:t},e.parse(e.serialize(o)).queryParams[n]}function bf(n,t,e,o,i,r){let a={};for(let[p,f]of Object.entries(o??{}))a[p]=Array.isArray(f)?f.map(_=>B0(p,_,r)):B0(p,f,r);let s;n===t?s=e:s=sb(n,t,e);let c=nb(ob(s));return new bt(c,a,i)}function sb(n,t,e){let o=Object.create(null);return Object.entries(n.children).forEach(([i,r])=>{r===t?o[i]=e:o[i]=sb(r,t,e)}),new me(n.segments,o)}var sd=class{isAbsolute;numberOfDoubleDots;commands;constructor(t,e,o){if(this.isAbsolute=t,this.numberOfDoubleDots=e,this.commands=o,t&&o.length>0&&ad(o[0]))throw new z(4003,!1);let i=o.find(cs);if(i&&i!==$k(o))throw new z(4004,!1)}toRoot(){return this.isAbsolute&&this.commands.length===1&&this.commands[0]=="/"}};function i2(n){if(typeof n[0]=="string"&&n.length===1&&n[0]==="/")return new sd(!0,0,n);let t=0,e=!1,o=n.reduce((i,r,a)=>{if(typeof r=="object"&&r!=null){if(r.outlets){let s={};return Object.entries(r.outlets).forEach(([c,p])=>{s[c]=typeof p=="string"?p.split("/"):p}),[...i,{outlets:s}]}if(r.segmentPath)return[...i,r.segmentPath]}return typeof r!="string"?[...i,r]:a===0?(r.split("/").forEach((s,c)=>{c==0&&s==="."||(c==0&&s===""?e=!0:s===".."?t++:s!=""&&i.push(s))}),i):[...i,r]},[]);return new sd(e,t,o)}var xr=class{segmentGroup;processChildren;index;constructor(t,e,o){this.segmentGroup=t,this.processChildren=e,this.index=o}};function r2(n,t,e){if(n.isAbsolute)return new xr(t,!0,0);if(!e)return new xr(t,!1,NaN);if(e.parent===null)return new xr(e,!0,0);let o=ad(n.commands[0])?0:1,i=e.segments.length-1+o;return a2(e,i,n.numberOfDoubleDots)}function a2(n,t,e){let o=n,i=t,r=e;for(;r>i;){if(r-=i,o=o.parent,!o)throw new z(4005,!1);i=o.segments.length}return new xr(o,!1,i-r)}function s2(n){return cs(n[0])?n[0].outlets:{[re]:n}}function cb(n,t,e){if(n??=new me([],{}),n.segments.length===0&&n.hasChildren())return rs(n,t,e);let o=c2(n,t,e),i=e.slice(o.commandIndex);if(o.match&&o.pathIndexr!==re)&&n.children[re]&&n.numberOfChildren===1&&n.children[re].segments.length===0){let r=rs(n.children[re],t,e);return new me(n.segments,r.children)}return Object.entries(o).forEach(([r,a])=>{typeof a=="string"&&(a=[a]),a!==null&&(i[r]=cb(n.children[r],t,a))}),Object.entries(n.children).forEach(([r,a])=>{o[r]===void 0&&(i[r]=a)}),new me(n.segments,i)}}function c2(n,t,e){let o=0,i=t,r={match:!1,pathIndex:0,commandIndex:0};for(;i=e.length)return r;let a=n.segments[i],s=e[o];if(cs(s))break;let c=`${s}`,p=o0&&c===void 0)break;if(c&&p&&typeof p=="object"&&p.outlets===void 0){if(!U0(c,p,a))return r;o+=2}else{if(!U0(c,{},a))return r;o++}i++}return{match:!0,pathIndex:i,commandIndex:o}}function Sf(n,t,e){let o=n.segments.slice(0,t),i=0;for(;i{typeof o=="string"&&(o=[o]),o!==null&&(t[e]=Sf(new me([],{}),0,o))}),t}function H0(n){let t={};return Object.entries(n).forEach(([e,o])=>t[e]=`${o}`),t}function U0(n,t,e){return n==e.path&&Ln(t,e.parameters)}var as="imperative",rt=(function(n){return n[n.NavigationStart=0]="NavigationStart",n[n.NavigationEnd=1]="NavigationEnd",n[n.NavigationCancel=2]="NavigationCancel",n[n.NavigationError=3]="NavigationError",n[n.RoutesRecognized=4]="RoutesRecognized",n[n.ResolveStart=5]="ResolveStart",n[n.ResolveEnd=6]="ResolveEnd",n[n.GuardsCheckStart=7]="GuardsCheckStart",n[n.GuardsCheckEnd=8]="GuardsCheckEnd",n[n.RouteConfigLoadStart=9]="RouteConfigLoadStart",n[n.RouteConfigLoadEnd=10]="RouteConfigLoadEnd",n[n.ChildActivationStart=11]="ChildActivationStart",n[n.ChildActivationEnd=12]="ChildActivationEnd",n[n.ActivationStart=13]="ActivationStart",n[n.ActivationEnd=14]="ActivationEnd",n[n.Scroll=15]="Scroll",n[n.NavigationSkipped=16]="NavigationSkipped",n})(rt||{}),jt=class{id;url;constructor(t,e){this.id=t,this.url=e}},Ti=class extends jt{type=rt.NavigationStart;navigationTrigger;restoredState;constructor(t,e,o="imperative",i=null){super(t,e),this.navigationTrigger=o,this.restoredState=i}toString(){return`NavigationStart(id: ${this.id}, url: '${this.url}')`}},mn=class extends jt{urlAfterRedirects;type=rt.NavigationEnd;constructor(t,e,o){super(t,e),this.urlAfterRedirects=o}toString(){return`NavigationEnd(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}')`}},pt=(function(n){return n[n.Redirect=0]="Redirect",n[n.SupersededByNewNavigation=1]="SupersededByNewNavigation",n[n.NoDataFromResolver=2]="NoDataFromResolver",n[n.GuardRejected=3]="GuardRejected",n[n.Aborted=4]="Aborted",n})(pt||{}),ls=(function(n){return n[n.IgnoredSameUrlNavigation=0]="IgnoredSameUrlNavigation",n[n.IgnoredByUrlHandlingStrategy=1]="IgnoredByUrlHandlingStrategy",n})(ls||{}),Zt=class extends jt{reason;code;type=rt.NavigationCancel;constructor(t,e,o,i){super(t,e),this.reason=o,this.code=i}toString(){return`NavigationCancel(id: ${this.id}, url: '${this.url}')`}};function lb(n){return n instanceof Zt&&(n.code===pt.Redirect||n.code===pt.SupersededByNewNavigation)}var co=class extends jt{reason;code;type=rt.NavigationSkipped;constructor(t,e,o,i){super(t,e),this.reason=o,this.code=i}},Ai=class extends jt{error;target;type=rt.NavigationError;constructor(t,e,o,i){super(t,e),this.error=o,this.target=i}toString(){return`NavigationError(id: ${this.id}, url: '${this.url}', error: ${this.error})`}},ds=class extends jt{urlAfterRedirects;state;type=rt.RoutesRecognized;constructor(t,e,o,i){super(t,e),this.urlAfterRedirects=o,this.state=i}toString(){return`RoutesRecognized(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state})`}},cd=class extends jt{urlAfterRedirects;state;type=rt.GuardsCheckStart;constructor(t,e,o,i){super(t,e),this.urlAfterRedirects=o,this.state=i}toString(){return`GuardsCheckStart(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state})`}},ld=class extends jt{urlAfterRedirects;state;shouldActivate;type=rt.GuardsCheckEnd;constructor(t,e,o,i,r){super(t,e),this.urlAfterRedirects=o,this.state=i,this.shouldActivate=r}toString(){return`GuardsCheckEnd(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state}, shouldActivate: ${this.shouldActivate})`}},dd=class extends jt{urlAfterRedirects;state;type=rt.ResolveStart;constructor(t,e,o,i){super(t,e),this.urlAfterRedirects=o,this.state=i}toString(){return`ResolveStart(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state})`}},ud=class extends jt{urlAfterRedirects;state;type=rt.ResolveEnd;constructor(t,e,o,i){super(t,e),this.urlAfterRedirects=o,this.state=i}toString(){return`ResolveEnd(id: ${this.id}, url: '${this.url}', urlAfterRedirects: '${this.urlAfterRedirects}', state: ${this.state})`}},gd=class{route;type=rt.RouteConfigLoadStart;constructor(t){this.route=t}toString(){return`RouteConfigLoadStart(path: ${this.route.path})`}},pd=class{route;type=rt.RouteConfigLoadEnd;constructor(t){this.route=t}toString(){return`RouteConfigLoadEnd(path: ${this.route.path})`}},fd=class{snapshot;type=rt.ChildActivationStart;constructor(t){this.snapshot=t}toString(){return`ChildActivationStart(path: '${this.snapshot.routeConfig&&this.snapshot.routeConfig.path||""}')`}},md=class{snapshot;type=rt.ChildActivationEnd;constructor(t){this.snapshot=t}toString(){return`ChildActivationEnd(path: '${this.snapshot.routeConfig&&this.snapshot.routeConfig.path||""}')`}},_d=class{snapshot;type=rt.ActivationStart;constructor(t){this.snapshot=t}toString(){return`ActivationStart(path: '${this.snapshot.routeConfig&&this.snapshot.routeConfig.path||""}')`}},hd=class{snapshot;type=rt.ActivationEnd;constructor(t){this.snapshot=t}toString(){return`ActivationEnd(path: '${this.snapshot.routeConfig&&this.snapshot.routeConfig.path||""}')`}};var kr=class{},us=class{},Er=class{url;navigationBehaviorOptions;constructor(t,e){this.url=t,this.navigationBehaviorOptions=e}};function d2(n){return!(n instanceof kr)&&!(n instanceof Er)&&!(n instanceof us)}var Cd=class{rootInjector;outlet=null;route=null;children;attachRef=null;get injector(){return this.route?.snapshot._environmentInjector??this.rootInjector}constructor(t){this.rootInjector=t,this.children=new Nr(this.rootInjector)}},Nr=(()=>{class n{rootInjector;contexts=new Map;constructor(e){this.rootInjector=e}onChildOutletCreated(e,o){let i=this.getOrCreateContext(e);i.outlet=o,this.contexts.set(e,i)}onChildOutletDestroyed(e){let o=this.getContext(e);o&&(o.outlet=null,o.attachRef=null)}onOutletDeactivated(){let e=this.contexts;return this.contexts=new Map,e}onOutletReAttached(e){this.contexts=e}getOrCreateContext(e){let o=this.getContext(e);return o||(o=new Cd(this.rootInjector),this.contexts.set(e,o)),o}getContext(e){return this.contexts.get(e)||null}static \u0275fac=function(o){return new(o||n)(Q(Le))};static \u0275prov=te({token:n,factory:n.\u0275fac,providedIn:"root"})}return n})(),Md=class{_root;constructor(t){this._root=t}get root(){return this._root.value}parent(t){let e=this.pathFromRoot(t);return e.length>1?e[e.length-2]:null}children(t){let e=kf(t,this._root);return e?e.children.map(o=>o.value):[]}firstChild(t){let e=kf(t,this._root);return e&&e.children.length>0?e.children[0].value:null}siblings(t){let e=Ef(t,this._root);return e.length<2?[]:e[e.length-2].children.map(i=>i.value).filter(i=>i!==t)}pathFromRoot(t){return Ef(t,this._root).map(e=>e.value)}};function kf(n,t){if(n===t.value)return t;for(let e of t.children){let o=kf(n,e);if(o)return o}return null}function Ef(n,t){if(n===t.value)return[t];for(let e of t.children){let o=Ef(n,e);if(o.length)return o.unshift(t),o}return[]}var Vt=class{value;children;constructor(t,e){this.value=t,this.children=e}toString(){return`TreeNode(${this.value})`}};function yr(n){let t={};return n&&n.children.forEach(e=>t[e.value.outlet]=e),t}var gs=class extends Md{snapshot;constructor(t,e){super(t),this.snapshot=e,zf(this,t)}toString(){return this.snapshot.toString()}};function db(n,t){let e=u2(n,t),o=new st([new Fo("",{})]),i=new st({}),r=new st({}),a=new st({}),s=new st(""),c=new lo(o,i,a,s,r,re,n,e.root);return c.snapshot=e.root,new gs(new Vt(c,[]),e)}function u2(n,t){let e={},o={},i={},a=new Dr([],e,i,"",o,re,n,null,{},t);return new ps("",new Vt(a,[]))}var lo=class{urlSubject;paramsSubject;queryParamsSubject;fragmentSubject;dataSubject;outlet;component;snapshot;_futureSnapshot;_routerState;_paramMap;_queryParamMap;title;url;params;queryParams;fragment;data;resources;_localInjector;pending;paramsSignal;queryParamsSignal;paramMapSignal;queryParamMapSignal;fragmentSignal;dataSignal;constructor(t,e,o,i,r,a,s,c){this.urlSubject=t,this.paramsSubject=e,this.queryParamsSubject=o,this.fragmentSubject=i,this.dataSubject=r,this.outlet=a,this.component=s,this._futureSnapshot=c,this.title=this.dataSubject?.pipe(Pe(p=>p[_s]))??se(void 0),this.url=t,this.params=e,this.queryParams=o,this.fragment=i,this.data=r}get routeConfig(){return this._futureSnapshot.routeConfig}get root(){return this._routerState.root}get parent(){return this._routerState.parent(this)}get firstChild(){return this._routerState.firstChild(this)}get children(){return this._routerState.children(this)}get pathFromRoot(){return this._routerState.pathFromRoot(this)}get paramMap(){return this._paramMap??=this.params.pipe(Pe(t=>Ii(t))),this._paramMap}get queryParamMap(){return this._queryParamMap??=this.queryParams.pipe(Pe(t=>Ii(t))),this._queryParamMap}toString(){return this.snapshot?this.snapshot.toString():`Future(${this._futureSnapshot})`}_setPending(t){this._futureSnapshot=t,this.pending?.set(!0)}},g2="always";function $f(n,t,e){let o,{routeConfig:i}=n;return t!==null&&(e==="always"||i?.path===""||!t.component&&!t.routeConfig?.loadComponent)?o={params:S(S({},t.params),n.params),data:S(S({},t.data),n.data),resolve:S(S(S(S({},n.data),t.data),i?.data),n._resolvedData)}:o={params:S({},n.params),data:S({},n.data),resolve:S(S({},n.data),n._resolvedData??{})},i&&gb(i)&&(o.resolve[_s]=i.title),o}var Dr=class{url;params;queryParams;fragment;data;outlet;component;routeConfig;_resolve;_resolvedData;_routerState;_paramMap;_queryParamMap;_environmentInjector;resources;get title(){return this.data?.[_s]}constructor(t,e,o,i,r,a,s,c,p,f){this.url=t,this.params=e,this.queryParams=o,this.fragment=i,this.data=r,this.outlet=a,this.component=s,this.routeConfig=c,this._resolve=p,this._environmentInjector=f}get root(){return this._routerState.root}get parent(){return this._routerState.parent(this)}get firstChild(){return this._routerState.firstChild(this)}get children(){return this._routerState.children(this)}get pathFromRoot(){return this._routerState.pathFromRoot(this)}get paramMap(){return this._paramMap??=Ii(this.params),this._paramMap}get queryParamMap(){return this._queryParamMap??=Ii(this.queryParams),this._queryParamMap}toString(){let t=this.url.map(o=>o.toString()).join("/"),e=this.routeConfig?this.routeConfig.path:"";return`Route(url:'${t}', path:'${e}')`}},ps=class extends Md{url;constructor(t,e){super(e),this.url=t,zf(this,e)}toString(){return ub(this._root)}};function zf(n,t){t.value._routerState=n,t.children.forEach(e=>zf(n,e))}function ub(n){let t=n.children.length>0?` { ${n.children.map(ub).join(", ")} } `:"";return`${n.value}${t}`}function Pf(n){if(n.snapshot){let t=n.snapshot,e=n._futureSnapshot;n.snapshot=e,Ln(t.queryParams,e.queryParams)||n.queryParamsSubject.next(e.queryParams),t.fragment!==e.fragment&&n.fragmentSubject.next(e.fragment),Ln(t.params,e.params)||n.paramsSubject.next(e.params),jk(t.url,e.url)||n.urlSubject.next(e.url),Ln(t.data,e.data)||n.dataSubject.next(e.data)}else n.snapshot=n._futureSnapshot,n.dataSubject.next(n._futureSnapshot.data)}function Df(n,t){let e=Ln(n.params,t.params)&&Uk(n.url,t.url),o=!n.parent!=!t.parent;return e&&!o&&(!n.parent||Df(n.parent,t.parent))}function gb(n){return typeof n.title=="string"||n.title===null}var pb=new B(""),hs=(()=>{class n{activated=null;get activatedComponentRef(){return this.activated}_activatedRoute=null;name=re;activateEvents=new Ke;deactivateEvents=new Ke;attachEvents=new Ke;detachEvents=new Ke;routerOutletData=Al();parentContexts=D(Nr);location=D(vi);changeDetector=D(oo);inputBinder=D(vd,{optional:!0});supportsBindingToComponentInputs=!0;ngOnChanges(e){if(e.name){let{firstChange:o,previousValue:i}=e.name;if(o)return;this.isTrackedInParentContexts(i)&&(this.deactivate(),this.parentContexts.onChildOutletDestroyed(i)),this.initializeOutletWithName()}}ngOnDestroy(){this.isTrackedInParentContexts(this.name)&&this.parentContexts.onChildOutletDestroyed(this.name),this.inputBinder?.unsubscribeFromRouteData(this)}isTrackedInParentContexts(e){return this.parentContexts.getContext(e)?.outlet===this}ngOnInit(){this.initializeOutletWithName()}initializeOutletWithName(){if(this.parentContexts.onChildOutletCreated(this.name,this),this.activated)return;let e=this.parentContexts.getContext(this.name);e?.route&&(e.attachRef?this.attach(e.attachRef,e.route):this.activateWith(e.route,e.injector))}get isActivated(){return!!this.activated}get component(){if(!this.activated)throw new z(4012,!1);return this.activated.instance}get activatedRoute(){if(!this.activated)throw new z(4012,!1);return this._activatedRoute}get activatedRouteData(){return this._activatedRoute?this._activatedRoute.snapshot.data:{}}detach(){if(!this.activated)throw new z(4012,!1);this.location.detach();let e=this.activated;return this.activated=null,this._activatedRoute=null,this.detachEvents.emit(e.instance),e}attach(e,o){this.activated=e,this._activatedRoute=o,this.location.insert(e.hostView),this.inputBinder?.bindActivatedRouteToOutletComponent(this,this.location.injector),this.attachEvents.emit(e.instance)}deactivate(){if(this.activated){let e=this.component;this.activated.destroy(),this.activated=null,this._activatedRoute=null,this.deactivateEvents.emit(e)}}activateWith(e,o){if(this.isActivated)throw new z(4013,!1);this._activatedRoute=e;let i=this.location,a=e.snapshot.component,s=this.parentContexts.getOrCreateContext(this.name).children,c=new If(e,s,i.injector,this.routerOutletData);this.activated=i.createComponent(a,{index:i.length,injector:c,environmentInjector:o}),this.changeDetector.markForCheck(),this.inputBinder?.bindActivatedRouteToOutletComponent(this,this.location.injector),this.activateEvents.emit(this.activated.instance)}static \u0275fac=function(o){return new(o||n)};static \u0275dir=qe({type:n,selectors:[["router-outlet"]],inputs:{name:"name",routerOutletData:[1,"routerOutletData"]},outputs:{activateEvents:"activate",deactivateEvents:"deactivate",attachEvents:"attach",detachEvents:"detach"},exportAs:["outlet"],features:[In]})}return n})(),If=class{route;childContexts;parent;outletData;constructor(t,e,o,i){this.route=t,this.childContexts=e,this.parent=o,this.outletData=i}get(t,e){return t===lo?this.route:t===Nr?this.childContexts:t===pb?this.outletData:this.parent.get(t,e)}},vd=new B("");var Bf=(()=>{class n{static \u0275fac=function(o){return new(o||n)};static \u0275cmp=Ge({type:n,selectors:[["ng-component"]],exportAs:["emptyRouterOutlet"],decls:1,vars:0,template:function(o,i){o&1&&$(0,"router-outlet")},dependencies:[hs],encapsulation:2,changeDetection:1})}return n})();function Hf(n){let t=n.children&&n.children.map(Hf),e=t?F(S({},n),{children:t}):S({},n);return!e.component&&!e.loadComponent&&(t||e.loadChildren)&&e.outlet&&e.outlet!==re&&(e.component=Bf),e}function p2(n,t,e){let o=new Set,i=fs(n,t._root,e?e._root:void 0,o);return{newlyCreatedRoutes:o,state:new gs(i,t)}}function fs(n,t,e,o){if(e&&n.shouldReuseRoute(t.value,e.value.snapshot)){let i=e.value;i._setPending(t.value);let r=f2(n,t,e,o);return new Vt(i,r)}else{if(n.shouldAttach(t.value)){let a=n.retrieve(t.value);if(a!==null){let s=a.route;return s.value._setPending(t.value),s.children=t.children.map(c=>fs(n,c,void 0,o)),s}}let i=m2(t.value);i._setPending(t.value),o.add(i);let r=t.children.map(a=>fs(n,a,void 0,o));return new Vt(i,r)}}function f2(n,t,e,o){return t.children.map(i=>{for(let r of e.children)if(n.shouldReuseRoute(i.value,r.value.snapshot))return fs(n,i,r,o);return fs(n,i,void 0,o)})}function m2(n){return new lo(new st(n.url),new st(n.params),new st(n.queryParams),new st(n.fragment),new st(n.data),n.outlet,n.component,n)}var Ir=class{redirectTo;navigationBehaviorOptions;constructor(t,e){this.redirectTo=t,this.navigationBehaviorOptions=e}},fb="ngNavigationCancelingError";function bd(n,t){let{redirectTo:e,navigationBehaviorOptions:o}=Vo(t)?{redirectTo:t,navigationBehaviorOptions:void 0}:t,i=mb(!1,pt.Redirect);return i.url=e,i.navigationBehaviorOptions=o,i}function mb(n,t){let e=new Error(`NavigationCancelingError: ${n||""}`);return e[fb]=!0,e.cancellationCode=t,e}function _2(n){return _b(n)&&Vo(n.url)}function _b(n){return!!n&&n[fb]}var Tf=class{routeReuseStrategy;futureState;currState;forwardEvent;inputBindingEnabled;constructor(t,e,o,i,r){this.routeReuseStrategy=t,this.futureState=e,this.currState=o,this.forwardEvent=i,this.inputBindingEnabled=r}activate(t){let e=this.futureState._root,o=this.currState?this.currState._root:null;this.deactivateChildRoutes(e,o,t),Pf(this.futureState.root),this.activateChildRoutes(e,o,t)}deactivateChildRoutes(t,e,o){let i=yr(e);t.children.forEach(r=>{let a=r.value.outlet;this.deactivateRoutes(r,i[a],o),delete i[a]}),Object.values(i).forEach(r=>{this.deactivateRouteAndItsChildren(r,o)})}deactivateRoutes(t,e,o){let i=t.value,r=e?e.value:null;if(i===r)if(i.component){let a=o.getContext(i.outlet);a&&this.deactivateChildRoutes(t,e,a.children)}else this.deactivateChildRoutes(t,e,o);else r&&this.deactivateRouteAndItsChildren(e,o)}deactivateRouteAndItsChildren(t,e){t.value.component&&this.routeReuseStrategy.shouldDetach(t.value.snapshot)?this.detachAndStoreRouteSubtree(t,e):this.deactivateRouteAndOutlet(t,e)}detachAndStoreRouteSubtree(t,e){let o=e.getContext(t.value.outlet),i=o&&t.value.component?o.children:e,r=yr(t);for(let a of Object.values(r))this.deactivateRouteAndItsChildren(a,i);if(o&&o.outlet){let a=o.outlet.detach(),s=o.children.onOutletDeactivated();this.routeReuseStrategy.store(t.value.snapshot,{componentRef:a,route:t,contexts:s})}}deactivateRouteAndOutlet(t,e){let o=e.getContext(t.value.outlet),i=o&&t.value.component?o.children:e,r=yr(t);for(let a of Object.values(r))this.deactivateRouteAndItsChildren(a,i);o&&(o.outlet&&(o.outlet.deactivate(),o.children.onOutletDeactivated()),o.attachRef=null,o.route=null),t.value._localInjector?.destroy()}activateChildRoutes(t,e,o){let i=yr(e);t.children.forEach(r=>{this.activateRoutes(r,i[r.value.outlet],o),this.forwardEvent(new hd(r.value.snapshot))}),t.children.length&&this.forwardEvent(new md(t.value.snapshot))}activateRoutes(t,e,o){let i=t.value,r=e?e.value:null;if(Pf(i),i===r)if(i.component){let a=o.getOrCreateContext(i.outlet);this.activateChildRoutes(t,e,a.children)}else this.activateChildRoutes(t,e,o);else if(i.component){let a=o.getOrCreateContext(i.outlet);if(this.routeReuseStrategy.shouldAttach(i.snapshot)){let s=this.routeReuseStrategy.retrieve(i.snapshot);this.routeReuseStrategy.store(i.snapshot,null),a.children.onOutletReAttached(s.contexts),a.attachRef=s.componentRef,a.route=s.route.value,a.outlet&&a.outlet.attach(s.componentRef,s.route.value),Pf(s.route.value),this.activateChildRoutes(t,null,a.children)}else a.attachRef=null,a.route=i,a.outlet&&a.outlet.activateWith(i,a.injector),this.activateChildRoutes(t,null,a.children)}else this.activateChildRoutes(t,null,o)}},Pd=class{path;route;constructor(t){this.path=t,this.route=this.path[this.path.length-1]}},wr=class{component;route;constructor(t,e){this.component=t,this.route=e}};function h2(n,t,e){let o=n._root,i=t?t._root:null;return is(o,i,e,[o.value])}function C2(n){let t=n.routeConfig?n.routeConfig.canActivateChild:null;return!t||t.length===0?null:{node:n,guards:t}}function Rr(n,t){let e=Symbol(),o=t.get(n,e);return o===e?typeof n=="function"&&!Vu(n)?n:t.get(n):o}function is(n,t,e,o,i={canDeactivateChecks:[],canActivateChecks:[]}){let r=yr(t);return n.children.forEach(a=>{M2(a,r[a.value.outlet],e,o.concat([a.value]),i),delete r[a.value.outlet]}),Object.entries(r).forEach(([a,s])=>ss(s,e.getContext(a),e,i)),i}function M2(n,t,e,o,i={canDeactivateChecks:[],canActivateChecks:[]}){let r=n.value,a=t?t.value:null,s=e?e.getContext(n.value.outlet):null;if(a&&r.routeConfig===a.routeConfig){let c=b2(a,r,r.routeConfig.runGuardsAndResolvers);c?i.canActivateChecks.push(new Pd(o)):(r.data=a.data,r._resolvedData=a._resolvedData),r.component?is(n,t,s?s.children:null,o,i):is(n,t,e,o,i),c&&s&&s.outlet&&s.outlet.isActivated&&i.canDeactivateChecks.push(new wr(s.outlet.component,a))}else a&&ss(t,s,e,i),i.canActivateChecks.push(new Pd(o)),r.component?is(n,null,s?s.children:null,o,i):is(n,null,e,o,i);return i}function b2(n,t,e){if(typeof e=="function")return et(t._environmentInjector,()=>e(n,t));switch(e){case"pathParamsChange":return!Di(n.url,t.url);case"pathParamsOrQueryParamsChange":return!Di(n.url,t.url)||!Ln(n.queryParams,t.queryParams);case"always":return!0;case"paramsOrQueryParamsChange":return!Df(n,t)||!Ln(n.queryParams,t.queryParams);default:return!Df(n,t)}}function ss(n,t,e,o){let i=yr(n),r=n.value;Object.entries(i).forEach(([a,s])=>{r.component?t?ss(s,t.children.getContext(a),t.children,o):ss(s,null,null,o):ss(s,e?e.getContext(a):null,e,o)}),r.component?t&&t.outlet&&t.outlet.isActivated?o.canDeactivateChecks.push(new wr(t.outlet.component,r)):o.canDeactivateChecks.push(new wr(null,r)):o.canDeactivateChecks.push(new wr(null,r))}function Cs(n){return typeof n=="function"}function P2(n){return typeof n=="boolean"}function O2(n){return n&&Cs(n.canLoad)}function v2(n){return n&&Cs(n.canActivate)}function y2(n){return n&&Cs(n.canActivateChild)}function x2(n){return n&&Cs(n.canDeactivate)}function w2(n){return n&&Cs(n.canMatch)}function hb(n){return n instanceof Qo||n?.name==="EmptyError"}var td=Symbol("INITIAL_VALUE");function Tr(){return vt(n=>Mu(n.map(t=>t.pipe(Bn(1),vu(td)))).pipe(Pe(t=>{for(let e of t)if(e!==!0){if(e===td)return td;if(e===!1||S2(e))return e}return!0}),Bt(t=>t!==td),Bn(1)))}function S2(n){return Vo(n)||n instanceof Ir}function Cb(n){return n.aborted?se(void 0).pipe(Bn(1)):new ie(t=>{let e=()=>{t.next(),t.complete()};return n.addEventListener("abort",e),()=>n.removeEventListener("abort",e)})}function Mb(n){return Xr(Cb(n))}function k2(n){return ut(t=>{let{targetSnapshot:e,currentSnapshot:o,guards:{canActivateChecks:i,canDeactivateChecks:r}}=t;return r.length===0&&i.length===0?se(F(S({},t),{guardsResult:!0})):E2(r,e,o).pipe(ut(a=>a&&P2(a)?D2(e,i,n):se(a)),Pe(a=>F(S({},t),{guardsResult:a})))})}function E2(n,t,e){return Fe(n).pipe(ut(o=>R2(o.component,o.route,e,t)),Hn(o=>o!==!0,!0))}function D2(n,t,e){return Fe(t).pipe(Jo(o=>Wi(T2(o.route.parent,e),I2(o.route,e),N2(n,o.path),A2(n,o.route))),Hn(o=>o!==!0,!0))}function I2(n,t){return n!==null&&t&&t(new _d(n)),se(!0)}function T2(n,t){return n!==null&&t&&t(new fd(n)),se(!0)}function A2(n,t){let e=t.routeConfig?t.routeConfig.canActivate:null;if(!e||e.length===0)return se(!0);let o=e.map(i=>Jr(()=>{let r=t._environmentInjector,a=Rr(i,r),s=v2(a)?a.canActivate(t,n):et(r,()=>a(t,n));return Ni(s).pipe(Hn())}));return se(o).pipe(Tr())}function N2(n,t){let e=t[t.length-1],i=t.slice(0,t.length-1).reverse().map(r=>C2(r)).filter(r=>r!==null).map(r=>Jr(()=>{let a=r.guards.map(s=>{let c=r.node._environmentInjector,p=Rr(s,c),f=y2(p)?p.canActivateChild(e,n):et(c,()=>p(e,n));return Ni(f).pipe(Hn())});return se(a).pipe(Tr())}));return se(i).pipe(Tr())}function R2(n,t,e,o){let i=t&&t.routeConfig?t.routeConfig.canDeactivate:null;if(!i||i.length===0)return se(!0);let r=i.map(a=>{let s=t._environmentInjector,c=Rr(a,s),p=x2(c)?c.canDeactivate(n,t,e,o):et(s,()=>c(n,t,e,o));return Ni(p).pipe(Hn())});return se(r).pipe(Tr())}function F2(n,t,e,o,i){let r=t.canLoad;if(r===void 0||r.length===0)return se(!0);let a=r.map(s=>{let c=Rr(s,n),p=O2(c)?c.canLoad(t,e):et(n,()=>c(t,e)),f=Ni(p);return i?f.pipe(Mb(i)):f});return se(a).pipe(Tr(),bb(o))}function bb(n){return $s(xe(t=>{if(typeof t!="boolean")throw bd(n,t)}),Pe(t=>t===!0))}function L2(n,t,e,o,i,r){let a=t.canMatch;if(!a||a.length===0)return se(!0);let s=a.map(c=>{let p=Rr(c,n),f=w2(p)?p.canMatch(t,e,i):et(n,()=>p(t,e,i));return Ni(f).pipe(Mb(r))});return se(s).pipe(Tr(),bb(o))}var so=class n extends Error{segmentGroup;constructor(t){super(),this.segmentGroup=t||null,Object.setPrototypeOf(this,n.prototype)}},ms=class n extends Error{urlTree;constructor(t){super(),this.urlTree=t,Object.setPrototypeOf(this,n.prototype)}};function V2(n){throw new z(4e3,!1)}function j2(n){throw mb(!1,pt.GuardRejected)}var Af=class{urlSerializer;urlTree;constructor(t,e){this.urlSerializer=t,this.urlTree=e}lineralizeSegments(t,e){return be(this,null,function*(){let o=[],i=e.root;for(;;){if(o=o.concat(i.segments),i.numberOfChildren===0)return o;if(i.numberOfChildren>1||!i.children[re])throw V2(`${t.redirectTo}`);i=i.children[re]}})}applyRedirectCommands(t,e,o,i,r){return be(this,null,function*(){let a=yield $2(e,i,r);if(a instanceof bt)throw new ms(a);let s=this.applyRedirectCreateUrlTree(a,this.urlSerializer.parse(a),t,o);if(a[0]==="/")throw new ms(s);return s})}applyRedirectCreateUrlTree(t,e,o,i){let r=this.createSegmentGroup(t,e.root,o,i);return new bt(r,this.createQueryParams(e.queryParams,this.urlTree.queryParams),e.fragment)}createQueryParams(t,e){let o={};return Object.entries(t).forEach(([i,r])=>{if(typeof r=="string"&&r[0]===":"){let s=r.substring(1);o[i]=e[s]}else o[i]=r}),o}createSegmentGroup(t,e,o,i){let r=this.createSegments(t,e.segments,o,i),a=Object.create(null);return Object.entries(e.children).forEach(([s,c])=>{a[s]=this.createSegmentGroup(t,c,o,i)}),new me(r,a)}createSegments(t,e,o,i){return e.map(r=>r.path[0]===":"?this.findPosParam(t,r,i):this.findOrReturn(r,o))}findPosParam(t,e,o){let i=o[e.path.substring(1)];if(!i)throw new z(4001,!1);return i}findOrReturn(t,e){let o=0;for(let i of e){if(i.path===t.path)return e.splice(o),i;o++}return t}};function $2(n,t,e){if(typeof n=="string")return Promise.resolve(n);let o=n;return rd(Ni(et(e,()=>o(t))))}function z2(n,t){return n.providers&&!n._injector&&(n._injector=Va(n.providers,t,`Route: ${n.path}`)),n._injector??t}function fn(n){return n.outlet||re}function B2(n,t){let e=n.filter(o=>fn(o)===t);return e.push(...n.filter(o=>fn(o)!==t)),e}var Nf={matched:!1,consumedSegments:[],remainingSegments:[],parameters:{},positionalParamSegments:{}};function Pb(n){return{routeConfig:n.routeConfig,url:n.url,params:n.params,queryParams:n.queryParams,fragment:n.fragment,data:n.data,outlet:n.outlet,title:n.title,paramMap:n.paramMap,queryParamMap:n.queryParamMap}}function H2(n,t,e,o,i,r,a){let s=Ob(n,t,e);if(!s.matched)return se(s);let c=Pb(r(s));return o=z2(t,o),L2(o,t,e,i,c,a).pipe(Pe(p=>p===!0?s:S({},Nf)))}function Ob(n,t,e){if(t.path==="")return t.pathMatch==="full"&&(n.hasChildren()||e.length>0)?S({},Nf):{matched:!0,consumedSegments:[],remainingSegments:e,parameters:{},positionalParamSegments:{}};let i=(t.matcher||W0)(e,n,t);if(!i)return S({},Nf);let r={};Object.entries(i.posParams??{}).forEach(([s,c])=>{r[s]=c.path});let a=i.consumed.length>0?S(S({},r),i.consumed[i.consumed.length-1].parameters):r;return{matched:!0,consumedSegments:i.consumed,remainingSegments:e.slice(i.consumed.length),parameters:a,positionalParamSegments:i.posParams??{}}}function G0(n,t,e,o,i){return e.length>0&&q2(n,e,o,i)?{segmentGroup:new me(t,G2(o,new me(e,n.children))),slicedSegments:[]}:e.length===0&&W2(n,e,o)?{segmentGroup:new me(n.segments,U2(n,e,o,n.children)),slicedSegments:e}:{segmentGroup:new me(n.segments,n.children),slicedSegments:e}}function U2(n,t,e,o){let i={};for(let r of e)if(yd(n,t,r)&&!o[fn(r)]){let a=new me([],{});i[fn(r)]=a}return S(S({},o),i)}function G2(n,t){let e={};e[re]=t;for(let o of n)if(o.path===""&&fn(o)!==re){let i=new me([],{});e[fn(o)]=i}return e}function q2(n,t,e,o){return e.some(i=>!yd(n,t,i)||!(fn(i)!==re)?!1:!(o!==void 0&&fn(i)===o))}function W2(n,t,e){return e.some(o=>yd(n,t,o))}function yd(n,t,e){return(n.hasChildren()||t.length>0)&&e.pathMatch==="full"?!1:e.path===""}function K2(n,t,e){return t.length===0&&!n.children[e]}var Rf=class{};function Y2(n,t,e,o,i,r,a,s){return be(this,null,function*(){return new Ff(n,t,e,o,i,a,r,s).recognize()})}var Z2=31,Ff=class{injector;configLoader;rootComponentType;config;urlTree;paramsInheritanceStrategy;urlSerializer;abortSignal;applyRedirects;absoluteRedirectCount=0;allowRedirects=!0;constructor(t,e,o,i,r,a,s,c){this.injector=t,this.configLoader=e,this.rootComponentType=o,this.config=i,this.urlTree=r,this.paramsInheritanceStrategy=a,this.urlSerializer=s,this.abortSignal=c,this.applyRedirects=new Af(this.urlSerializer,this.urlTree)}noMatchError(t){return new z(4002,`'${t.segmentGroup}'`)}recognize(){return be(this,null,function*(){let t=G0(this.urlTree.root,[],[],this.config).segmentGroup,{children:e,rootSnapshot:o}=yield this.match(t),i=new Vt(o,e),r=new ps("",i),a=ib(o,[],this.urlTree.queryParams,this.urlTree.fragment);return a.queryParams=this.urlTree.queryParams,r.url=this.urlSerializer.serialize(a),{state:r,tree:a}})}match(t){return be(this,null,function*(){let e=new Dr([],Object.freeze({}),Object.freeze(S({},this.urlTree.queryParams)),this.urlTree.fragment,Object.freeze({}),re,this.rootComponentType,null,{},this.injector);try{return{children:yield this.processSegmentGroup(this.injector,this.config,t,re,e),rootSnapshot:e}}catch(o){if(o instanceof ms)return this.urlTree=o.urlTree,this.match(o.urlTree.root);throw o instanceof so?this.noMatchError(o):o}})}processSegmentGroup(t,e,o,i,r){return be(this,null,function*(){if(o.segments.length===0&&o.hasChildren())return this.processChildren(t,e,o,r);let a=yield this.processSegment(t,e,o,o.segments,i,!0,r);return a instanceof Vt?[a]:[]})}processChildren(t,e,o,i){return be(this,null,function*(){let r=[];for(let c of Object.keys(o.children))c==="primary"?r.unshift(c):r.push(c);let a=[];for(let c of r){let p=o.children[c],f=B2(e,c),_=yield this.processSegmentGroup(t,f,p,c,i);a.push(..._)}let s=vb(a);return Q2(s),s})}processSegment(t,e,o,i,r,a,s){return be(this,null,function*(){for(let c of e)try{return yield this.processSegmentAgainstRoute(c._injector??t,e,c,o,i,r,a,s)}catch(p){if(p instanceof so||hb(p))continue;throw p}if(K2(o,i,r))return new Rf;throw new so(o)})}processSegmentAgainstRoute(t,e,o,i,r,a,s,c){return be(this,null,function*(){if(fn(o)!==a&&(a===re||!yd(i,r,o)))throw new so(i);if(o.redirectTo===void 0)return this.matchSegmentAgainstRoute(t,i,o,r,a,c);if(this.allowRedirects&&s)return this.expandSegmentAgainstRouteUsingRedirect(t,i,e,o,r,a,c);throw new so(i)})}expandSegmentAgainstRouteUsingRedirect(t,e,o,i,r,a,s){return be(this,null,function*(){let{matched:c,parameters:p,consumedSegments:f,positionalParamSegments:_,remainingSegments:C}=Ob(e,i,r);if(!c)throw new so(e);typeof i.redirectTo=="string"&&i.redirectTo[0]==="/"&&(this.absoluteRedirectCount++,this.absoluteRedirectCount>Z2&&(this.allowRedirects=!1));let h=this.createSnapshot(t,i,r,p,s);if(this.abortSignal.aborted)throw new Error(this.abortSignal.reason);let P=yield this.applyRedirects.applyRedirectCommands(f,i.redirectTo,_,Pb(h),t),T=yield this.applyRedirects.lineralizeSegments(i,P);return this.processSegment(t,o,e,T.concat(C),a,!1,s)})}createSnapshot(t,e,o,i,r){let a=new Dr(o,i,Object.freeze(S({},this.urlTree.queryParams)),this.urlTree.fragment,X2(e),fn(e),e.component??e._loadedComponent??null,e,eE(e),t),s=$f(a,r,this.paramsInheritanceStrategy);return a.params=Object.freeze(s.params),a.data=Object.freeze(s.data),a}matchSegmentAgainstRoute(t,e,o,i,r,a){return be(this,null,function*(){if(this.abortSignal.aborted)throw new Error(this.abortSignal.reason);let s=U=>this.createSnapshot(t,o,U.consumedSegments,U.parameters,a),c=yield rd(H2(e,o,i,t,this.urlSerializer,s,this.abortSignal));if(o.path==="**"&&(e.children={}),!c?.matched)throw new so(e);t=o._injector??t;let{routes:p}=yield this.getChildConfig(t,o,i),f=o._loadedInjector??t,{parameters:_,consumedSegments:C,remainingSegments:h}=c,P=this.createSnapshot(t,o,C,_,a),{segmentGroup:T,slicedSegments:A}=G0(e,C,h,p,r);if(A.length===0&&T.hasChildren()){let U=yield this.processChildren(f,p,T,P);return new Vt(P,U)}if(p.length===0&&A.length===0)return new Vt(P,[]);let N=fn(o)===r,E=yield this.processSegment(f,p,T,A,N?re:r,!0,P);return new Vt(P,E instanceof Vt?[E]:[])})}getChildConfig(t,e,o){return be(this,null,function*(){if(e.children)return{routes:e.children,injector:t};if(e.loadChildren){if(e._loadedRoutes!==void 0){let r=e._loadedNgModuleFactory;return r&&!e._loadedInjector&&(e._loadedInjector=r.create(t).injector),{routes:e._loadedRoutes,injector:e._loadedInjector}}if(this.abortSignal.aborted)throw new Error(this.abortSignal.reason);if(yield rd(F2(t,e,o,this.urlSerializer,this.abortSignal))){let r=yield this.configLoader.loadChildren(t,e);return e._loadedRoutes=r.routes,e._loadedInjector=r.injector,e._loadedNgModuleFactory=r.factory,r}throw j2(e)}return{routes:[],injector:t}})}};function Q2(n){n.sort((t,e)=>t.value.outlet===re?-1:e.value.outlet===re?1:t.value.outlet.localeCompare(e.value.outlet))}function J2(n){let t=n.value.routeConfig;return t&&t.path===""}function vb(n){let t=[],e=new Set;for(let o of n){if(!J2(o)){t.push(o);continue}let i=t.find(r=>o.value.routeConfig===r.value.routeConfig);i!==void 0?(i.children.push(...o.children),e.add(i)):t.push(o)}for(let o of e){let i=vb(o.children);t.push(new Vt(o.value,i))}return t.filter(o=>!e.has(o))}function X2(n){return n.data||{}}function eE(n){return n.resolve||{}}function tE(n,t,e,o,i,r,a){return ut(s=>be(null,null,function*(){let{state:c,tree:p}=yield Y2(n,t,e,o,s.extractedUrl,i,r,a);return F(S({},s),{targetSnapshot:c,urlAfterRedirects:p})}))}function nE(n){return ut(t=>{let{targetSnapshot:e,guards:{canActivateChecks:o}}=t;if(!o.length)return se(t);let i=new Set(o.map(s=>s.route)),r=new Set;for(let s of i)if(!r.has(s))for(let c of yb(s))r.add(c);let a=0;return Fe(r).pipe(Jo(s=>i.has(s)?oE(s,e,n):(s.data=$f(s,s.parent,n).resolve,se(void 0))),xe(()=>a++),ac(1),ut(s=>a===r.size?se(t):ct))})}function yb(n){let t=n.children.map(e=>yb(e)).flat();return[n,...t]}function oE(n,t,e){let o=n.routeConfig,i=n._resolve;return o?.title!==void 0&&!gb(o)&&(i[_s]=o.title),Jr(()=>(n.data=$f(n,n.parent,e).resolve,iE(i,n,t).pipe(Pe(r=>(n._resolvedData=r,n.data=S(S({},n.data),r),null)))))}function iE(n,t,e){let o=vf(n);if(o.length===0)return se({});let i={};return Fe(o).pipe(ut(r=>rE(n[r],t,e).pipe(Hn(),xe(a=>{if(a instanceof Ir)throw bd(new Lo,a);i[r]=a}))),ac(1),Pe(()=>i),Ki(r=>hb(r)?ct:Cu(r)))}function rE(n,t,e){let o=t._environmentInjector,i=Rr(n,o),r=i.resolve?i.resolve(t,e):et(o,()=>i(t,e));return Ni(r)}var xb=new B("");function Lf(n){return vt(t=>{let e=n(t);return e?Fe(e).pipe(Pe(()=>t)):se(t)})}var Uf=(()=>{class n{buildTitle(e){let o,i=e.root;for(;i!==void 0;)o=this.getResolvedTitleForRoute(i)??o,i=i.children.find(r=>r.outlet===re);return o}getResolvedTitleForRoute(e){return e.data[_s]}static \u0275fac=function(o){return new(o||n)};static \u0275prov=Oe({token:n,factory:()=>D(wb)})}return n})(),wb=(()=>{class n extends Uf{title;constructor(e){super(),this.title=e}updateTitle(e){let o=this.buildTitle(e);o!==void 0&&this.title.setTitle(o)}static \u0275fac=function(o){return new(o||n)(Q($0))};static \u0275prov=te({token:n,factory:n.\u0275fac,providedIn:"root"})}return n})(),Fr=new B("",{factory:()=>({})}),Ms=new B(""),Sb=(()=>{class n{componentLoaders=new WeakMap;childrenLoaders=new WeakMap;onLoadStartListener;onLoadEndListener;compiler=D(qp);loadComponent(e,o){return be(this,null,function*(){if(this.componentLoaders.get(o))return this.componentLoaders.get(o);if(o._loadedComponent)return Promise.resolve(o._loadedComponent);this.onLoadStartListener&&this.onLoadStartListener(o);let i=be(this,null,function*(){try{let r=yield Y0(et(e,()=>o.loadComponent())),a=yield Eb(Yp(r));return this.onLoadEndListener&&this.onLoadEndListener(o),o._loadedComponent=a,a}finally{this.componentLoaders.delete(o)}});return this.componentLoaders.set(o,i),i})}loadChildren(e,o){if(this.childrenLoaders.get(o))return this.childrenLoaders.get(o);if(o._loadedRoutes)return Promise.resolve({routes:o._loadedRoutes,injector:o._loadedInjector});this.onLoadStartListener&&this.onLoadStartListener(o);let i=be(this,null,function*(){try{let r=yield kb(o,this.compiler,e,this.onLoadEndListener);return o._loadedRoutes=r.routes,o._loadedInjector=r.injector,o._loadedNgModuleFactory=r.factory,r}finally{this.childrenLoaders.delete(o)}});return this.childrenLoaders.set(o,i),i}static \u0275fac=function(o){return new(o||n)};static \u0275prov=Oe({token:n,factory:n.\u0275fac})}return n})();function kb(n,t,e,o){return be(this,null,function*(){let i=yield Y0(et(e,()=>n.loadChildren())),r=yield Eb(Yp(i)),a;r instanceof wl||Array.isArray(r)?a=r:a=yield t.compileModuleAsync(r),o&&o(n);let s,c,p=!1,f;return Array.isArray(a)?(c=a,p=!0):(s=a.create(e).injector,f=a,c=s.get(Ms,[],{optional:!0,self:!0}).flat()),{routes:c.map(Hf),injector:s,factory:f}})}function Eb(n){return be(this,null,function*(){return n})}var xd=(()=>{class n{static \u0275fac=function(o){return new(o||n)};static \u0275prov=Oe({token:n,factory:()=>D(aE)})}return n})(),aE=(()=>{class n{shouldProcessUrl(e){return!0}extract(e){return e}merge(e,o){return e}static \u0275fac=function(o){return new(o||n)};static \u0275prov=Oe({token:n,factory:n.\u0275fac})}return n})(),Db=new B("");var sE=()=>{},Ib=new B(""),Tb=(()=>{class n{currentNavigation=x(null,{equal:()=>!1});currentTransition=null;lastSuccessfulNavigation=x(null);events=new ye;transitionAbortWithErrorSubject=new ye;configLoader=D(Sb);environmentInjector=D(Le);destroyRef=D(Ae);urlSerializer=D(Ar);rootContexts=D(Nr);location=D(br);inputBindingEnabled=D(vd,{optional:!0})!==null;titleStrategy=D(Uf);options=D(Fr,{optional:!0})||{};paramsInheritanceStrategy=this.options.paramsInheritanceStrategy||g2;urlHandlingStrategy=D(xd);createViewTransition=D(Db,{optional:!0});navigationErrorHandler=D(Ib,{optional:!0});routerResourcesFeature=D(xb,{optional:!0});navigationId=0;get hasRequestedNavigation(){return this.navigationId!==0}transitions;afterPreactivation=()=>se(void 0);rootComponentType=null;destroyed=!1;constructor(){let e=i=>this.events.next(new gd(i)),o=i=>this.events.next(new pd(i));this.configLoader.onLoadEndListener=o,this.configLoader.onLoadStartListener=e,this.destroyRef.onDestroy(()=>{this.destroyed=!0})}complete(){this.transitions?.complete()}handleNavigationRequest(e){let o=++this.navigationId;de(()=>{this.transitions?.next(F(S({},e),{extractedUrl:this.urlHandlingStrategy.extract(e.rawUrl),targetSnapshot:null,targetRouterState:null,guards:{canActivateChecks:[],canDeactivateChecks:[]},guardsResult:null,id:o,routesRecognizeHandler:{},beforeActivateHandler:{}}))})}setupNavigations(e){return this.transitions=new st(null),this.transitions.pipe(Bt(o=>o!==null),vt(o=>{let i=!0,r=!1,a=new AbortController,s=()=>!r&&this.currentTransition?.id===o.id;return se(o).pipe(vt(c=>{if(this.navigationId>o.id)return this.cancelNavigationTransition(o,"",pt.SupersededByNewNavigation),ct;this.currentTransition=o;let p=this.lastSuccessfulNavigation();this.currentNavigation.set({id:c.id,initialUrl:c.rawUrl,extractedUrl:c.extractedUrl,targetBrowserUrl:typeof c.extras.browserUrl=="string"?this.urlSerializer.parse(c.extras.browserUrl):c.extras.browserUrl,trigger:c.source,extras:c.extras,previousNavigation:p?F(S({},p),{previousNavigation:null}):null,abort:()=>a.abort(),routesRecognizeHandler:c.routesRecognizeHandler,beforeActivateHandler:c.beforeActivateHandler});let f=!e.navigated||this.isUpdatingInternalState()||this.isUpdatedBrowserUrl(),_=c.extras.onSameUrlNavigation??e.onSameUrlNavigation;if(!f&&_!=="reload")return this.events.next(new co(c.id,this.urlSerializer.serialize(c.rawUrl),"",ls.IgnoredSameUrlNavigation)),c.resolve(!1),ct;if(this.urlHandlingStrategy.shouldProcessUrl(c.rawUrl))return se(c).pipe(vt(C=>(this.events.next(new Ti(C.id,this.urlSerializer.serialize(C.extractedUrl),C.source,C.restoredState)),C.id!==this.navigationId?ct:Promise.resolve(C))),tE(this.environmentInjector,this.configLoader,this.rootComponentType,e.config,this.urlSerializer,this.paramsInheritanceStrategy,a.signal),xe(C=>{o.targetSnapshot=C.targetSnapshot,o.urlAfterRedirects=C.urlAfterRedirects,this.currentNavigation.update(h=>(h.finalUrl=C.urlAfterRedirects,h)),this.events.next(new us)}),vt(C=>Fe(o.routesRecognizeHandler.deferredHandle??se(void 0)).pipe(Pe(()=>C))),xe(()=>{let C=new ds(c.id,this.urlSerializer.serialize(c.extractedUrl),this.urlSerializer.serialize(c.urlAfterRedirects),c.targetSnapshot);this.events.next(C)}));if(f&&this.urlHandlingStrategy.shouldProcessUrl(c.currentRawUrl)){let{id:C,extractedUrl:h,source:P,restoredState:T,extras:A}=c,N=new Ti(C,this.urlSerializer.serialize(h),P,T);this.events.next(N);let E=db(this.rootComponentType,this.environmentInjector).snapshot;return this.currentTransition=o=F(S({},c),{targetSnapshot:E,urlAfterRedirects:h,extras:F(S({},A),{skipLocationChange:!1,replaceUrl:!1})}),this.currentNavigation.update(U=>(U.finalUrl=h,U)),se(o)}else return this.events.next(new co(c.id,this.urlSerializer.serialize(c.extractedUrl),"",ls.IgnoredByUrlHandlingStrategy)),c.resolve(!1),ct}),Pe(c=>{let p=new cd(c.id,this.urlSerializer.serialize(c.extractedUrl),this.urlSerializer.serialize(c.urlAfterRedirects),c.targetSnapshot);return this.events.next(p),this.currentTransition=o=F(S({},c),{guards:h2(c.targetSnapshot,c.currentSnapshot,this.rootContexts)}),o}),k2(c=>this.events.next(c)),vt(c=>{if(o.guardsResult=c.guardsResult,c.guardsResult&&typeof c.guardsResult!="boolean")throw bd(this.urlSerializer,c.guardsResult);let p=new ld(c.id,this.urlSerializer.serialize(c.extractedUrl),this.urlSerializer.serialize(c.urlAfterRedirects),c.targetSnapshot,!!c.guardsResult);if(this.events.next(p),!s())return ct;if(!c.guardsResult)return this.cancelNavigationTransition(c,"",pt.GuardRejected),ct;if(c.guards.canActivateChecks.length===0)return se(c);let f=new dd(c.id,this.urlSerializer.serialize(c.extractedUrl),this.urlSerializer.serialize(c.urlAfterRedirects),c.targetSnapshot);if(this.events.next(f),!s())return ct;let _=!1;return se(c).pipe(nE(this.paramsInheritanceStrategy),xe({next:()=>{_=!0;let C=new ud(c.id,this.urlSerializer.serialize(c.extractedUrl),this.urlSerializer.serialize(c.urlAfterRedirects),c.targetSnapshot);this.events.next(C)},complete:()=>{_||this.cancelNavigationTransition(c,"",pt.NoDataFromResolver)}}))}),Lf(c=>{let p=_=>{let C=[];if(_.routeConfig?._loadedComponent)_.component=_.routeConfig?._loadedComponent;else if(_.routeConfig?.loadComponent){let h=_._environmentInjector;C.push(this.configLoader.loadComponent(h,_.routeConfig).then(P=>{_.component=P}))}for(let h of _.children)C.push(...p(h));return C},f=p(c.targetSnapshot.root);return f.length===0?se(c):Fe(Promise.all(f).then(()=>c))}),vt(c=>{let{newlyCreatedRoutes:p,state:f}=p2(e.routeReuseStrategy,c.targetSnapshot,c.currentRouterState);return this.currentTransition=o=c=F(S({},c),{targetRouterState:f,newlyCreatedRoutes:p}),this.currentNavigation.update(_=>(_.targetRouterState=f,_)),se(c)}),this.routerResourcesFeature?.setupAndRunResources(a.signal)??(c=>c),Lf(()=>this.afterPreactivation()),vt(()=>{let{currentSnapshot:c,targetSnapshot:p}=o,f=this.createViewTransition?.(this.environmentInjector,c.root,p.root);return f?Fe(f).pipe(Pe(()=>o)):se(o)}),Bn(1),vt(c=>{i=!1,this.events.next(new kr);let p=o.beforeActivateHandler.deferredHandle;return p?Fe(p.then(()=>c)):se(c)}),xe(c=>{new Tf(e.routeReuseStrategy,o.targetRouterState,o.currentRouterState,p=>this.events.next(p),this.inputBindingEnabled).activate(this.rootContexts),c.newlyCreatedRoutes?.clear(),s()&&(Ab(c.targetRouterState),r=!0,this.currentNavigation.update(p=>(p.abort=sE,p)),this.lastSuccessfulNavigation.set(de(this.currentNavigation)),this.events.next(new mn(c.id,this.urlSerializer.serialize(c.extractedUrl),this.urlSerializer.serialize(c.urlAfterRedirects))),this.titleStrategy?.updateTitle(c.targetRouterState.snapshot),c.resolve(!0))}),Xr(Cb(a.signal).pipe(Bt(()=>!r&&i),xe(()=>{this.cancelNavigationTransition(o,a.signal.reason+"",pt.Aborted)}))),xe({complete:()=>{r=!0}}),Xr(this.transitionAbortWithErrorSubject.pipe(xe(c=>{throw c}))),_o(()=>{a.abort(),r||this.cancelNavigationTransition(o,"",pt.SupersededByNewNavigation),this.currentTransition?.id===o.id&&(this.currentNavigation.set(null),this.currentTransition=null)}),Ki(c=>{if(r=!0,q0(o),this.destroyed)return o.resolve(!1),ct;if(_b(c))this.events.next(new Zt(o.id,this.urlSerializer.serialize(o.extractedUrl),c.message,c.cancellationCode)),_2(c)?this.events.next(new Er(c.url,c.navigationBehaviorOptions)):o.resolve(!1);else{let p=new Ai(o.id,this.urlSerializer.serialize(o.extractedUrl),c,o.targetSnapshot??void 0);try{let f=et(this.environmentInjector,()=>this.navigationErrorHandler?.(p));if(f instanceof Ir){let{message:_,cancellationCode:C}=bd(this.urlSerializer,f);this.events.next(new Zt(o.id,this.urlSerializer.serialize(o.extractedUrl),_,C)),this.events.next(new Er(f.redirectTo,f.navigationBehaviorOptions))}else throw this.events.next(p),c}catch(f){this.options.resolveNavigationPromiseOnError?o.resolve(!1):o.reject(f)}}return ct}))}))}cancelNavigationTransition(e,o,i){q0(e);let r=new Zt(e.id,this.urlSerializer.serialize(e.extractedUrl),o,i);this.events.next(r),e.resolve(!1)}isUpdatingInternalState(){return this.currentTransition?.extractedUrl.toString()!==this.currentTransition?.currentUrlTree.toString()}isUpdatedBrowserUrl(){let e=this.urlHandlingStrategy.extract(this.urlSerializer.parse(this.location.path(!0))),o=de(this.currentNavigation),i=o?.targetBrowserUrl??o?.extractedUrl;return e.toString()!==i?.toString()&&!o?.extras.skipLocationChange}static \u0275fac=function(o){return new(o||n)};static \u0275prov=Oe({token:n,factory:n.\u0275fac})}return n})();function cE(n){return n!==as}function q0(n){for(let t of n.newlyCreatedRoutes??[])t._localInjector?.destroy(),t._localInjector=void 0;Ab(n.targetRouterState)}function Ab(n){if(!n)return;let t=e=>{e.value.pending?.set(!1),e.children.forEach(t)};t(n._root)}var Nb=new B("");var Rb=(()=>{class n{static \u0275fac=function(o){return new(o||n)};static \u0275prov=Oe({token:n,factory:()=>D(lE)})}return n})(),Od=class{shouldDetach(t){return!1}store(t,e){}shouldAttach(t){return!1}retrieve(t){return null}shouldReuseRoute(t,e){return t.routeConfig===e.routeConfig}shouldDestroyInjector(t){return!0}},lE=(()=>{class n extends Od{static \u0275fac=function(o){return new(o||n)};static \u0275prov=Oe({token:n,factory:n.\u0275fac})}return n})(),wd=(()=>{class n{urlSerializer=D(Ar);options=D(Fr,{optional:!0})||{};canceledNavigationResolution=this.options.canceledNavigationResolution||"replace";location=D(br);urlHandlingStrategy=D(xd);urlUpdateStrategy=this.options.urlUpdateStrategy||"deferred";currentUrlTree=new bt;getCurrentUrlTree(){return this.currentUrlTree}rawUrlTree=this.currentUrlTree;getRawUrlTree(){return this.rawUrlTree}createBrowserPath({finalUrl:e,initialUrl:o,targetBrowserUrl:i}){let r=e!==void 0?this.urlHandlingStrategy.merge(e,o):o,a=i??r;return a instanceof bt?this.urlSerializer.serialize(a):a}routerUrlState(e){return e?.targetBrowserUrl===void 0||e?.finalUrl===void 0?{}:{\u0275routerUrl:this.urlSerializer.serialize(e.finalUrl)}}commitTransition({targetRouterState:e,finalUrl:o,initialUrl:i}){o&&e?(this.currentUrlTree=o,this.rawUrlTree=this.urlHandlingStrategy.merge(o,i),this.routerState=e):this.rawUrlTree=i}routerState=db(null,D(Le));getRouterState(){return this.routerState}_stateMemento=this.createStateMemento();get stateMemento(){return this._stateMemento}updateStateMemento(){this._stateMemento=this.createStateMemento()}createStateMemento(){return{rawUrlTree:this.rawUrlTree,currentUrlTree:this.currentUrlTree,routerState:this.routerState}}restoredState(){return this.location.getState()}static \u0275fac=function(o){return new(o||n)};static \u0275prov=Oe({token:n,factory:()=>D(dE)})}return n})(),dE=(()=>{class n extends wd{currentPageId=0;lastSuccessfulId=-1;get browserPageId(){return this.canceledNavigationResolution!=="computed"?this.currentPageId:this.restoredState()?.\u0275routerPageId??this.currentPageId}registerNonRouterCurrentEntryChangeListener(e){return this.location.subscribe(o=>{o.type==="popstate"&&setTimeout(()=>{e(o.url,o.state,"popstate",{replaceUrl:!0})})})}handleRouterEvent(e,o){e instanceof Ti?this.updateStateMemento():e instanceof co?this.commitTransition(o):e instanceof ds?this.urlUpdateStrategy==="eager"&&(o.extras.skipLocationChange||this.setBrowserUrl(this.createBrowserPath(o),o)):e instanceof kr?(this.commitTransition(o),this.urlUpdateStrategy==="deferred"&&!o.extras.skipLocationChange&&this.setBrowserUrl(this.createBrowserPath(o),o)):e instanceof Zt&&!lb(e)?this.restoreHistory(o):e instanceof Ai?this.restoreHistory(o,!0):e instanceof mn&&(this.lastSuccessfulId=e.id,this.currentPageId=this.browserPageId)}setBrowserUrl(e,o){let{extras:i,id:r}=o,{replaceUrl:a,state:s}=i;if(this.location.isCurrentPathEqualTo(e)||a){let c=this.browserPageId,p=S(S({},s),this.generateNgRouterState(r,c,o));this.location.replaceState(e,"",p)}else{let c=S(S({},s),this.generateNgRouterState(r,this.browserPageId+1,o));this.location.go(e,"",c)}}restoreHistory(e,o=!1){if(this.canceledNavigationResolution==="computed"){let i=this.browserPageId,r=this.currentPageId-i;r!==0?this.location.historyGo(r):this.getCurrentUrlTree()===e.finalUrl&&r===0&&(this.resetInternalState(e),this.resetUrlToCurrentUrlTree())}else this.canceledNavigationResolution==="replace"&&(o&&this.resetInternalState(e),this.resetUrlToCurrentUrlTree())}resetInternalState({finalUrl:e}){this.routerState=this.stateMemento.routerState,this.currentUrlTree=this.stateMemento.currentUrlTree,this.rawUrlTree=this.urlHandlingStrategy.merge(this.currentUrlTree,e??this.rawUrlTree)}resetUrlToCurrentUrlTree(){this.location.replaceState(this.urlSerializer.serialize(this.getRawUrlTree()),"",this.generateNgRouterState(this.lastSuccessfulId,this.currentPageId))}generateNgRouterState(e,o,i){return this.canceledNavigationResolution==="computed"?S({navigationId:e,\u0275routerPageId:o},this.routerUrlState(i)):S({navigationId:e},this.routerUrlState(i))}static \u0275fac=function(o){return new(o||n)};static \u0275prov=Oe({token:n,factory:n.\u0275fac})}return n})();function Gf(n,t){n.events.pipe(Bt(e=>e instanceof mn||e instanceof Zt||e instanceof Ai||e instanceof co),Pe(e=>e instanceof mn||e instanceof co?0:(e instanceof Zt?e.code===pt.Redirect||e.code===pt.SupersededByNewNavigation:!1)?2:1),Bt(e=>e!==2),Bn(1)).subscribe(()=>{t()})}var uo=(()=>{class n{get currentUrlTree(){return this.stateManager.getCurrentUrlTree()}get rawUrlTree(){return this.stateManager.getRawUrlTree()}disposed=!1;nonRouterCurrentEntryChangeSubscription;console=D($p);stateManager=D(wd);options=D(Fr,{optional:!0})||{};pendingTasks=D(kn);urlUpdateStrategy=this.options.urlUpdateStrategy||"deferred";navigationTransitions=D(Tb);urlSerializer=D(Ar);location=D(br);urlHandlingStrategy=D(xd);injector=D(Le);_events=new ye;get events(){return this._events}get routerState(){return this.stateManager.getRouterState()}navigated=!1;routeReuseStrategy=D(Rb);injectorCleanup=D(Nb,{optional:!0});onSameUrlNavigation=this.options.onSameUrlNavigation||"ignore";config=D(Ms,{optional:!0})?.flat()??[];componentInputBindingEnabled=!!D(vd,{optional:!0});currentNavigation=this.navigationTransitions.currentNavigation.asReadonly();constructor(){this.resetConfig(this.config),this.navigationTransitions.setupNavigations(this).subscribe({error:e=>{}}),this.subscribeToNavigationEvents()}eventsSubscription=new Be;subscribeToNavigationEvents(){let e=this.navigationTransitions.events.subscribe(o=>{try{let i=this.navigationTransitions.currentTransition,r=de(this.navigationTransitions.currentNavigation);if(i!==null&&r!==null){if(this.stateManager.handleRouterEvent(o,r),o instanceof Zt&&o.code!==pt.Redirect&&o.code!==pt.SupersededByNewNavigation)this.navigated=!0;else if(o instanceof mn)this.navigated=!0,this.injectorCleanup?.(this.routeReuseStrategy,this.routerState,this.config);else if(o instanceof Er){let a=o.navigationBehaviorOptions,s=this.urlHandlingStrategy.merge(o.url,i.currentRawUrl),c=S({scroll:i.extras.scroll,browserUrl:i.extras.browserUrl,info:i.extras.info,skipLocationChange:i.extras.skipLocationChange,replaceUrl:i.extras.replaceUrl||this.urlUpdateStrategy==="eager"||cE(i.source)},a);this.scheduleNavigation(s,as,null,c,{resolve:i.resolve,reject:i.reject,promise:i.promise})}}d2(o)&&this._events.next(o)}catch(i){this.navigationTransitions.transitionAbortWithErrorSubject.next(i)}});this.eventsSubscription.add(e)}resetRootComponentType(e){this.routerState.root.component=e,this.navigationTransitions.rootComponentType=e}initialNavigation(){this.setUpLocationChangeListener(),this.navigationTransitions.hasRequestedNavigation||this.navigateToSyncWithBrowser(this.location.path(!0),as,this.stateManager.restoredState(),{replaceUrl:!0})}setUpLocationChangeListener(){this.nonRouterCurrentEntryChangeSubscription??=this.stateManager.registerNonRouterCurrentEntryChangeListener((e,o,i,r)=>{this.navigateToSyncWithBrowser(e,i,o,r)})}navigateToSyncWithBrowser(e,o,i,r){let a=i?.navigationId?i:null,s=i?.\u0275routerUrl??e;if(i?.\u0275routerUrl&&(r=F(S({},r),{browserUrl:e})),i){let p=S({},i);delete p.navigationId,delete p.\u0275routerPageId,delete p.\u0275routerUrl,Object.keys(p).length!==0&&(r.state=p)}let c=this.parseUrl(s);this.scheduleNavigation(c,o,a,r).catch(p=>{this.disposed||this.injector.get(Nt)(p)})}get url(){return this.serializeUrl(this.currentUrlTree)}getCurrentNavigation(){return de(this.navigationTransitions.currentNavigation)}get lastSuccessfulNavigation(){return this.navigationTransitions.lastSuccessfulNavigation}resetConfig(e){this.config=e.map(Hf),this.navigated=!1}ngOnDestroy(){this.dispose()}dispose(){this._events.unsubscribe(),this.navigationTransitions.complete(),this.nonRouterCurrentEntryChangeSubscription?.unsubscribe(),this.nonRouterCurrentEntryChangeSubscription=void 0,this.disposed=!0,this.eventsSubscription.unsubscribe()}createUrlTree(e,o={}){let{relativeTo:i,queryParams:r,fragment:a,queryParamsHandling:s,preserveFragment:c}=o,p=c?this.currentUrlTree.fragment:a,f=null;switch(s??this.options.defaultQueryParamsHandling){case"merge":f=S(S({},this.currentUrlTree.queryParams),r);break;case"preserve":f=this.currentUrlTree.queryParams;break;default:f=r||null}f!==null&&(f=this.removeEmptyProps(f));let _;try{let C=i?i.snapshot:this.routerState.snapshot.root;_=rb(C)}catch(C){(typeof e[0]!="string"||e[0][0]!=="/")&&(e=[]),_=this.currentUrlTree.root}return ab(_,e,f,p??null,this.urlSerializer)}navigateByUrl(e,o={skipLocationChange:!1}){let i=Vo(e)?e:this.parseUrl(e),r=this.urlHandlingStrategy.merge(i,this.rawUrlTree);return this.scheduleNavigation(r,as,null,o)}navigate(e,o={skipLocationChange:!1}){return uE(e),this.navigateByUrl(this.createUrlTree(e,o),o)}serializeUrl(e){return this.urlSerializer.serialize(e)}parseUrl(e){try{return this.urlSerializer.parse(e)}catch(o){return this.console.warn(Wn(4018,!1)),this.urlSerializer.parse("/")}}isActive(e,o){let i;if(o===!0?i=S({},Vf):o===!1?i=S({},Sr):i=S(S({},Sr),o),Vo(e))return yf(this.currentUrlTree,e,i);let r=this.parseUrl(e);return yf(this.currentUrlTree,r,i)}removeEmptyProps(e){return Object.entries(e).reduce((o,[i,r])=>(r!=null&&(o[i]=r),o),{})}scheduleNavigation(e,o,i,r,a){if(this.disposed)return Promise.resolve(!1);let s,c,p;a?(s=a.resolve,c=a.reject,p=a.promise):p=new Promise((_,C)=>{s=_,c=C});let f=this.pendingTasks.add();return Gf(this,()=>{queueMicrotask(()=>this.pendingTasks.remove(f))}),this.navigationTransitions.handleNavigationRequest({source:o,restoredState:i,currentUrlTree:this.currentUrlTree,currentRawUrl:this.currentUrlTree,rawUrl:e,extras:r,resolve:s,reject:c,promise:p,currentSnapshot:this.routerState.snapshot,currentRouterState:this.routerState}),p.catch(Promise.reject.bind(Promise))}static \u0275fac=function(o){return new(o||n)};static \u0275prov=Oe({token:n,factory:n.\u0275fac})}return n})();function uE(n){for(let t=0;t{class n{router=D(uo);stateManager=D(wd);fragment=x("");queryParams=x({});path=x("");serializer=D(Ar);constructor(){this.updateState(),this.router.events?.subscribe(e=>{e instanceof mn&&this.updateState()})}updateState(){let{fragment:e,root:o,queryParams:i}=this.stateManager.getCurrentUrlTree();this.fragment.set(e),this.queryParams.set(i),this.path.set(this.serializer.serialize(new bt(o)))}static \u0275fac=function(o){return new(o||n)};static \u0275prov=Oe({token:n,factory:n.\u0275fac})}return n})(),Lr=(()=>{class n{router;route;tabIndexAttribute;renderer;el;locationStrategy;hrefAttributeValue=D(new Tl("href"),{optional:!0});reactiveHref=Wp(()=>this.isAnchorElement?this.computeHref(this._urlTree()):this.hrefAttributeValue);get href(){return de(this.reactiveHref)}set href(e){this.reactiveHref.set(e)}set target(e){this._target.set(e)}get target(){return de(this._target)}_target=x(void 0);set queryParams(e){this._queryParams.set(e)}get queryParams(){return de(this._queryParams)}_queryParams=x(void 0,{equal:()=>!1});set fragment(e){this._fragment.set(e)}get fragment(){return de(this._fragment)}_fragment=x(void 0);set queryParamsHandling(e){this._queryParamsHandling.set(e)}get queryParamsHandling(){return de(this._queryParamsHandling)}_queryParamsHandling=x(void 0);set state(e){this._state.set(e)}get state(){return de(this._state)}_state=x(void 0,{equal:()=>!1});set info(e){this._info.set(e)}get info(){return de(this._info)}_info=x(void 0,{equal:()=>!1});set relativeTo(e){this._relativeTo.set(e)}get relativeTo(){return de(this._relativeTo)}_relativeTo=x(void 0);set preserveFragment(e){this._preserveFragment.set(e)}get preserveFragment(){return de(this._preserveFragment)}_preserveFragment=x(!1);set skipLocationChange(e){this._skipLocationChange.set(e)}get skipLocationChange(){return de(this._skipLocationChange)}_skipLocationChange=x(!1);set replaceUrl(e){this._replaceUrl.set(e)}get replaceUrl(){return de(this._replaceUrl)}_replaceUrl=x(!1);browserUrl=Al(void 0);isAnchorElement;onChanges=new ye;applicationErrorHandler=D(Nt);options=D(Fr,{optional:!0});reactiveRouterState=D(pE);constructor(e,o,i,r,a,s){this.router=e,this.route=o,this.tabIndexAttribute=i,this.renderer=r,this.el=a,this.locationStrategy=s;let c=a.nativeElement.tagName?.toLowerCase();this.isAnchorElement=c==="a"||c==="area"||!!(typeof customElements=="object"&&customElements.get(c)?.observedAttributes?.includes?.("href"))}setTabIndexIfNotOnNativeEl(e){this.tabIndexAttribute!=null||this.isAnchorElement||this.applyAttributeValue("tabindex",e)}ngOnChanges(e){this.onChanges.next(this)}routerLinkInput=x(null);set routerLink(e){e==null?(this.routerLinkInput.set(null),this.setTabIndexIfNotOnNativeEl(null)):(Vo(e)?this.routerLinkInput.set(e):this.routerLinkInput.set(Array.isArray(e)?e:[e]),this.setTabIndexIfNotOnNativeEl("0"))}onClick(e,o,i,r,a){let s=this._urlTree();if(s===null||this.isAnchorElement&&(e!==0||o||i||r||a||typeof this.target=="string"&&this.target!="_self"))return!0;let c=this.browserUrl(),p=S({skipLocationChange:this.skipLocationChange,replaceUrl:this.replaceUrl,state:this.state,info:this.info},c!==void 0&&{browserUrl:c});return this.router.navigateByUrl(s,p)?.catch(f=>{this.applicationErrorHandler(f)}),!this.isAnchorElement}ngOnDestroy(){}applyAttributeValue(e,o){let i=this.renderer,r=this.el.nativeElement;o!==null?i.setAttribute(r,e,o):i.removeAttribute(r,e)}_urlTree=I(()=>{this.reactiveRouterState.path(),this._preserveFragment()&&this.reactiveRouterState.fragment();let e=i=>i==="preserve"||i==="merge";(e(this._queryParamsHandling())||e(this.options?.defaultQueryParamsHandling))&&this.reactiveRouterState.queryParams();let o=this.routerLinkInput();return o===null||!this.router.createUrlTree?null:Vo(o)?o:this.router.createUrlTree(o,{relativeTo:this._relativeTo()!==void 0?this._relativeTo():this.route,queryParams:this._queryParams(),fragment:this._fragment(),queryParamsHandling:this._queryParamsHandling(),preserveFragment:this._preserveFragment()})},{equal:(e,o)=>this.computeHref(e)===this.computeHref(o)});get urlTree(){return de(this._urlTree)}computeHref(e){return e!==null&&this.locationStrategy?this.locationStrategy?.prepareExternalUrl(this.router.serializeUrl(e))??"":null}static \u0275fac=function(o){return new(o||n)(le(uo),le(lo),Ra("tabindex"),le(wt),le(Mt),le(Mr))};static \u0275dir=qe({type:n,selectors:[["","routerLink",""]],hostVars:2,hostBindings:function(o,i){o&1&&k("click",function(a){return i.onClick(a.button,a.ctrlKey,a.shiftKey,a.altKey,a.metaKey)}),o&2&&Wt("href",i.reactiveHref(),_p)("target",i._target())},inputs:{target:"target",queryParams:"queryParams",fragment:"fragment",queryParamsHandling:"queryParamsHandling",state:"state",info:"info",relativeTo:"relativeTo",preserveFragment:[2,"preserveFragment","preserveFragment",Si],skipLocationChange:[2,"skipLocationChange","skipLocationChange",Si],replaceUrl:[2,"replaceUrl","replaceUrl",Si],browserUrl:[1,"browserUrl"],routerLink:"routerLink"},features:[In]})}return n})(),qf=(()=>{class n{router;element;renderer;cdr;links;classes=[];routerEventsSubscription;linkInputChangesSubscription;_isActive=!1;get isActive(){return this._isActive}routerLinkActiveOptions={exact:!1};ariaCurrentWhenActive;isActiveChange=new Ke;link=D(Lr,{optional:!0});constructor(e,o,i,r){this.router=e,this.element=o,this.renderer=i,this.cdr=r,this.routerEventsSubscription=e.events.subscribe(a=>{a instanceof mn&&this.update()})}ngAfterContentInit(){se(this.links.changes,se(null)).pipe(qi()).subscribe(e=>{this.update(),this.subscribeToEachLinkOnChanges()})}subscribeToEachLinkOnChanges(){this.linkInputChangesSubscription?.unsubscribe();let e=[...this.links.toArray(),this.link].filter(o=>!!o).map(o=>o.onChanges);this.linkInputChangesSubscription=Fe(e).pipe(qi()).subscribe(o=>{this._isActive!==this.isLinkActive(this.router)(o)&&this.update()})}set routerLinkActive(e){if(e==null){this.classes=[];return}let o=Array.isArray(e)?e:e.split(" ");this.classes=o.filter(i=>!!i)}ngOnChanges(e){this.update()}ngOnDestroy(){this.routerEventsSubscription.unsubscribe(),this.linkInputChangesSubscription?.unsubscribe()}update(){!this.links||!this.router.navigated||this.routerLinkActiveOptions===null&&!this._isActive||queueMicrotask(()=>{let e=this.hasActiveLinks();this.classes.forEach(o=>{e?this.renderer.addClass(this.element.nativeElement,o):this.renderer.removeClass(this.element.nativeElement,o)}),e&&this.ariaCurrentWhenActive!==void 0?this.renderer.setAttribute(this.element.nativeElement,"aria-current",this.ariaCurrentWhenActive.toString()):this.renderer.removeAttribute(this.element.nativeElement,"aria-current"),this._isActive!==e&&(this._isActive=e,this.cdr.markForCheck(),this.isActiveChange.emit(e))})}isLinkActive(e){let o=this.routerLinkActiveOptions;if(o===null)return()=>!1;let i;return o===void 0?i=S({},Sr):fE(o)?i=o:o.exact??!1?i=S({},Vf):i=S({},Sr),r=>{let a=r.urlTree;return a?de(jf(a,e,i)):!1}}hasActiveLinks(){let e=this.isLinkActive(this.router);return this.link&&e(this.link)||this.links.some(e)}static \u0275fac=function(o){return new(o||n)(le(uo),le(Mt),le(wt),le(oo))};static \u0275dir=qe({type:n,selectors:[["","routerLinkActive",""]],contentQueries:function(o,i,r){if(o&1&&El(r,Lr,5),o&2){let a;eo(a=to())&&(i.links=a)}},inputs:{routerLinkActiveOptions:"routerLinkActiveOptions",ariaCurrentWhenActive:"ariaCurrentWhenActive",routerLinkActive:"routerLinkActive"},outputs:{isActiveChange:"isActiveChange"},exportAs:["routerLinkActive"],features:[In]})}return n})();function fE(n){let t=n;return!!(t.paths||t.matrixParams||t.queryParams||t.fragment)}var mE=new B("");function Wf(n,...t){return bo([{provide:Ms,multi:!0,useValue:n},{provide:lo,useFactory:_E},{provide:kl,multi:!0,useFactory:hE},t.map(e=>e.\u0275providers)])}function _E(){return D(uo).routerState.root}function hE(){let n=D(Ct);return t=>{let e=n.get(Xn);if(t!==e.components[0])return;let o=n.get(uo),i=n.get(CE);n.get(ME)===1&&o.initialNavigation(),n.get(bE,null,{optional:!0})?.setUpPreloading(),n.get(mE,null,{optional:!0})?.init(),o.resetRootComponentType(e.componentTypes[0]),i.closed||(i.next(),i.complete(),i.unsubscribe())}}var CE=new B("",{factory:()=>new ye}),ME=new B("",{factory:()=>1});var bE=new B("");var Gb=(()=>{class n{_renderer;_elementRef;onChange=e=>{};onTouched=()=>{};constructor(e,o){this._renderer=e,this._elementRef=o}setProperty(e,o){this._renderer.setProperty(this._elementRef.nativeElement,e,o)}registerOnTouched(e){this.onTouched=e}registerOnChange(e){this.onChange=e}setDisabledState(e){this.setProperty("disabled",e)}static \u0275fac=function(o){return new(o||n)(le(wt),le(Mt))};static \u0275dir=qe({type:n})}return n})(),Td=(()=>{class n extends Gb{static \u0275fac=(()=>{let e;return function(i){return(e||(e=ko(n)))(i||n)}})();static \u0275dir=qe({type:n,features:[gn]})}return n})(),xs=new B(""),PE={provide:xs,useExisting:on(()=>em),multi:!0},em=(()=>{class n extends Td{writeValue(e){this.setProperty("checked",e)}static \u0275fac=(()=>{let e;return function(i){return(e||(e=ko(n)))(i||n)}})();static \u0275dir=qe({type:n,selectors:[["input","type","checkbox","formControlName","",3,"ngNoCva",""],["input","type","checkbox","formControl","",3,"ngNoCva",""],["input","type","checkbox","ngModel","",3,"ngNoCva",""]],hostBindings:function(o,i){o&1&&k("change",function(a){return i.onChange(a.target.checked)})("blur",function(){return i.onTouched()})},standalone:!1,features:[To([PE]),gn]})}return n})(),OE={provide:xs,useExisting:on(()=>zr),multi:!0};function vE(){let n=Kt()?Kt().getUserAgent():"";return/android (\d+)/.test(n.toLowerCase())}var yE=new B(""),zr=(()=>{class n extends Gb{_compositionMode;_composing=!1;constructor(e,o,i){super(e,o),this._compositionMode=i,this._compositionMode==null&&(this._compositionMode=!vE())}writeValue(e){let o=e??"";this.setProperty("value",o)}_handleInput(e){(!this._compositionMode||this._compositionMode&&!this._composing)&&this.onChange(e)}_compositionStart(){this._composing=!0}_compositionEnd(e){this._composing=!1,this._compositionMode&&this.onChange(e)}static \u0275fac=function(o){return new(o||n)(le(wt),le(Mt),le(yE,8))};static \u0275dir=qe({type:n,selectors:[["input","formControlName","",3,"type","checkbox",3,"ngNoCva",""],["textarea","formControlName","",3,"ngNoCva",""],["input","formControl","",3,"type","checkbox",3,"ngNoCva",""],["textarea","formControl","",3,"ngNoCva",""],["input","ngModel","",3,"type","checkbox",3,"ngNoCva",""],["textarea","ngModel","",3,"ngNoCva",""],["","ngDefaultControl",""]],hostBindings:function(o,i){o&1&&k("input",function(a){return i._handleInput(a.target.value)})("blur",function(){return i.onTouched()})("compositionstart",function(){return i._compositionStart()})("compositionend",function(a){return i._compositionEnd(a.target.value)})},standalone:!1,features:[To([OE]),gn]})}return n})();function tm(n){return n==null||nm(n)===0}function nm(n){return n==null?null:Array.isArray(n)||typeof n=="string"?n.length:n instanceof Set?n.size:null}var qb=new B(""),xE=new B(""),wE=/^(?=.{1,254}$)(?=.{1,64}@)[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/,Kf=class{static min(t){return SE(t)}static max(t){return kE(t)}static required(t){return Wb(t)}static requiredTrue(t){return EE(t)}static email(t){return DE(t)}static minLength(t){return IE(t)}static maxLength(t){return TE(t)}static pattern(t){return AE(t)}static nullValidator(t){return kd()}static compose(t){return Xb(t)}static composeAsync(t){return tP(t)}};function SE(n){return t=>{if(t.value==null||n==null)return null;let e=parseFloat(t.value);return!isNaN(e)&&e{if(t.value==null||n==null)return null;let e=parseFloat(t.value);return!isNaN(e)&&e>n?{max:{max:n,actual:t.value}}:null}}function Wb(n){return tm(n.value)?{required:!0}:null}function EE(n){return n.value===!0?null:{required:!0}}function DE(n){return tm(n.value)||wE.test(n.value)?null:{email:!0}}function IE(n){return t=>{let e=t.value?.length??nm(t.value);return e===null||e===0?null:e{let e=t.value?.length??nm(t.value);return e!==null&&e>n?{maxlength:{requiredLength:n,actualLength:e}}:null}}function AE(n){if(!n)return kd;let t,e;return typeof n=="string"?(e="",n.charAt(0)!=="^"&&(e+="^"),e+=n,n.charAt(n.length-1)!=="$"&&(e+="$"),t=new RegExp(e)):(e=n.toString(),t=n),o=>{if(tm(o.value))return null;let i=o.value;return t.test(i)?null:{pattern:{requiredPattern:e,actualValue:i}}}}function kd(n){return null}function Kb(n){return n!=null}function Yb(n){return Eo(n)?Fe(n):n}function Zb(n){let t={};return n.forEach(e=>{t=e!=null?S(S({},t),e):t}),Object.keys(t).length===0?null:t}function Qb(n,t){return t.map(e=>e(n))}function NE(n){return!n.validate}function Jb(n){return n.map(t=>NE(t)?t:e=>t.validate(e))}function Xb(n){if(!n)return null;let t=n.filter(Kb);return t.length==0?null:function(e){return Zb(Qb(e,t))}}function eP(n){return n!=null?Xb(Jb(n)):null}function tP(n){if(!n)return null;let t=n.filter(Kb);return t.length==0?null:function(e){let o=Qb(e,t).map(Yb);return bu(o).pipe(Pe(Zb))}}function nP(n){return n!=null?tP(Jb(n)):null}function Fb(n,t){return n===null?[t]:Array.isArray(n)?[...n,t]:[n,t]}function RE(n){return n._rawValidators}function FE(n){return n._rawAsyncValidators}function Yf(n){return n?Array.isArray(n)?n:[n]:[]}function Ed(n,t){return Array.isArray(n)?n.includes(t):n===t}function Lb(n,t){let e=Yf(t);return Yf(n).forEach(i=>{Ed(e,i)||e.push(i)}),e}function Vb(n,t){return Yf(t).filter(e=>!Ed(n,e))}var Dd=class{get value(){return this.control?this.control.value:null}get valid(){return this.control?this.control.valid:null}get invalid(){return this.control?this.control.invalid:null}get pending(){return this.control?this.control.pending:null}get disabled(){return this.control?this.control.disabled:null}get enabled(){return this.control?this.control.enabled:null}get errors(){return this.control?this.control.errors:null}get pristine(){return this.control?this.control.pristine:null}get dirty(){return this.control?this.control.dirty:null}get touched(){return this.control?this.control.touched:null}get status(){return this.control?this.control.status:null}get untouched(){return this.control?this.control.untouched:null}get statusChanges(){return this.control?this.control.statusChanges:null}get valueChanges(){return this.control?this.control.valueChanges:null}get path(){return null}_composedValidatorFn;_composedAsyncValidatorFn;_rawValidators=[];_rawAsyncValidators=[];_setValidators(t){this._rawValidators=t||[],this._composedValidatorFn=eP(this._rawValidators)}_setAsyncValidators(t){this._rawAsyncValidators=t||[],this._composedAsyncValidatorFn=nP(this._rawAsyncValidators)}get validator(){return this._composedValidatorFn||null}get asyncValidator(){return this._composedAsyncValidatorFn||null}_onDestroyCallbacks=[];_registerOnDestroy(t){this._onDestroyCallbacks.push(t)}_invokeOnDestroyCallbacks(){this._onDestroyCallbacks.forEach(t=>t()),this._onDestroyCallbacks=[]}reset(t=void 0){this.control?.reset(t)}hasError(t,e){return this.control?this.control.hasError(t,e):!1}getError(t,e){return this.control?this.control.getError(t,e):null}},Zf=class extends Dd{name;get formDirective(){return null}get path(){return null}};var bs="VALID",Sd="INVALID",Vr="PENDING",Ps="DISABLED",Ri=class{},Id=class extends Ri{value;source;constructor(t,e){super(),this.value=t,this.source=e}},Os=class extends Ri{pristine;source;constructor(t,e){super(),this.pristine=t,this.source=e}},vs=class extends Ri{touched;source;constructor(t,e){super(),this.touched=t,this.source=e}},jr=class extends Ri{status;source;constructor(t,e){super(),this.status=t,this.source=e}};var ys=class extends Ri{source;constructor(t){super(),this.source=t}};function LE(n){return(Ad(n)?n.validators:n)||null}function VE(n){return Array.isArray(n)?eP(n):n||null}function jE(n,t){return(Ad(t)?t.asyncValidators:n)||null}function $E(n){return Array.isArray(n)?nP(n):n||null}function Ad(n){return n!=null&&!Array.isArray(n)&&typeof n=="object"}var Qf=class{_pendingDirty=!1;_hasOwnPendingAsyncValidator=null;_pendingTouched=!1;_onCollectionChange=()=>{};_updateOn;_hasRequired=x(!1);_parent=null;_asyncValidationSubscription;_composedValidatorFn;_composedAsyncValidatorFn;_rawValidators;_rawAsyncValidators;value;constructor(t,e){this._assignValidators(t),this._assignAsyncValidators(e)}get validator(){return this._composedValidatorFn}set validator(t){this._rawValidators=this._composedValidatorFn=t,this._updateHasRequiredValidator()}get asyncValidator(){return this._composedAsyncValidatorFn}set asyncValidator(t){this._rawAsyncValidators=this._composedAsyncValidatorFn=t}get parent(){return this._parent}get status(){return de(this.statusReactive)}set status(t){de(()=>this.statusReactive.set(t))}_status=I(()=>this.statusReactive());statusReactive=x(void 0);get valid(){return this.status===bs}get invalid(){return this.status===Sd}get pending(){return this.status===Vr}get disabled(){return this.status===Ps}get enabled(){return this.status!==Ps}errors;get pristine(){return de(this.pristineReactive)}set pristine(t){de(()=>this.pristineReactive.set(t))}_pristine=I(()=>this.pristineReactive());pristineReactive=x(!0);get dirty(){return!this.pristine}get touched(){return de(this.touchedReactive)}set touched(t){de(()=>this.touchedReactive.set(t))}_touched=I(()=>this.touchedReactive());touchedReactive=x(!1);get untouched(){return!this.touched}_events=new ye;events=this._events.asObservable();valueChanges;statusChanges;get updateOn(){return this._updateOn?this._updateOn:this.parent?this.parent.updateOn:"change"}setValidators(t){this._assignValidators(t)}setAsyncValidators(t){this._assignAsyncValidators(t)}addValidators(t){this.setValidators(Lb(t,this._rawValidators))}addAsyncValidators(t){this.setAsyncValidators(Lb(t,this._rawAsyncValidators))}removeValidators(t){this.setValidators(Vb(t,this._rawValidators))}removeAsyncValidators(t){this.setAsyncValidators(Vb(t,this._rawAsyncValidators))}hasValidator(t){return Ed(this._rawValidators,t)}hasAsyncValidator(t){return Ed(this._rawAsyncValidators,t)}clearValidators(){this.validator=null}clearAsyncValidators(){this.asyncValidator=null}markAsTouched(t={}){let e=this.touched===!1;this.touched=!0;let o=t.sourceControl??this;t.onlySelf||this._parent?.markAsTouched(F(S({},t),{sourceControl:o})),e&&t.emitEvent!==!1&&this._events.next(new vs(!0,o))}markAllAsDirty(t={}){this.markAsDirty({onlySelf:!0,emitEvent:t.emitEvent,sourceControl:this}),this._forEachChild(e=>e.markAllAsDirty(t))}markAllAsTouched(t={}){this.markAsTouched({onlySelf:!0,emitEvent:t.emitEvent,sourceControl:this}),this._forEachChild(e=>e.markAllAsTouched(t))}markAsUntouched(t={}){let e=this.touched===!0;this.touched=!1,this._pendingTouched=!1;let o=t.sourceControl??this;this._forEachChild(i=>{i.markAsUntouched({onlySelf:!0,emitEvent:t.emitEvent,sourceControl:o})}),t.onlySelf||this._parent?._updateTouched(t,o),e&&t.emitEvent!==!1&&this._events.next(new vs(!1,o))}markAsDirty(t={}){let e=this.pristine===!0;this.pristine=!1;let o=t.sourceControl??this;t.onlySelf||this._parent?.markAsDirty(F(S({},t),{sourceControl:o})),e&&t.emitEvent!==!1&&this._events.next(new Os(!1,o))}markAsPristine(t={}){let e=this.pristine===!1;this.pristine=!0,this._pendingDirty=!1;let o=t.sourceControl??this;this._forEachChild(i=>{i.markAsPristine({onlySelf:!0,emitEvent:t.emitEvent})}),t.onlySelf||this._parent?._updatePristine(t,o),e&&t.emitEvent!==!1&&this._events.next(new Os(!0,o))}markAsPending(t={}){this.status=Vr;let e=t.sourceControl??this;t.emitEvent!==!1&&(this._events.next(new jr(this.status,e)),this.statusChanges.emit(this.status)),t.onlySelf||this._parent?.markAsPending(F(S({},t),{sourceControl:e}))}disable(t={}){let e=this._parentMarkedDirty(t.onlySelf);this.status=Ps,this.errors=null,this._forEachChild(i=>{i.disable(F(S({},t),{onlySelf:!0}))}),this._updateValue();let o=t.sourceControl??this;t.emitEvent!==!1&&(this._events.next(new Id(this.value,o)),this._events.next(new jr(this.status,o)),this.valueChanges.emit(this.value),this.statusChanges.emit(this.status)),this._updateAncestors(F(S({},t),{skipPristineCheck:e}),this),this._onDisabledChange.forEach(i=>i(!0))}enable(t={}){let e=this._parentMarkedDirty(t.onlySelf);this.status=bs,this._forEachChild(o=>{o.enable(F(S({},t),{onlySelf:!0}))}),this.updateValueAndValidity({onlySelf:!0,emitEvent:t.emitEvent}),this._updateAncestors(F(S({},t),{skipPristineCheck:e}),this),this._onDisabledChange.forEach(o=>o(!1))}_updateAncestors(t,e){t.onlySelf||(this._parent?.updateValueAndValidity(t),t.skipPristineCheck||this._parent?._updatePristine({},e),this._parent?._updateTouched({},e))}setParent(t){this._parent=t}getRawValue(){return this.value}updateValueAndValidity(t={}){if(this._setInitialStatus(),this._updateValue(),this.enabled){let o=this._cancelExistingSubscription();this.errors=this._runValidator(),this.status=this._calculateStatus(),(this.status===bs||this.status===Vr)&&this._runAsyncValidator(o,t.emitEvent)}let e=t.sourceControl??this;t.emitEvent!==!1&&(this._events.next(new Id(this.value,e)),this._events.next(new jr(this.status,e)),this.valueChanges.emit(this.value),this.statusChanges.emit(this.status)),t.onlySelf||this._parent?.updateValueAndValidity(F(S({},t),{sourceControl:e}))}_updateTreeValidity(t={emitEvent:!0}){this._forEachChild(e=>e._updateTreeValidity(t)),this.updateValueAndValidity({onlySelf:!0,emitEvent:t.emitEvent})}_setInitialStatus(){this.status=this._allControlsDisabled()?Ps:bs}_runValidator(){return this.validator?this.validator(this):null}_runAsyncValidator(t,e){if(this.asyncValidator){this.status=Vr,this._hasOwnPendingAsyncValidator={emitEvent:e!==!1,shouldHaveEmitted:t!==!1};let o=Yb(this.asyncValidator(this));this._asyncValidationSubscription=o.subscribe(i=>{this._hasOwnPendingAsyncValidator=null,this.setErrors(i,{emitEvent:e,shouldHaveEmitted:t})})}}_cancelExistingSubscription(){if(this._asyncValidationSubscription){this._asyncValidationSubscription.unsubscribe();let t=(this._hasOwnPendingAsyncValidator?.emitEvent||this._hasOwnPendingAsyncValidator?.shouldHaveEmitted)??!1;return this._hasOwnPendingAsyncValidator=null,t}return!1}setErrors(t,e={}){this.errors=t,this._updateControlsErrors(e.emitEvent!==!1,this,e.shouldHaveEmitted)}get(t){let e=t;return e==null||(Array.isArray(e)||(e=e.split(".")),e.length===0)?null:e.reduce((o,i)=>o&&o._find(i),this)}getError(t,e){let o=e?this.get(e):this;return o?.errors?o.errors[t]:null}hasError(t,e){return!!this.getError(t,e)}get root(){let t=this;for(;t._parent;)t=t._parent;return t}_updateControlsErrors(t,e,o){this.status=this._calculateStatus(),t&&this.statusChanges.emit(this.status),(t||o)&&this._events.next(new jr(this.status,e)),this._parent&&this._parent._updateControlsErrors(t,e,o)}_initObservables(){this.valueChanges=new Ke,this.statusChanges=new Ke}_calculateStatus(){return this._allControlsDisabled()?Ps:this.errors?Sd:this._hasOwnPendingAsyncValidator||this._anyControlsHaveStatus(Vr)?Vr:this._anyControlsHaveStatus(Sd)?Sd:bs}_anyControlsHaveStatus(t){return this._anyControls(e=>e.status===t)}_anyControlsDirty(){return this._anyControls(t=>t.dirty)}_anyControlsTouched(){return this._anyControls(t=>t.touched)}_updatePristine(t,e){let o=!this._anyControlsDirty(),i=this.pristine!==o;this.pristine=o,t.onlySelf||this._parent?._updatePristine(t,e),i&&this._events.next(new Os(this.pristine,e))}_updateTouched(t={},e){this.touched=this._anyControlsTouched(),this._events.next(new vs(this.touched,e)),t.onlySelf||this._parent?._updateTouched(t,e)}_onDisabledChange=[];_registerOnCollectionChange(t){this._onCollectionChange=t}_setUpdateStrategy(t){Ad(t)&&t.updateOn!=null&&(this._updateOn=t.updateOn)}_parentMarkedDirty(t){return!t&&!!this._parent?.dirty&&!this._parent._anyControlsDirty()}_find(t){return null}_assignValidators(t){this._rawValidators=Array.isArray(t)?t.slice():t,this._composedValidatorFn=VE(this._rawValidators),this._updateHasRequiredValidator()}_assignAsyncValidators(t){this._rawAsyncValidators=Array.isArray(t)?t.slice():t,this._composedAsyncValidatorFn=$E(this._rawAsyncValidators)}_updateHasRequiredValidator(){de(()=>this._hasRequired.set(this.hasValidator(Kf.required)))}};function zE(n){return n.tagName==="INPUT"||n.tagName==="SELECT"||n.tagName==="TEXTAREA"}function BE(n,t,e,o){switch(e){case"name":n.setAttribute(t,e,o);break;case"disabled":case"readonly":case"required":o?n.setAttribute(t,e,""):n.removeAttribute(t,e);break;case"max":case"min":case"minLength":case"maxLength":o!==void 0?n.setAttribute(t,e,o.toString()):n.removeAttribute(t,e);break}}var Jf=class{kind;context;control;message;constructor({kind:t,context:e,control:o}){this.kind=t,this.context=e,this.control=o}};var HE=(()=>{class n{_validator=kd;_onChange;_enabled;ngOnChanges(e){if(this.inputName in e){let o=this.normalizeInput(e[this.inputName].currentValue);this._enabled=this.enabled(o),this._validator=this._enabled?this.createValidator(o):kd,this._onChange?.()}}validate(e){return this._validator(e)}registerOnValidatorChange(e){this._onChange=e}enabled(e){return e!=null}static \u0275fac=function(o){return new(o||n)};static \u0275dir=qe({type:n,features:[In]})}return n})();var UE={provide:qb,useExisting:on(()=>oP),multi:!0};var oP=(()=>{class n extends HE{required;inputName="required";normalizeInput=Si;createValidator=e=>Wb;enabled(e){return e}static \u0275fac=(()=>{let e;return function(i){return(e||(e=ko(n)))(i||n)}})();static \u0275dir=qe({type:n,selectors:[["","required","","formControlName","",3,"type","checkbox"],["","required","","formControl","",3,"type","checkbox"],["","required","","ngModel","",3,"type","checkbox"]],hostVars:1,hostBindings:function(o,i){o&2&&Wt("required",i._enabled?"":null)},inputs:{required:"required"},standalone:!1,features:[To([UE]),gn]})}return n})();var GE=new B(""),iP=new B("",{factory:()=>om}),om="always";function qE(n,t){return[...t.path,n]}function jb(n,t,e=om){KE(n,t),t.valueAccessor.writeValue(n.value),(n.disabled||e==="always")&&t.valueAccessor.setDisabledState?.(n.disabled),YE(n,t),QE(n,t),ZE(n,t),WE(n,t)}function $b(n,t){n.forEach(e=>{e.registerOnValidatorChange&&e.registerOnValidatorChange(t)})}function WE(n,t){if(t.valueAccessor.setDisabledState){let e=o=>{t.valueAccessor.setDisabledState(o)};n.registerOnDisabledChange(e),t._registerOnDestroy(()=>{n._unregisterOnDisabledChange(e)})}}function KE(n,t){let e=RE(n);t.validator!==null?n.setValidators(Fb(e,t.validator)):typeof e=="function"&&n.setValidators([e]);let o=FE(n);t.asyncValidator!==null?n.setAsyncValidators(Fb(o,t.asyncValidator)):typeof o=="function"&&n.setAsyncValidators([o]);let i=()=>n.updateValueAndValidity();$b(t._rawValidators,i),$b(t._rawAsyncValidators,i)}function YE(n,t){t.valueAccessor.registerOnChange(e=>{n._pendingValue=e,n._pendingChange=!0,n._pendingDirty=!0,n.updateOn==="change"&&rP(n,t)})}function ZE(n,t){t.valueAccessor.registerOnTouched(()=>{n._pendingTouched=!0,n.updateOn==="blur"&&n._pendingChange&&rP(n,t),n.updateOn!=="submit"&&n.markAsTouched()})}function rP(n,t){n._pendingDirty&&n.markAsDirty(),n.setValue(n._pendingValue,{emitModelToViewChange:!1}),t.viewToModelUpdate(n._pendingValue),n._pendingChange=!1}function QE(n,t){let e=(o,i)=>{t.valueAccessor.writeValue(o),i&&t.viewToModelUpdate(o)};n.registerOnChange(e),t._registerOnDestroy(()=>{n._unregisterOnChange(e)})}function JE(n,t){if(!Object.hasOwn(n,"model"))return!1;let e=n.model;return e.isFirstChange()?!0:!Object.is(t,e.currentValue)}function XE(n){return Object.getPrototypeOf(n.constructor)===Td}function eD(n,t){if(!t)return null;Array.isArray(t);let e,o,i;return t.forEach(r=>{r.constructor===zr?e=r:XE(r)?o=r:i=r}),i||o||e||null}var tD={provide:GE,useFactory:()=>{let n=D($r,{self:!0});return{setParseErrors:t=>{n.setParseErrorSource(t)},set onReset(t){n.onReset=t}}}},$r=class extends Dd{_parent=null;name=null;valueAccessor=null;isCustomControlBased=!1;userOnReset;resetSubscription;set onReset(t){this.userOnReset=t,this.resetSubscription?.unsubscribe(),this.resetSubscription=void 0,this.control&&(this.resetSubscription=this.control.events.subscribe(e=>{e instanceof ys&&this.control&&this.userOnReset?.(this.control.value)}),this.subscription?.add(this.resetSubscription))}isNativeFormElement=!1;rawValueAccessors;_selectedValueAccessor=null;get selectedValueAccessor(){return this._selectedValueAccessor??=eD(this,this.rawValueAccessors)}parseErrorsValidator=null;renderer;injector;requiredValidatorViaDi;subscription;customControlBindings=null;constructor(t,e,o){super(),this.injector=t,this.renderer=e,this.rawValueAccessors=o,this.injector?.get(Ae)?.onDestroy(()=>{this.removeParseErrorsValidator(this.control),this.subscription?.unsubscribe()})}setupCustomControl(){this.subscription?.unsubscribe();let t=this.injector?.get(oo);if(!this.control||!t)return;let e=t.markForCheck.bind(t);this.subscription=new Be,this.subscription.add(this.control.valueChanges.subscribe(e)),this.subscription.add(this.control.statusChanges.subscribe(e)),this.resetSubscription?.unsubscribe(),this.resetSubscription=void 0,this.userOnReset&&(this.resetSubscription=this.control.events.subscribe(o=>{o instanceof ys&&this.control&&this.userOnReset?.(this.control.value)}),this.subscription.add(this.resetSubscription)),this.parseErrorsValidator&&this.control.addValidators(this.parseErrorsValidator)}ngControlCreate(t){!t.nativeElement.hasAttribute?.("ngNoCva")&&(this.rawValueAccessors&&this.rawValueAccessors.length>0||this.valueAccessor!==null)||!t.customControl||(this.isCustomControlBased=!0,t.listenToCustomControlModel(i=>{this.control?.setValue(i,{emitModelToViewChange:!1}),this.control?.markAsDirty(),this.viewToModelUpdate(i)}),t.listenToCustomControlOutput("touch",()=>{this.control?.markAsTouched()}),this.customControlBindings={},this.isNativeFormElement=zE(t.nativeElement),this.requiredValidatorViaDi=this._rawValidators.find(i=>i instanceof oP))}ngControlUpdate(t,e){if(!this.isCustomControlBased)return;let o=this.control,i=this.customControlBindings;Object.is(i.value,o.value)||(i.value=o.value,t.setCustomControlModelInput(o.value)),this.bindControlProperty(t,i,"touched",o.touched),this.bindControlProperty(t,i,"dirty",o.dirty),this.bindControlProperty(t,i,"valid",o.valid),this.bindControlProperty(t,i,"invalid",o.invalid),this.bindControlProperty(t,i,"pending",o.pending),this.bindControlProperty(t,i,"disabled",o.disabled),this.shouldBindRequired&&this.bindControlProperty(t,i,"required",this.isRequired);let r=o.errors;if(i.errors!==r){i.errors=r;let a=this._convertErrors(r);t.setInputOnDirectives("errors",a)}}get isRequired(){return(this.requiredValidatorViaDi?._enabled||this.control?._hasRequired())??!1}get shouldBindRequired(){return!0}bindControlProperty(t,e,o,i){if(e[o]===i)return;e[o]=i;let r=t.setInputOnDirectives(o,i);this.isNativeFormElement&&!r&&(o==="disabled"||o==="required")&&this.renderer&&BE(this.renderer,t.nativeElement,o,i)}_convertErrors(t){if(t===null)return[];let e=this.control;return Object.entries(t).map(([o,i])=>new Jf({context:i,kind:o,control:e}))}setParseErrorSource(t){if(t===void 0)return;let e=null,o=I(()=>{let i=t();return i.length===0?null:i.reduce((r,a)=>(r[a.kind]=a,r),{})});this.parseErrorsValidator=(()=>e).bind(this),Ye(()=>{e=o(),this.control?.updateValueAndValidity({emitEvent:!1})},{injector:this.injector})}removeParseErrorsValidator(t){this.parseErrorsValidator&&(t?.removeValidators(this.parseErrorsValidator),t?.updateValueAndValidity({emitEvent:!1}))}},Xf=class{_cd;constructor(t){this._cd=t}get isTouched(){return this._cd?.control?._touched?.(),!!this._cd?.control?.touched}get isUntouched(){return!!this._cd?.control?.untouched}get isPristine(){return this._cd?.control?._pristine?.(),!!this._cd?.control?.pristine}get isDirty(){return!!this._cd?.control?.dirty}get isValid(){return this._cd?.control?._status?.(),!!this._cd?.control?.valid}get isInvalid(){return!!this._cd?.control?.invalid}get isPending(){return!!this._cd?.control?.pending}get isSubmitted(){return this._cd?._submitted?.(),!!this._cd?.submitted}};var Nd=(()=>{class n extends Xf{constructor(e){super(e)}static \u0275fac=function(o){return new(o||n)(le($r,2))};static \u0275dir=qe({type:n,selectors:[["","formControlName",""],["","ngModel",""],["","formControl",""]],hostVars:14,hostBindings:function(o,i){o&2&&w("ng-untouched",i.isUntouched)("ng-touched",i.isTouched)("ng-pristine",i.isPristine)("ng-dirty",i.isDirty)("ng-valid",i.isValid)("ng-invalid",i.isInvalid)("ng-pending",i.isPending)},standalone:!1,features:[gn]})}return n})();function zb(n,t){let e=n.indexOf(t);e>-1&&n.splice(e,1)}function Bb(n){return typeof n=="object"&&n!==null&&Object.keys(n).length===2&&"value"in n&&"disabled"in n}var nD=class extends Qf{defaultValue=null;_onChange=[];_pendingValue;_pendingChange=!1;constructor(t=null,e,o){super(LE(e),jE(o,e)),this._applyFormState(t),this._setUpdateStrategy(e),this._initObservables(),this.updateValueAndValidity({onlySelf:!0,emitEvent:!!this.asyncValidator}),Ad(e)&&(e.nonNullable||e.initialValueIsDefault)&&(Bb(t)?this.defaultValue=t.value:this.defaultValue=t)}setValue(t,e={}){de(()=>{this.value=this._pendingValue=t,this._onChange.length&&e.emitModelToViewChange!==!1&&this._onChange.forEach(o=>o(this.value,e.emitViewToModelChange!==!1)),this.updateValueAndValidity(e)})}patchValue(t,e={}){this.setValue(t,e)}reset(t=this.defaultValue,e={}){this._applyFormState(t),this.markAsPristine(e),this.markAsUntouched(e),this.setValue(this.value,e),e.overwriteDefaultValue&&(this.defaultValue=this.value),this._pendingChange=!1,e?.emitEvent!==!1&&this._events.next(new ys(this))}_updateValue(){}_anyControls(t){return!1}_allControlsDisabled(){return this.disabled}registerOnChange(t){this._onChange.push(t)}_unregisterOnChange(t){zb(this._onChange,t)}registerOnDisabledChange(t){this._onDisabledChange.push(t)}_unregisterOnDisabledChange(t){zb(this._onDisabledChange,t)}_forEachChild(t){}_syncPendingControls(){return this.updateOn==="submit"&&(this._pendingDirty&&this.markAsDirty(),this._pendingTouched&&this.markAsTouched(),this._pendingChange)?(this.setValue(this._pendingValue,{onlySelf:!0,emitModelToViewChange:!1}),!0):!1}_applyFormState(t){Bb(t)?(this.value=this._pendingValue=t.value,t.disabled?this.disable({onlySelf:!0,emitEvent:!1}):this.enable({onlySelf:!0,emitEvent:!1})):this.value=this._pendingValue=t}};var oD={provide:$r,useExisting:on(()=>ws)},Hb=Promise.resolve(),ws=(()=>{class n extends $r{_changeDetectorRef;callSetDisabledState;control=new nD;static ngAcceptInputType_isDisabled;_registered=!1;_ngModelInjector;viewModel;name="";isDisabled;model;options;update=new Ke;constructor(e,o,i,r,a,s,c,p){super(c,p,r),this._changeDetectorRef=a,this.callSetDisabledState=s,this._parent=e,this._setValidators(o),this._setAsyncValidators(i)}ngOnChanges(e){if(this._registered,this._checkForErrors(),!this._registered||"name"in e){if(this._registered&&(this._checkName(),this.formDirective)){let o=e.name.previousValue;this.formDirective.removeControl({name:o,path:this._getPath(o)})}this._setUpControl()}"isDisabled"in e&&this._updateDisabled(e),JE(e,this.viewModel)&&(this._updateValue(this.model),this.viewModel=this.model)}ngOnDestroy(){this.formDirective?.removeControl(this)}\u0275ngControlCreate(e){super.ngControlCreate(e)}\u0275ngControlUpdate(e){super.ngControlUpdate(e,!1)}get shouldBindRequired(){return!1}get path(){return this._getPath(this.name)}get formDirective(){return this._parent?this._parent.formDirective:null}viewToModelUpdate(e){this.viewModel=e,this.update.emit(e)}_setUpControl(){this._setUpdateStrategy(),this._isStandalone()?this._setUpStandalone():this.formDirective.addControl(this),this._registered=!0}_setUpdateStrategy(){this.options&&this.options.updateOn!=null&&(this.control._updateOn=this.options.updateOn)}_isStandalone(){return!this._parent||!!(this.options&&this.options.standalone)}_setUpStandalone(){this.isCustomControlBased?this.setupCustomControl():(this.valueAccessor??=this.selectedValueAccessor,jb(this.control,this,this.callSetDisabledState)),this.control.updateValueAndValidity({emitEvent:!1})}_setupWithForm(e){this.isCustomControlBased?this.setupCustomControl():(this.valueAccessor??=this.selectedValueAccessor,jb(this.control,this,e))}_checkForErrors(){this._checkName()}_checkName(){this.options&&this.options.name&&(this.name=this.options.name),!this._isStandalone()&&this.name}_updateValue(e){Hb.then(()=>{this.control.setValue(e,{emitViewToModelChange:!1}),this._changeDetectorRef?.markForCheck()})}_updateDisabled(e){let o=e.isDisabled.currentValue,i=o!==0&&Si(o);Hb.then(()=>{i&&!this.control.disabled?this.control.disable():!i&&this.control.disabled&&this.control.enable(),this._changeDetectorRef?.markForCheck()})}_getPath(e){return this._parent?qE(e,this._parent):[e]}static \u0275fac=function(o){return new(o||n)(le(Zf,9),le(qb,10),le(xE,10),le(xs,10),le(oo,8),le(iP,8),le(Ct,8),le(wt,8))};static \u0275dir=qe({type:n,selectors:[["","ngModel","",3,"formControlName","",3,"formControl",""]],inputs:{name:"name",isDisabled:[0,"disabled","isDisabled"],model:[0,"ngModel","model"],options:[0,"ngModelOptions","options"]},outputs:{update:"ngModelChange"},exportAs:["ngModel"],standalone:!1,features:[To([oD,tD]),gn,In,jp(null)]})}return n})();var iD={provide:xs,useExisting:on(()=>sP),multi:!0};function aP(n,t){return n==null?`${t}`:(t&&typeof t=="object"&&(t="Object"),`${n}: ${t}`.slice(0,50))}function rD(n){return n.split(":")[0]}var sP=(()=>{class n extends Td{value;_optionMap=new Map;_idCounter=0;set compareWith(e){this._compareWith=e}_compareWith=Object.is;appRefInjector=D(Xn).injector;destroyRef=D(Ae);cdr=D(oo);_queuedWrite=!1;_writeValueAfterRender(){this._queuedWrite||this.appRefInjector.destroyed||(this._queuedWrite=!0,La({write:()=>{this.destroyRef.destroyed||(this._queuedWrite=!1,this.writeValue(this.value))}},{injector:this.appRefInjector}))}writeValue(e){this.cdr.markForCheck(),this.value=e;let o=this._getOptionId(e),i=aP(o,e);this.setProperty("value",i)}registerOnChange(e){this.onChange=o=>{this.value=this._getOptionValue(o),e(this.value)}}_registerOption(){return(this._idCounter++).toString()}_getOptionId(e){for(let o of this._optionMap.keys())if(this._compareWith(this._optionMap.get(o),e))return o;return null}_getOptionValue(e){let o=rD(e);return this._optionMap.has(o)?this._optionMap.get(o):e}static \u0275fac=(()=>{let e;return function(i){return(e||(e=ko(n)))(i||n)}})();static \u0275dir=qe({type:n,selectors:[["select","formControlName","",3,"multiple","",3,"ngNoCva",""],["select","formControl","",3,"multiple","",3,"ngNoCva",""],["select","ngModel","",3,"multiple","",3,"ngNoCva",""]],hostBindings:function(o,i){o&1&&k("change",function(a){return i.onChange(a.target.value)})("blur",function(){return i.onTouched()})},inputs:{compareWith:"compareWith"},standalone:!1,features:[To([iD]),gn]})}return n})(),cP=(()=>{class n{_element;_renderer;_select;id;constructor(e,o,i){this._element=e,this._renderer=o,this._select=i,this._select&&(this.id=this._select._registerOption())}set ngValue(e){this._select!=null&&(this._select._optionMap.set(this.id,e),this._setElementValue(aP(this.id,e)),this._select._writeValueAfterRender())}set value(e){this._setElementValue(e),this._select?._writeValueAfterRender()}_setElementValue(e){this._renderer.setProperty(this._element.nativeElement,"value",e)}ngOnDestroy(){this._select?._optionMap.delete(this.id),this._select?._writeValueAfterRender()}static \u0275fac=function(o){return new(o||n)(le(Mt),le(wt),le(sP,9))};static \u0275dir=qe({type:n,selectors:[["option"]],inputs:{ngValue:"ngValue",value:"value"},standalone:!1})}return n})(),aD={provide:xs,useExisting:on(()=>lP),multi:!0};function Ub(n,t){return n==null?`${t}`:(typeof t=="string"&&(t=`'${t}'`),t&&typeof t=="object"&&(t="Object"),`${n}: ${t}`.slice(0,50))}function sD(n){return n.split(":")[0]}var lP=(()=>{class n extends Td{value;_optionMap=new Map;_idCounter=0;set compareWith(e){this._compareWith=e}_compareWith=Object.is;writeValue(e){this.value=e;let o;if(Array.isArray(e)){let i=e.map(r=>this._getOptionId(r));o=(r,a)=>{r._setSelected(i.indexOf(a)>-1)}}else o=i=>{i._setSelected(!1)};this._optionMap.forEach(o)}registerOnChange(e){this.onChange=o=>{let i=[],r=o.selectedOptions;if(r!==void 0){let a=r;for(let s=0;s{let e;return function(i){return(e||(e=ko(n)))(i||n)}})();static \u0275dir=qe({type:n,selectors:[["select","multiple","","formControlName","",3,"ngNoCva",""],["select","multiple","","formControl","",3,"ngNoCva",""],["select","multiple","","ngModel","",3,"ngNoCva",""]],hostBindings:function(o,i){o&1&&k("change",function(a){return i.onChange(a.target)})("blur",function(){return i.onTouched()})},inputs:{compareWith:"compareWith"},standalone:!1,features:[To([aD]),gn]})}return n})(),dP=(()=>{class n{_element;_renderer;_select;id;_value;constructor(e,o,i){this._element=e,this._renderer=o,this._select=i,this._select&&(this.id=this._select._registerOption(this))}set ngValue(e){this._select!=null&&(this._value=e,this._setElementValue(Ub(this.id,e)),this._select.writeValue(this._select.value))}set value(e){this._select?(this._value=e,this._setElementValue(Ub(this.id,e)),this._select.writeValue(this._select.value)):this._setElementValue(e)}_setElementValue(e){this._renderer.setProperty(this._element.nativeElement,"value",e)}_setSelected(e){this._renderer.setProperty(this._element.nativeElement,"selected",e)}ngOnDestroy(){this._select&&(this._select._optionMap.delete(this.id),this._select.writeValue(this._select.value))}static \u0275fac=function(o){return new(o||n)(le(Mt),le(wt),le(lP,9))};static \u0275dir=qe({type:n,selectors:[["option"]],inputs:{ngValue:"ngValue",value:"value"},standalone:!1})}return n})();var cD=(()=>{class n{static \u0275fac=function(o){return new(o||n)};static \u0275mod=Jn({type:n});static \u0275inj=Pn({})}return n})();var Qt=(()=>{class n{static withConfig(e){return{ngModule:n,providers:[{provide:iP,useValue:e.callSetDisabledState??om}]}}static \u0275fac=function(o){return new(o||n)};static \u0275mod=Jn({type:n});static \u0275inj=Pn({imports:[cD]})}return n})();function oe(n){if(n==null)return null;if(Array.isArray(n)){if(n.length===4&&n.every(e=>typeof e=="number"&&!isNaN(e))||n.length===2&&n.every(e=>typeof e=="number"&&!isNaN(e)))return n;if(n.length===2&&Array.isArray(n[0])&&Array.isArray(n[1])){let e=oe(n[0]),o=oe(n[1]);if(e&&o&&e.length===2&&o.length===2)return[e[0],e[1],o[0],o[1]]}let t=[];for(let e of n)if(typeof e=="number"&&!isNaN(e))t.push(e);else if(typeof e=="string"){let o=e.match(/-?\d+(?:\.\d+)?/g);o&&o.forEach(i=>t.push(Number(i)))}else if(Array.isArray(e)){let o=oe(e);o&&t.push(...o)}if(t.length===4||t.length===2)return t}if(typeof n=="string"){let t=n.trim();if(!t)return null;try{let o=JSON.parse(t),i=oe(o);if(i)return i}catch(o){}let e=t.match(/-?\d+(?:\.\d+)?/g);if(e&&(e.length===4||e.length===2))return e.map(o=>Number(o))}return null}function Br(n){if(!n)return null;if(typeof n=="string"){let t=n.trim();try{let r=JSON.parse(t),a=Br(r);if(a&&a.length>0)return a}catch(r){}let e=[],o=/\[\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\]/g,i;for(;(i=o.exec(t))!==null;)e.push([Number(i[1]),Number(i[2])]);if(e.length>0)return e}if(Array.isArray(n)){let t=[];for(let e of n){let o=oe(e);o&&o.length===2?t.push([o[0],o[1]]):Array.isArray(e)&&e.length>=2&&typeof e[0]=="number"&&typeof e[1]=="number"&&t.push([e[0],e[1]])}if(t.length>0)return t}return null}function Pt(n){if(typeof n!="string")return!1;let t=n.trim().toLowerCase();return/\d/.test(t)?!1:["up","down","left","right","swipe up","swipe down","swipe left","swipe right","scroll up","scroll down","scroll left","scroll right","top","bottom"].includes(t)}function Rd(n,t,e){if(!n||!t||!e)return;t.innerHTML="";let o=Array.isArray(e)?e:[e],i=n.naturalWidth||1080,r=n.naturalHeight||2400;o.forEach(a=>{if(!a||typeof a!="object")return;let s=(a.action||a.name||"tap").toLowerCase(),c=a.payload&&typeof a.payload=="object"?a.payload:{},p=c.args&&typeof c.args=="object"?c.args:a.args||{};if(["manage_app","launch_app","open_app","stop_app","close_app","press_key","press_home","press_back","wait_for_delay","wait","delay","report_task_status","report_failure_analysis"].includes(s))return;let _=s.includes("sequence")||!!(a.sequence||p.sequence||a.normalized_sequence||p.normalized_sequence),C=a.normalized_sequence||p.normalized_sequence||a.sequence||p.sequence||a.targets||p.targets||(Array.isArray(a.coordinates)&&Array.isArray(a.coordinates[0])?a.coordinates:null)||(Array.isArray(p.coordinates)&&Array.isArray(p.coordinates[0])?p.coordinates:null),h=Br(C);if(h&&h.length>0&&(_||h.length>1)){let X=!!(a.normalized_sequence||p.normalized_sequence)||h.every(W=>W[0]<=1e3&&W[1]<=1e3);for(let W=0;W{rm(W[0],W[1],s,i,r,t,X,Me+1,h.length)});return}let P=s.includes("swipe")||s.includes("scroll")||s.includes("drag")||s.includes("slide"),T=oe(a.normalized_start_coordinates||p.normalized_start_coordinates),A=oe(a.normalized_end_coordinates||p.normalized_end_coordinates),N=T||oe(a.start_coordinates||a.start_point||a.start||a.from||p.start_coordinates||p.start_point||p.start||p.from),E=A||oe(a.end_coordinates||a.end_point||a.end||a.to||p.end_coordinates||p.end_point||p.end||p.to);if(N&&E&&N.length>=2&&E.length>=2){am(N[0],N[1],E[0],E[1],i,r,t,!!(T&&A),s);return}let U=oe(a.normalized_coordinates||p.normalized_coordinates),ne=!!U,J=U||oe(a.coordinates)||oe(p.coordinates)||oe(a.coords)||oe(p.coords)||oe(a.target)||oe(p.target)||(P?oe(p.action):null)||(P?oe(a.action):null)||(P?oe(p.gesture):null)||(P?oe(a.gesture):null);if(J&&Array.isArray(J)){if(J.length===4){am(J[0],J[1],J[2],J[3],i,r,t,ne,s);return}else if(J.length===2&&!P){rm(J[0],J[1],s,i,r,t,ne);return}}if(P){let X=(typeof p.action=="string"&&Pt(p.action)?p.action:null)||(typeof a.action=="string"&&a.action!==s&&Pt(a.action)?a.action:null)||(Pt(p.direction)?p.direction:null)||(Pt(a.direction)?a.direction:null)||(Pt(p.gesture)?p.gesture:null)||(Pt(a.gesture)?a.gesture:null)||"",W=String(X).toLowerCase().trim();if(W){let Me=500,Ee=750,ft=500,Jt=250,Xt="SWIPE";W.includes("up")||W.includes("top")||s.includes("up")?(Me=500,Ee=750,ft=500,Jt=250,Xt="SWIPE UP"):W.includes("down")||W.includes("bottom")||s.includes("down")?(Me=500,Ee=250,ft=500,Jt=750,Xt="SWIPE DOWN"):W.includes("left")||s.includes("left")?(Me=800,Ee=500,ft=200,Jt=500,Xt="SWIPE LEFT"):(W.includes("right")||s.includes("right"))&&(Me=200,Ee=500,ft=800,Jt=500,Xt="SWIPE RIGHT"),am(Me,Ee,ft,Jt,i,r,t,!0,Xt);return}}J&&Array.isArray(J)&&J.length===2&&rm(J[0],J[1],s,i,r,t,ne)})}function rm(n,t,e,o,i,r,a=!1,s,c){let p,f;a||!a&&n<=1e3&&t<=1e3?(p=n/10,f=t/10):(p=n/(o||1080)*100,f=t/(i||2400)*100);let _=Math.max(1.5,Math.min(98.5,p)),C=Math.max(1.5,Math.min(98.5,f)),h=document.createElement("div");h.className="action-point-marker",h.style.position="absolute",h.style.left=`${_}%`,h.style.top=`${C}%`,h.style.transform="translate(-50%, -50%)",h.style.pointerEvents="none",h.style.zIndex="10";let P=s!==void 0?20:14,T=document.createElement("div");T.style.width=`${P}px`,T.style.height=`${P}px`,T.style.borderRadius="50%",T.style.backgroundColor="#ef4444",T.style.border="2px solid white",T.style.boxShadow="0 0 6px rgba(0,0,0,0.5)",s!==void 0&&(T.style.display="flex",T.style.alignItems="center",T.style.justifyContent="center",T.style.fontSize="11px",T.style.fontWeight="bold",T.style.color="white",T.innerText=String(s));let A=P*2,N=document.createElement("div");N.style.position="absolute",N.style.top=`-${P/2+2}px`,N.style.left=`-${P/2+2}px`,N.style.width=`${A}px`,N.style.height=`${A}px`,N.style.borderRadius="50%",N.style.border="2px solid #ef4444",N.style.animation="pulse-animation 1.5s infinite ease-out",N.style.pointerEvents="none";let E=document.createElement("div");s!==void 0?E.innerText=c&&c>1?`#${s} TAP`:e.toUpperCase():E.innerText=e.toUpperCase(),E.style.position="absolute",E.style.backgroundColor="rgba(15, 23, 42, 0.9)",E.style.color="white",E.style.padding="1.5px 5px",E.style.borderRadius="3px",E.style.fontSize="9px",E.style.fontWeight="bold",E.style.letterSpacing="0.5px",E.style.whiteSpace="nowrap",E.style.boxShadow="0 1px 4px rgba(0,0,0,0.4)",E.style.border="1px solid rgba(255,255,255,0.2)",C>85?(E.style.bottom=`${P+2}px`,E.style.top="auto"):(E.style.top=`${P+2}px`,E.style.bottom="auto"),_>82?(E.style.right="0px",E.style.left="auto",E.style.transform="none"):_<18?(E.style.left="0px",E.style.right="auto",E.style.transform="none"):(E.style.left="50%",E.style.right="auto",E.style.transform="translateX(-50%)"),h.appendChild(N),h.appendChild(T),h.appendChild(E),r.appendChild(h)}function lD(n,t,e,o,i,r,a,s=!1){let c=s||n<=1e3&&t<=1e3&&e<=1e3&&o<=1e3,p=c?n/10:n/(i||1080)*100,f=c?t/10:t/(r||2400)*100,_=c?e/10:e/(i||1080)*100,C=c?o/10:o/(r||2400)*100,h=Math.max(1.5,Math.min(98.5,p)),P=Math.max(1.5,Math.min(98.5,f)),T=Math.max(1.5,Math.min(98.5,_)),A=Math.max(1.5,Math.min(98.5,C)),N=a.querySelector("svg");N||(N=document.createElementNS("http://www.w3.org/2000/svg","svg"),N.setAttribute("width","100%"),N.setAttribute("height","100%"),N.style.position="absolute",N.style.top="0",N.style.left="0",N.style.pointerEvents="none",N.style.overflow="visible",N.style.zIndex="5",a.appendChild(N));let E=N.querySelector("defs");E||(E=document.createElementNS("http://www.w3.org/2000/svg","defs"),N.insertBefore(E,N.firstChild));let U="seq-arrow-"+Math.random().toString(36).substring(2,9),ne=document.createElementNS("http://www.w3.org/2000/svg","marker");ne.setAttribute("id",U),ne.setAttribute("viewBox","0 0 12 12"),ne.setAttribute("refX","8"),ne.setAttribute("refY","6"),ne.setAttribute("markerWidth","6"),ne.setAttribute("markerHeight","6"),ne.setAttribute("orient","auto");let J=document.createElementNS("http://www.w3.org/2000/svg","path");J.setAttribute("d","M 1 2 L 10 6 L 1 10 z"),J.setAttribute("fill","#ef4444"),J.setAttribute("stroke","#ffffff"),J.setAttribute("stroke-width","1"),ne.appendChild(J),E.appendChild(ne);let X=document.createElementNS("http://www.w3.org/2000/svg","line");X.setAttribute("x1",`${h}%`),X.setAttribute("y1",`${P}%`),X.setAttribute("x2",`${T}%`),X.setAttribute("y2",`${A}%`),X.setAttribute("stroke","#ffffff"),X.setAttribute("stroke-width","4"),X.setAttribute("stroke-linecap","round"),X.setAttribute("opacity","0.8");let W=document.createElementNS("http://www.w3.org/2000/svg","line");W.setAttribute("x1",`${h}%`),W.setAttribute("y1",`${P}%`),W.setAttribute("x2",`${T}%`),W.setAttribute("y2",`${A}%`),W.setAttribute("stroke","#ef4444"),W.setAttribute("stroke-width","2.5"),W.setAttribute("stroke-linecap","round"),W.setAttribute("marker-end",`url(#${U})`),W.setAttribute("stroke-dasharray","4,4"),N.appendChild(X),N.appendChild(W)}function am(n,t,e,o,i,r,a,s=!1,c="swipe"){let p=s||n<=1e3&&t<=1e3&&e<=1e3&&o<=1e3,f=p?n/10:n/(i||1080)*100,_=p?t/10:t/(r||2400)*100,C=p?e/10:e/(i||1080)*100,h=p?o/10:o/(r||2400)*100,P=Math.max(1.5,Math.min(98.5,f)),T=Math.max(1.5,Math.min(98.5,_)),A=Math.max(1.5,Math.min(98.5,C)),N=Math.max(1.5,Math.min(98.5,h)),E=a.querySelector("svg");E||(E=document.createElementNS("http://www.w3.org/2000/svg","svg"),E.setAttribute("width","100%"),E.setAttribute("height","100%"),E.style.position="absolute",E.style.top="0",E.style.left="0",E.style.pointerEvents="none",E.style.overflow="visible",E.style.zIndex="5",a.appendChild(E));let U=E.querySelector("defs");U||(U=document.createElementNS("http://www.w3.org/2000/svg","defs"),E.insertBefore(U,E.firstChild));let ne="action-arrow-"+Math.random().toString(36).substring(2,9),J=document.createElementNS("http://www.w3.org/2000/svg","marker");J.setAttribute("id",ne),J.setAttribute("viewBox","0 0 12 12"),J.setAttribute("refX","8"),J.setAttribute("refY","6"),J.setAttribute("markerWidth","8"),J.setAttribute("markerHeight","8"),J.setAttribute("orient","auto");let X=document.createElementNS("http://www.w3.org/2000/svg","path");X.setAttribute("d","M 1 2 L 10 6 L 1 10 z"),X.setAttribute("fill","#ef4444"),X.setAttribute("stroke","#ffffff"),X.setAttribute("stroke-width","1.5"),X.setAttribute("stroke-linejoin","round"),J.appendChild(X),U.appendChild(J);let W=document.createElementNS("http://www.w3.org/2000/svg","line");W.setAttribute("x1",`${P}%`),W.setAttribute("y1",`${T}%`),W.setAttribute("x2",`${A}%`),W.setAttribute("y2",`${N}%`),W.setAttribute("stroke","#ffffff"),W.setAttribute("stroke-width","6"),W.setAttribute("stroke-linecap","round"),W.setAttribute("opacity","0.9");let Me=document.createElementNS("http://www.w3.org/2000/svg","line");Me.setAttribute("x1",`${P}%`),Me.setAttribute("y1",`${T}%`),Me.setAttribute("x2",`${A}%`),Me.setAttribute("y2",`${N}%`),Me.setAttribute("stroke","#ef4444"),Me.setAttribute("stroke-width","3.5"),Me.setAttribute("stroke-linecap","round"),Me.setAttribute("marker-end",`url(#${ne})`),Me.setAttribute("stroke-dasharray","6,4");let Ee=document.createElementNS("http://www.w3.org/2000/svg","circle");Ee.setAttribute("cx",`${P}%`),Ee.setAttribute("cy",`${T}%`),Ee.setAttribute("r","6.5"),Ee.setAttribute("fill","#ef4444"),Ee.setAttribute("stroke","white"),Ee.setAttribute("stroke-width","2");let ft=document.createElementNS("http://www.w3.org/2000/svg","circle");ft.setAttribute("cx",`${P}%`),ft.setAttribute("cy",`${T}%`),ft.setAttribute("r","2.5"),ft.setAttribute("fill","white"),E.appendChild(W),E.appendChild(Me),E.appendChild(Ee),E.appendChild(ft);let Jt=(P+A)/2,Xt=(T+N)/2,$t=document.createElement("div");$t.className="action-point-marker action-line-label",$t.style.position="absolute",$t.style.left=`${Jt}%`,$t.style.top=`${Xt}%`,$t.style.transform="translate(-50%, -50%)",$t.style.pointerEvents="none",$t.style.zIndex="10";let zt=document.createElement("div");zt.innerText=c.toUpperCase(),zt.style.backgroundColor="rgba(15, 23, 42, 0.92)",zt.style.color="white",zt.style.padding="2px 6px",zt.style.borderRadius="4px",zt.style.fontSize="9px",zt.style.fontWeight="bold",zt.style.letterSpacing="0.5px",zt.style.whiteSpace="nowrap",zt.style.boxShadow="0 2px 6px rgba(0,0,0,0.5)",zt.style.border="1px solid rgba(255,255,255,0.3)",$t.appendChild(zt),a.appendChild($t)}var uP=new WeakMap;function at(n){if(!n)return{};if(typeof n=="object"){let t=uP.get(n);if(t!==void 0)return t;let e=gP(n);return uP.set(n,e),e}return gP(n)}function gP(n){if(n.payload){let t=n.payload;if(typeof t=="string")try{t=JSON.parse(t)}catch(e){}if(t&&typeof t=="object")return t.args&&typeof t.args=="object"?t.args:t}if(n.args){let t=n.args;if(typeof t=="string")try{t=JSON.parse(t)}catch(e){}if(t&&typeof t=="object")return t}return{}}function pP(n){if(!n||!n.name)return!1;let e=n.name.replace(/^(_)?(self\.)?exec_/,"").toLowerCase();return["save_note","read_note","list_notes","update_note","append_note"].includes(e)}function Fd(n){if(!n||!n.name)return!1;let e=n.name.replace(/^(_)?(self\.)?exec_/,"").toLowerCase();return["video_analysis","video_analyzer","video_analyzer_pure","spawn_sub_agent","analyze_audio_only"].includes(e)}function sm(n){if(!n||typeof n!="object")return null;let t=Number(n.start),e=Number(n.end);return!Number.isFinite(t)||!Number.isFinite(e)||e<=t?null:{start:t,end:e,category:n.category?String(n.category):void 0,retryable:n.retryable===!0}}function dD(n){let t=n.trim();return t?t.startsWith("CACHED VIDEO ANALYSIS:")?{outcome:"complete",reuse:"full",summary:t.replace(/^CACHED VIDEO ANALYSIS:\s*/,"")}:t.startsWith("PARTIAL VIDEO ANALYSIS")?{outcome:"partial",reuse:"partial",summary:t}:t.startsWith("All sub-agent chunks failed")||t.startsWith("Error:")?{outcome:"failed",summary:t}:t.includes("Analysis is already in progress in another video agent")?{outcome:"waiting",summary:"Another video agent is already analyzing this evidence."}:{outcome:"complete",summary:t}:{}}function Fi(n){if(!Fd(n))return null;let t=at(n),e=n?.payload&&typeof n.payload=="object"?n.payload:{},o=e.result??n.result??null,i=o&&typeof o=="object"?o:{},r=typeof o=="string"?dD(o):{},a=String(i.outcome||e.outcome||r.outcome||"").toLowerCase();["running","recovering","waiting","complete","partial","failed"].includes(a)||(a=n?.status==="failed"||n?.status==="error"?"failed":n?.status==="running"?"running":"complete");let s=i.recovering===!0||i.fallback_used===!0;a==="running"&&s&&(a="recovering");let c=Number(t.start_time??i.requested_range?.start),p=Number(t.end_time??i.requested_range?.end),f=Number.isFinite(c)&&Number.isFinite(p)&&p>c?{start:c,end:p}:sm(i.requested_range),_=Array.isArray(i.completed_ranges)?i.completed_ranges.map(sm).filter(A=>!!A):[],C=Array.isArray(i.failed_ranges)?i.failed_ranges.map(sm).filter(A=>!!A):[],h=Number(i.completed_count??_.length??0),P=Number(i.total_count??_.length+C.length),T={running:"Analyzing screen recording",recovering:"Analyzing unfinished recording segment",waiting:"Waiting for existing video analysis",complete:i.reuse==="full"||r.reuse==="full"?"Reused video analysis":"Analyzed screen recording",partial:"Video analysis partially completed",failed:"Video analysis returned no result"};return{outcome:a,title:T[a],query:String(t.specific_query||t.query||t.prompt||i.query||""),summary:String(i.summary||r.summary||""),reuse:i.reuse||r.reuse||"none",requestedRange:f,completedRanges:_,failedRanges:C,evidenceCount:Number(i.evidence_count||0),completedCount:Number.isFinite(h)?h:0,totalCount:Number.isFinite(P)?P:0,fallbackUsed:i.fallback_used===!0}}function cm(n){let t=Math.max(0,Number(n)||0),e=Math.floor(t/60),o=Math.floor(t%60);return`${String(e).padStart(2,"0")}:${String(o).padStart(2,"0")}`}function fP(n){if(!n)return null;let t=at(n);if(t.video_path||t.video_file||t.file_path||t.file){let e=String(t.video_path||t.video_file||t.file_path||t.file);return e.split("/").pop()||e}if(t.time_description)return String(t.time_description);if(t.start_time!==void 0||t.end_time!==void 0){let e=t.start_time??0,o=t.end_time?`${t.end_time}s`:"end";return`${e}s - ${o}`}return t.purpose?String(t.purpose):"screen_record.mp4"}var uD=new Set(["click","click_sequence","long_press","long_press_on","input","input_text","swipe","scroll","press_key","press_home","press_back","launch_app","manage_app","open_app","focus_and_input_text","focus_and_clear_text","tap","wait_for_delay","wait","run_adb_command","run_short_adb_command"]);function Ld(n){if(!n||!n.name||n.type==="llm_call")return!1;let t=n.name.replace(/^(_)?(self\.)?exec_/,"").toLowerCase();return uD.has(t)}function gD(n){if(!n||!n.name)return!0;let t=n.name.replace(/^(_)?(self\.)?exec_/,"").toLowerCase();return!!(t.includes("safety_net")||t==="report_task_status"||t==="report_status"||t==="submit_task_status")}function mP(n,t){if(!n||!n.name||n.type==="llm_call"||n.type==="agent"||gD(n))return!1;if(t&&t.action_taken&&jn(t.action_taken)&&Ld(n)){let e=(n.agent_name||n.agent||"").toLowerCase();if(!(e.includes("failure")||e.includes("analyzer")||e.includes("diagnos")||e&&e!=="operator"&&e!=="flashrunner"&&e!=="validator"))return!1}return!0}function _P(n){if(!n)return null;let t=(n.agent_name||n.agent||"").toLowerCase();return t.includes("failure")||t.includes("analyzer")?null:t.includes("outputter")?"Outputter":t.includes("validator")?"Validator":t.includes("diagnos")?"Diagnoser":t.includes("explorer")?"Explorer":null}function Vd(n){if(!n)return[];let t=n.filter(_=>_?.type==="llm_call"&&_?.status==="retrying"&&(_?.name==="llm_retry"||_?.name==="llm_retry_group")&&_?.payload?.source==="provider_sdk"&&["google","gemini"].includes(String(_?.payload?.provider||"").toLowerCase())),e=_=>String(_?.payload?.request_id||`legacy-${_?.step_id||"unassigned"}`),o=new Map;for(let _ of t){let C=e(_);o.set(C,[...o.get(C)||[],_])}let i=new Set(n.filter(_=>_?.type==="llm_call"&&_?.status==="failed").map(_=>_?.payload?.request_id).filter(_=>typeof _=="string"&&!!_)),r=_=>{let C=_[0],h=_[_.length-1];return F(S({},C),{trace_id:`llm-retry-group-${C?.payload?.request_id||C?.trace_id||C?.timestamp||"unknown"}`,name:"llm_retry_group",payload:F(S({},h?.payload||{}),{retry_count:_.length,total_delay:_.reduce((P,T)=>P+(Number(T?.payload?.delay)||0),0),retries:_.map(P=>({trace_id:P.trace_id,timestamp:P.timestamp,error:P?.payload?.error||P.error,delay:Number(P?.payload?.delay)||0,provider:P?.payload?.provider,source:P?.payload?.source,request_id:P?.payload?.request_id,scheduled_at:P?.payload?.scheduled_at}))})})},a=new Map;for(let _ of n)_&&_.trace_id&&a.set(_.trace_id,_);let s=_=>(_||"").replace(/^(_)?(self\.)?exec_/,"").toLowerCase().trim(),c=[],p=new Set,f=new Set;for(let _ of n){if(!_||!_.name||_.type==="agent")continue;if(_.type==="llm_call"&&_.status==="retrying"){let P=e(_),T=o.get(P),A=_?.payload?.request_id;T&&!f.has(P)&&!(A&&i.has(A))&&(c.push(r(T)),f.add(P));continue}if(_.type==="llm_call"&&_.status==="failed"){(_.name==="llm_pause"||_?.payload?.pause===!0)&&c.push(_);continue}let C=s(_.name),h=!1;if(_.parent_trace_id){let P=a.get(_.parent_trace_id);P&&s(P.name)===C&&(h=!0)}h||(_.trace_id?p.has(_.trace_id)||(p.add(_.trace_id),c.push(a.get(_.trace_id)||_)):c.push(_))}return pD(c)}function pD(n){let t=new Map,e=[];n.forEach((i,r)=>{if(!Fd(i)){e.push({index:r,tool:i});return}let a=String(i.name||"").replace(/^(_)?(self\.)?exec_/,"").toLowerCase(),s=String(a==="video_analyzer"||a==="video_analyzer_pure"?i.trace_id||i.parent_trace_id||`video-${r}`:i.parent_trace_id||i.trace_id||`video-${r}`),c=t.get(s);c?c.tools.push(i):t.set(s,{firstIndex:r,tools:[i]})});let o=[...e];for(let[i,r]of t){let a=r.tools.map(Fi).filter(E=>!!E);if(!a.length)continue;let s=a.some(E=>E.outcome==="running"||E.outcome==="recovering"),c=a.filter(E=>E.outcome==="complete"),p=a.some(E=>E.outcome==="partial"),f=a.filter(E=>E.outcome==="failed"),_=a.filter(E=>E.outcome==="waiting"),C;s?C=a.some(E=>E.outcome==="recovering")?"recovering":"running":p||c.length>0&&f.length>0?C="partial":c.length>0?C="complete":_.length>0?C="waiting":C="failed";let h=a.map(E=>E.requestedRange).filter(E=>!!E),P=h.length?{start:Math.min(...h.map(E=>E.start)),end:Math.max(...h.map(E=>E.end))}:null,T=c.length>0&&c.every(E=>E.reuse==="full")?"full":a.some(E=>E.reuse!=="none")?"partial":"none",A=r.tools[0],N={outcome:C,reuse:T,requested_range:P,completed_count:c.length,total_count:a.length,evidence_count:a.reduce((E,U)=>E+U.evidenceCount,0),query:a.find(E=>E.query)?.query||"",recovering:C==="recovering",fallback_used:a.some(E=>E.fallbackUsed)};o.push({index:r.firstIndex,tool:F(S({},A),{trace_id:`video-analysis-${i}`,name:"video_analysis",status:C==="running"||C==="recovering"?"running":"success",payload:F(S({},A.payload||{}),{args:F(S({},A.payload?.args||A.args||{}),{start_time:P?.start,end_time:P?.end}),result:N})})})}return o.sort((i,r)=>i.index-r.index),o.map(i=>i.tool)}function hP(n){if(!n||!n.name)return null;let e=(n.payload?.args||n.args)?.key;return e?e.toLowerCase().endsWith(".md")?e:`${e}.md`:null}function CP(n,t=!1){if(!n||!n.name)return"";let e=n.name.replace(/^(_)?exec_/,""),o=e.toLowerCase(),i=at(n);switch(o){case"manage_app":case"launch_app":{let r=i.app_name||i.package_name||i.app||"",a=r?r.charAt(0).toUpperCase()+r.slice(1):"Application",s=i.action?String(i.action).toLowerCase():"";return`${s==="launch"?"Launching":s==="stop"||s==="close"?"Stopping":"Managing"} "${a}"`}case"wait_for_delay":case"wait_delay":case"wait":{let r=i.delay_seconds||i.seconds||i.delay||i.duration;return r?`Waiting for ${r} second${Number(r)>1?"s":""}...`:"Waiting for delay..."}case"wait_for_text":{let r=i.text||i.target_text||"";return r?`Waiting for text "${r}" to appear on screen`:"Waiting for text on screen"}case"input_text":case"input":{let r=i.text||i.input_text||"";return r?`Entering text "${r}" into field`:"Entering text into input field"}case"focus_and_input_text":{let r=i.text||i.input_text||"";return r?`Focusing field and typing "${r}"`:"Focusing field and entering text"}case"focus_and_clear_text":return"Focusing and clearing field text";case"click":case"tap":{let r=i.target_text||i.text||i.query||"";return r?`Tapping on "${r}"`:"Tapping on screen element"}case"click_sequence":return"Executing click sequence";case"long_press":{let r=i.target_text||i.text||"";return r?`Long pressing on "${r}"`:"Long pressing screen element"}case"swipe":{let r=i.action||i.direction||"";return r?`Swiping ${String(r).toUpperCase()} on screen`:"Swiping screen"}case"press_key":{let r=i.key||i.keycode||"";return r?`Pressing key ${String(r).toUpperCase()}`:"Pressing hardware key"}case"save_note":return t?"Creating note":"Saving note";case"read_note":return"Reading note";case"list_notes":return"Browsing all saved notes";case"update_note":return"Updating note";case"append_note":return"Updating note";case"object_detection":{let r=Array.isArray(i.queries)?i.queries.join(", "):i.queries||"";return r?`Locating on screen: "${r}"`:"Locating elements on screen"}case"ask_explorer":{let r=i.query||i.prompt||"";return r?`Searching on screen: "${r}"`:"Searching on screen"}case"report_failure_analysis":{let r=i.reason||i.analysis||"";return r?`Investigating issue: ${r}`:"Investigating execution issue"}case"run_adb_command":case"run_short_adb_command":{let r=i.command||i.cmd||"";return r?`Running command: ${r}`:"Running system command"}case"search_logs":case"read_logs":{let r=i.query||i.filter||"";return r?`Searching logs for "${r}"`:"Analyzing system logs"}case"log_analyzer":case"output_analyzer":return"Analyzing logs";case"diagnoser":case"diagnose":return"Diagnosing issue";case"video_analyzer":case"video_analyzer_pure":return"Analyzing screen recording";case"extract_segment_metadata":{let r=i.start_time!==void 0?`${i.start_time}s`:"",a=i.end_time!==void 0?`${i.end_time}s`:"";return`Cropping screen recording segment${r&&a?` (${r} - ${a})`:r?` (from ${r})`:""}`}case"spawn_sub_agent":{let r=i.specific_query||i.query||i.prompt||"";return r?`Analyzing recording with sub-agent: "${r}"`:"Analyzing recording with sub-agent"}case"analyze_audio_only":{let r=i.specific_query||i.query||"";return r?`Analyzing audio track: "${r}"`:"Analyzing recording audio track"}case"get_step_detail":case"get_step_details":{let r=i.step_no??i.step_number??i.step;return r!==void 0&&r!==""?`Reviewing step ${r}`:"Reviewing step details"}case"get_step_screenshot":{let r=i.step_no??i.step_number??i.step,a=String(i.which||"").toLowerCase()==="post"?"after":"before";return r!==void 0&&r!==""?`Looking at the screen ${a} step ${r}`:"Looking at a step screenshot"}case"probe_device":{let r=i.kind?String(i.kind).replace(/_/g," "):"";return r?`Reading ${r} from the device`:"Reading device state"}case"search_history_for_text":{let r=i.text||i.query||"";return r?`Searching execution trace for "${r}"`:"Searching execution trace"}case"outputter":case"output_synthesis":return"Synthesizing output report";case"web_search":{let r=i.query||"";return r?`Searching web for "${r}"`:"Searching the web"}case"read_url":return"Fetching web page";default:return`Executing ${e.split("_").map(r=>r.charAt(0).toUpperCase()+r.slice(1)).join(" ")}`}}function MP(n){if(!n||!n.name)return"settings";switch(n.name.toLowerCase().replace(/^(_)?exec_/,"")){case"click":case"tap":return"ads_click";case"click_sequence":case"long_press":return"touch_app";case"input_text":case"input":return"keyboard";case"swipe":return"swipe";case"press_key":return"keyboard_tab";case"manage_app":return"open_in_new";case"wait_for_delay":return"timer";case"wait_for_text":return"hourglass_empty";case"object_detection":return"search";case"ask_explorer":return"search";case"report_failure_analysis":return"assessment";case"run_adb_command":case"run_short_adb_command":return"terminal";case"web_search":return"travel_explore";case"read_url":return"language";case"search_logs":case"read_logs":case"log_analyzer":case"output_analyzer":return"receipt_long";case"diagnoser":case"diagnose":return"medical_services";case"video_analyzer":case"video_analyzer_pure":return"video_camera_back";case"extract_segment_metadata":return"crop";case"spawn_sub_agent":return"smart_toy";case"analyze_audio_only":return"graphic_eq";case"get_step_detail":case"get_step_details":return"manage_search";case"get_step_screenshot":return"image_search";case"probe_device":return"sensors";case"search_history_for_text":return"find_in_page";case"outputter":case"output_synthesis":return"assignment_turned_in";default:return"build"}}function bP(n){if(!n||!n.name)return"Tool Call";let t=n.name.replace(/^(_)?exec_/,"");switch(t.toLowerCase()){case"click":case"tap":return"Tapping Element";case"click_sequence":return"Executing Click Sequence";case"long_press":return"Long Pressing Element";case"input_text":case"input":return"Entering Text";case"swipe":case"scroll":{let o=at(n),i=o.direction||o.gesture||(typeof o.action=="string"?o.action:"");return i&&Pt(i)?`Swiping Screen (${String(i).toUpperCase()})`:"Swiping Screen"}case"drag":case"drag_and_drop":return"Dragging Screen";case"press_key":return"Pressing Hardware Key";case"manage_app":case"launch_app":{let o=at(n),i=o.action?String(o.action).toLowerCase():"";return i==="launch"?"Launching Application":i==="stop"||i==="close"?"Stopping Application":"Managing Application"}case"wait_for_delay":case"wait_delay":return"Waiting for Delay";case"wait_for_text":return"Waiting for Text";case"object_detection":return"Locating Elements";case"ask_explorer":return"Searching on Screen";case"report_failure_analysis":return"Investigating Issue";case"run_adb_command":case"run_short_adb_command":return"Running System Command";case"web_search":return"Web Search";case"read_url":return"Fetching Web Page";case"search_logs":case"read_logs":return"Searching Logs";case"log_analyzer":case"output_analyzer":return"Analyzing Logs";case"diagnoser":case"diagnose":return"Diagnosing Issue";case"video_analyzer":case"video_analyzer_pure":return"Analyzing Screen Recording";case"extract_segment_metadata":return"Cropping Screen Recording";case"spawn_sub_agent":return"Delegating Video Analysis";case"analyze_audio_only":return"Analyzing Audio Track";default:return t.replace(/_/g," ").replace(/\b\w/g,o=>o.toUpperCase())}}function lm(n){if(!n||!n.name)return"";let t=at(n),e=n.name.toLowerCase().replace(/^(_)?exec_/,"");if(e==="manage_app"||e==="launch_app"){let o=t.app_name||t.package_name||t.app||"",i=o?o.charAt(0).toUpperCase()+o.slice(1):"",r=t.action?String(t.action).toLowerCase():"",a=r?r.charAt(0).toUpperCase()+r.slice(1):"";return a&&i?`${a} ${i}`:i||a||""}return e==="swipe"&&typeof t.action=="string"&&Pt(t.action)?t.action:e==="wait_for_text"?t.text||"":e==="object_detection"?Array.isArray(t.queries)?t.queries.join(", "):t.queries||"":e==="ask_explorer"||e==="web_search"?t.query||t.prompt||"":e==="read_url"?t.url||"":e==="search_logs"||e==="read_logs"?t.query||t.filter||"":e==="report_failure_analysis"?t.status||t.reason||t.analysis||"":t.target&&typeof t.target=="string"?t.target:t.target&&typeof t.target=="number"?`Element #${t.target}`:t.index!==void 0?`Element #${t.index}`:t.target_text||t.target_class||t.element||t.element_text||(e!=="input_text"?t.text:"")||""}function PP(n){if(!n||!n.name)return"Input";let t=n.name.toLowerCase().replace(/^(_)?exec_/,"");if(t==="wait_for_delay"||t==="wait_delay"||t==="delay"||t==="wait")return"Duration";if(t==="swipe"||t==="scroll"||t==="drag"||t==="drag_and_drop"){let e=at(n),o=e.direction||e.gesture||(typeof e.action=="string"?e.action:"");return o&&Pt(o)?"Direction":"Input"}return t==="press_key"||t==="press_home"||t==="press_back"?"Key":t==="input_text"||t==="input"?"Input Text":"Input"}function dm(n){if(!n||!n.name)return"";let t=at(n),e=n.name.toLowerCase().replace(/^(_)?exec_/,"");if(e==="press_key")return t.key||t.keycode||"";if(e==="swipe"||e==="scroll"||e==="drag"||e==="drag_and_drop"){let o=t.direction||t.gesture||(typeof t.action=="string"?t.action:"");return o&&Pt(o)?String(o).toUpperCase():""}return e==="wait_for_delay"||e==="wait_delay"?t.time_in_ms?`${t.time_in_ms}ms`:t.delay_ms?`${t.delay_ms}ms`:t.time?`${t.time}`:"":e==="long_press"&&t.duration?`Duration: ${t.duration}ms`:e==="input_text"||e==="input"?t.text||t.input_text||"":t.input_text||""}function um(n){if(!n||!n.name)return"";let t=at(n),o=n.name.replace(/^(_)?exec_/,"").toLowerCase(),i=o==="click_sequence"||o==="tap_sequence"||!!(t.sequence||t.normalized_sequence),r=t.normalized_sequence||t.sequence||t.targets||(Array.isArray(t.coordinates)&&Array.isArray(t.coordinates[0])?t.coordinates:null),a=Br(r);if(a&&a.length>0&&(i||a.length>1))return a.map(f=>`[${f[0]}, ${f[1]}]`).join(" \u2192 ");let s=oe(t.normalized_start_coordinates||t.start_coordinates||t.start),c=oe(t.normalized_end_coordinates||t.end_coordinates||t.end);if(s&&c&&s.length===2&&c.length===2)return`[${s[0]}, ${s[1]}] \u2192 [${c[0]}, ${c[1]}]`;let p=oe(t.normalized_coordinates)||oe(t.coordinates)||oe(t.target)||oe(t.action)||oe(t.gesture)||oe(t.point);return p&&Array.isArray(p)?p.length===4?`[${p[0]}, ${p[1]}] \u2192 [${p[2]}, ${p[3]}]`:p.length===2?`[${p[0]}, ${p[1]}]`:p.join(", "):t.x!==void 0&&t.y!==void 0?`[${t.x}, ${t.y}]`:""}function gm(n){if(!n||!n.name)return"";let t=at(n);return t.analysis||t.reasoning||t.summary||""}function pm(n){if(!n||!n.name)return!1;let t=n.name.toLowerCase().replace(/^(_)?exec_/,"");return t==="run_adb_command"||t==="run_short_adb_command"}function OP(n){return at(n).CommandLine||""}function vP(n){return at(n).Cwd||""}function yP(n){return at(n).RequestedTerminalID||""}function xP(n){if(!n||pm(n)||lm(n)||dm(n)||um(n)||gm(n))return"";let t=at(n);if(!t||typeof t!="object")return"";let e=new Set(["state","controller","ctx","session_id","step_id","trace_id","parent_trace_id","times","delay_ms"]),o=Object.entries(t).filter(([i,r])=>!e.has(i)&&r!==void 0&&r!==null&&r!=="");return o.length===0?"":o.map(([i,r])=>`${i}: ${typeof r=="object"?JSON.stringify(r):r}`).join(", ")}function wP(n){if(!n)return!1;if(n.status==="failed"||n.status==="error")return!0;let t=at(n);return t.status==="failed"||t.status==="error"||t.status==="cannot_fix"}function SP(n){if(!n)return"Tool Failed";let t=at(n);return t.status==="cannot_fix"?"Status: cannot_fix":t.error||t.message||t.failure_reason?t.error||t.message||t.failure_reason:n.error||n.message?n.error||n.message:"Action Failed"}function kP(n,t){if(!n)return[];let e=n.payload||n.args||n;if(!e||typeof e!="object")return[];if(t&&t.has(e))return t.get(e);let o=new Set(["action","name","type","target_text","text","input_text","target","coordinates","coords","target_bounds","bounds","target_resource_id","resource_id","target_class","class_name","normalized_coordinates","pre_image_name","post_image_name","pre_screenshot","post_screenshot","before_screenshot","after_screenshot","status","success","timestamp","created_at","start_time","execution_id","controller","agent","session_id","step_id","app_name","package_name","app","key","keycode","time_in_ms","delay_ms","delay_seconds","duration","args","kwargs","parameters","extra_params","payload","trace_id","result","error","analysis","details","command","cwd","terminal_id"]),i=[],r=n.args||n.Args||n.kwargs||n.parameters||n.payload,a={};if(r){if(typeof r=="object")a=r;else if(typeof r=="string")try{a=JSON.parse(r)}catch(c){!r.includes("<")&&!r.includes("object at")&&(a={details:r})}}let s=S(S(S({},n),typeof e=="object"?e:{}),a);for(let[c,p]of Object.entries(s)){let f=c.toLowerCase();if(o.has(f)||p==null||p==="")continue;let _=String(p);if(_.includes("object at 0x")||_.startsWith("h.toUpperCase());(f==="time_in_ms"||f==="delay_ms")&&(C="Delay"),i.push({key:C,value:_})}return t&&t.set(e,i),i}function EP(n){if(!n)return!1;let t=n.trim();if(!t)return!1;if(t.startsWith("```")){let e=t.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);e&&(t=e[1].trim())}if(t.startsWith("{")||t.startsWith("["))return!1;try{let e=JSON.parse(t);if(typeof e=="object"&&e!==null)return!1}catch(e){}return!(/^\s*\{.*"label"\s*:.*"results"\s*:/s.test(t)||/^\s*\{.*"point"\s*:.*"label"\s*:/s.test(t)||/^\s*\{.*"action"\s*:/s.test(t)||/^\s*\{.*"name"\s*:/s.test(t)||/^\s*\{\s*"[^"]+"\s*:\s*/.test(t)||/^\s*\[\s*\{\s*"[^"]+"\s*:\s*/.test(t))}function Vn(n){if(typeof n=="object"&&n!==null){if(n.message&&typeof n.message=="string")return Vn(n.message);if(n.error?.message&&typeof n.error?.message=="string")return Vn(n.error.message);if(n.detail&&typeof n.detail=="string")return Vn(n.detail);try{return JSON.stringify(n)}catch(a){return String(n)}}let t=String(n).trim();if(!t)return"Unknown error";let e=t.match(/"message"\s*:\s*"((?:[^"\\]|\\.)*)"/i);if(e&&e[1]){let a=e[1].replace(/\\"/g,'"').replace(/\\n/g," ").trim();if(a)return a}let o=t.match(/'message'\s*:\s*['"]((?:[^'\\]|\\.)*)['"]/i);if(o&&o[1]){let a=o[1].replace(/\\'/g,"'").replace(/\\n/g," ").trim();if(a)return a}let i=t.match(/\{[\s\S]*\}/);if(i)try{let a=i[0].replace(/'/g,'"').replace(/True/g,"true").replace(/False/g,"false").replace(/None/g,"null"),s=JSON.parse(a);if(s?.message)return String(s.message);if(s?.error?.message)return String(s.error.message)}catch(a){}let r=t;return r.includes("=== Source Location Trace")&&(r=r.split("=== Source Location Trace")[0]),r.includes("[type.googleapis.com")&&(r=r.split("[type.googleapis.com")[0]),r=r.replace(/^Pre-execution validation failed:\s*/i,"").replace(/^Pixel-level validation failed:\s*/i,"").replace(/^Execution error:\s*/i,"").replace(/^ServerError:\s*/i,"").replace(/^(?:LLM\s+(?:Request\s+)?Error\s*:\s*)+/i,"").trim(),r||"Unknown error"}function ke(n){return n?Array.isArray(n)?n.length>0?n[0]:null:n:null}function jn(n){if(!n)return!1;let t="";if(typeof n=="string")t=n.toLowerCase();else{let e=ke(n);if(!e)return!1;t=(e.name||e.action||"").toLowerCase()}return["tap","click","click_sequence","input","input_text","swipe","scroll","press_key","press_home","press_back","launch_app","manage_app","open_app","focus_and_input_text","focus_and_clear_text","long_press","long_press_on","wait_for_delay","wait"].includes(t)}function jo(n){if(!n)return!1;let t="";if(typeof n=="string")t=n.toLowerCase();else{let e=ke(n);if(!e)return!1;t=(e.name||e.action||"").toLowerCase()}return t=t.replace(/^(_)?(self\.)?exec_/,""),t==="report_task_status"||t==="report_status"||t==="submit_task_status"}function TP(n){let t=ke(n);if(!t)return"completed";let o=(t.args&&typeof t.args=="object"?t.args:t.payload?.args||t.payload||t).status||t.status;return typeof o=="string"?o.toLowerCase():"completed"}function $d(n){let t=ke(n);if(!t)return"";let e=t.args&&typeof t.args=="object"?t.args:t.payload?.args||t.payload||t;return e.explanation||e.reason||e.summary||t.explanation||t.reason||t.summary||""}function zd(n){let t=ke(n);if(!t)return"ads_click";switch((t.name||t.action||"").toLowerCase()){case"tap":case"click":case"click_sequence":return"ads_click";case"input":case"input_text":case"focus_and_input_text":return"keyboard";case"focus_and_clear_text":case"clear_text":return"backspace";case"swipe":case"scroll":return"swipe";case"drag":case"drag_and_drop":return"drag_indicator";case"press_key":case"press_home":case"press_back":return"keyboard_tab";case"launch_app":case"manage_app":case"open_app":return"open_in_new";case"long_press":case"long_press_on":return"touch_app";case"wait_for_delay":case"delay":case"wait":return"hourglass_empty";default:return"settings"}}function mm(n){let t=ke(n);if(!t)return"Action";let e=(t.name||t.action||"").toLowerCase();switch(e){case"tap":case"click":case"tap_element":case"click_element":return"Tapping Element";case"input":case"input_text":case"focus_and_input_text":return"Entering Text";case"focus_and_clear_text":case"clear_text":return"Clearing Text";case"swipe":case"scroll":{let o=ke(n),i=o?.args&&typeof o.args=="object"?o.args:{},r=o?.direction||o?.gesture||i.direction||i.gesture||(typeof i.action=="string"?i.action:"")||(typeof o?.action=="string"&&o.action!==e?o.action:"");return r&&Pt(r)?`Swiping Screen (${String(r).toUpperCase()})`:"Swiping Screen"}case"drag":case"drag_and_drop":return"Dragging Screen";case"press_key":case"press_home":case"press_back":return"Pressing Hardware Key";case"launch_app":case"open_app":return"Launching Application";case"stop_app":case"close_app":return"Stopping Application";case"manage_app":{let o=ke(n),i=(o?.action||o?.args?.action||"").toLowerCase();return i==="launch"?"Launching Application":i==="stop"||i==="close"?"Stopping Application":"Managing Application"}case"wait_for_delay":case"delay":case"wait":return"Waiting for Delay";case"long_press":case"long_press_on":return"Long Pressing Element";case"click_sequence":return"Clicking Sequence";default:return e.replace(/_/g," ").replace(/\b\w/g,o=>o.toUpperCase())}}function _m(n){let t=ke(n);if(!t)return"";let e=(t.name||t.action||"").toLowerCase(),o=t.args&&typeof t.args=="object"?t.args:{};if(e.includes("app")){let i=t.app_name||t.package_name||t.app||o.app_name||o.package_name||o.app||"",r=i?i.charAt(0).toUpperCase()+i.slice(1):"",a=t.action||o.action||"",s=a?String(a).charAt(0).toUpperCase()+String(a).slice(1).toLowerCase():"";return s&&r?`${s} ${r}`:r||s||""}if(e.includes("delay")||e.includes("wait")){let i=t.time_in_ms||t.delay_ms||t.delay_seconds||o.time_in_ms||o.delay_ms||o.delay_seconds||o.duration;if(i)return`${i}ms`}return t.target_text||t.target_class||t.element_id||o.target_text||o.element_id||""}function AP(n){let t=ke(n);if(!t)return"Input";let e=(t.name||t.action||"").toLowerCase();if(e.includes("delay")||e.includes("wait"))return"Duration";if(e==="swipe"||e==="scroll"||e==="drag"||e==="drag_and_drop"){let o=ke(n),i=o?.args&&typeof o.args=="object"?o.args:{},r=o?.direction||o?.gesture||i.direction||i.gesture||(typeof i.action=="string"?i.action:"")||(typeof o?.action=="string"&&o.action!==e?o.action:"");return r&&Pt(r)?"Direction":"Input"}return e==="press_key"||e.includes("key")?"Key":e==="input_text"||e.includes("input")?"Input Text":"Input"}function NP(n){let t=ke(n);if(!t)return"";let e=(t.name||t.action||"").toLowerCase(),o=t.args&&typeof t.args=="object"?t.args:{};if(e==="press_key"||e.includes("key"))return t.key||t.keycode||o.key||o.keycode||"";if(e==="swipe"||e==="scroll"||e==="drag"||e==="drag_and_drop"){let i=t.direction||t.gesture||o.direction||o.gesture||(typeof o.action=="string"?o.action:"")||(typeof t.action=="string"&&t.action!==e?t.action:"");return i&&Pt(i)?String(i).toUpperCase():""}if(e.includes("delay")||e.includes("wait")){let i=t.time_in_ms||t.delay_ms||t.delay_seconds||o.time_in_ms||o.delay_ms||o.delay_seconds||o.duration;if(i)return`${i}ms`}return t.text||t.input_text||o.text||o.input_text||""}function hm(n){let t=ke(n);if(!t)return"";let e=t.args&&typeof t.args=="object"?t.args:{},o=(t.name||t.action||"").toLowerCase(),i=o==="click_sequence"||o==="tap_sequence"||!!(t.sequence||e.sequence||t.normalized_sequence||e.normalized_sequence),r=t.normalized_sequence||e.normalized_sequence||t.sequence||e.sequence||t.targets||e.targets||(Array.isArray(t.coordinates)&&Array.isArray(t.coordinates[0])?t.coordinates:null)||(Array.isArray(e.coordinates)&&Array.isArray(e.coordinates[0])?e.coordinates:null),a=Br(r);if(a&&a.length>0&&(i||a.length>1))return a.map(h=>`[${h[0]}, ${h[1]}]`).join(" \u2192 ");let s=oe(t.normalized_start_coordinates||e.normalized_start_coordinates),c=oe(t.normalized_end_coordinates||e.normalized_end_coordinates);if(s&&c&&s.length===2&&c.length===2)return`[${s[0]}, ${s[1]}] \u2192 [${c[0]}, ${c[1]}]`;let p=oe(t.start_coordinates||t.start_point||t.start||t.from||e.start_coordinates||e.start_point||e.start||e.from),f=oe(t.end_coordinates||t.end_point||t.end||t.to||e.end_coordinates||e.end_point||e.end||e.to);if(p&&f&&p.length===2&&f.length===2)return`[${p[0]}, ${p[1]}] \u2192 [${f[0]}, ${f[1]}]`;let C=oe(t.normalized_coordinates||e.normalized_coordinates)||oe(t.coordinates)||oe(e.coordinates)||oe(t.coords)||oe(e.coords)||oe(t.target)||oe(e.target)||oe(e.action)||oe(t.action)||oe(e.gesture)||oe(t.gesture);return C&&Array.isArray(C)?C.length===4?`[${C[0]}, ${C[1]}] \u2192 [${C[2]}, ${C[3]}]`:C.length===2?`[${C[0]}, ${C[1]}]`:C.join(", "):""}function Bd(n,t){let e=ke(n);if(e&&(e.status==="failed"||e.status==="error"||e.success===!1))return!0;if(t){if(t.status==="failed"||t.status==="error")return!0;if(t.last_execution_result){let o=typeof t.last_execution_result=="string"?(()=>{try{return JSON.parse(t.last_execution_result)}catch(i){return null}})():t.last_execution_result;if(o&&typeof o=="object"){if(o.status==="failed"||o.status==="error"||o.success===!1||o.is_successful===!1||o.repair_status==="fixed"||o.repair_status==="failed"||o.repair_status==="cannot_fix")return!0;if(Array.isArray(o.execution)&&o.execution.length>0){let i=o.execution[0];if(i&&(i.status==="failed"||i.status==="error"||i.error||Array.isArray(i.attempts)&&i.attempts.length>0))return!0}}}}return!1}function RP(n,t){let e=ke(n);if(e&&(e.error||e.message||e.failure_reason))return Vn(e.error||e.message||e.failure_reason);if(t&&t.last_execution_result){let o=typeof t.last_execution_result=="string"?(()=>{try{return JSON.parse(t.last_execution_result)}catch(i){return null}})():t.last_execution_result;if(o&&typeof o=="object"){if(Array.isArray(o.execution)&&o.execution.length>0){let i=o.execution[0];if(i){if(Array.isArray(i.attempts)&&i.attempts.length>0)return Vn(i.attempts[0]);if(i.error||i.failure_reason||i.message)return Vn(i.error||i.failure_reason||i.message)}}if(o.error||o.message||o.failure_reason)return Vn(o.error||o.message||o.failure_reason)}}return"Action Failed"}function FP(n){let t=ke(n);if(!t)return"";let e=t.target_bounds||t.bounds||t.args&&(t.args.target_bounds||t.args.bounds);return Array.isArray(e)?e.join(", "):e?String(e):""}function LP(n){let t=ke(n);return t&&(t.target_resource_id||t.resource_id||t.args&&(t.args.target_resource_id||t.args.resource_id))||""}function VP(n){let t=ke(n);return t&&(t.target_class||t.class_name||t.args&&(t.args.target_class||t.args.class_name))||""}function jP(n,t){let e=ke(n);if(!e||typeof e!="object")return[];if(t&&t.has(e))return t.get(e);let o=new Set(["action","name","type","target_text","text","input_text","target","coordinates","coords","target_bounds","bounds","target_resource_id","resource_id","target_class","class_name","normalized_coordinates","normalized_start_coordinates","normalized_end_coordinates","start_coordinates","end_coordinates","start","end","from","to","pre_image_name","post_image_name","pre_screenshot","post_screenshot","before_screenshot","after_screenshot","status","success","timestamp","created_at","start_time","execution_id","controller","agent","session_id","step_id","app_name","package_name","app","key","keycode","time_in_ms","delay_ms","delay_seconds","duration","args","kwargs","parameters","extra_params","direction","gesture"]),i=[],r=e.args||e.Args||e.kwargs||e.parameters,a={};if(r){if(typeof r=="object")a=r;else if(typeof r=="string")try{a=JSON.parse(r)}catch(p){!r.includes("<")&&!r.includes("object at")&&(a={details:r})}}let s=S(S({},e),a),c=s.duration||s.duration_ms;c!=null&&c!==""&&i.push({key:"Duration",value:typeof c=="number"?`${c}ms`:String(c)});for(let[p,f]of Object.entries(s)){let _=p.toLowerCase();if(o.has(_)||f==null||f==="")continue;let C=String(f);if(C.includes("object at 0x")||C.startsWith("P.toUpperCase());(_==="time_in_ms"||_==="delay_ms")&&(h="Delay"),i.push({key:h,value:C})}return t&&t.set(e,i),i}function fm(n){if(!n||n==="None"||n==="null"||n==="undefined"||typeof n!="string")return null;let t=n.trim();return!t||t==="None"||t==="null"||t==="undefined"?null:t.startsWith("data:")||t.startsWith("http://")||t.startsWith("https://")||t.startsWith("/local_file")||t.startsWith("/images/")||t.startsWith("/api/images/")?t:t.startsWith("file://")?`/local_file?path=${encodeURIComponent(t)}`:t.startsWith("/")?`/local_file?path=${encodeURIComponent("file://"+t)}`:`/images/${t}`}function fD(n,t){return!n||n===t?.action_taken||Array.isArray(t?.action_taken)&&(t.action_taken===n||t.action_taken.includes(n))||n.is_primary_step_action?!1:!!(n.trace_id||n.payload||n.type==="tool"||n.type==="action"&&n.trace_id)}var DP=new WeakMap;function go(n){if(typeof n=="string"&&(n.trim().startsWith("{")||n.trim().startsWith("[")))try{return JSON.parse(n)}catch(t){return n}return n}function $P(n){let t=ke(n);if(!t)return null;let e=go(t.payload),o=go(t.result||e?.result),i=go(t.args||e?.args),r=o?.pre_image_name||o?.pre_screenshot_name||o?.pre_screenshot||e?.pre_image_name||e?.pre_screenshot_name||e?.pre_screenshot||i?.pre_image_name||i?.pre_screenshot_name||i?.pre_screenshot||t.pre_image_name||t.pre_screenshot_name||t.pre_screenshot||t.before_screenshot||t.screenshot;return r?String(r):null}function jd(n){let t=ke(n);if(!t)return null;let e=go(t.payload),o=go(t.result||e?.result),i=go(t.args||e?.args),r=o?.post_image_name||o?.post_screenshot_name||o?.post_screenshot||e?.post_image_name||e?.post_screenshot_name||e?.post_screenshot||i?.post_image_name||i?.post_screenshot_name||i?.post_screenshot||t.post_image_name||t.post_screenshot_name||t.post_screenshot||t.after_screenshot;return r?String(r):null}function mD(n){if(!n)return null;if(n.failed_screenshot)return String(n.failed_screenshot);if(n.failure_screenshot)return String(n.failure_screenshot);if(n.failed_post_image_name)return String(n.failed_post_image_name);if(Array.isArray(n.generic_tools))for(let t of n.generic_tools){let e=ke(t),o=go(e?.payload),i=go(e?.args||o?.args),r=String(e?.name||e?.agent_name||"").toLowerCase();if(i?.post_screenshot_name)return String(i.post_screenshot_name);if(i?.post_screenshot&&(r.includes("failure")||r.includes("validator")||r.includes("diagnos")))return String(i.post_screenshot);if(e?.post_screenshot_name&&(r.includes("failure")||r.includes("validator")))return String(e.post_screenshot_name)}return null}function IP(n){if(!n)return 0;let t=n.timestamp??n.start_time??n.created_at;if(typeof t=="number")return t<1e11?t*1e3:t;if(typeof t=="string"){let e=new Date(t).getTime();if(!isNaN(e)&&e>0)return e}return 0}function _D(n){let t=new Map;if(!n||typeof n!="object")return t;let e=Array.isArray(n.generic_tools)?n.generic_tools.length:0,o=`${n.step_id||""}_${e}_${n.post_image_name||""}_${n.pre_image_name||""}`,i=DP.get(n);if(i&&i.key===o)return i.map;let r=new Map,a=[];n.action_taken&&a.push({item:n.action_taken,act:ke(n.action_taken),timestamp:IP(n.action_taken),isPrimary:!0}),Array.isArray(n.generic_tools)&&n.generic_tools.forEach(h=>{h&&a.push({item:h,act:ke(h),timestamp:IP(h),isPrimary:!1})}),a.sort((h,P)=>h.timestamp-P.timestamp);let s=mD(n),c=!!s||n.action_taken&&Bd(n.action_taken,n),p=a.some(h=>!h.isPrimary&&(jn(h.act)||!!jd(h.act))),f=null;for(let h=a.length-1;h>=0;h--){let P=a[h];if(jn(P.act)||jd(P.act)||P.isPrimary){f=P.item;break}}let _=n.pre_image_name||n.pre_screenshot||null,C=!1;for(let h of a){let P=h.act,T=$P(P),A=jd(P),N=String(P?.agent_name||P?.agent||go(P?.payload)?.agent_name||P?.name||"").toLowerCase(),E=N.includes("failure")||N.includes("analyzer")||N.includes("validator")||N.includes("diagnos");s&&!C&&(E||!h.isPrimary&&c)&&(_=s,C=!0);let U=T||_,ne=null,J=!1;A?(ne=A,J=!0):(h.isPrimary&&!c||h.item===f&&!c||h.item===f&&E)&&(ne=n.post_image_name||n.post_screenshot||null),ne&&(_=ne);let X={pre:U,post:ne,hasExplicitPost:J};r.set(h.item,X),P&&P!==h.item&&r.set(P,X),P?.trace_id&&r.set(P.trace_id,X)}return DP.set(n,{key:o,map:r}),r}function zP(n,t){let e=_D(n);if(!t)return{pre:n?.pre_image_name||n?.pre_screenshot||null,post:n?.post_image_name||n?.post_screenshot||null,hasExplicitPost:!!(n?.post_image_name||n?.post_screenshot)};let o=ke(t);if(e.has(t))return e.get(t);if(o&&e.has(o))return e.get(o);if(o?.trace_id&&e.has(o.trace_id))return e.get(o.trace_id);let i=$P(o),r=jd(o),a=i||n?.pre_image_name||n?.pre_screenshot||null,s=r||(fD(t,n)?null:n?.post_image_name||n?.post_screenshot||null);return{pre:a,post:s,hasExplicitPost:!!r}}function Cm(n,t){let e=zP(n,t);return fm(e.pre)}function Mm(n,t){let e=zP(n,t),o=fm(e.post),i=fm(e.pre);return o&&i&&o===i&&!e.hasExplicitPost?null:o}function BP(n){if(!Array.isArray(n)||n.length===0)return[];let t=[];for(let s of n)s&&((s.type==="step_updated"||s.type==="step_recorded"||s.type==="step")&&s.data?t.push(s.data):(s.step_number!==void 0||s.step_id!==void 0)&&t.push(s));let e=[],o=new Map,i=new Map;for(let s of t){let c=s.step_id!==void 0&&s.step_id!==null&&String(s.step_id).trim()!==""?String(s.step_id).trim():null,p=s.step_number!==void 0&&s.step_number!==null?Number(s.step_number):null,f=-1;if(c&&o.has(c)?f=o.get(c):p!==null&&i.has(p)&&(f=i.get(p)),f>=0){let _=e[f],C=_.action_taken,h=s.action_taken,P=C&&jn(C)&&jo(h),T=h&&jn(h)&&jo(C),A=p!==null?p:_.step_number,N=_.step_id||s.step_id,E=F(S(S({},_),s),{step_id:N,step_number:A,pre_image_name:P?_.pre_image_name:s.pre_image_name||_.pre_image_name,post_image_name:P?_.post_image_name||s.post_image_name:s.post_image_name||_.post_image_name,pre_screenshot:P?_.pre_screenshot:s.pre_screenshot||_.pre_screenshot,post_screenshot:P?_.post_screenshot||s.post_screenshot:s.post_screenshot||_.post_screenshot,action_taken:P?_.action_taken:T?h:s.action_taken||_.action_taken,last_execution_result:s.last_execution_result||_.last_execution_result,summary:P?_.summary||s.summary:s.summary||_.summary,operator_raw_thinking:s.operator_raw_thinking||_.operator_raw_thinking,operator_native_thinking:s.operator_native_thinking||_.operator_native_thinking,generic_tools:s.generic_tools||_.generic_tools,timestamp:_.timestamp||s.timestamp});e[f]=E,N&&o.set(String(N),f),A!=null&&i.set(Number(A),f)}else{let _=e.length;e.push(S({},s)),c&&o.set(c,_),p!==null&&i.set(p,_)}}let r=[];for(let s of e){let c=s.action_taken,p=Cm(s,c),f=Mm(s,c),_=p||f;_&&r.push({stepData:s,act:c,preUrl:p,postUrl:f,primaryImg:_})}r.sort((s,c)=>{let p=Number(s.stepData.step_number??99999),f=Number(c.stepData.step_number??99999);return p!==f?p-f:Number(s.stepData.timestamp??0)-Number(c.stepData.timestamp??0)});let a=[];for(let s=0;s{let t=this.rawSessions(),e=this.pendingQueue(),o=this.agentStatus(),i=this.runningGoal(),r=this.runningSessionId(),a=this.activeTasks(),s=new Map,c=new Map(e.map(f=>[f.session_id,f])),p=new Map(a.map(f=>[f.session_id,f]));if(t.forEach(f=>{let _=p.get(f.session_id),C=c.get(f.session_id),h=(o==="running"||o==="paused")&&r===f.session_id||!!_,P=f.status==="completed"||f.status==="success"||f.status==="failed"||f.status==="cancelled",T=f.status;P?T=f.status==="success"?"completed":f.status:T=h?o==="paused"?"paused":"running":!!C&&!h?"pending":f.status;let A=f.device_serial||f.device_id||_?.device_id||C?.device_serial||null;if(!A&&f.device_info)try{let E=typeof f.device_info=="string"?JSON.parse(f.device_info):f.device_info;A=E?.device_id||E?.device_serial||null}catch(E){}let N=F(S({},f),{status:T,device_serial:A,model_info:h&&this.activeModel()?this.activeModel():f.model_info});s.set(f.session_id,N),P?this.activeSessionTracking.delete(f.session_id):(T==="running"||T==="paused"||T==="pending")&&this.activeSessionTracking.set(f.session_id,N)}),e.forEach(f=>{if(!s.has(f.session_id)){let _=p.get(f.session_id),C=(o==="running"||o==="paused")&&r===f.session_id||!!_,h=F(S({},f),{status:C?o==="paused"?"paused":"running":f.status||"pending",device_serial:f.device_serial||f.device_id||_?.device_id||null});s.set(f.session_id,h),this.activeSessionTracking.set(f.session_id,h)}}),a.length>0)a.forEach(f=>{let _=f.session_id||`active-${f.device_id}`,C=s.get(_);if(C){if(C.status==="pending"){let h=F(S({},C),{status:"running",device_serial:f.device_id||C.device_serial});s.set(_,h),this.activeSessionTracking.set(_,h)}}else{let h={session_id:_,initial_goal:f.goal||i||"",start_time:f.acquired_at?new Date(f.acquired_at).getTime()/1e3:Date.now()/1e3,status:"running",model_info:this.activeModel()||void 0,device_serial:f.device_id||null};s.set(_,h),this.activeSessionTracking.set(_,h)}});else if((o==="running"||o==="paused")&&r&&!s.has(r)){let f={session_id:r,initial_goal:i||"",start_time:Date.now()/1e3,status:o,model_info:this.activeModel()||void 0};s.set(r,f),this.activeSessionTracking.set(r,f)}return this.activeSessionTracking.forEach((f,_)=>{s.has(_)||s.set(_,F(S({},f),{status:"running"}))}),Array.from(s.values()).sort((f,_)=>_.start_time-f.start_time)});currentSessionId=x(null);sessionLogs=x([]);isSessionContentLoading=x(!1);agentStatus=x("idle");runningSessionId=x(null);runningGoal=x(null);isPaused=x(!1);pausedError=x(null);isRetrying=x(!1);retryMessage=x(null);activeModel=x(null);userPinnedSessionId=x(null);startupProgressBySession=x({});pendingStartupProgress=x([]);currentStartupProgress=I(()=>{let t=this.currentSessionId();if(t){let e=String(t).trim().toLowerCase(),o=this.startupProgressBySession(),i=o[t];if(i?.length)return i;let r=Object.entries(o).find(([a])=>a.trim().toLowerCase()===e);if(r?.[1]?.length)return r[1]}return this.pendingStartupProgress()});currentNotes=x({});selectedNoteKey=x("task_plan.md");activeTab=x("tasks");isVideoWindowOpen=x(!1);isVideoMinimized=x(!1);activeVideoUrl=x(null);activeVideoSegments=x([]);activeVideoTitle=x("");isVideoLoading=x(!1);recordingPlaybackStatus=x("idle");recordingPlaybackMessage=x("");shouldAutoplayVideo=x(!1);videoSeekRequest=x(null);stepSeekRequest=x(null);playerMode=x("video");activeVideoSessionId=null;videoSeekRequestId=0;stepSeekRequestId=0;videoRequestGeneration=0;videoRetryTimer=null;videoWaitStartedAt=0;stepLogsForReplay=I(()=>this.sessionLogs().filter(t=>t&&(t.type==="step_updated"||t.type==="step_recorded")),{equal:(t,e)=>t.length===e.length&&t.every((o,i)=>o===e[i])});currentSessionStepFrames=I(()=>BP(this.stepLogsForReplay()));hasCurrentSessionStepFrames=I(()=>this.currentSessionStepFrames().length>0);setPlayerMode(t){this.playerMode.set(t)}togglePlayerMode(){this.playerMode.update(t=>t==="video"?"steps":"video")}requestStepSeek(t){this.stepSeekRequestId++,this.stepSeekRequest.set({index:t,requestId:this.stepSeekRequestId})}clearUserPinnedSession(){this.userPinnedSessionId.set(null)}currentSession=I(()=>{let t=this.currentSessionId();return t&&this.sessions().find(e=>e.session_id===t)||null});isCurrentSessionRunning=I(()=>{let t=this.currentSessionId();if(!t)return!1;let e=this.currentSession();return e?e.status==="running"||e.status==="paused":!!((this.agentStatus()==="running"||this.agentStatus()==="paused")&&this.runningSessionId()===t||this.activeTasks().some(i=>i.session_id===t))});currentSessionVideoUrl=I(()=>{let t=this.currentSessionId();return t&&this.sessions().find(o=>o.session_id===t)?.video_url||null});currentSessionRecordingStatus=I(()=>{let e=this.currentSession()?.recording_status;return e==="recording"||e==="finalizing"||e==="processing"?"processing":e});eventSource=null;statusInterval=null;sessionLoadGeneration=0;sessionSnapshotRequestId=0;sessionSnapshotAppliedId=0;pendingSnapshotRequests=new Set;pendingStreamChunks=new Map;streamFlushTimer=null;lastQueueSignature=null;lastActiveTasksSignature=null;lastPersistedSessionsJson=null;onVisibilityChange=()=>{typeof document<"u"&&!document.hidden&&(this.fetchStatus(),this.fetchSessions())};constructor(){this.restoreSessionsCache(),this.fetchSessions(),this.startStatusPolling(),this.ensureLiveStream(),typeof document<"u"&&document.addEventListener("visibilitychange",this.onVisibilityChange),D(Ae).onDestroy(()=>this.destroy())}isRunningTask=I(()=>this.agentStatus()==="running"||this.agentStatus()==="paused"?!0:this.sessions().some(t=>t.status==="running"||t.status==="paused"));getProTuningDefaults(){return this.http.get("/api/run/defaults")}runTask(t,e="flash",o,i,r){return new ie(a=>{let s={stage:"submitting",message:"Submitting the task",timestamp:Date.now()/1e3};this.pendingStartupProgress.set([s]);let c={goal:t,profile:e};o&&o.trim()&&(c.expected_output=o.trim()),i!==void 0&&(c.enable_outputter=i),r?.verificationLevel&&(c.verification_level=r.verificationLevel),r?.explorerMode&&(c.explorer_mode=r.explorerMode),this.clearUserPinnedSession(),this.http.post("/api/run",c).subscribe({next:p=>{if(p&&p.tasks&&p.tasks.length>0){let f=p.tasks[0].session_id;if(f){this.appendStartupProgress(F(S({},s),{session_id:f}),f),this.pendingStartupProgress.set([]);let _=this.agentStatus()==="running"||this.sessions().some(h=>h.status==="running"),C=this.runningSessionId()||this.sessions().find(h=>h.status==="running"||h.status==="paused")?.session_id;(!_||C===f)&&this.selectSession(f,!1)}}this.pendingStartupProgress.set([]),a.next(p),a.complete()},error:p=>{this.pendingStartupProgress.set([]),a.error(p)}})})}stopTask(t,e=!1){let o=null,i=e;typeof t=="boolean"?i=t:typeof t=="string"&&t.trim()?o=t.trim():o=this.currentSessionId()||this.runningSessionId()||null,!i&&!o&&(o=this.sessions().find(c=>c.status==="running"||c.status==="paused")?.session_id||null),o&&this.setSessionStatus(o,"cancelled");let r=this.sessions().filter(c=>(c.status==="running"||c.status==="paused")&&c.session_id!==o);i||r.length===0?(this.agentStatus.set("idle"),this.runningSessionId.set(null),this.runningGoal.set(null)):this.runningSessionId()===o&&(this.runningSessionId.set(r[0].session_id),this.runningGoal.set(r[0].initial_goal||null)),this.isPaused.set(!1),this.pausedError.set(null),this.isRetrying.set(!1),this.invalidateStatusSignatures(),i&&this.pendingQueue.set([]),(!o||this.currentSessionId()===o)&&(this.flushStreamChunks(),this.sessionLogs.update(c=>c.some(p=>p.type==="llm_stream"&&!p.data?.isCompleted)?c.map(p=>p.type==="llm_stream"&&!p.data?.isCompleted?F(S({},p),{data:F(S({},p.data),{isCompleted:!0})}):p):c)),o&&this.activeTasks.update(c=>c.filter(p=>p.session_id!==o));let a=`/api/stop?all=${i}`;!i&&o&&(a+=`&session_id=${encodeURIComponent(o)}`);let s={all:i};o&&(s.session_id=o),this.http.post(a,s).subscribe({next:()=>{this.fetchStatus(),this.fetchSessions()},error:c=>{console.error("Failed to stop task:",c),this.fetchSessions(),this.fetchStatus()}})}resumeTask(){this.http.post("/api/resume",{}).subscribe({next:t=>{if(t?.status!=="resumed"){this.fetchStatus();return}let e=this.runningSessionId();this.isPaused.set(!1),this.pausedError.set(null),this.agentStatus.set("running"),this.setSessionStatus(e,"running"),this.fetchStatus()},error:t=>{console.error("Failed to resume task:",t)}})}fetchSessions(){this.http.get("/api/sessions").subscribe({next:t=>{this.rawSessions.set(t),this.persistSessionsCache(t),this.updateModelForCurrentView(t),!this.currentSessionId()&&!this.userPinnedSessionId()&&this.agentStatus()!=="running"&&t.length>0&&this.selectSession(t[0].session_id,!1)},error:t=>{console.error("Failed to fetch sessions from backend:",t)}})}deleteSession(t){if(this.invalidateStatusSignatures(),this.rawSessions.update(e=>e.filter(o=>o.session_id!==t)),this.persistSessionsCache(this.rawSessions()),this.pendingQueue.update(e=>e.filter(o=>o.session_id!==t)),this.userPinnedSessionId()===t&&this.userPinnedSessionId.set(null),this.currentSessionId()===t){this.selectSession("",!1),this.isVideoWindowOpen()&&this.closeVideoPlayer();let e=this.runningSessionId();e&&this.agentStatus()==="running"&&this.selectSession(e,!1)}return new ie(e=>{this.http.post(`/api/sessions/${t}/delete`,{}).subscribe({next:o=>{this.fetchSessions(),this.fetchStatus(),e.next(o),e.complete()},error:o=>{console.error(`Failed to delete session ${t}:`,o),this.fetchSessions(),this.fetchStatus(),e.error(o)}})})}clearAllHistory(){return this.invalidateStatusSignatures(),this.userPinnedSessionId.set(null),this.rawSessions.set([]),this.clearSessionsCache(),this.pendingQueue.set([]),this.selectSession("",!1),this.isVideoWindowOpen()&&this.closeVideoPlayer(),new ie(t=>{this.http.post("/api/cleanup",{}).subscribe({next:e=>{this.fetchSessions(),this.fetchStatus(),t.next(e),t.complete()},error:e=>{console.error("Failed to cleanup history:",e),this.fetchSessions(),this.fetchStatus(),t.error(e)}})})}selectSession(t,e=!1){if(e&&(t&&t===this.runningSessionId()&&this.agentStatus()==="running"?this.userPinnedSessionId.set(null):t?this.userPinnedSessionId.set(t):this.userPinnedSessionId.set(null)),!t){this.sessionLoadGeneration++,this.pendingSnapshotRequests.clear(),this.discardPendingStreamChunks(),this.isSessionContentLoading.set(!1),this.currentSessionId.set(null),this.sessionLogs.set([]),this.currentNotes.set({});return}if(this.currentSessionId()===t)return;let o=++this.sessionLoadGeneration;this.pendingSnapshotRequests.clear(),this.discardPendingStreamChunks(),this.sessionSnapshotAppliedId=0,this.isSessionContentLoading.set(!0),this.currentSessionId.set(t),this.sessionLogs.set([]),this.activePauseCardKey=null,this.isPaused.set(!1),this.pausedError.set(null),this.fetchNotes(t),this.fetchChecks(t),this.updateModelForCurrentView(),this.isVideoWindowOpen()&&this.openVideoPlayer(t),console.debug(`Selecting session: ${t}`),this.backfillSessionSteps(t,o),this.ensureLiveStream()}ensureLiveStream(){this.eventSource||(console.debug("Establishing persistent unified live stream via /api/stream"),this.zone.runOutsideAngular(()=>{this.eventSource=new EventSource("/api/stream"),this.eventSource.addEventListener("info",()=>{let e=this.currentSessionId();e&&this.backfillSessionSteps(e)}),this.eventSource.addEventListener("keep-alive",()=>{}),["llm_stream","trace_recorded","step_recorded","step_updated","background_tasks_updated","task_paused","task_resumed","llm_retrying","startup_progress","session_started","session_ended","recording_ready","recording_failed","checker_event"].forEach(e=>{this.eventSource?.addEventListener(e,o=>{try{let i=JSON.parse(o.data),r=i?.session_id;if(e!=="llm_stream"&&this.flushStreamChunks(),e==="recording_ready"||e==="recording_failed"){this.isVideoWindowOpen()&&r&&String(r)===String(this.activeVideoSessionId)&&(e==="recording_ready"?this.refreshActiveRecording(!0):(this.cancelVideoRetry(),this.isVideoLoading.set(!1),this.activeVideoUrl.set(null),this.activeVideoSegments.set([]),this.recordingPlaybackStatus.set("failed"),this.recordingPlaybackMessage.set(i?.error||"Recording finalization failed.")));return}if(e==="background_tasks_updated"){this.fetchStatus();return}if(e==="session_started"){this.agentStatus.set("running"),i?.session_id&&this.runningSessionId.set(i.session_id),i?.initial_goal&&this.runningGoal.set(i.initial_goal),this.fetchSessions(),!this.userPinnedSessionId()&&i?.session_id&&this.selectSession(i.session_id,!1);return}if(e==="session_ended"){let s=r||this.runningSessionId();s&&(this.applySessionEndedStatus(s,i),this.invalidateStatusSignatures(),this.activeTasks.update(p=>p.filter(f=>f.session_id!==s)));let c=this.activeTasks();if(c.length>0)this.agentStatus.set("running"),this.runningSessionId.set(c[0].session_id),this.runningGoal.set(c[0].goal||null);else if(this.pendingQueue().length>0){let p=this.pendingQueue()[0];this.agentStatus.set("running"),this.runningSessionId.set(p.session_id),this.runningGoal.set(p.initial_goal||null)}else this.agentStatus.set("idle"),this.runningSessionId.set(null),this.runningGoal.set(null);this.isRetrying.set(!1),this.isPaused.set(!1),this.fetchSessions(),this.fetchStatus(),s&&(this.fetchNotes(s),String(s)===String(this.currentSessionId()||"")&&this.fetchChecks(s),this.isVideoWindowOpen()&&this.activeVideoSessionId===s&&this.recordingPlaybackStatus()==="live"&&this.beginRecordingFinalization(s)),this.updateModelForCurrentView();return}if(e==="startup_progress"){let s=r||this.currentSessionId();if(s){this.appendStartupProgress(i,s);let c=this.currentSessionId();!this.userPinnedSessionId()&&(!c||String(s).trim().toLowerCase()!==String(c).trim().toLowerCase())&&(this.agentStatus.set("running"),this.runningSessionId.set(s),this.selectSession(s,!1))}return}let a=this.currentSessionId();if(r&&a&&String(r).trim().toLowerCase()!==String(a).trim().toLowerCase())return;if(e==="llm_retrying"){this.isRetrying.set(!0);let s=Number(i.attempt||0),c=Number(i.max_retries||0),p=Number(i.delay||0),f=s&&c?` (Attempt ${s}/${c})`:"",_=p>0?`; retrying in ${p.toFixed(2).replace(/\.00$/,"")}s`:"";this.retryMessage.set(`AI service is temporarily busy${f}${_}...`),a&&this.appendLiveLLMRetryTrace(i,a);return}if(e==="task_paused"){if(this.isPaused.set(!0),this.isRetrying.set(!1),this.agentStatus.set("paused"),a){this.runningSessionId.set(a),this.setSessionStatus(a,"paused");let s=i.error||"AI call failed";this.pausedError.set(s),this.appendPausedErrorCard(s,a,i.timestamp,i.step_id,i)}return}if(e==="task_resumed"){this.isPaused.set(!1),this.isRetrying.set(!1),this.pausedError.set(null),a&&this.runningSessionId()===a&&(this.agentStatus.set("running"),this.setSessionStatus(a,"running")),this.activePauseCardKey=null;return}if((e==="step_updated"||e==="step_recorded"||e==="llm_stream")&&this.isRetrying.set(!1),e==="llm_stream"?this.queueStreamChunk(i):this.sessionLogs.update(s=>{let p=s.some(C=>C.type==="llm_stream"&&!C.data.isCompleted)?s.map(C=>C.type==="llm_stream"&&!C.data.isCompleted?F(S({},C),{data:F(S({},C.data),{isCompleted:!0})}):C):[...s],f=typeof i?.timestamp=="number"?new Date(i.timestamp*1e3).toISOString():new Date().toISOString(),_={type:e,timestamp:f,data:i};if(e==="trace_recorded"&&i?.trace_id){let C=p.findIndex(h=>h.type==="trace_recorded"&&h.data?.trace_id===i.trace_id);if(C>-1){let h=[...p];return h[C]=_,h}}return[...p,_]}),e==="trace_recorded"&&i){let s=i.name||"";if(["save_note","read_note","update_note","append_note","list_notes","outputter"].includes(s.toLowerCase())){let c=this.currentSessionId();c&&this.fetchNotes(c)}}}catch(i){console.error(`Failed to parse ${e} event data:`,i),this.sessionLogs.update(r=>[...r,{type:e,timestamp:new Date().toISOString(),data:o.data}])}})}),this.eventSource.onerror=e=>{console.warn("Persistent live stream issue, browser will auto-reconnect:",e)}}))}queueStreamChunk(t){let e=t.execution_id,o=t.step_id,i=t.parent_trace_id||null,r=t.stream_type||"text",a=`${e}|${r}`,s=this.pendingStreamChunks.get(a);if(s?(s.chunk+=t.chunk,o&&(s.stepId=o),i&&(s.parentTraceId=i)):this.pendingStreamChunks.set(a,{execId:e,stepId:o,parentTraceId:i,streamType:r,chunk:t.chunk}),!this.streamFlushTimer){let c=typeof document<"u"&&document.hidden?500:80;this.streamFlushTimer=setTimeout(()=>this.flushStreamChunks(),c)}}discardPendingStreamChunks(){this.pendingStreamChunks?.clear(),this.streamFlushTimer&&(clearTimeout(this.streamFlushTimer),this.streamFlushTimer=null)}flushStreamChunks(){if(this.streamFlushTimer&&(clearTimeout(this.streamFlushTimer),this.streamFlushTimer=null),!this.pendingStreamChunks?.size)return;let t=Array.from(this.pendingStreamChunks.values());this.pendingStreamChunks.clear(),this.sessionLogs.update(e=>{let o=[...e];for(let i of t){let r=o.findIndex(a=>a.type==="llm_stream"&&a.data.execution_id===i.execId&&(a.data.stream_type||"text")===i.streamType);if(r>-1){let a=o[r];o[r]=F(S({},a),{data:F(S({},a.data),{text:a.data.text+i.chunk,step_id:i.stepId||a.data.step_id,parent_trace_id:i.parentTraceId||a.data.parent_trace_id||null})})}else o=o.map(a=>a.type==="llm_stream"&&(a.data.stream_type||"text")===i.streamType&&!a.data.isCompleted&&(a.data.parent_trace_id||null)===(i.parentTraceId||null)?F(S({},a),{data:F(S({},a.data),{isCompleted:!0})}):a),o.push({type:"llm_stream",timestamp:new Date().toISOString(),data:{execution_id:i.execId,step_id:i.stepId,parent_trace_id:i.parentTraceId||null,text:i.chunk,stream_type:i.streamType,isCompleted:!1}})}return o})}backfillSessionSteps(t,e=this.sessionLoadGeneration){let o=++this.sessionSnapshotRequestId;this.pendingSnapshotRequests.add(o),this.http.get(`/api/sessions/${t}/steps`).subscribe({next:i=>{if(this.pendingSnapshotRequests.delete(o),this.currentSessionId()!==t||e!==this.sessionLoadGeneration||o({type:"step_updated",session_id:t,timestamp:new Date((a.timestamp||Date.now()/1e3)*1e3).toISOString(),data:a,history_snapshot:!0}));this.sessionLogs.update(a=>[...r,...a.filter(s=>!s.history_snapshot)]),this.isSessionContentLoading.set(!1)},error:i=>{this.pendingSnapshotRequests.delete(o),console.error("Failed to backfill session steps:",i),this.currentSessionId()===t&&e===this.sessionLoadGeneration&&this.pendingSnapshotRequests.size===0&&this.isSessionContentLoading.set(!1)}}),this.http.get(`/api/sessions/${t}/startup_progress`).subscribe({next:i=>{Array.isArray(i)&&i.length>0&&this.startupProgressBySession.update(r=>{let s=[...r[t]||[]];for(let c of i)s.some(p=>p.stage===c.stage)||s.push(c);return F(S({},r),{[t]:s})})},error:()=>{}})}restoreSessionsCache(){try{let t=localStorage.getItem(bm);if(!t)return;let e=JSON.parse(t);Array.isArray(e)&&(this.lastPersistedSessionsJson=t,this.rawSessions.set(e))}catch(t){this.clearSessionsCache()}}persistSessionsCache(t){try{let e=JSON.stringify(t);if(e===this.lastPersistedSessionsJson)return;this.lastPersistedSessionsJson=e,localStorage.setItem(bm,e)}catch(e){}}clearSessionsCache(){this.lastPersistedSessionsJson=null;try{localStorage.removeItem(bm)}catch(t){}}appendLiveLLMRetryTrace(t,e){let o=typeof t?.timestamp=="number"?t.timestamp>1e11?t.timestamp/1e3:t.timestamp:Date.now()/1e3,i=t?.trace_id||`llm-retry-live-${t?.request_id||"unknown"}-${o}`,r={error:t?.error,delay:t?.delay,attempt:t?.attempt,max_retries:t?.max_retries,provider:t?.provider,source:t?.source,recoverable:t?.recoverable,request_id:t?.request_id,scheduled_at:t?.scheduled_at??o},a={trace_id:i,session_id:e,step_id:t?.step_id||null,type:"llm_call",name:"llm_retry",timestamp:o,status:"retrying",payload:r};this.sessionLogs.update(s=>{let c=s.findIndex(_=>_.type==="trace_recorded"&&(_.data?.trace_id===i||t?.request_id&&_.data?.payload?.request_id===t.request_id&&Number(_.data?.payload?.scheduled_at)===Number(r.scheduled_at))),p={type:"trace_recorded",session_id:e,timestamp:new Date(o*1e3).toISOString(),data:a};if(c<0)return[...s,p];let f=[...s];return f[c]=p,f})}appendPausedErrorCard(t,e,o,i,r){let a=typeof t=="string"?t:JSON.stringify(t),s=`${e}:${a}`;this.activePauseCardKey!==s&&(this.sessionLogs.update(c=>{if(c.some(C=>(C?.type==="trace_recorded"?[C.data]:Array.isArray(C?.data?.generic_tools)?C.data.generic_tools:[]).some(P=>P?.type==="llm_call"&&P?.status==="failed"&&String(P?.payload?.error??P?.error??"")===a)))return c;let f=i||[...c].reverse().find(C=>(C.type==="step_updated"||C.type==="step_recorded")&&C.data?.step_id)?.data?.step_id||null,_=typeof o=="number"?o<1e11?o*1e3:o:o?new Date(o).getTime():Date.now();return[...c,{type:"trace_recorded",session_id:e,timestamp:new Date(_).toISOString(),data:{trace_id:`task-paused-${e}-${_}`,session_id:e,step_id:f,type:"llm_call",name:"llm_pause",status:"failed",timestamp:_/1e3,payload:{error:a,pause:!0,request_id:r?.request_id,provider:r?.provider,waited_seconds:r?.waited_seconds,retries:Array.isArray(r?.retries)?r.retries:[]}}}]}),this.activePauseCardKey=s)}pollCounter=0;setSessionStatus(t,e){t&&(this.invalidateStatusSignatures(),this.rawSessions.update(o=>o.map(i=>i.session_id===t?F(S({},i),{status:e}):i)),this.pendingQueue.update(o=>o.map(i=>i.session_id===t?F(S({},i),{status:e}):i)))}applySessionEndedStatus(t,e){if(!t)return;let o=String(e?.status||"").toLowerCase(),i=e?.was_stopped_manually||o==="cancelled"?"cancelled":o==="failed"?"failed":o==="completed"||o==="success"?"completed":null;i&&(this.activeSessionTracking.delete(String(t)),this.setSessionStatus(String(t),i),this.invalidateStatusSignatures(),this.activeTasks.update(r=>r.filter(a=>a.session_id!==String(t))))}startStatusPolling(){this.fetchStatus(),this.zone.runOutsideAngular(()=>{this.statusInterval=setInterval(()=>{typeof document<"u"&&document.hidden||(this.fetchStatus(),this.pollCounter++,this.pollCounter%3===0&&this.fetchSessions())},2e3)})}invalidateStatusSignatures(){this.lastQueueSignature=null,this.lastActiveTasksSignature=null}fetchStatus(){this.http.get("/api/status").subscribe({next:t=>{if(t&&t.status){let e=this.agentStatus(),o=this.runningSessionId(),i=t.status==="running"||t.status==="paused";if(this.agentStatus.set(t.status),this.runningSessionId.set(t.session_id||null),this.runningGoal.set(t.goal||null),t.model_info&&this.activeModel.set(t.model_info),this.isPaused.set(t.status==="paused"),t.status==="paused"){this.isRetrying.set(!1);let s=t.paused_error||this.pausedError()||"AI model request failed. The task is paused.";this.pausedError.set(s),t.session_id&&this.currentSessionId()===t.session_id&&this.appendPausedErrorCard(s,t.session_id)}else this.pausedError.set(null),this.activePauseCardKey=null;let r=JSON.stringify(t.queue||[]);if(r!==this.lastQueueSignature){this.lastQueueSignature=r;let s=(t.queue||[]).map((c,p)=>typeof c=="object"&&c!==null?{session_id:c.session_id||`pending-task-${p}`,initial_goal:c.goal||"",start_time:c.start_time||c.created_at||Date.now()/1e3+p,status:c.status||"pending",device_serial:c.device_serial||c.device_id||null}:{session_id:`task-queued-${p}`,initial_goal:String(c),start_time:Date.now()/1e3+p,status:"pending"});this.pendingQueue.set(s)}let a=JSON.stringify(t.active_tasks||[]);if(a!==this.lastActiveTasksSignature&&(this.lastActiveTasksSignature=a,this.activeTasks.set(t.active_tasks||[])),(e!==t.status||o!==t.session_id)&&this.fetchSessions(),!i&&(e==="running"||e==="paused")&&o&&this.isVideoWindowOpen()&&this.activeVideoSessionId===o&&this.recordingPlaybackStatus()==="live"&&this.beginRecordingFinalization(o),i&&t.session_id){let s=this.currentSessionId();(!s||!this.userPinnedSessionId()&&s!==t.session_id)&&this.selectSession(t.session_id,!1)}}},error:t=>{console.error("Failed to fetch status from backend:",t),this.agentStatus.set("offline"),this.runningSessionId.set(null),this.runningGoal.set(null)}})}updateModelForCurrentView(t){let e=this.currentSessionId(),o=t||this.sessions();if(e){let i=o.find(r=>r.session_id===e);if(i&&i.model_info){this.activeModel.set(i.model_info);return}}}destroy(){this.statusInterval&&(clearInterval(this.statusInterval),this.statusInterval=null),this.eventSource&&(this.eventSource.close(),this.eventSource=null),this.discardPendingStreamChunks(),typeof document<"u"&&document.removeEventListener("visibilitychange",this.onVisibilityChange),this.cancelVideoRetry()}fetchChecks(t){t&&this.http.get(`/api/sessions/${t}/checks`).subscribe({next:e=>{if(this.currentSessionId()!==t)return;let o=this.buildCheckerSnapshotLogs(t,e?.records||[],e?.run_outcome||null);this.sessionLogs.update(i=>[...i.filter(r=>!r.checks_snapshot),...o])},error:e=>{console.error(`Failed to fetch checks for session ${t}:`,e)}})}buildCheckerSnapshotLogs(t,e,o){let i=new Map;for(let a of e){if(!a||!a.attempt_id)continue;let s=String(a.attempt_id),c=String(a.checkpoint_id||""),p=i.get(s);p||(p={event:"attempt_finished",phase:c==="final"?"final":"checkpoint",attempt_id:s,checkpoint_id:c,subgoal_text:String(a.subgoal_text||(c==="final"?"Final review against the user's original goal":`Subgoal ${c.slice(0,8)}`)),anchor_step_id:a.anchor_step_id??null,trace_id:a.trace_id??null,status:"done",verdicts:[],findings:[],ts:typeof a.ts=="number"?a.ts:void 0,session_id:t},i.set(s,p)),typeof a.ts=="number"&&(p.ts=p.ts===void 0?a.ts:Math.min(p.ts,a.ts)),!p.trace_id&&a.trace_id&&(p.trace_id=a.trace_id),p.verdicts.push({item_text:String(a.item_text||""),kind:String(a.kind||""),status:String(a.status||""),evidence:String(a.evidence||""),suggestion:a.suggestion?String(a.suggestion):"",when:a.when?String(a.when):void 0})}let r=[];for(let a of i.values()){let s=new Set(a.verdicts.map(p=>p.status));if(s.size===1){let p=[...s][0];(p==="superseded"||p==="unchecked")&&(a.status=p)}let c=a.ts??Date.now()/1e3;a.timestamp=c,r.push({type:"checker_event",session_id:t,timestamp:new Date(c*1e3).toISOString(),checks_snapshot:!0,data:a})}if(o&&typeof o=="object"){let s=(r.length>0?Math.max(...r.map(c=>new Date(c.timestamp).getTime()/1e3)):Date.now()/1e3)+.001;r.push({type:"checker_event",session_id:t,timestamp:new Date(s*1e3).toISOString(),checks_snapshot:!0,data:F(S({event:"run_outcome",phase:"outcome"},o),{ts:s,timestamp:s,session_id:t})})}return r}fetchNotes(t){if(!t){this.currentNotes.set({});return}this.http.get(`/api/sessions/${t}/notes`).subscribe({next:e=>{if(e&&e.notes){this.currentNotes.set(e.notes);let o=Object.keys(e.notes);if(o.length>0){let i=this.selectedNoteKey();o.includes(i)||(o.includes("task_plan.md")?this.selectedNoteKey.set("task_plan.md"):this.selectedNoteKey.set(o[0]))}}},error:e=>{console.error(`Failed to fetch notes for session ${t}:`,e)}})}openVideoPlayer(t,e,o,i,r){let a=t||this.currentSessionId(),s=this.sessions().find(f=>f.session_id===a),c=e||s?.video_url||null,p=o||s?.initial_goal||(a?`Task: ${a.slice(0,8)}...`:"Screen Recording");if(this.activeVideoTitle.set(p),this.isVideoWindowOpen.set(!0),this.isVideoMinimized.set(!1),this.activeVideoSessionId=a,this.cancelVideoRetry(),this.videoRequestGeneration++,this.shouldAutoplayVideo.set(!1),this.recordingPlaybackMessage.set(""),this.activeVideoSegments.set([]),Number.isFinite(i)&&this.requestVideoSeek(Number(i)),Number.isFinite(r)&&(this.playerMode?.set("steps"),this.requestStepSeek(Number(r))),!a){this.activeVideoUrl.set(null),this.isVideoLoading.set(!1),this.recordingPlaybackStatus.set("unavailable");return}if(s?.status==="running"||s?.status==="paused"){this.activeVideoUrl.set(null),this.isVideoLoading.set(!1),this.recordingPlaybackStatus.set("live");return}this.activeVideoUrl.set(c),this.shouldAutoplayVideo.set(!0),this.videoWaitStartedAt=Date.now(),this.isVideoLoading.set(!0),this.recordingPlaybackStatus.set("processing"),this.recordingPlaybackMessage.set("Loading screen recording..."),c?this.playerMode?.set("video"):this.hasCurrentSessionStepFrames?.()?this.playerMode?.set("steps"):this.playerMode?.set("video"),this.requestSessionVideo(a,this.videoRequestGeneration)}appendStartupProgress(t,e){if(!t?.stage||!t?.message)return;let o=typeof t.timestamp=="number"?t.timestamp>1e11?t.timestamp/1e3:t.timestamp:Date.now()/1e3,i={session_id:String(t.session_id||e),stage:String(t.stage),message:String(t.message),timestamp:o},r=String(t.session_id||e).trim();this.startupProgressBySession.update(a=>{let s=[...a[r]||a[e]||[]],c=s.findIndex(f=>f.stage===i.stage);c>=0?s[c]=i:s.push(i),s.sort((f,_)=>f.timestamp-_.timestamp);let p=F(S({},a),{[r]:s});return e&&e!==r&&(p[e]=s),p})}beginRecordingFinalization(t){this.activeVideoSessionId===t&&(this.cancelVideoRetry(),this.videoRequestGeneration++,this.videoWaitStartedAt=Date.now(),this.activeVideoUrl.set(null),this.activeVideoSegments.set([]),this.shouldAutoplayVideo.set(!0),this.isVideoLoading.set(!0),this.recordingPlaybackStatus.set("processing"),this.recordingPlaybackMessage.set("Finalizing screen recording..."),this.requestSessionVideo(t,this.videoRequestGeneration))}requestSessionVideo(t,e){this.http.get(`/api/sessions/${t}/video`).subscribe({next:o=>{if(e!==this.videoRequestGeneration||t!==this.activeVideoSessionId)return;let i=o.status||(o.has_video&&o.video_url?"ready":"unavailable");if(i==="ready"&&o.video_url){this.cancelVideoRetry(),this.isVideoLoading.set(!1),this.recordingPlaybackStatus.set("ready"),this.recordingPlaybackMessage.set(""),this.activeVideoUrl.set(o.video_url),this.activeVideoSegments.set(o.video_segments||[]),this.playerMode?.set("video"),this.rawSessions.update(r=>r.map(a=>a.session_id===t?F(S({},a),{video_url:o.video_url||void 0,recording_status:"ready"}):a));return}if(i==="processing"){this.activeVideoUrl.set(null),this.activeVideoSegments.set([]),this.isVideoLoading.set(!0),this.recordingPlaybackStatus.set("processing"),this.recordingPlaybackMessage.set("Finalizing screen recording..."),this.scheduleVideoRetry(t,e,o.retry_after_ms);return}this.cancelVideoRetry(),this.isVideoLoading.set(!1),this.activeVideoUrl.set(null),this.activeVideoSegments.set([]),this.recordingPlaybackStatus.set(i==="failed"?"failed":"unavailable"),this.recordingPlaybackMessage.set(o.message||(i==="failed"?"Recording finalization failed.":"No screen recording is available for this task.")),this.hasCurrentSessionStepFrames?.()&&this.playerMode?.set("steps")},error:()=>{if(!(e!==this.videoRequestGeneration||t!==this.activeVideoSessionId)){if(this.recordingPlaybackStatus()==="processing"){this.scheduleVideoRetry(t,e,1e3);return}this.isVideoLoading.set(!1),this.recordingPlaybackStatus.set("failed"),this.recordingPlaybackMessage.set("Unable to load the screen recording."),this.hasCurrentSessionStepFrames?.()&&this.playerMode?.set("steps")}}})}scheduleVideoRetry(t,e,o=1e3){if(this.cancelVideoRetry(),Date.now()-this.videoWaitStartedAt>12e4){this.isVideoLoading.set(!1),this.recordingPlaybackStatus.set("failed"),this.recordingPlaybackMessage.set("Recording finalization timed out. You can retry.");return}let i=Math.max(500,Math.min(3e3,o));this.videoRetryTimer=setTimeout(()=>{this.videoRetryTimer=null,this.requestSessionVideo(t,e)},i)}cancelVideoRetry(){this.videoRetryTimer&&(clearTimeout(this.videoRetryTimer),this.videoRetryTimer=null)}refreshActiveRecording(t){let e=this.activeVideoSessionId;e&&(this.cancelVideoRetry(),this.videoRequestGeneration++,t&&this.shouldAutoplayVideo.set(!0),this.requestSessionVideo(e,this.videoRequestGeneration))}retryVideoRecording(){let t=this.activeVideoSessionId;t&&this.beginRecordingFinalization(t)}consumeVideoAutoplay(){let t=this.shouldAutoplayVideo();return this.shouldAutoplayVideo.set(!1),t}requestVideoSeek(t){Number.isFinite(t)&&this.videoSeekRequest.set({seconds:Math.max(0,t),requestId:++this.videoSeekRequestId})}toggleVideoPlayer(){this.isVideoWindowOpen()?this.isVideoWindowOpen.set(!1):this.openVideoPlayer()}closeVideoPlayer(){this.isVideoWindowOpen.set(!1),this.cancelVideoRetry(),this.videoRequestGeneration++,this.activeVideoSessionId=null}static \u0275fac=function(e){return new(e||n)};static \u0275prov=te({token:n,factory:n.\u0275fac,providedIn:"root"})};var Hd=class n{http=D(Ro);readinessReport=x(null);hasReadinessReport=I(()=>this.readinessReport()!==null);isLoading=x(!1);isRestartingAdb=x(!1);adbServerStatus=x(null);isRemoteAdbServer=I(()=>this.adbServerStatus()?.endpoint.mode==="remote");launchingAvd=x(null);emulatorLaunchState=x(null);isEmulatorLaunching=I(()=>{let t=this.emulatorLaunchState()?.status;return t==="starting"||t==="waiting_for_adb"||t==="booting"});lastCheckedTime=x(null);emulatorPollTimer=null;pythonProbe=I(()=>this.probes().find(t=>t.id==="python_runtime")||null);configProbe=I(()=>this.probes().find(t=>t.id==="system_config")||null);toolchainProbe=I(()=>this.probes().find(t=>t.id==="toolchain")||null);adbProbe=I(()=>this.probes().find(t=>t.id==="android_adb")||null);llmProbe=I(()=>this.probes().find(t=>t.id==="gemini_api_key"||t.id==="llm_api_key")||null);geminiProbe=I(()=>this.llmProbe());ocrProbe=I(()=>this.probes().find(t=>t.id==="vision_ocr_key"||t.id==="ocr_api_key")||null);isEnvironmentReady=I(()=>{let t=this.pythonProbe(),e=this.configProbe(),o=this.adbProbe(),i=this.toolchainProbe(),r=t?.status==="pass",a=e?.status==="pass",s=o?.metadata?.installed??o?.status!=="fail",c=i?.status==="pass"||i?.is_blocker===!1;return r&&a&&s&&c});isSkipCredentialsCheck=x(!1);isCredentialsReady=I(()=>this.isSkipCredentialsCheck()?!0:this.llmProbe()?.status==="pass");isDeviceReady=I(()=>this.adbProbe()?.status==="pass");totalStepCount=I(()=>3);passedStepCount=I(()=>{let t=0;return this.isEnvironmentReady()&&t++,this.isCredentialsReady()&&t++,this.isDeviceReady()&&t++,t});isReady=I(()=>this.isEnvironmentReady()&&this.isCredentialsReady()&&this.isDeviceReady());blockerCount=I(()=>this.totalStepCount());passedBlockerCount=I(()=>this.passedStepCount());probes=I(()=>this.readinessReport()?.probes??[]);activeDevice=I(()=>this.readinessReport()?.active_device??null);osType=I(()=>{let t=this.readinessReport()?.os_type;return t==="windows"||t==="win32"?"windows":t==="darwin"||t==="macos"||t==="mac"?"darwin":"linux"});skipCredentialsCheck(){this.isSkipCredentialsCheck.set(!0)}setSkipCredentialsCheck(t){this.isSkipCredentialsCheck.set(t)}connectedDevices=I(()=>{let t=this.adbProbe()?.metadata;return t&&Array.isArray(t.devices)?t.devices:[]});installedAvds=I(()=>{let t=this.adbProbe()?.metadata;return t&&Array.isArray(t.installed_avds)?t.installed_avds:[]});emulatorPath=I(()=>this.adbProbe()?.metadata?.emulator_path||null);isEmulatorInPath=I(()=>this.adbProbe()?.metadata?.is_emulator_in_path??!0);pollingTimer=null;readinessRequest$=null;zone=D(ge);lastAppliedReportJson=null;onVisibilityChange=()=>{typeof document<"u"&&!document.hidden&&this.fetchReadiness(!0).subscribe({error:()=>{}})};constructor(){this.startAutoPolling(3e3),typeof document<"u"&&document.addEventListener("visibilitychange",this.onVisibilityChange),D(Ae).onDestroy(()=>{this.stopAutoPolling(),this.stopEmulatorStatusPolling(),typeof document<"u"&&document.removeEventListener("visibilitychange",this.onVisibilityChange)})}startAutoPolling(t=3e3){this.pollingTimer&&clearInterval(this.pollingTimer),this.zone.runOutsideAngular(()=>{this.pollingTimer=setInterval(()=>{typeof document<"u"&&document.hidden||this.fetchReadiness(!0).subscribe({error:()=>{}})},t)})}stopAutoPolling(){this.pollingTimer&&(clearInterval(this.pollingTimer),this.pollingTimer=null)}fetchReadiness(t=!1,e=!1){if(t||this.isLoading.set(!0),this.readinessRequest$)return this.readinessRequest$;let o=this.http.get("/api/system/readiness",{params:e?{force:!0}:{}}).pipe(xe({next:i=>{this.applyReadinessReport(i)},error:i=>{console.error("Failed to fetch system readiness:",i)}}),_o(()=>{this.readinessRequest$===o&&(this.readinessRequest$=null),this.isLoading.set(!1)}),Ou({bufferSize:1,refCount:!1}));return this.readinessRequest$=o,o}applyReadinessReport(t){let e=this.readinessReport();if(e&&t.timestamp{this.emulatorLaunchState.set(t),t.status==="ready"?(this.launchingAvd.set(null),this.stopEmulatorStatusPolling(),this.fetchReadiness().subscribe()):t.status==="failed"||t.status==="stopped"||t.status==="idle"?(this.launchingAvd.set(null),this.stopEmulatorStatusPolling()):t.avd_name&&this.launchingAvd.set(t.avd_name)},error:t=>{console.error("Failed to fetch emulator status:",t)}}))}startEmulatorStatusPolling(){this.emulatorPollTimer&&clearInterval(this.emulatorPollTimer),this.emulatorPollTimer=setInterval(()=>{this.fetchEmulatorStatus().subscribe({error:()=>{}})},1e3)}stopEmulatorStatusPolling(){this.emulatorPollTimer&&(clearInterval(this.emulatorPollTimer),this.emulatorPollTimer=null)}launchEmulator(t){return this.launchingAvd.set(t),this.emulatorLaunchState.set({avd_name:t,status:"starting",pid:null,serial:null,stage_message:"Spawning emulator process...",progress_percent:15,started_at:Date.now()/1e3,elapsed_seconds:0,error:null,logs:[`Initiating launch for AVD: ${t}...`],can_retry:!0}),this.http.post("/api/system/emulator/launch",{avd_name:t}).pipe(xe({next:e=>{this.emulatorLaunchState.set(e),e.status==="failed"?this.launchingAvd.set(null):this.startEmulatorStatusPolling()},error:e=>{console.error("Failed to launch emulator:",e),this.launchingAvd.set(null);let o=e?.error?.detail||e?.message||"Failed to start emulator process.";this.emulatorLaunchState.set({avd_name:t,status:"failed",pid:null,serial:null,stage_message:"Failed to initiate launch.",progress_percent:0,started_at:null,elapsed_seconds:0,error:o,logs:[o],can_retry:!0})}}))}stopEmulator(){return this.http.post("/api/system/emulator/stop",{}).pipe(xe({next:()=>{this.launchingAvd.set(null),this.stopEmulatorStatusPolling(),this.fetchEmulatorStatus().subscribe(),this.fetchReadiness().subscribe()}}))}dismissEmulatorStatus(){return this.http.post("/api/system/emulator/dismiss",{}).pipe(xe({next:()=>{this.emulatorLaunchState.set(null),this.launchingAvd.set(null),this.stopEmulatorStatusPolling()}}))}restartAdb(){return this.isRestartingAdb.set(!0),this.http.post("/api/system/adb/restart",{}).pipe(xe({next:t=>{t?.report&&this.applyReadinessReport(t.report),this.isRestartingAdb.set(!1)},error:t=>{console.error("Failed to restart ADB server:",t),this.isRestartingAdb.set(!1)}}))}connectWirelessAdb(t,e=5555){return this.http.post("/api/system/adb/connect",{host:t,port:e}).pipe(xe({next:o=>{o?.report&&this.applyReadinessReport(o.report)}}))}fetchAdbServerStatus(){return this.http.get("/api/system/adb/server").pipe(xe(t=>this.adbServerStatus.set(t)))}probeAdbServer(t,e){return this.http.post("/api/system/adb/server/probe",{host:t,port:e,persist:!1})}connectAdbServer(t,e,o=!0){return this.http.post("/api/system/adb/server/connect",{host:t,port:e,persist:o}).pipe(xe(i=>{i.connection_result.success&&(this.adbServerStatus.set({endpoint:i.connection_result.endpoint}),i.report&&this.applyReadinessReport(i.report))}))}useLocalAdbServer(t=!0){return this.http.post("/api/system/adb/server/local",{},{params:{persist:t}}).pipe(xe(e=>{this.adbServerStatus.set({endpoint:e.connection_result.endpoint}),e.report&&this.applyReadinessReport(e.report)}))}selectDevice(t){return this.isLoading.set(!0),this.http.post("/api/system/devices/select",{serial:t}).pipe(xe({next:e=>{e?.report&&this.applyReadinessReport(e.report),this.isLoading.set(!1)},error:e=>{console.error("Failed to select active device:",e),this.isLoading.set(!1)}}))}currentApiKey=I(()=>this.llmProbe()?.metadata?.current_key||"");apiKeysMap=I(()=>this.llmProbe()?.metadata?.api_keys||{});modelConfigEnv=x(null);fetchModelConfigEnv(){return this.http.get("/api/system/model-config-env").pipe(xe({next:t=>{this.modelConfigEnv.set(t)},error:t=>{console.error("Failed to fetch model config and env:",t)}}))}testApiKey(t,e,o){return this.http.post("/api/system/credentials/test",{provider:t,api_key:e,base_url:o})}updateApiKey(t,e,o=!0){return this.http.post("/api/system/credentials",{provider:t,api_key:e,persist_to_env:o}).pipe(xe({next:i=>{i?.report&&this.applyReadinessReport(i.report),this.fetchModelConfigEnv().subscribe()},error:i=>{console.error(`Failed to update credentials for ${t}:`,i)}}))}static \u0275fac=function(e){return new(e||n)};static \u0275prov=te({token:n,factory:n.\u0275fac,providedIn:"root"})};var HP={"com.google.android.apps.maps":{name:"Maps",icon:"explore",pkg:"com.google.android.apps.maps",category:"navigation"},"com.google.android.gm":{name:"Gmail",icon:"mail",pkg:"com.google.android.gm",category:"productivity"},"com.android.chrome":{name:"Chrome",icon:"public",pkg:"com.android.chrome",category:"browser"},"com.google.android.youtube":{name:"YouTube",icon:"smart_display",pkg:"com.google.android.youtube",category:"entertainment"},"com.android.settings":{name:"Settings",icon:"settings",pkg:"com.android.settings",category:"system"},"com.google.android.deskclock":{name:"Clock",icon:"timer",pkg:"com.google.android.deskclock",category:"utility"},"com.android.deskclock":{name:"Clock",icon:"timer",pkg:"com.android.deskclock",category:"utility"},"com.google.android.calculator":{name:"Calculator",icon:"calculate",pkg:"com.google.android.calculator",category:"utility"},"com.android.calculator2":{name:"Calculator",icon:"calculate",pkg:"com.android.calculator2",category:"utility"},"com.google.android.apps.photos":{name:"Photos",icon:"photo_library",pkg:"com.google.android.apps.photos",category:"media"},"com.google.android.calendar":{name:"Calendar",icon:"calendar_month",pkg:"com.google.android.calendar",category:"productivity"},"com.google.android.keep":{name:"Keep Notes",icon:"note_alt",pkg:"com.google.android.keep",category:"productivity"},"com.android.vending":{name:"Play Store",icon:"storefront",pkg:"com.android.vending",category:"tools"},"com.google.android.apps.messaging":{name:"Messages",icon:"chat",pkg:"com.google.android.apps.messaging",category:"communication"},"com.tencent.mm":{name:"WeChat",icon:"forum",pkg:"com.tencent.mm",category:"social"},"com.xingin.xhs":{name:"Xiaohongshu",icon:"auto_stories",pkg:"com.xingin.xhs",category:"social"},"com.sankuai.meituan":{name:"Meituan",icon:"restaurant",pkg:"com.sankuai.meituan",category:"lifestyle"},"com.dianping.v1":{name:"Dianping",icon:"star",pkg:"com.dianping.v1",category:"lifestyle"},"tv.danmaku.bili":{name:"Bilibili",icon:"video_library",pkg:"tv.danmaku.bili",category:"entertainment"},"com.eg.android.AlipayGphone":{name:"Alipay",icon:"account_balance_wallet",pkg:"com.eg.android.AlipayGphone",category:"finance"},"com.netease.cloudmusic":{name:"NetEase Music",icon:"headphones",pkg:"com.netease.cloudmusic",category:"entertainment"},"com.spotify.music":{name:"Spotify",icon:"music_note",pkg:"com.spotify.music",category:"entertainment"}},UP=[{id:"maps_coffee",title:"Find Specialty Coffee",description:"Search nearby top-rated cafes in Google Maps",goal:"Open Google Maps, search for top-rated specialty coffee shops nearby, and view the top result details.",profile:"flash",category:"flash",tag:"Maps",apps:[{name:"Maps",icon:"explore",pkg:"com.google.android.apps.maps"}],requiredPackages:["com.google.android.apps.maps"],priority:95},{id:"pro_commute_share",title:"Commute ETA & Message Draft",description:"Check transit time on Maps and draft arrival ETA in Messages",goal:"Open Google Maps to check commute time to the International Airport, calculate arrival time, then open Messages and draft an ETA text message.",profile:"pro",category:"cross_app",tag:"Maps + Messages",apps:[{name:"Maps",icon:"explore",pkg:"com.google.android.apps.maps"},{name:"Messages",icon:"chat",pkg:"com.google.android.apps.messaging"}],requiredPackages:["com.google.android.apps.maps","com.google.android.apps.messaging"],matchMode:"all",priority:92},{id:"gmail_receipts",title:"Search Order Receipts",description:"Find recent flight or delivery confirmation emails in Gmail",goal:"Open Gmail and search for recent flight or package delivery confirmation emails.",profile:"flash",category:"flash",tag:"Gmail",apps:[{name:"Gmail",icon:"mail",pkg:"com.google.android.gm"}],requiredPackages:["com.google.android.gm"],priority:90},{id:"pro_email_to_calendar",title:"Email Itinerary to Calendar",description:"Extract flight or event dates from Gmail and schedule in Calendar",goal:"Open Gmail to find the latest event invitation or itinerary, extract dates and location, then open Google Calendar and create a corresponding calendar event.",profile:"pro",category:"cross_app",tag:"Gmail + Calendar",apps:[{name:"Gmail",icon:"mail",pkg:"com.google.android.gm"},{name:"Calendar",icon:"calendar_month",pkg:"com.google.android.calendar"}],requiredPackages:["com.google.android.gm"],priority:94},{id:"chrome_research",title:"Search AI News Breakthroughs",description:"Search latest multimodal AI developments in Chrome browser",goal:"Open Chrome browser and search for latest breakthroughs in multimodal mobile AI agents.",profile:"flash",category:"flash",tag:"Chrome",apps:[{name:"Chrome",icon:"public",pkg:"com.android.chrome"}],requiredPackages:["com.android.chrome"],priority:88},{id:"pro_research_keep",title:"Product Research & Notes Note",description:"Compare top 3 headphones on Chrome and record comparison in Keep",goal:"Open Chrome, research top 3 noise-cancelling headphones comparing price and battery life, then write a structured comparison summary note in Keep Notes.",profile:"pro",category:"pro",tag:"Chrome + Keep",apps:[{name:"Chrome",icon:"public",pkg:"com.android.chrome"},{name:"Keep Notes",icon:"note_alt",pkg:"com.google.android.keep"}],requiredPackages:["com.android.chrome"],priority:91},{id:"youtube_lofi",title:"Play Lo-Fi Music Radio",description:"Search and play a Lo-Fi hip hop live stream on YouTube",goal:'Open YouTube, search for "Lofi hip hop beats relaxing radio" and tap on the live stream.',profile:"flash",category:"flash",tag:"YouTube",apps:[{name:"YouTube",icon:"smart_display",pkg:"com.google.android.youtube"}],requiredPackages:["com.google.android.youtube"],priority:85},{id:"settings_display_wifi",title:"Dark Mode & Wi-Fi Check",description:"Toggle dark theme and verify network connection in Settings",goal:"Open Settings app, navigate to Display settings, ensure Dark theme is enabled, and check Wi-Fi connection status.",profile:"flash",category:"flash",tag:"Settings",apps:[{name:"Settings",icon:"settings",pkg:"com.android.settings"}],requiredPackages:["com.android.settings"],priority:87},{id:"pro_settings_qa",title:"Subsystem Health & Crash Probe",description:"Traverse Settings submenus to verify screens and check for crash dialogs",goal:"Explore Settings submenus (Network, Connected devices, Apps, Battery, Storage), verify each screen loads properly without ANR or crash dialogs, and summarize results.",profile:"pro",category:"monitor",tag:"Settings QA",apps:[{name:"Settings",icon:"settings",pkg:"com.android.settings"}],requiredPackages:["com.android.settings"],priority:93},{id:"clock_timer",title:"25-Min Pomodoro Timer",description:"Start a 25-minute focus countdown timer in Clock app",goal:"Open Clock app, switch to Timer tab, set 25 minutes and start the countdown timer.",profile:"flash",category:"flash",tag:"Clock",apps:[{name:"Clock",icon:"timer",pkg:"com.google.android.deskclock"}],requiredPackages:["com.google.android.deskclock","com.android.deskclock"],priority:86},{id:"calc_gratuity",title:"Split Bill & Calculate Tip",description:"Calculate 18% gratuity on $186.40 for 3 people in Calculator",goal:"Open Calculator and calculate 18% tip on a bill of $186.40, then divide by 3 people.",profile:"flash",category:"flash",tag:"Calculator",apps:[{name:"Calculator",icon:"calculate",pkg:"com.google.android.calculator"}],requiredPackages:["com.google.android.calculator","com.android.calculator2"],priority:84},{id:"photos_inspect",title:"Inspect Recent Screenshot",description:"Open Google Photos and review the latest screenshot taken",goal:"Open Google Photos and view the most recent screenshot in the screenshots album.",profile:"flash",category:"flash",tag:"Photos",apps:[{name:"Photos",icon:"photo_library",pkg:"com.google.android.apps.photos"}],requiredPackages:["com.google.android.apps.photos"],priority:82},{id:"wechat_browse",title:"Check WeChat Messages",description:"Open WeChat and view top recent chat conversations",goal:"Open WeChat and view the top recent chat messages.",profile:"flash",category:"flash",tag:"WeChat",apps:[{name:"WeChat",icon:"forum",pkg:"com.tencent.mm"}],requiredPackages:["com.tencent.mm"],priority:89},{id:"pro_wechat_to_calendar",title:"WeChat Notice to Calendar",description:"Extract meeting notice from WeChat chat and add to Calendar",goal:"Open WeChat, locate the latest meeting announcement or event message in the top chat, extract the time and topic, then open Calendar and schedule an event.",profile:"pro",category:"cross_app",tag:"WeChat + Calendar",apps:[{name:"WeChat",icon:"forum",pkg:"com.tencent.mm"},{name:"Calendar",icon:"calendar_month",pkg:"com.google.android.calendar"}],requiredPackages:["com.tencent.mm"],priority:93},{id:"xhs_coffee_guide",title:"RED Cafe Guide Search",description:"Search trending specialty cafe reviews on Xiaohongshu",goal:"Open Xiaohongshu, search for top-rated specialty coffee shops, and view the top post.",profile:"flash",category:"flash",tag:"Xiaohongshu",apps:[{name:"Xiaohongshu",icon:"auto_stories",pkg:"com.xingin.xhs"}],requiredPackages:["com.xingin.xhs"],priority:87},{id:"meituan_ramen_search",title:"Meituan Food Search",description:"Search top-rated Ramen nearby on Meituan or Dianping",goal:"Open Meituan or Dianping, search for top-rated Ramen nearby, and view top restaurant rating.",profile:"flash",category:"flash",tag:"Meituan",apps:[{name:"Meituan",icon:"restaurant",pkg:"com.sankuai.meituan"}],requiredPackages:["com.sankuai.meituan","com.dianping.v1"],priority:86},{id:"bilibili_stream",title:"Bilibili Tech Video",description:"Search and play an AI Agent tutorial video on Bilibili",goal:'Open Bilibili, search for "AI Agent Architecture", and play the top matching video.',profile:"flash",category:"flash",tag:"Bilibili",apps:[{name:"Bilibili",icon:"video_library",pkg:"tv.danmaku.bili"}],requiredPackages:["tv.danmaku.bili"],priority:85},{id:"pro_playstore_review",title:"Play Store App Review Study",description:"Compare top task management apps and ratings on Google Play",goal:"Open Google Play Store, search for top rated task management apps, compare ratings and latest user reviews of the top 2 candidates, and record recommendations.",profile:"pro",category:"pro",tag:"Play Store",apps:[{name:"Play Store",icon:"storefront",pkg:"com.android.vending"}],requiredPackages:["com.android.vending"],priority:88}];var Ud=class n{allTasks=UP;appRegistry=HP;isSuggestionOnDevice(t,e){return e.size===0?!1:!t.requiredPackages||t.requiredPackages.length===0?!0:t.matchMode==="all"?t.requiredPackages.every(o=>e.has(o)):t.requiredPackages.some(o=>e.has(o))}filterAndRankTasks(t,e,o=0){let i=t.size>0,a=this.allTasks.map(c=>{let p=this.isSuggestionOnDevice(c,t),f=c.priority??50;return i&&(p?(f+=100,c.requiredPackages&&c.requiredPackages.length>1&&c.matchMode==="all"&&(f+=30)):f-=40),{task:c,isMatched:p,score:f}}).filter(c=>e==="flash"?c.task.profile==="flash":e==="pro"?c.task.profile==="pro":e==="cross_app"?c.task.category==="cross_app":e==="monitor"?c.task.category==="monitor":!0);a.sort((c,p)=>p.score-c.score);let s=a.map(c=>c.task);if(o>0&&s.length>0){let c=o%s.length;s=[...s.slice(c),...s.slice(0,c)]}return s}static \u0275fac=function(e){return new(e||n)};static \u0275prov=te({token:n,factory:n.\u0275fac,providedIn:"root"})};var _n=[{id:"off",label:"Off",tagline:"No checking. The run ends as soon as the task looks done.",latency:"no extra time",runs:["Each step is treated as finished the moment it is carried out.","You get the full action trace, but no pass / fail verdict."],skips:["Nothing is double-checked and nothing is retried."],bestFor:"Quick tries and demos, when you only want to watch what happens."},{id:"final",label:"At the end",tagline:"One check of the finished result against your goal. This is the default.",latency:"adds about 20\u201360 s at the end",runs:["When the task finishes, the final screen, the step history and the device state are compared with what you asked for.","If the result does not match, the task goes back and tries to fix it, up to 3 times."],skips:["Nothing is checked while the task is still running."],bestFor:"Everyday tasks: an honest pass / fail without slowing the run down."},{id:"checkpoints",label:"Every step",tagline:"Each step is checked as soon as it is done, plus the final check.",latency:"a short check after each step, done in the background",runs:["Every step is checked right after it completes, using the screenshots from that moment.","If a step went wrong, it gets fixed before moving on (up to 2 tries per step).","A failed test condition is written down and the task keeps going.","The final check still runs at the end."],bestFor:"Long tasks where one early mistake would spoil everything after it."},{id:"strict",label:"Strict",tagline:"Every step is checked, with more retries. The first failed test stops the run.",latency:"slowest: more checks and more retries",runs:["Each check takes longer and gets more attempts: 4 fixes per step and 5 at the end.","The first failed test condition stops the run immediately, with the evidence attached."],bestFor:"Release checks and regression runs, where a wrong pass is never acceptable."}],hn=[{id:"flash",label:"Quick glance",tagline:"Finds buttons and text on the screen in a single look.",latency:"1 look per search",runs:["Something on screen is asked for by name, icon or colour and its position comes back straight away.","Several things can be looked up at once."],skips:["No zooming in and no second try."],bestFor:"Ordinary apps with clearly labelled buttons, icons and text."},{id:"pro",label:"Second look",tagline:"Takes up to 3 looks, thinking in between, before answering.",latency:"up to 3 looks per search",runs:["The screen layout is read first, then the picture is searched.","If the first try misses, a different approach is tried within the 3 looks."],skips:["Still no zooming into small areas, to keep searches short."],bestFor:'Things described by where they are ("the switch next to Wi-Fi") or with unclear labels.'},{id:"ultra",label:"Close-up",tagline:"Zooms into parts of the screen and takes up to 8 looks.",latency:"up to 8 looks per search (slowest)",runs:["Parts of the screen can be cropped and magnified to read tiny text and crowded layouts piece by piece.","Later looks reuse the earlier ones, so they cost less time than they sound."],bestFor:"Crowded screens, tiny targets, charts and drawings, and checks where exact placement matters."}],Hr="final",Ur="flash";function Li(n,t,e){let o=String(t??"").trim().toLowerCase(),i=n.findIndex(r=>r.id===o);return i>=0?i:Math.max(0,n.findIndex(r=>r.id===e))}function Gd(n,t){return t<=1?0:Math.min(Math.max(n,0),t-1)/(t-1)*100}var hD=()=>[],GP=(n,t)=>t.name,Pm=(n,t)=>t.serial,qP=(n,t)=>t.id,CD=(n,t)=>t.kind;function MD(n,t){n&1&&(l(0,"span",14),u(1,"check_circle"),d())}function bD(n,t){n&1&&(l(0,"span",15),u(1,"tune"),d())}function PD(n,t){n&1&&(l(0,"span",17),u(1,"lock"),d())}function OD(n,t){n&1&&(l(0,"span",15),u(1,"rocket_launch"),d())}function vD(n,t){n&1&&(l(0,"span",23),u(1,"check"),d())}function yD(n,t){n&1&&(l(0,"span"),u(1,"1"),d())}function xD(n,t){if(n&1){let e=L();l(0,"div",31)(1,"div",72)(2,"div",73)(3,"span",74),u(4,"bolt"),d()(),l(5,"div",75)(6,"div",76)(7,"div",77),u(8,"Automated Dependency Setup"),d(),l(9,"div",78)(10,"button",79),k("click",function(){v(e);let i=m(2);return y(i.setSelectedOs("linux"))}),u(11,"Linux"),d(),l(12,"button",79),k("click",function(){v(e);let i=m(2);return y(i.setSelectedOs("darwin"))}),u(13,"macOS"),d(),l(14,"button",79),k("click",function(){v(e);let i=m(2);return y(i.setSelectedOs("windows"))}),u(15,"Windows"),d()()(),l(16,"div",80),u(17,"Run in terminal to install ADB, FFmpeg, scrcpy, and project dependencies:"),d()()(),l(18,"div",81)(19,"code"),u(20),d(),l(21,"button",82),k("click",function(){v(e);let i=m(2);return y(i.copyToClipboard(i.oneClickSetupCmd(),"one_click_deps"))}),l(22,"span",29),u(23),d(),l(24,"span"),u(25),d()()()()}if(n&2){let e=m(2);g(10),w("active",e.effectiveOs()==="linux"),g(2),w("active",e.effectiveOs()==="darwin"),g(2),w("active",e.effectiveOs()==="windows"),g(6),O(e.oneClickSetupCmd()),g(),w("copied",e.copiedId()==="one_click_deps"),g(2),O(e.copiedId()==="one_click_deps"?"check":"content_copy"),g(2),O(e.copiedId()==="one_click_deps"?"Copied!":"Copy")}}function wD(n,t){if(n&1){let e=L();l(0,"div",42)(1,"div",83)(2,"div",81)(3,"span",84),u(4,"$"),d(),l(5,"code",85),u(6,"uv python install 3.12"),d(),l(7,"button",82),k("click",function(){v(e);let i=m(2);return y(i.copyToClipboard("uv python install 3.12","py_install"))}),l(8,"span",29),u(9),d(),l(10,"span"),u(11),d()()()()()}if(n&2){let e=m(2);g(7),w("copied",e.copiedId()==="py_install"),g(2),O(e.copiedId()==="py_install"?"check":"content_copy"),g(2),O(e.copiedId()==="py_install"?"Copied!":"Copy")}}function SD(n,t){if(n&1){let e=L();l(0,"div",42)(1,"div",83)(2,"div",81)(3,"span",84),u(4,"$"),d(),l(5,"code",85),u(6),d(),l(7,"button",82),k("click",function(){v(e);let i=m(2);return y(i.copyToClipboard(i.adbInstallCmd(),"env_adb_cmd"))}),l(8,"span",29),u(9),d(),l(10,"span"),u(11),d()()()()()}if(n&2){let e=m(2);g(6),O(e.adbInstallCmd()),g(),w("copied",e.copiedId()==="env_adb_cmd"),g(2),O(e.copiedId()==="env_adb_cmd"?"check":"content_copy"),g(2),O(e.copiedId()==="env_adb_cmd"?"Copied!":"Copy")}}function kD(n,t){if(n&1){let e=L();l(0,"div",42)(1,"div",83)(2,"div",81)(3,"span",84),u(4,"$"),d(),l(5,"code",85),u(6,"artemis init"),d(),l(7,"button",82),k("click",function(){v(e);let i=m(2);return y(i.copyToClipboard("artemis init","cfg_init"))}),l(8,"span",29),u(9),d(),l(10,"span"),u(11),d()()()()()}if(n&2){let e=m(2);g(7),w("copied",e.copiedId()==="cfg_init"),g(2),O(e.copiedId()==="cfg_init"?"check":"content_copy"),g(2),O(e.copiedId()==="cfg_init"?"Copied!":"Copy")}}function ED(n,t){if(n&1){let e=L();l(0,"div",42)(1,"div",83)(2,"div",81)(3,"span",84),u(4,"$"),d(),l(5,"code",85),u(6),d(),l(7,"button",82),k("click",function(){v(e);let i=m(2);return y(i.copyToClipboard(i.toolchainInstallCmd(),"install_toolchain"))}),l(8,"span",29),u(9),d(),l(10,"span"),u(11),d()()()()()}if(n&2){let e=m(2);g(6),O(e.toolchainInstallCmd()),g(),w("copied",e.copiedId()==="install_toolchain"),g(2),O(e.copiedId()==="install_toolchain"?"check":"content_copy"),g(2),O(e.copiedId()==="install_toolchain"?"Copied!":"Copy")}}function DD(n,t){n&1&&(l(0,"span",23),u(1,"check"),d())}function ID(n,t){n&1&&(l(0,"span"),u(1,"2"),d())}function TD(n,t){if(n&1){let e=L();l(0,"button",111),k("click",function(){v(e);let i=m(3);return y(i.copyToClipboard(i.geminiKeyInput(),"gemini_key_field"))}),l(1,"span",29),u(2),d()()}if(n&2){let e=m(3);g(),w("text-emerald",e.copiedId()==="gemini_key_field"),g(),V(" ",e.copiedId()==="gemini_key_field"?"check":"content_copy"," ")}}function AD(n,t){if(n&1&&(l(0,"span",112),u(1,"sync"),d(),l(2,"span"),u(3),d()),n&2){let e=m(3);g(3),O(e.geminiKeyInput().trim()?"Verifying & Saving...":"Clearing...")}}function ND(n,t){if(n&1&&(l(0,"span",29),u(1),d(),l(2,"span"),u(3),d()),n&2){let e=m(3);g(),O(e.geminiKeyInput().trim()?"save":"delete_outline"),g(2),O(e.geminiKeyInput().trim()?e.isGeminiModified()?"Save & Apply":"Save Key":"Clear Key")}}function RD(n,t){n&1&&(l(0,"span",112),u(1,"sync"),d(),l(2,"span"),u(3,"Testing..."),d())}function FD(n,t){n&1&&(l(0,"span",29),u(1,"network_check"),d(),l(2,"span"),u(3,"Test Key"),d())}function LD(n,t){if(n&1&&(l(0,"div",99)(1,"span",29),u(2,"check_circle"),d(),l(3,"span"),u(4),d()()),n&2){let e=m(3);g(4),O(e.geminiSaveMessage())}}function VD(n,t){if(n&1&&(l(0,"div",100)(1,"span",29),u(2,"error"),d(),l(3,"span"),u(4),d()()),n&2){let e=m(3);g(4),O(e.geminiSaveError())}}function jD(n,t){n&1&&$(0,"span",108)}function $D(n,t){if(n&1){let e=L();l(0,"button",111),k("click",function(){v(e);let i=m(4);return y(i.copyToClipboard(i.ocrKeyInput(),"ocr_key_field"))}),l(1,"span",29),u(2),d()()}if(n&2){let e=m(4);g(),w("text-emerald",e.copiedId()==="ocr_key_field"),g(),V(" ",e.copiedId()==="ocr_key_field"?"check":"content_copy"," ")}}function zD(n,t){if(n&1&&(l(0,"span",112),u(1,"sync"),d(),l(2,"span"),u(3),d()),n&2){let e=m(4);g(3),O(e.ocrKeyInput().trim()?"Verifying & Saving...":"Clearing...")}}function BD(n,t){if(n&1&&(l(0,"span",29),u(1),d(),l(2,"span"),u(3),d()),n&2){let e=m(4);g(),O(e.ocrKeyInput().trim()?"save":"delete_outline"),g(2),O(e.ocrKeyInput().trim()?e.isOcrModified()?"Save & Apply":"Save Key":"Clear Key")}}function HD(n,t){n&1&&(l(0,"span",112),u(1,"sync"),d(),l(2,"span"),u(3,"Testing..."),d())}function UD(n,t){n&1&&(l(0,"span",29),u(1,"network_check"),d(),l(2,"span"),u(3,"Test Key"),d())}function GD(n,t){if(n&1&&(l(0,"div",99)(1,"span",29),u(2,"check_circle"),d(),l(3,"span"),u(4),d()()),n&2){let e=m(4);g(4),O(e.ocrSaveMessage())}}function qD(n,t){if(n&1&&(l(0,"div",100)(1,"span",29),u(2,"error"),d(),l(3,"span"),u(4),d()()),n&2){let e=m(4);g(4),O(e.ocrSaveError())}}function WD(n,t){if(n&1){let e=L();l(0,"div",110)(1,"p",113),u(2," Provides screen text coordinate grounding to improve element detection and interaction accuracy on text-heavy apps. "),d(),l(3,"div",114)(4,"div",87)(5,"div",88)(6,"span",89),u(7,"key"),d(),l(8,"span"),u(9,"Cloud Vision API Key:"),d()()(),l(10,"div",91)(11,"div",92)(12,"span",93),u(13,"key"),d(),l(14,"input",115),k("ngModelChange",function(i){v(e);let r=m(3);return y(r.onOcrKeyChange(i))}),d(),Rt(),l(15,"button",95),k("click",function(){v(e);let i=m(3);return y(i.toggleOcrKeyVisibility())}),l(16,"span",29),u(17),d()(),M(18,$D,3,3,"button",96),d(),l(19,"button",97),k("click",function(){v(e);let i=m(3);return y(i.saveOcrKey())}),M(20,zD,4,1)(21,BD,4,2),d(),l(22,"button",116),k("click",function(){v(e);let i=m(3);return y(i.testOcrKey())}),M(23,HD,4,0)(24,UD,4,0),d()(),M(25,GD,5,1,"div",99),M(26,qD,5,1,"div",100),d()()}if(n&2){let e=m(3);g(14),w("is-masked",!e.showOcrKey()),R("type",e.showOcrKey()?"text":"password")("ngModel",e.ocrKeyInput()),Ft(),g(),R("title",e.showOcrKey()?"Mask Key":"Show Plaintext"),g(2),V(" ",e.showOcrKey()?"visibility_off":"visibility"," "),g(),b(e.ocrKeyInput()?18:-1),g(),w("btn-dirty",e.isOcrModified()),R("disabled",e.isSavingOcrKey()||e.isTestingOcrKey()||!e.isOcrModified()&&!e.ocrKeyInput().trim()),g(),b(e.isSavingOcrKey()?20:21),g(2),R("disabled",e.isTestingOcrKey()||e.isSavingOcrKey()||!e.ocrKeyInput().trim()),g(),b(e.isTestingOcrKey()?23:24),g(2),b(e.ocrSaveMessage()?25:-1),g(),b(e.ocrSaveError()?26:-1)}}function KD(n,t){if(n&1){let e=L();l(0,"div",86)(1,"div",87)(2,"div",88)(3,"span",89),u(4,"vpn_key"),d(),l(5,"span"),u(6,"Enter Gemini API Key:"),d()(),l(7,"a",90)(8,"span",29),u(9,"open_in_new"),d(),l(10,"span"),u(11,"Get Free API Key (Google AI Studio)"),d()()(),l(12,"div",91)(13,"div",92)(14,"span",93),u(15,"key"),d(),l(16,"input",94),k("ngModelChange",function(i){v(e);let r=m(2);return y(r.onGeminiKeyChange(i))}),d(),Rt(),l(17,"button",95),k("click",function(){v(e);let i=m(2);return y(i.toggleGeminiKeyVisibility())}),l(18,"span",29),u(19),d()(),M(20,TD,3,3,"button",96),d(),l(21,"button",97),k("click",function(){v(e);let i=m(2);return y(i.saveGeminiKey())}),M(22,AD,4,1)(23,ND,4,2),d(),l(24,"button",98),k("click",function(){v(e);let i=m(2);return y(i.testGeminiKey())}),M(25,RD,4,0)(26,FD,4,0),d()(),M(27,LD,5,1,"div",99),M(28,VD,5,1,"div",100),d(),l(29,"div",101)(30,"div",102),k("click",function(){v(e);let i=m(2);return y(i.toggleOcrConfig())}),l(31,"div",103)(32,"span",104),u(33,"document_scanner"),d(),l(34,"div",105)(35,"span",106),u(36,"Optional: Google Cloud Vision OCR"),d()()(),l(37,"div",107),M(38,jD,1,0,"span",108),l(39,"span",109),u(40),d()()(),M(41,WD,27,15,"div",110),d()}if(n&2){let e=m(2);g(16),w("is-masked",!e.showGeminiKey()),R("type",e.showGeminiKey()?"text":"password")("ngModel",e.geminiKeyInput()),Ft(),g(),R("title",e.showGeminiKey()?"Mask Key":"Show Plaintext"),g(2),V(" ",e.showGeminiKey()?"visibility_off":"visibility"," "),g(),b(e.geminiKeyInput()?20:-1),g(),w("btn-dirty",e.isGeminiModified()),R("disabled",e.isSavingGeminiKey()||e.isTestingGeminiKey()||!e.isGeminiModified()&&!e.geminiKeyInput().trim()),g(),b(e.isSavingGeminiKey()?22:23),g(2),R("disabled",e.isTestingGeminiKey()||e.isSavingGeminiKey()||!e.geminiKeyInput().trim()),g(),b(e.isTestingGeminiKey()?25:26),g(2),b(e.geminiSaveMessage()?27:-1),g(),b(e.geminiSaveError()?28:-1),g(10),b(e.isOcrConfigured()?38:-1),g(2),V(" ",e.showOcrConfig()?"expand_less":"expand_more"," "),g(),b(e.showOcrConfig()?41:-1)}}function YD(n,t){if(n&1&&(l(0,"div",128)(1,"span",129),u(2,"Fallback Model"),d(),l(3,"div",138)(4,"span"),u(5),d()()()),n&2){let e=m(3);g(5),We("",e.modelConfigEnv()?.default_model?.fallback?.provider,"/",e.modelConfigEnv()?.default_model?.fallback?.model)}}function ZD(n,t){if(n&1&&(l(0,"div",135)(1,"pre",139)(2,"code"),u(3),d()()()),n&2){let e=m(3);g(3),O(e.modelConfigEnv()?.config_content)}}function QD(n,t){if(n&1&&(l(0,"code",146),u(1),d()),n&2){let e=m(2).$implicit;g(),V("(",e.preview,")")}}function JD(n,t){if(n&1&&(l(0,"span",144)(1,"span",29),u(2,"check_circle"),d(),l(3,"span"),u(4,"Configured"),d(),M(5,QD,2,1,"code",146),d()),n&2){let e=m().$implicit;g(5),b(e.preview?5:-1)}}function XD(n,t){n&1&&(l(0,"span",145)(1,"span",29),u(2,"radio_button_unchecked"),d(),l(3,"span"),u(4,"Not Set"),d()())}function eI(n,t){if(n&1&&(l(0,"div",140)(1,"div",141)(2,"code",142),u(3),d()(),l(4,"div",143),M(5,JD,6,1,"span",144)(6,XD,5,0,"span",145),d()()),n&2){let e=t.$implicit;w("is-configured",e.is_set),g(3),O(e.name),g(2),b(e.is_set?5:6)}}function tI(n,t){if(n&1){let e=L();l(0,"div",61)(1,"div",117)(2,"div",118)(3,"div",119)(4,"span",120),u(5,"tune"),d(),l(6,"div",121)(7,"span",122),u(8,"Active Model Configuration"),d(),l(9,"span",123),u(10,"Defined in "),l(11,"code"),u(12),d()()()(),l(13,"div",124)(14,"button",125),k("click",function(){v(e);let i=m(2);return y(i.copyToClipboard(i.modelConfigEnv()?.config_path||"config/artemis.jsonc","config_path_btn"))}),l(15,"span",29),u(16),d(),l(17,"span"),u(18,"Copy Path"),d()(),l(19,"button",126),k("click",function(){v(e);let i=m(2);return y(i.toggleFullConfigFile())}),l(20,"span",29),u(21),d(),l(22,"span"),u(23),d()()()(),l(24,"div",127)(25,"div",128)(26,"span",129),u(27,"Default Model"),d(),l(28,"div",130),$(29,"span",131),l(30,"span",132),u(31),d(),l(32,"span",133),u(33,"/"),d(),l(34,"span",134),u(35),d()()(),M(36,YD,6,2,"div",128),d(),M(37,ZD,4,1,"div",135),d(),l(38,"div",117)(39,"div",118)(40,"div",119)(41,"span",120),u(42,"vpn_key"),d(),l(43,"div",121)(44,"span",122),u(45,"Environment Variables & API Keys"),d(),l(46,"span",123),u(47,"Loaded from "),l(48,"code"),u(49),d()()()(),l(50,"button",125),k("click",function(){v(e);let i=m(2);return y(i.copyToClipboard(i.modelConfigEnv()?.env_path||".env","env_path_btn"))}),l(51,"span",29),u(52),d(),l(53,"span"),u(54,"Copy Path"),d()()(),l(55,"div",136),G(56,eI,7,4,"div",137,GP),d()()()}if(n&2){let e=m(2);g(12),O(e.modelConfigEnv()?.config_path||"config/artemis.jsonc"),g(3),w("text-emerald",e.copiedId()==="config_path_btn"),g(),V(" ",e.copiedId()==="config_path_btn"?"check":"content_copy"," "),g(5),V(" ",e.showFullConfigFile()?"unfold_less":"code"," "),g(2),O(e.showFullConfigFile()?"Hide JSONC":"View JSONC"),g(8),O(e.modelConfigEnv()?.default_model?.provider||"google"),g(4),O(e.modelConfigEnv()?.default_model?.model||"gemini-3.8-flash"),g(),b(e.modelConfigEnv()?.default_model?.fallback?36:-1),g(),b(e.showFullConfigFile()?37:-1),g(12),O(e.modelConfigEnv()?.env_path||".env"),g(2),w("text-emerald",e.copiedId()==="env_path_btn"),g(),V(" ",e.copiedId()==="env_path_btn"?"check":"content_copy"," "),g(4),q(e.modelConfigEnv()?.env_vars||za(14,hD))}}function nI(n,t){n&1&&(l(0,"span",23),u(1,"check"),d())}function oI(n,t){n&1&&(l(0,"span"),u(1,"3"),d())}function iI(n,t){n&1&&(l(0,"span",147),u(1,"sync"),d(),l(2,"span"),u(3,"Restarting..."),d())}function rI(n,t){if(n&1&&(l(0,"span",148),u(1,"check"),d(),l(2,"span",149),u(3),d()),n&2){let e=m(2);g(3),O(e.adbRestartFeedback())}}function aI(n,t){if(n&1&&(l(0,"span",29),u(1,"sync"),d(),l(2,"span"),u(3),d()),n&2){let e=m(2);g(3),O(e.isRemoteAdbServer()?"Refresh Devices":"Restart ADB")}}function sI(n,t){n&1&&(l(0,"span"),u(1,"Virtual Device"),d(),l(2,"span",160),u(3,"\xB7"),d())}function cI(n,t){if(n&1&&(l(0,"span",160),u(1,"\xB7"),d(),l(2,"span"),u(3),d()),n&2){let e=m(3);g(3),O(e.activeDevice()?.screen_resolution)}}function lI(n,t){if(n&1&&(l(0,"span",160),u(1,"\xB7"),d(),l(2,"span"),u(3),d()),n&2){let e=m(3);g(3),V("Android ",e.activeDevice()?.android_version)}}function dI(n,t){if(n&1&&(l(0,"span",160),u(1,"\xB7"),d(),l(2,"span",161),u(3),d()),n&2){let e=m(3);g(3),We(" Remote via ",e.adbServerStatus()?.endpoint?.host,":",e.adbServerStatus()?.endpoint?.port," ")}}function uI(n,t){n&1&&(l(0,"span",167),u(1,"\u2713 Active"),d())}function gI(n,t){if(n&1){let e=L();l(0,"button",165),k("click",function(){let i=v(e).$implicit,r=m(4);return y(r.selectTargetDevice(i.serial))}),l(1,"span",166),u(2," phone_android "),d(),l(3,"span"),u(4),d(),M(5,uI,2,0,"span",167),d()}if(n&2){let e=t.$implicit,o=m(4);w("selected",e.serial===o.activeDevice()?.serial),g(4),O(e.model||e.serial),g(),b(e.serial===o.activeDevice()?.serial?5:-1)}}function pI(n,t){if(n&1&&(l(0,"div",159)(1,"span",162),u(2,"Select Active Device:"),d(),l(3,"div",163),G(4,gI,6,4,"button",164,Pm),d()()),n&2){let e=m(3);g(4),q(e.connectedDevices())}}function fI(n,t){if(n&1&&(l(0,"div",64)(1,"div",150)(2,"div",151)(3,"span",152),u(4," phone_android "),d()(),l(5,"div",153)(6,"div",154),u(7),d(),l(8,"div",155),M(9,sI,4,0),l(10,"span"),u(11),d(),M(12,cI,4,1),M(13,lI,4,1),M(14,dI,4,2),d()(),l(15,"div",156),$(16,"span",157),l(17,"span",158),u(18,"Connected"),d()()(),M(19,pI,6,0,"div",159),d()),n&2){let e=m(2);g(7),V(" ",e.activeDevice()?.model||e.activeDevice()?.serial," "),g(2),b(e.activeDevice()?.is_emulator?9:-1),g(2),O(e.activeDevice()?.serial),g(),b(e.activeDevice()?.screen_resolution?12:-1),g(),b(e.activeDevice()?.android_version?13:-1),g(),b(e.isRemoteAdbServer()?14:-1),g(5),b(e.connectedDevices().length>1?19:-1)}}function mI(n,t){n&1&&(l(0,"span",185),u(1,"check"),d())}function _I(n,t){n&1&&$(0,"span",186)}function hI(n,t){n&1&&(l(0,"span",185),u(1,"check"),d())}function CI(n,t){n&1&&$(0,"span",186)}function MI(n,t){n&1&&(l(0,"span"),u(1,"2"),d())}function bI(n,t){n&1&&(l(0,"span",185),u(1,"check"),d())}function PI(n,t){n&1&&$(0,"span",186)}function OI(n,t){n&1&&(l(0,"span"),u(1,"3"),d())}function vI(n,t){if(n&1&&(l(0,"div",204),u(1),d()),n&2){let e=t.$implicit;g(),O(e)}}function yI(n,t){if(n&1&&G(0,vI,2,1,"div",204,Ie),n&2){let e=m(4);q(e.emulatorLaunchState()?.logs)}}function xI(n,t){n&1&&(l(0,"div",203),u(1,"Waiting for process output..."),d())}function wI(n,t){if(n&1){let e=L();l(0,"div",198)(1,"div",199)(2,"div",200)(3,"span",29),u(4,"terminal"),d(),l(5,"span"),u(6,"Emulator Console Stream (stdout / stderr)"),d()(),l(7,"button",201),k("click",function(){v(e);let i=m(3);return y(i.copyToClipboard(i.emulatorLaunchState()?.logs?.join(` -`)||"","boot_logs"))}),l(8,"span",29),u(9),d(),l(10,"span"),u(11),d()()(),l(12,"pre",202),M(13,yI,2,0)(14,xI,2,0,"div",203),d()()}if(n&2){let e=m(3);g(9),O(e.copiedId()==="boot_logs"?"check":"content_copy"),g(2),O(e.copiedId()==="boot_logs"?"Copied":"Copy Logs"),g(2),b(e.emulatorLaunchState()?.logs?.length?13:14)}}function SI(n,t){if(n&1){let e=L();l(0,"div",65)(1,"div",168)(2,"div",169)(3,"div",170)(4,"div",171)(5,"span",172),u(6,"sync"),d()(),l(7,"div",173)(8,"div",174)(9,"h4",175),u(10," Launching Android Emulator: "),l(11,"span",176),u(12),d()(),l(13,"span",177),u(14),d()(),l(15,"p",178),u(16),d()()(),l(17,"div",179)(18,"button",180),k("click",function(){v(e);let i=m(2);return y(i.toggleLaunchLogs())}),l(19,"span",29),u(20,"terminal"),d(),l(21,"span"),u(22),d()(),l(23,"button",181),k("click",function(){v(e);let i=m(2);return y(i.stopEmulator())}),l(24,"span",29),u(25,"cancel"),d(),l(26,"span"),u(27,"Stop"),d()()()(),l(28,"div",182)(29,"div",183)(30,"div",184),M(31,mI,2,0,"span",185)(32,_I,1,0,"span",186),d(),l(33,"span",187),u(34,"1. Process Start"),d()(),$(35,"div",188),l(36,"div",183)(37,"div",184),M(38,hI,2,0,"span",185)(39,CI,1,0,"span",186)(40,MI,2,0,"span"),d(),l(41,"span",187),u(42,"2. ADB Handshake"),d()(),$(43,"div",188),l(44,"div",183)(45,"div",184),M(46,bI,2,0,"span",185)(47,PI,1,0,"span",186)(48,OI,2,0,"span"),d(),l(49,"span",187),u(50,"3. Android OS Boot"),d()(),$(51,"div",188),l(52,"div",183)(53,"div",184)(54,"span",185),u(55,"check_circle"),d()(),l(56,"span",187),u(57,"4. Ready"),d()()(),l(58,"div",189)(59,"div",190),$(60,"div",191),d(),l(61,"div",192)(62,"span",193),u(63),d(),l(64,"span",194)(65,"span",195),u(66,"timer"),d(),u(67),d()()(),l(68,"div",196)(69,"span",197),u(70,"info"),d(),l(71,"span"),u(72,"Android virtualization cold boot usually takes 20-45 seconds. Live progress is actively tracked above\u2014please do not click launch repeatedly."),d()(),M(73,wI,15,3,"div",198),d()()}if(n&2){let e=m(2);g(12),O(e.emulatorLaunchState()?.avd_name||e.activeDevice()?.serial||"Virtual Device"),g(2),O(e.emulatorLaunchState()?.status==="booting"?"OS Booting":"Starting"),g(2),O(e.emulatorLaunchState()?.stage_message||"Initializing Android Virtual Device..."),g(2),w("active",e.showLaunchLogs()),g(4),O(e.showLaunchLogs()?"Hide Logs":"View Boot Logs"),g(7),w("done",e.emulatorLaunchState()?.progress_percent>=35)("active",e.emulatorLaunchState()?.status==="starting"),g(2),b(e.emulatorLaunchState()?.progress_percent>=35?31:32),g(4),w("done",e.emulatorLaunchState()?.progress_percent>=35),g(),w("done",e.emulatorLaunchState()?.progress_percent>=65)("active",e.emulatorLaunchState()?.status==="waiting_for_adb"),g(2),b(e.emulatorLaunchState()?.progress_percent>=65?38:e.emulatorLaunchState()?.status==="waiting_for_adb"?39:40),g(5),w("done",e.emulatorLaunchState()?.progress_percent>=65),g(),w("done",e.emulatorLaunchState()?.status==="ready")("active",e.emulatorLaunchState()?.status==="booting"||e.adbProbe()?.summary==="Device Booting"),g(2),b(e.emulatorLaunchState()?.status==="ready"?46:e.emulatorLaunchState()?.status==="booting"||e.adbProbe()?.summary==="Device Booting"?47:48),g(5),w("done",e.emulatorLaunchState()?.status==="ready"),g(),w("done",e.emulatorLaunchState()?.status==="ready"),g(8),Se("width",e.emulatorLaunchState()?.progress_percent||25,"%"),g(3),V("",e.emulatorLaunchState()?.progress_percent||25,"% Complete"),g(4),V(" Elapsed: ",e.emulatorLaunchState()?.elapsed_seconds||0,"s "),g(6),b(e.showLaunchLogs()?73:-1)}}function kI(n,t){if(n&1&&(l(0,"div",204),u(1),d()),n&2){let e=t.$implicit;g(),O(e)}}function EI(n,t){if(n&1){let e=L();l(0,"div",214)(1,"div",199)(2,"span"),u(3,"Process Output / Failure Logs:"),d(),l(4,"button",201),k("click",function(){v(e);let i=m(3);return y(i.copyToClipboard(i.emulatorLaunchState()?.logs?.join(` -`)||"","fail_logs"))}),l(5,"span",29),u(6),d(),l(7,"span"),u(8),d()()(),l(9,"pre",219),G(10,kI,2,1,"div",204,Ie),d()()}if(n&2){let e=m(3);g(6),O(e.copiedId()==="fail_logs"?"check":"content_copy"),g(2),O(e.copiedId()==="fail_logs"?"Copied":"Copy Logs"),g(2),q(e.emulatorLaunchState()?.logs)}}function DI(n,t){if(n&1){let e=L();l(0,"button",220),k("click",function(){v(e);let i=m(3);return y(i.launchAvdEmulator(i.emulatorLaunchState().avd_name))}),l(1,"span",29),u(2,"refresh"),d(),l(3,"span"),u(4),d()()}if(n&2){let e=m(3);R("disabled",e.isRemoteAdbServer()),g(4),V("Retry Launch (",e.emulatorLaunchState()?.avd_name,")")}}function II(n,t){if(n&1){let e=L();l(0,"div",66)(1,"div",205)(2,"div",206)(3,"div",207)(4,"div",208)(5,"span",209),u(6,"error"),d()(),l(7,"div",210)(8,"h4",211),u(9,"Emulator Launch Failed"),d(),l(10,"p",212),u(11),d()()(),l(12,"button",213),k("click",function(){v(e);let i=m(2);return y(i.dismissEmulatorStatus())}),l(13,"span",29),u(14,"close"),d()()(),M(15,EI,12,2,"div",214),l(16,"div",215),M(17,DI,5,2,"button",216),l(18,"button",217),k("click",function(){v(e);let i=m(2);return y(i.restartAdbServer())}),l(19,"span",29),u(20,"sync"),d(),l(21,"span"),u(22,"Restart ADB Server"),d()(),l(23,"button",218),k("click",function(){v(e);let i=m(2);return y(i.dismissEmulatorStatus())}),l(24,"span"),u(25,"Dismiss"),d()()()()()}if(n&2){let e=m(2);g(11),O(e.emulatorLaunchState()?.error||"The Android emulator process failed to start or crashed during initialization."),g(4),b(e.emulatorLaunchState()?.logs?.length?15:-1),g(2),b(e.emulatorLaunchState()?.avd_name?17:-1)}}function TI(n,t){if(n&1&&u(0),n&2){let e=m(3);We(" ",e.installedAvds().length," local virtual device",e.installedAvds().length>1?"s":""," available ")}}function AI(n,t){n&1&&u(0," Launch a local Android Virtual Device ")}function NI(n,t){if(n&1){let e=L();l(0,"div",231)(1,"div",234)(2,"span",29),u(3,"hub"),d(),l(4,"span"),u(5," Artemis is currently using "),l(6,"strong"),u(7),d(),u(8,". Switch to local ADB before using a local emulator, USB cable, or Wireless ADB. "),d()(),l(9,"button",235),k("click",function(){v(e);let i=m(3);return y(i.switchToLocalAdbServer())}),l(10,"span",29),u(11,"sync"),d(),l(12,"span"),u(13),d()()()}if(n&2){let e=m(3);g(7),We("",e.adbServerStatus()?.endpoint?.host,":",e.adbServerStatus()?.endpoint?.port),g(2),R("disabled",e.isSwitchingToLocalAdb()),g(),w("spinning",e.isSwitchingToLocalAdb()),g(3),O(e.isSwitchingToLocalAdb()?"Switching...":"Use Local ADB")}}function RI(n,t){if(n&1&&($(0,"span",252),l(1,"span"),u(2),d()),n&2){let e=m(6);g(2),We("",e.emulatorLaunchState()?.status==="booting"?"Booting OS":"Starting"," (",e.emulatorLaunchState()?.elapsed_seconds||0,"s)")}}function FI(n,t){n&1&&(l(0,"span",253),u(1,"play_arrow"),d(),l(2,"span"),u(3,"Launch"),d())}function LI(n,t){if(n&1){let e=L();l(0,"div",243)(1,"div",244)(2,"div",245)(3,"span",29),u(4,"smartphone"),d()(),l(5,"div",246)(6,"div",247)(7,"span",248),u(8),d(),l(9,"span",249),u(10,"Virtual Device"),d()()()(),l(11,"div",250)(12,"button",251),k("click",function(){let i=v(e).$implicit,r=m(5);return y(r.launchAvdEmulator(i))}),M(13,RI,3,2)(14,FI,4,0),d()()()}if(n&2){let e=t.$implicit,o=m(5);g(8),O(e),g(4),w("launching",o.launchingAvd()===e||o.isEmulatorLaunching()&&o.emulatorLaunchState()?.avd_name===e),R("disabled",o.isEmulatorLaunching()||o.isRemoteAdbServer())("title",o.isEmulatorLaunching()?"An emulator is currently launching...":"Launch "+e),g(),b(o.launchingAvd()===e||o.isEmulatorLaunching()&&o.emulatorLaunchState()?.avd_name===e?13:14)}}function VI(n,t){if(n&1&&(l(0,"div",236)(1,"div",238)(2,"div",239)(3,"span",120),u(4,"devices"),d(),l(5,"span",240),u(6,"Installed Virtual Devices (AVD)"),d(),l(7,"span",241),u(8),d()()(),l(9,"div",242),G(10,LI,15,6,"div",243,Ie),d()()),n&2){let e=m(4);g(8),V("",e.installedAvds().length," Available"),g(2),q(e.installedAvds())}}function jI(n,t){if(n&1&&(l(0,"div",264)(1,"div",266)(2,"div",267)(3,"span",268),u(4,"1"),d(),l(5,"span",269),u(6,"download"),d()(),l(7,"div",270),u(8,"Install Android Studio"),d(),l(9,"div",271),u(10),l(11,"a",272),u(12,"developer.android.com/studio"),d(),u(13," and install. "),d()(),l(14,"div",266)(15,"div",267)(16,"span",268),u(17,"2"),d(),l(18,"span",269),u(19,"developer_mode"),d()(),l(20,"div",270),u(21,"Open Device Manager"),d(),l(22,"div",271),u(23," On Studio welcome screen, open "),l(24,"strong"),u(25,"More Actions > Virtual Device Manager"),d(),u(26," (or "),l(27,"strong"),u(28,"Tools > Device Manager"),d(),u(29,"). "),d()(),l(30,"div",266)(31,"div",267)(32,"span",268),u(33,"3"),d(),l(34,"span",269),u(35,"add_circle"),d()(),l(36,"div",270),u(37,"Create Virtual Device"),d(),l(38,"div",271),u(39," Click "),l(40,"strong"),u(41,"Create Device"),d(),u(42,", choose "),l(43,"strong"),u(44,"Pixel 8"),d(),u(45,", select "),l(46,"strong"),u(47,"Android 14 (API 34)"),d(),u(48,", and click "),l(49,"strong"),u(50,"Finish"),d(),u(51,". "),d()(),l(52,"div",266)(53,"div",267)(54,"span",268),u(55,"4"),d(),l(56,"span",269),u(57,"play_arrow"),d()(),l(58,"div",270),u(59,"Launch Emulator"),d(),l(60,"div",271),u(61," Click \u25B6 "),l(62,"strong"),u(63,"Play"),d(),u(64," in Device Manager. Artemis will detect and connect to it automatically! "),d()()()),n&2){let e=m(5);g(10),V(" Download for ",e.effectiveOs()==="darwin"?"macOS":e.effectiveOs()==="windows"?"Windows":"Linux"," from ")}}function $I(n,t){if(n&1){let e=L();l(0,"div",265)(1,"div",273)(2,"div",274),u(3,"1"),d(),l(4,"div",275)(5,"div",26),u(6),d(),l(7,"p",276),u(8),d(),l(9,"div",81)(10,"span",84),u(11),d(),l(12,"code",85),u(13),d(),l(14,"button",82),k("click",function(){v(e);let i=m(5);return y(i.copyToClipboard(i.emuHypervisorCmd(),"emu_kvm"))}),l(15,"span",29),u(16),d(),l(17,"span"),u(18),d()()()()(),l(19,"div",273)(20,"div",274),u(21,"2"),d(),l(22,"div",275)(23,"div",26),u(24,"Download Emulator & System Image (API 34)"),d(),l(25,"p",276),u(26,"Install Android 14 Google APIs system image using SDK Manager:"),d(),l(27,"div",81)(28,"span",84),u(29),d(),l(30,"code",85),u(31),d(),l(32,"button",82),k("click",function(){v(e);let i=m(5);return y(i.copyToClipboard(i.emuSdkInstallCmd(),"emu_sdk"))}),l(33,"span",29),u(34),d(),l(35,"span"),u(36),d()()()()(),l(37,"div",273)(38,"div",274),u(39,"3"),d(),l(40,"div",275)(41,"div",26),u(42,"Create Virtual Device (Pixel 8 AVD)"),d(),l(43,"p",276),u(44,"Initialize a new Android Virtual Device named "),l(45,"code"),u(46,"Pixel_8_API_34"),d(),u(47,":"),d(),l(48,"div",81)(49,"span",84),u(50),d(),l(51,"code",85),u(52),d(),l(53,"button",82),k("click",function(){v(e);let i=m(5);return y(i.copyToClipboard(i.emuCreateAvdCmd(),"emu_create"))}),l(54,"span",29),u(55),d(),l(56,"span"),u(57),d()()()()(),l(58,"div",273)(59,"div",274),u(60,"4"),d(),l(61,"div",275)(62,"div",26),u(63,"Start the Emulator"),d(),l(64,"p",276),u(65,"Launch the virtual device (or add "),l(66,"code"),u(67,"-no-window"),d(),u(68," for headless execution):"),d(),l(69,"div",81)(70,"span",84),u(71),d(),l(72,"code",85),u(73,"emulator -avd Pixel_8_API_34 -no-boot-anim"),d(),l(74,"button",82),k("click",function(){v(e);let i=m(5);return y(i.copyToClipboard("emulator -avd Pixel_8_API_34 -no-boot-anim","emu_run"))}),l(75,"span",29),u(76),d(),l(77,"span"),u(78),d()()()()()()}if(n&2){let e=m(5);g(6),O(e.emuHypervisorTitle()),g(2),O(e.emuHypervisorDesc()),g(3),O(e.effectiveOs()==="windows"?"PS>":"$"),g(2),O(e.emuHypervisorCmd()),g(),w("copied",e.copiedId()==="emu_kvm"),g(2),O(e.copiedId()==="emu_kvm"?"check":"content_copy"),g(2),O(e.copiedId()==="emu_kvm"?"Copied!":"Copy"),g(11),O(e.effectiveOs()==="windows"?"PS>":"$"),g(2),O(e.emuSdkInstallCmd()),g(),w("copied",e.copiedId()==="emu_sdk"),g(2),O(e.copiedId()==="emu_sdk"?"check":"content_copy"),g(2),O(e.copiedId()==="emu_sdk"?"Copied!":"Copy"),g(14),O(e.effectiveOs()==="windows"?"PS>":"$"),g(2),O(e.emuCreateAvdCmd()),g(),w("copied",e.copiedId()==="emu_create"),g(2),O(e.copiedId()==="emu_create"?"check":"content_copy"),g(2),O(e.copiedId()==="emu_create"?"Copied!":"Copy"),g(14),O(e.effectiveOs()==="windows"?"PS>":"$"),g(3),w("copied",e.copiedId()==="emu_run"),g(2),O(e.copiedId()==="emu_run"?"check":"content_copy"),g(2),O(e.copiedId()==="emu_run"?"Copied!":"Copy")}}function zI(n,t){if(n&1){let e=L();l(0,"div",237)(1,"div",254)(2,"div",255)(3,"div",256)(4,"span",29),u(5,"devices_off"),d()(),l(6,"div",257)(7,"div",258)(8,"h4",259),u(9,"No Android Virtual Devices (AVD) Detected"),d(),l(10,"div",78)(11,"button",79),k("click",function(){v(e);let i=m(4);return y(i.setSelectedOs("linux"))}),u(12,"Linux"),d(),l(13,"button",79),k("click",function(){v(e);let i=m(4);return y(i.setSelectedOs("darwin"))}),u(14,"macOS"),d(),l(15,"button",79),k("click",function(){v(e);let i=m(4);return y(i.setSelectedOs("windows"))}),u(16,"Windows"),d()()(),l(17,"p",260),u(18,"Follow the step-by-step instructions below to install and start an emulator:"),d()()(),l(19,"div",261)(20,"button",262),k("click",function(){v(e);let i=m(4);return y(i.setEmulatorSetupMode("studio"))}),l(21,"span",263),u(22,"desktop_windows"),d(),l(23,"span"),u(24,"Android Studio (GUI)"),d()(),l(25,"button",262),k("click",function(){v(e);let i=m(4);return y(i.setEmulatorSetupMode("cli"))}),l(26,"span",263),u(27,"terminal"),d(),l(28,"span"),u(29,"Command Line (CLI)"),d()()()(),M(30,jI,65,1,"div",264),M(31,$I,79,25,"div",265),d()}if(n&2){let e=m(4);g(11),w("active",e.effectiveOs()==="linux"),g(2),w("active",e.effectiveOs()==="darwin"),g(2),w("active",e.effectiveOs()==="windows"),g(5),w("active",e.emulatorSetupMode()==="studio"),g(5),w("active",e.emulatorSetupMode()==="cli"),g(5),b(e.emulatorSetupMode()==="studio"?30:-1),g(),b(e.emulatorSetupMode()==="cli"?31:-1)}}function BI(n,t){if(n&1&&(l(0,"div",232),M(1,VI,12,1,"div",236)(2,zI,32,12,"div",237),d()),n&2){let e=m(3);g(),b(e.installedAvds().length>0?1:2)}}function HI(n,t){n&1&&(l(0,"div",232)(1,"div",277)(2,"span",278),u(3,"cable"),d(),l(4,"span"),u(5,"Connect your Android phone via USB cable and enable USB Debugging:"),d()(),l(6,"div",279)(7,"div",266)(8,"div",267)(9,"span",268),u(10,"1"),d(),l(11,"span",269),u(12,"usb"),d()(),l(13,"div",270),u(14,"Connect USB Cable"),d(),l(15,"div",271),u(16,"Plug your Android phone into this computer with a data cable."),d()(),l(17,"div",266)(18,"div",267)(19,"span",268),u(20,"2"),d(),l(21,"span",269),u(22,"touch_app"),d()(),l(23,"div",270),u(24,"Enable Developer Options"),d(),l(25,"div",271),u(26,"In phone "),l(27,"strong"),u(28,"Settings > About Phone"),d(),u(29,", tap "),l(30,"strong"),u(31,"Build Number"),d(),u(32," 7 times."),d()(),l(33,"div",266)(34,"div",267)(35,"span",268),u(36,"3"),d(),l(37,"span",269),u(38,"toggle_on"),d()(),l(39,"div",270),u(40,"Enable USB Debugging"),d(),l(41,"div",271),u(42,"In "),l(43,"strong"),u(44,"Settings > Developer Options"),d(),u(45,", turn "),l(46,"strong"),u(47,"USB Debugging"),d(),u(48," ON. "),d()(),l(49,"div",266)(50,"div",267)(51,"span",268),u(52,"4"),d(),l(53,"span",269),u(54,"verified_user"),d()(),l(55,"div",270),u(56,"Authorize Computer"),d(),l(57,"div",271),u(58,"Check "),l(59,"strong"),u(60,'"Always allow"'),d(),u(61," and tap "),l(62,"strong"),u(63,"Allow"),d(),u(64," on the phone prompt. "),d()()()())}function UI(n,t){n&1&&(l(0,"span",147),u(1,"sync"),d(),l(2,"span"),u(3,"Connecting..."),d())}function GI(n,t){n&1&&(l(0,"span",29),u(1,"bolt"),d(),l(2,"span"),u(3,"Connect"),d())}function qI(n,t){if(n&1&&(l(0,"div",292)(1,"span",29),u(2,"check_circle"),d(),l(3,"span"),u(4),d()()),n&2){let e=m(4);g(4),O(e.wifiConnectMessage())}}function WI(n,t){if(n&1&&(l(0,"div",293)(1,"span",29),u(2,"error"),d(),l(3,"span"),u(4),d()()),n&2){let e=m(4);g(4),O(e.wifiConnectError())}}function KI(n,t){if(n&1){let e=L();l(0,"div",232)(1,"div",280)(2,"span",278),u(3,"wifi"),d(),l(4,"span"),u(5,"Connect your phone and computer on the same Wi-Fi network:"),d()(),l(6,"div",281)(7,"div",282)(8,"div",283)(9,"label",284),u(10,"Device IP Address"),d(),l(11,"div",285)(12,"span",286),u(13,"router"),d(),l(14,"input",287),k("ngModelChange",function(i){v(e);let r=m(3);return y(r.wifiHost.set(i))}),d(),Rt(),d()(),l(15,"div",288)(16,"label",284),u(17,"Port"),d(),l(18,"div",285)(19,"span",286),u(20,"tag"),d(),l(21,"input",289),k("ngModelChange",function(i){v(e);let r=m(3);return y(r.wifiPort.set(i))}),d(),Rt(),d()(),l(22,"div",290)(23,"button",291),k("click",function(){v(e);let i=m(3);return y(i.connectWifiDevice())}),M(24,UI,4,0)(25,GI,4,0),d()()(),M(26,qI,5,1,"div",292),M(27,WI,5,1,"div",293),d(),l(28,"div",294)(29,"span",295),u(30,"help_outline"),d(),l(31,"div",296),u(32," Find IP & port in phone "),l(33,"strong"),u(34,"Settings > Developer Options > Wireless Debugging"),d(),u(35,". "),d()()()}if(n&2){let e=m(3);g(14),R("ngModel",e.wifiHost()),Ft(),g(7),R("ngModel",e.wifiPort()),Ft(),g(2),R("disabled",e.isConnectingWifi()||e.isRemoteAdbServer()||!e.wifiHost().trim()),g(),b(e.isConnectingWifi()?24:25),g(2),b(e.wifiConnectMessage()?26:-1),g(),b(e.wifiConnectError()?27:-1)}}function YI(n,t){n&1&&(l(0,"span",147),u(1,"sync"),d(),l(2,"span"),u(3,"Checking..."),d())}function ZI(n,t){n&1&&(l(0,"span",29),u(1,"bolt"),d(),l(2,"span"),u(3,"Test Connection"),d())}function QI(n,t){if(n&1&&(l(0,"div",311)(1,"span",29),u(2,"check_circle"),d(),l(3,"span"),u(4),d()()),n&2){let e=m(4);g(4),O(e.remoteAdbMessage())}}function JI(n,t){if(n&1&&(l(0,"div",312)(1,"span",29),u(2,"error"),d(),l(3,"span"),u(4),d()()),n&2){let e=m(4);g(4),O(e.remoteAdbError())}}function XI(n,t){if(n&1&&(l(0,"div",316)(1,"span",29),u(2,"phone_android"),d(),l(3,"div",317)(4,"strong"),u(5),d(),l(6,"span"),u(7),d()(),l(8,"span",318),u(9),d()()),n&2){let e=t.$implicit;g(5),O(e.model||e.serial),g(2),O(e.serial),g(),w("ready",e.state==="device"),g(),V(" ",e.state==="device"?"Ready":e.state," ")}}function eT(n,t){if(n&1&&(l(0,"div",313),G(1,XI,10,5,"div",316,Pm),d()),n&2){let e=m(4);g(),q(e.remoteAdbDevices())}}function tT(n,t){n&1&&(l(0,"span",147),u(1,"sync"),d(),l(2,"span"),u(3,"Connecting..."),d())}function nT(n,t){if(n&1&&(l(0,"span",29),u(1,"check"),d(),l(2,"span"),u(3),d()),n&2){let e=m(5);g(3),O(e.remoteAdbHasReadyDevice()?"Use Connection":"Use Anyway")}}function oT(n,t){if(n&1){let e=L();l(0,"button",319),k("click",function(){v(e);let i=m(4);return y(i.activateRemoteAdbServer())}),M(1,tT,4,0)(2,nT,4,1),d()}if(n&2){let e=m(4);R("disabled",e.isActivatingRemoteAdb()),g(),b(e.isActivatingRemoteAdb()?1:2)}}function iT(n,t){if(n&1){let e=L();l(0,"div",233)(1,"div",297)(2,"span",278),u(3,"hub"),d(),l(4,"span"),u(5,"Enter the host and port of an ADB server endpoint that this computer can access, so Artemis can discover and control a phone physically connected to another computer."),d()(),l(6,"div",298)(7,"div",299)(8,"div",300)(9,"label",301),u(10,"ADB Server Host"),d(),l(11,"div",302)(12,"span",286),u(13,"dns"),d(),l(14,"input",303),k("ngModelChange",function(i){v(e);let r=m(3);return y(r.updateRemoteAdbHost(i))}),d(),Rt(),d()(),l(15,"div",304)(16,"label",305),u(17,"ADB Server Port"),d(),l(18,"div",302)(19,"span",286),u(20,"tag"),d(),l(21,"input",306),k("ngModelChange",function(i){v(e);let r=m(3);return y(r.updateRemoteAdbPort(i))}),d(),Rt(),d()(),l(22,"div",307)(23,"button",308),k("click",function(){v(e);let i=m(3);return y(i.connectRemoteAdbServer())}),M(24,YI,4,0)(25,ZI,4,0),d()()(),l(26,"label",309)(27,"input",310),k("ngModelChange",function(i){v(e);let r=m(3);return y(r.rememberRemoteAdb.set(i))}),d(),Rt(),l(28,"span"),u(29,"Remember this endpoint for future Artemis launches"),d()(),M(30,QI,5,1,"div",311),M(31,JI,5,1,"div",312),M(32,eT,3,0,"div",313),M(33,oT,3,2,"button",314),d(),l(34,"p",315),u(35," Artemis sends all ADB commands to this server instead of the default local server. Any phone visible to that server becomes available to Artemis. "),d()()}if(n&2){let e=m(3);g(14),R("ngModel",e.remoteAdbHost()),Ft(),g(7),R("ngModel",e.remoteAdbPort()),Ft(),g(2),R("disabled",e.isConnectingRemoteAdb()||e.isActivatingRemoteAdb()||!e.remoteAdbHost().trim()),g(),b(e.isConnectingRemoteAdb()?24:25),g(3),R("ngModel",e.rememberRemoteAdb()),Ft(),g(3),b(e.remoteAdbMessage()?30:-1),g(),b(e.remoteAdbError()?31:-1),g(),b(e.remoteAdbDevices().length>0?32:-1),g(),b(e.remoteAdbProbeResult()?.success&&!e.isProbedRemoteAdbActive()?33:-1)}}function rT(n,t){if(n&1){let e=L();l(0,"div",67)(1,"div",221)(2,"button",222),k("click",function(){v(e);let i=m(2);return y(i.setAdbGuideTab("emulator"))}),l(3,"div",223)(4,"div",224)(5,"span",29),u(6,"smartphone"),d()(),l(7,"div",225)(8,"div",226)(9,"span",227),u(10,"Android Emulator"),d()(),l(11,"span",228),M(12,TI,1,2)(13,AI,1,0),d()()(),l(14,"div",229),$(15,"span",230),d()(),l(16,"button",222),k("click",function(){v(e);let i=m(2);return y(i.setAdbGuideTab("usb"))}),l(17,"div",223)(18,"div",224)(19,"span",29),u(20,"usb"),d()(),l(21,"div",225)(22,"div",226)(23,"span",227),u(24,"USB Cable"),d()(),l(25,"span",228),u(26,"Connect physical phone via USB"),d()()(),l(27,"div",229),$(28,"span",230),d()(),l(29,"button",222),k("click",function(){v(e);let i=m(2);return y(i.setAdbGuideTab("wifi"))}),l(30,"div",223)(31,"div",224)(32,"span",29),u(33,"wifi"),d()(),l(34,"div",225)(35,"div",226)(36,"span",227),u(37,"Wireless ADB"),d()(),l(38,"span",228),u(39,"Connect over local Wi-Fi"),d()()(),l(40,"div",229),$(41,"span",230),d()(),l(42,"button",222),k("click",function(){v(e);let i=m(2);return y(i.setAdbGuideTab("remote"))}),l(43,"div",223)(44,"div",224)(45,"span",29),u(46,"devices"),d()(),l(47,"div",225)(48,"div",226)(49,"span",227),u(50,"Remote Computer"),d()(),l(51,"span",228),u(52,"Phone connected to another computer"),d()()(),l(53,"div",229),$(54,"span",230),d()()(),M(55,NI,14,6,"div",231),M(56,BI,3,1,"div",232),M(57,HI,65,0,"div",232),M(58,KI,36,6,"div",232),M(59,iT,36,9,"div",233),d()}if(n&2){let e=m(2);g(2),w("active",e.activeAdbGuideTab()==="emulator"),g(10),b(e.installedAvds().length>0?12:13),g(3),w("checked",e.activeAdbGuideTab()==="emulator"),g(),w("active",e.activeAdbGuideTab()==="usb"),g(12),w("checked",e.activeAdbGuideTab()==="usb"),g(),w("active",e.activeAdbGuideTab()==="wifi"),g(12),w("checked",e.activeAdbGuideTab()==="wifi"),g(),w("active",e.activeAdbGuideTab()==="remote"),g(12),w("checked",e.activeAdbGuideTab()==="remote"),g(),b(e.activeAdbGuideTab()!=="remote"&&e.isRemoteAdbServer()?55:-1),g(),b(e.activeAdbGuideTab()==="emulator"?56:-1),g(),b(e.activeAdbGuideTab()==="usb"?57:-1),g(),b(e.activeAdbGuideTab()==="wifi"?58:-1),g(),b(e.activeAdbGuideTab()==="remote"?59:-1)}}function aT(n,t){n&1&&(l(0,"span",167),u(1,"\u2713 Active"),d())}function sT(n,t){if(n&1){let e=L();l(0,"button",165),k("click",function(){let i=v(e).$implicit,r=m(4);return y(r.selectTargetDevice(i.serial))}),l(1,"span",166),u(2," phone_android "),d(),l(3,"span"),u(4),d(),M(5,aT,2,0,"span",167),d()}if(n&2){let e=t.$implicit,o=m(4);w("selected",e.serial===o.activeDevice()?.serial),g(4),O(e.model||e.serial),g(),b(e.serial===o.activeDevice()?.serial?5:-1)}}function cT(n,t){if(n&1&&(l(0,"div",326)(1,"span",162),u(2,"Select Active Device:"),d(),l(3,"div",163),G(4,sT,6,4,"button",164,Pm),d()()),n&2){let e=m(3);g(4),q(e.connectedDevices())}}function lT(n,t){if(n&1){let e=L();l(0,"div",68)(1,"div",320)(2,"span",321),u(3,"lock"),d(),l(4,"div",322)(5,"div",323),u(6),d(),l(7,"div",324),u(8),d()(),l(9,"button",325),k("click",function(){v(e);let i=m(2);return y(i.refreshReadiness())}),l(10,"span",29),u(11,"refresh"),d(),l(12,"span"),u(13),d()()(),M(14,cT,6,0,"div",326),d()}if(n&2){let e=m(2);g(6),V(" ",e.adbProbe()?.summary==="Device Locked"?"Device Is Locked":"Unable to Verify Lock State"," "),g(2),V(" ",e.adbProbe()?.description," "),g(),R("disabled",e.isRefreshingDiagnostics()),g(),w("spinning",e.isRefreshingDiagnostics()),g(3),O(e.isRefreshingDiagnostics()?"Checking...":"Check Again"),g(),b(e.connectedDevices().length>1?14:-1)}}function dT(n,t){if(n&1){let e=L();l(0,"div",69)(1,"div",327)(2,"span",321),u(3,"screen_lock_portrait"),d(),l(4,"div",328)(5,"div",329),u(6,"USB Debugging Authorization Pending"),d(),l(7,"div",330),u(8," Your Android device was detected, but it requires authorization. Please unlock your phone screen and tap "),l(9,"strong"),u(10,'"Allow USB Debugging"'),d(),u(11,". "),d()(),l(12,"button",331),k("click",function(){v(e);let i=m(2);return y(i.restartAdbServer())}),l(13,"span",29),u(14,"refresh"),d(),l(15,"span"),u(16),d()()()()}if(n&2){let e=m(2);g(16),O(e.isRemoteAdbServer()?"Refresh Devices":"Restart ADB & Check")}}function uT(n,t){n&1&&(l(0,"span",337),u(1,"Detected"),d())}function gT(n,t){n&1&&(l(0,"span",337),u(1,"Detected"),d())}function pT(n,t){n&1&&(l(0,"span",337),u(1,"Detected"),d())}function fT(n,t){if(n&1){let e=L();l(0,"div",70)(1,"div",332)(2,"span",333),u(3,"error"),d(),l(4,"div")(5,"strong"),u(6,"Android Platform Tools (adb) not found."),d(),u(7," Please install ADB on your operating system: "),d()(),l(8,"div",334)(9,"div",335)(10,"div",336)(11,"span"),u(12,"Linux (Ubuntu / Debian)"),d(),M(13,uT,2,0,"span",337),d(),l(14,"div",81)(15,"span",84),u(16,"$"),d(),l(17,"code",85),u(18,"sudo apt-get install -y adb"),d(),l(19,"button",82),k("click",function(){v(e);let i=m(2);return y(i.copyToClipboard("sudo apt-get install -y adb","linux_adb"))}),l(20,"span",29),u(21),d(),l(22,"span"),u(23),d()()()(),l(24,"div",335)(25,"div",336)(26,"span"),u(27,"macOS (Homebrew)"),d(),M(28,gT,2,0,"span",337),d(),l(29,"div",81)(30,"span",84),u(31,"$"),d(),l(32,"code",85),u(33,"brew install android-platform-tools"),d(),l(34,"button",82),k("click",function(){v(e);let i=m(2);return y(i.copyToClipboard("brew install android-platform-tools","mac_adb"))}),l(35,"span",29),u(36),d(),l(37,"span"),u(38),d()()()(),l(39,"div",335)(40,"div",336)(41,"span"),u(42,"Windows (WinGet)"),d(),M(43,pT,2,0,"span",337),d(),l(44,"div",81)(45,"span",84),u(46,">"),d(),l(47,"code",85),u(48,"winget install Google.PlatformTools"),d(),l(49,"button",82),k("click",function(){v(e);let i=m(2);return y(i.copyToClipboard("winget install Google.PlatformTools","win_adb"))}),l(50,"span",29),u(51),d(),l(52,"span"),u(53),d()()()()()()}if(n&2){let e=m(2);g(9),w("highlight-os",e.effectiveOs()==="linux"),g(4),b(e.effectiveOs()==="linux"?13:-1),g(6),w("copied",e.copiedId()==="linux_adb"),g(2),O(e.copiedId()==="linux_adb"?"check":"content_copy"),g(2),O(e.copiedId()==="linux_adb"?"Copied!":"Copy"),g(),w("highlight-os",e.effectiveOs()==="darwin"),g(4),b(e.effectiveOs()==="darwin"?28:-1),g(6),w("copied",e.copiedId()==="mac_adb"),g(2),O(e.copiedId()==="mac_adb"?"check":"content_copy"),g(2),O(e.copiedId()==="mac_adb"?"Copied!":"Copy"),g(),w("highlight-os",e.effectiveOs()==="windows"),g(4),b(e.effectiveOs()==="windows"?43:-1),g(6),w("copied",e.copiedId()==="win_adb"),g(2),O(e.copiedId()==="win_adb"?"check":"content_copy"),g(2),O(e.copiedId()==="win_adb"?"Copied!":"Copy")}}function mT(n,t){if(n&1){let e=L();l(0,"div",71)(1,"div",338)(2,"div",339)(3,"span",23),u(4,"check"),d()(),l(5,"div",340)(6,"h4",341),u(7,"System Ready for Autonomous Tasks"),d(),l(8,"p",342),u(9,"All dependencies, AI model credentials, and Android device are verified and ready to run."),d()()(),l(10,"button",343),k("click",function(){v(e);let i=m(2);return y(i.setTab("launcher"))}),l(11,"span"),u(12,"Launch Autonomous Task"),d(),l(13,"span",344),u(14,"rocket_launch"),d()()()}}function _T(n,t){if(n&1){let e=L();l(0,"section",18)(1,"div",20)(2,"div",21)(3,"div",22),M(4,vD,2,0,"span",23)(5,yD,2,0,"span"),d(),l(6,"div",24)(7,"div",25)(8,"h3",26),u(9,"System & Environment"),d()(),l(10,"p",27),u(11,"Core tools, Android debug bridge, and video recording runtime"),d()(),l(12,"button",28),k("click",function(){v(e);let i=m();return y(i.refreshReadiness())}),l(13,"span",29),u(14,"refresh"),d(),l(15,"span"),u(16),d()()(),l(17,"div",30),M(18,xD,26,11,"div",31),l(19,"div",32)(20,"div",33)(21,"div",34)(22,"div",35)(23,"span",36),u(24,"terminal"),d()(),l(25,"div",37)(26,"div",38),u(27,"Python Runtime"),d(),l(28,"div",39),u(29),d()(),l(30,"div",40),$(31,"span",41),d()(),M(32,wD,12,4,"div",42),d(),l(33,"div",33)(34,"div",34)(35,"div",35)(36,"span",36),u(37,"construction"),d()(),l(38,"div",37)(39,"div",38),u(40,"Android Platform-Tools"),d(),l(41,"div",39),u(42),d()(),l(43,"div",40),$(44,"span",41),d()(),M(45,SD,12,5,"div",42),d(),l(46,"div",33)(47,"div",34)(48,"div",35)(49,"span",36),u(50,"tune"),d()(),l(51,"div",37)(52,"div",38),u(53,"System Configuration"),d(),l(54,"div",39),u(55),d()(),l(56,"div",40),$(57,"span",41),d()(),M(58,kD,12,4,"div",42),d(),l(59,"div",33)(60,"div",34)(61,"div",35)(62,"span",36),u(63,"videocam"),d()(),l(64,"div",37)(65,"div",38),u(66,"Video Toolchain"),d(),l(67,"div",39),u(68),d()(),l(69,"div",40),$(70,"span",41),d()(),M(71,ED,12,5,"div",42),d()()()(),l(72,"div",20)(73,"div",21)(74,"div",22),M(75,DD,2,0,"span",23)(76,ID,2,0,"span"),d(),l(77,"div",24)(78,"div",25)(79,"h3",26),u(80,"AI Model Setup"),d()(),l(81,"p",27),u(82,"Google Gemini or custom LLM providers (OpenAI, Claude, Ollama)"),d()()(),l(83,"div",30)(84,"div",43)(85,"div",44),k("click",function(){v(e);let i=m();return y(i.setModelSetupMode("gemini"))}),l(86,"div",45)(87,"div",46),$(88,"span",47),d(),l(89,"div",48),yo(),l(90,"svg",49)(91,"defs")(92,"linearGradient",50),$(93,"stop",51)(94,"stop",52)(95,"stop",53),d()(),$(96,"path",54),d()(),rr(),l(97,"div",55)(98,"div",56)(99,"span",57),u(100,"Google Gemini"),d(),l(101,"span",58),u(102,"Recommended"),d()(),l(103,"p",59),u(104,"Fast multimodal reasoning with free API key from Google AI Studio."),d()()()(),l(105,"div",44),k("click",function(){v(e);let i=m();return y(i.setModelSetupMode("custom"))}),l(106,"div",45)(107,"div",46),$(108,"span",47),d(),l(109,"div",60)(110,"span",29),u(111,"tune"),d()(),l(112,"div",55)(113,"div",56)(114,"span",57),u(115,"Custom Configuration"),d()(),l(116,"p",59),u(117,"Define models in "),l(118,"code"),u(119,"config/artemis.jsonc"),d(),u(120," & keys in "),l(121,"code"),u(122,".env"),d(),u(123," (OpenAI, Claude, Ollama)."),d()()()()(),M(124,KD,42,18)(125,tI,58,15,"div",61),d()(),l(126,"div",20)(127,"div",21)(128,"div",22),M(129,nI,2,0,"span",23)(130,oI,2,0,"span"),d(),l(131,"div",24)(132,"div",25)(133,"h3",26),u(134,"Android Device & Emulator Connection"),d()(),l(135,"p",27),u(136,"Local emulator, attached phone, wireless device, or another ADB server"),d()(),l(137,"button",62),k("click",function(){v(e);let i=m();return y(i.toggleConnectionMethods())}),l(138,"span",29),u(139,"swap_horiz"),d(),l(140,"span"),u(141),d()(),l(142,"button",63),k("click",function(){v(e);let i=m();return y(i.restartAdbServer())}),M(143,iI,4,0)(144,rI,4,1)(145,aI,4,1),d()(),l(146,"div",30),M(147,fI,20,7,"div",64),M(148,SI,74,34,"div",65),M(149,II,26,3,"div",66),M(150,rT,60,22,"div",67),M(151,lT,15,7,"div",68),M(152,dT,17,1,"div",69),M(153,fT,54,21,"div",70),d()(),M(154,mT,15,0,"div",71),d()}if(n&2){let e=m();g(),w("card-pass",e.isEnvironmentReady())("card-warn",!e.isEnvironmentReady()),g(2),w("step-pass",e.isEnvironmentReady()),g(),b(e.isEnvironmentReady()?4:5),g(8),R("disabled",e.isRefreshingDiagnostics()),g(),w("spinning",e.isRefreshingDiagnostics()),g(3),O(e.isRefreshingDiagnostics()?"Checking...":"Re-check"),g(2),b(e.hasReadinessReport()&&!e.isEnvironmentReady()?18:-1),g(2),w("item-pass",e.pythonProbe()?.status==="pass")("item-fail",e.pythonProbe()?.status==="fail"),g(9),O(e.pythonProbe()?.summary||"Checking\u2026"),g(2),w("dot-pass",e.pythonProbe()?.status==="pass")("dot-fail",e.pythonProbe()?.status==="fail"),g(),b(e.pythonProbe()?.status==="fail"?32:-1),g(),w("item-pass",e.adbProbe()?.metadata?.installed)("item-fail",e.adbProbe()?.status==="fail"),g(9),O(e.adbProbe()?e.adbProbe()?.metadata?.installed?"ADB Installed":"ADB Missing":"Checking\u2026"),g(2),w("dot-pass",e.adbProbe()?.metadata?.installed)("dot-fail",e.adbProbe()?.status==="fail"),g(),b(e.adbProbe()?.status==="fail"?45:-1),g(),w("item-pass",e.configProbe()?.status==="pass")("item-fail",e.configProbe()?.status==="fail"),g(9),O(e.configProbe()?.summary||"Config Valid"),g(2),w("dot-pass",e.configProbe()?.status==="pass")("dot-fail",e.configProbe()?.status==="fail"),g(),b(e.configProbe()?.status==="fail"?58:-1),g(),w("item-pass",e.toolchainProbe()?.status==="pass")("item-fail",e.toolchainProbe()?.status!=="pass"),g(9),O(e.toolchainProbe()?.summary||"FFmpeg & scrcpy"),g(2),w("dot-pass",e.toolchainProbe()?.status==="pass")("dot-fail",e.toolchainProbe()?.status!=="pass"),g(),b(e.toolchainProbe()?.status!=="pass"?71:-1),g(),w("card-pass",e.isCredentialsReady())("card-warn",!e.isCredentialsReady()),g(2),w("step-pass",e.isCredentialsReady()),g(),b(e.isCredentialsReady()?75:76),g(10),w("selected",e.modelSetupMode()==="gemini"),g(3),w("checked",e.modelSetupMode()==="gemini"),g(17),w("selected",e.modelSetupMode()==="custom"),g(3),w("checked",e.modelSetupMode()==="custom"),g(16),b(e.modelSetupMode()==="gemini"?124:125),g(2),w("card-pass",e.isDeviceReady())("card-warn",!e.isDeviceReady()),g(2),w("step-pass",e.isDeviceReady()),g(),b(e.isDeviceReady()?129:130),g(12),O(e.showConnectionMethods()?"Hide Options":"Change Connection"),g(),R("disabled",e.isRestartingAdb())("title",e.isRemoteAdbServer()?"Refresh devices without stopping the remote ADB server":"Restart local ADB daemon"),g(),b(e.isRestartingAdb()?143:e.adbRestartFeedback()?144:145),g(4),b(e.isDeviceReady()?147:-1),g(),b(e.isEmulatorLaunching()||!e.isDeviceReady()&&e.adbProbe()?.summary==="Device Booting"?148:-1),g(),b(!e.isDeviceReady()&&!e.isEmulatorLaunching()&&e.emulatorLaunchState()?.status==="failed"?149:-1),g(),b(e.showConnectionMethods()||!e.isDeviceReady()&&!e.isEmulatorLaunching()&&e.adbProbe()?.summary!=="Device Booting"&&e.adbProbe()?.summary!=="Device Unauthorized"&&e.adbProbe()?.summary!=="Device Locked"&&e.adbProbe()?.summary!=="Lock State Unknown"&&e.adbProbe()?.status!=="fail"&&e.emulatorLaunchState()?.status!=="failed"?150:-1),g(),b(e.adbProbe()?.status==="warn"&&(e.adbProbe()?.summary==="Device Locked"||e.adbProbe()?.summary==="Lock State Unknown")?151:-1),g(),b(e.adbProbe()?.status==="warn"&&e.adbProbe()?.summary==="Device Unauthorized"?152:-1),g(),b(e.adbProbe()?.status==="fail"?153:-1),g(),b(e.isReady()?154:-1)}}function hT(n,t){if(n&1){let e=L();l(0,"div",381),k("click",function(){v(e);let i=m(2);return y(i.setTab("diagnostics"))}),l(1,"span",382),u(2),d(),l(3,"span"),u(4),d(),l(5,"span",383),u(6,"arrow_forward"),d()()}if(n&2){let e=m(2);g(2),O(e.adbProbe()?.summary==="Device Locked"?"lock":"warning"),g(2),V(" ",e.adbProbe()?.summary==="Device Locked"?"Android device is locked. Unlock it and enter the home screen before running a task.":"Prerequisites incomplete: runtime, credentials, or Android device is not ready. Click here to open Setup Guide."," ")}}function CT(n,t){if(n&1){let e=L();l(0,"div",347)(1,"div",384)(2,"span",385),u(3,"auto_awesome"),d(),l(4,"span",386),u(5," Continuous monitoring or complex task detected. Recommended: "),l(6,"strong"),u(7,"Pro Mode"),d()()(),l(8,"button",387),k("click",function(){v(e);let i=m(2);return y(i.setProfile("pro"))}),l(9,"span"),u(10,"Switch to Pro"),d(),l(11,"span",388),u(12,"arrow_forward"),d()()()}}function MT(n,t){n&1&&(l(0,"p",353)(1,"span",389),u(2,"Flash"),d(),l(3,"span",390),u(4,"\xB7"),d(),l(5,"span"),u(6,"Token-efficient, rapid "),l(7,"strong"),u(8,"3\u20135s/step"),d(),u(9," decisions straight from the live screen. Best for everyday tasks with a clear path, however long they run."),d(),l(10,"span",391),u(11,"(No plan, no result check, no ADB commands)"),d()())}function bT(n,t){n&1&&(l(0,"p",353)(1,"span",389),u(2,"Pro"),d(),l(3,"span",390),u(4,"\xB7"),d(),l(5,"span"),u(6,"Our most powerful mode. Plans first, checks its own results and recovers when a tap is blocked. Handles "),l(7,"strong"),u(8,"100+ step"),d(),u(9," workflows and live phone monitoring ("),l(10,"strong"),u(11,"15\u201340s"),d(),u(12," / step)."),d()())}function PT(n,t){if(n&1&&(l(0,"div",354)(1,"span",392),u(2,"error"),d(),l(3,"span"),u(4),d()()),n&2){let e=m(2);g(4),O(e.errorMessage())}}function OT(n,t){if(n&1&&(l(0,"span",363),u(1),d()),n&2){let e=m(2);g(),V("",e.taskGoal().length," chars")}}function vT(n,t){n&1&&($(0,"span",393),l(1,"span"),u(2,"Starting..."),d())}function yT(n,t){n&1&&(l(0,"span",394),u(1,"play_arrow"),d(),l(2,"span"),u(3,"Run Task"),d())}function xT(n,t){if(n&1){let e=L();l(0,"button",408),k("click",function(){v(e);let i=m(3);return y(i.expectedOutput.set(""))}),l(1,"span",29),u(2,"close"),d()()}}function wT(n,t){if(n&1){let e=L();l(0,"button",409),k("click",function(){v(e);let i=m(3);return y(i.resetTuning())}),u(1,"Reset"),d()}}function ST(n,t){if(n&1&&$(0,"span",422),n&2){let e=t.$index,o=m().$implicit,i=m(3);Se("--%NS%n",i.notchFraction(e,o.ladder.length)),w("reached",e<=o.index)}}function kT(n,t){if(n&1&&(yo(),$(0,"rect",426)),n&2){let e=t.$implicit;Se("opacity",e.opacity)("animation-delay",e.delay,"ms"),Wt("x",e.x+"%")("y",e.y)}}function ET(n,t){if(n&1&&($(0,"div",423),yo(),l(1,"svg",424),G(2,kT,1,6,":svg:rect",425,Do),d()),n&2){let e=m(4);g(2),q(e.ditherPixels)}}function DT(n,t){n&1&&$(0,"span",420)}function IT(n,t){if(n&1&&(l(0,"li"),u(1),d()),n&2){let e=t.$implicit;g(),O(e)}}function TT(n,t){if(n&1&&(l(0,"li"),u(1),d()),n&2){let e=t.$implicit;g(),O(e)}}function AT(n,t){if(n&1&&(l(0,"div",435)(1,"span",434),u(2,"Not included"),d(),l(3,"ul"),G(4,TT,2,1,"li",null,Do),d()()),n&2){let e=m(2).$implicit;g(4),q(e.level.skips)}}function NT(n,t){if(n&1&&($(0,"div",427),l(1,"div",428)(2,"div",429)(3,"span",430),u(4),d(),l(5,"span",431),u(6),d()(),l(7,"p",432),u(8),d(),l(9,"div",433)(10,"span",434),u(11,"What happens"),d(),l(12,"ul"),G(13,IT,2,1,"li",null,Do),d()(),M(15,AT,6,0,"div",435),l(16,"div",436)(17,"span",434),u(18,"Use it for"),d(),l(19,"span"),u(20),d()()()),n&2){let e=m().$implicit;g(4),O(e.level.label),g(2),O(e.level.latency),g(2),O(e.level.tagline),g(5),q(e.level.runs),g(2),b(e.level.skips?.length?15:-1),g(5),O(e.level.bestFor)}}function RT(n,t){if(n&1){let e=L();l(0,"div",410)(1,"div",411)(2,"span",412),u(3),d(),l(4,"span",413),u(5),d()(),l(6,"div",414)(7,"span"),u(8),d(),l(9,"span"),u(10),d()(),l(11,"div",415),k("pointerenter",function(){let i=v(e).$implicit,r=m(3);return y(r.showTuningTip(i.kind))})("pointerleave",function(){let i=v(e).$implicit,r=m(3);return y(r.hideTuningTip(i.kind))}),l(12,"div",416),$(13,"div",417),G(14,ST,1,4,"span",418,qP),M(16,ET,4,0),d(),$(17,"div",419),M(18,DT,1,0,"span",420),l(19,"input",421),k("input",function(i){let r=v(e).$implicit,a=m(3);return y(a.setTuningIndex(r.kind,i.target.value))})("focus",function(){let i=v(e).$implicit,r=m(3);return y(r.showTuningTip(i.kind))})("blur",function(){let i=v(e).$implicit,r=m(3);return y(r.hideTuningTip(i.kind))}),d(),M(20,NT,21,5),d()()}if(n&2){let e=t.$implicit,o=m(3);Se("--%NS%pct",e.fraction),w("verify",e.kind==="verify")("explore",e.kind==="explore")("dragging",o.activeTuningTip()===e.kind),g(3),O(e.name),g(2),O(e.level.label),g(3),O(e.ends[0]),g(2),O(e.ends[1]),g(4),q(e.ladder),g(2),b(e.index===e.ladder.length-1?16:-1),g(2),b(e.index===e.ladder.length-1?18:-1),g(),R("max",e.ladder.length-1)("value",e.index),Wt("aria-label",e.name)("aria-valuetext",e.level.label+": "+e.level.tagline),g(),b(o.activeTuningTip()===e.kind?20:-1)}}function FT(n,t){if(n&1){let e=L();l(0,"div",395)(1,"div",396)(2,"span",397),u(3,"Expected Output"),d(),l(4,"span",398),u(5,"(Optional)"),d()(),l(6,"div",399)(7,"input",400),k("ngModelChange",function(i){v(e);let r=m(2);return y(r.expectedOutput.set(i))}),d(),Rt(),M(8,xT,3,0,"button",401),d()(),l(9,"div",402)(10,"div",403)(11,"span",404),u(12,"Pro settings"),d(),M(13,wT,2,0,"button",405),d(),l(14,"div",406),G(15,RT,21,19,"div",407,CD),d()()}if(n&2){let e=m(2);g(7),R("ngModel",e.expectedOutput()),Ft(),g(),b(e.expectedOutput().trim()?8:-1),g(),w("tip-open",e.activeTuningTip()!==null),g(4),b(e.isTuningDefault()?-1:13),g(2),q(e.tuningSliders())}}function LT(n,t){if(n&1&&(l(0,"div",440)(1,"span",29),u(2),d()()),n&2){let e=t.$implicit;R("title",e.name),g(2),O(e.icon)}}function VT(n,t){if(n&1){let e=L();l(0,"div",437),k("click",function(){let i=v(e).$implicit,r=m(2);return y(r.applySuggestion(i))}),l(1,"div",438)(2,"div",439),G(3,LT,3,2,"div",440,GP),l(5,"span",441),u(6),d()(),l(7,"span",442),u(8),d()(),l(9,"div",443)(10,"h4",444),u(11),d(),l(12,"p",445),u(13),d()(),l(14,"div",446)(15,"span",447),u(16),d(),l(17,"div",448)(18,"span"),u(19,"Use"),d(),l(20,"span",449),u(21,"arrow_forward"),d()()()()}if(n&2){let e=t.$implicit,o=m(2);w("is-pro-card",e.profile==="pro")("is-flash-card",e.profile==="flash"),g(3),q(e.apps),g(3),O(o.getAppNamesDisplay(e.apps)),g(),w("mode-pro",e.profile==="pro")("mode-flash",e.profile==="flash"),R("title",e.profile==="pro"?"Uses ARTEMIS Pro (Multi-Agent Cognitive State Graph)":"Uses ARTEMIS Flash (Reactive Fast Loop)"),g(),V(" ",e.profile==="pro"?"Pro":"Flash"," "),g(3),O(e.title),g(2),O(e.description),g(3),O(e.tag)}}function jT(n,t){if(n&1){let e=L();l(0,"section",19),M(1,hT,7,2,"div",345),l(2,"div",346),M(3,CT,13,0,"div",347),l(4,"div",348)(5,"textarea",349),hr("ngModelChange",function(i){v(e);let r=m();return $a(r.taskGoal,i)||(r.taskGoal=i),y(i)}),k("keydown.control.enter",function(){v(e);let i=m();return y(i.runTask())})("keydown.meta.enter",function(){v(e);let i=m();return y(i.runTask())}),d(),Rt(),d(),l(6,"div",350)(7,"div",351)(8,"span",29),u(9),d()(),l(10,"div",352),M(11,MT,12,0,"p",353)(12,bT,13,0,"p",353),d()(),M(13,PT,5,1,"div",354),l(14,"div",355)(15,"div",356)(16,"div",357)(17,"button",358),k("click",function(){v(e);let i=m();return y(i.setProfile("flash"))}),l(18,"span",359),u(19,"bolt"),d(),l(20,"span",360),u(21,"Flash"),d()(),l(22,"button",361),k("click",function(){v(e);let i=m();return y(i.setProfile("pro"))}),l(23,"span",359),u(24,"psychology"),d(),l(25,"span",360),u(26,"Pro"),d()()()(),l(27,"div",362),M(28,OT,2,1,"span",363),l(29,"button",364),k("click",function(){v(e);let i=m();return y(i.runTask())}),M(30,vT,3,0)(31,yT,4,0),d()()()(),M(32,FT,17,5),l(33,"div",365)(34,"div",366)(35,"div",367)(36,"div",368)(37,"span",369),u(38,"auto_awesome"),d(),l(39,"span",370),u(40,"Recommended Tasks"),d()(),l(41,"div",371)(42,"button",372),k("click",function(){v(e);let i=m();return y(i.setCategory("all"))}),l(43,"span"),u(44,"All"),d()(),l(45,"button",373),k("click",function(){v(e);let i=m();return y(i.setCategory("flash"))}),l(46,"span",374),u(47,"bolt"),d(),l(48,"span"),u(49,"Flash"),d()(),l(50,"button",375),k("click",function(){v(e);let i=m();return y(i.setCategory("pro"))}),l(51,"span",374),u(52,"psychology"),d(),l(53,"span"),u(54,"Pro"),d()(),l(55,"button",372),k("click",function(){v(e);let i=m();return y(i.setCategory("cross_app"))}),l(56,"span",374),u(57,"sync_alt"),d(),l(58,"span"),u(59,"Multi-App"),d()(),l(60,"button",372),k("click",function(){v(e);let i=m();return y(i.setCategory("monitor"))}),l(61,"span",374),u(62,"monitoring"),d(),l(63,"span"),u(64,"Monitor"),d()()()(),l(65,"div",376)(66,"button",377),k("click",function(){v(e);let i=m();return y(i.shuffleSuggestions())}),l(67,"span",378),u(68,"shuffle"),d(),l(69,"span"),u(70,"Shuffle"),d()()()(),l(71,"div",379),G(72,VT,22,14,"div",380,qP),d()()()}if(n&2){let e=m();g(),b(e.hasReadinessReport()&&!e.isReady()?1:-1),g(),w("is-pro-active",e.selectedProfile()==="pro"),g(),b(e.showIntentSuggestion()?3:-1),g(2),_r("ngModel",e.taskGoal),R("disabled",e.isSubmitting()),Ft(),g(),w("is-pro",e.selectedProfile()==="pro"),g(3),O(e.selectedProfile()==="flash"?"bolt":"psychology"),g(2),b(e.selectedProfile()==="flash"?11:12),g(2),b(e.errorMessage()?13:-1),g(4),w("active",e.selectedProfile()==="flash"),g(5),w("active",e.selectedProfile()==="pro"),g(6),b(e.taskGoal().length>0?28:-1),g(),w("btn-run-pro",e.selectedProfile()==="pro"),R("disabled",!e.taskGoal().trim()||e.isSubmitting()||!e.isReady())("title",e.isReady()?"Run task":e.adbProbe()?.summary==="Device Locked"?"Unlock the Android device before running":"Complete System Setup before running"),g(),b(e.isSubmitting()?30:31),g(2),b(e.selectedProfile()==="pro"?32:-1),g(10),w("active",e.selectedCategory()==="all"),g(3),w("active",e.selectedCategory()==="flash"),g(5),w("active",e.selectedCategory()==="pro"),g(5),w("active",e.selectedCategory()==="cross_app"),g(5),w("active",e.selectedCategory()==="monitor"),g(12),q(e.filteredSuggestions())}}function $T(n=260,t=7){let e=t>>>0,o=()=>(e=Math.imul(e,1664525)+1013904223>>>0,e/4294967296),i=[];for(let r=0;rthis.systemService.modelConfigEnv());geminiKeyInput=x("");showGeminiKey=x(!1);isSavingGeminiKey=x(!1);isTestingGeminiKey=x(!1);geminiSaveMessage=x(null);geminiSaveError=x(null);isGeminiKeyEdited=x(!1);ocrKeyInput=x("");showOcrKey=x(!1);isSavingOcrKey=x(!1);isTestingOcrKey=x(!1);ocrSaveMessage=x(null);ocrSaveError=x(null);isOcrKeyEdited=x(!1);copiedId=x(null);isRefreshingDiagnostics=x(!1);wifiHost=x("192.168.1.100");wifiPort=x("5555");isConnectingWifi=x(!1);wifiConnectMessage=x(null);wifiConnectError=x(null);adbRestartFeedback=x(null);showConnectionMethods=x(!1);remoteAdbHost=x("127.0.0.1");remoteAdbPort=x("5038");rememberRemoteAdb=x(!0);isConnectingRemoteAdb=x(!1);isActivatingRemoteAdb=x(!1);isSwitchingToLocalAdb=x(!1);remoteAdbMessage=x(null);remoteAdbError=x(null);remoteAdbDevices=x([]);remoteAdbProbeResult=x(null);adbServerStatus=I(()=>this.systemService.adbServerStatus());isRemoteAdbServer=I(()=>this.systemService.isRemoteAdbServer());remoteAdbHasReadyDevice=I(()=>this.remoteAdbDevices().some(t=>t.state==="device"));isProbedRemoteAdbActive=I(()=>{let t=this.remoteAdbProbeResult()?.endpoint,e=this.adbServerStatus()?.endpoint;return!!t&&!!e&&t.identity===e.identity});wifiCommand=I(()=>{let t=this.wifiHost().trim()||"",e=this.wifiPort().trim()||"5555";return`adb connect ${t}:${e}`});selectedProfile=x("flash");taskGoal=x("");isSubmitting=x(!1);errorMessage=x(null);expectedOutput=x("");enableOutputter=x(!0);showOutputterDrawer=x(!1);verificationLevels=_n;explorerModes=hn;verificationIndex=x(Li(_n,Hr,Hr));explorerIndex=x(Li(hn,Ur,Ur));tuningDefaults=x({verification:Hr,explorer:Ur});tuningTouched=x(!1);activeTuningTip=x(null);ditherPixels=$T();verificationLevel=I(()=>_n[this.verificationIndex()]);explorerMode=I(()=>hn[this.explorerIndex()]);isTuningDefault=I(()=>{let t=this.tuningDefaults();return this.verificationLevel().id===t.verification&&this.explorerMode().id===t.explorer});tuningSliders=I(()=>{let t=this.verificationIndex(),e=this.explorerIndex();return[{kind:"verify",name:"Result check",ends:["Off","Strict"],ladder:_n,index:t,level:_n[t],fraction:Gd(t,_n.length)/100},{kind:"explore",name:"Screen reading",ends:["Faster","Sharper"],ladder:hn,index:e,level:hn[e],fraction:Gd(e,hn.length)/100}]});notchFraction(t,e){return Gd(t,e)/100}setTuningIndex(t,e){let o=Math.round(Number(e));if(!Number.isFinite(o))return;let i=t==="verify"?_n:hn,r=Math.min(Math.max(o,0),i.length-1);this.tuningTouched.set(!0),t==="verify"?this.verificationIndex.set(r):this.explorerIndex.set(r),this.activeTuningTip.set(t)}showTuningTip(t){this.activeTuningTip.set(t)}hideTuningTip(t){this.activeTuningTip()===t&&this.activeTuningTip.set(null)}resetTuning(){let t=this.tuningDefaults();this.verificationIndex.set(Li(_n,t.verification,Hr)),this.explorerIndex.set(Li(hn,t.explorer,Ur)),this.tuningTouched.set(!1)}loadProTuningDefaults(){this.agentService.getProTuningDefaults().subscribe({next:t=>{let e=Li(_n,t?.verification_level,Hr),o=Li(hn,t?.explorer_mode,Ur);this.tuningDefaults.set({verification:_n[e].id,explorer:hn[o].id}),this.tuningTouched()||(this.verificationIndex.set(e),this.explorerIndex.set(o))},error:()=>{}})}toggleOutputterDrawer(){this.showOutputterDrawer.update(t=>!t)}applyOutputPreset(t){this.expectedOutput()===t?this.expectedOutput.set(""):(this.expectedOutput.set(t),this.showOutputterDrawer.set(!0))}isIntentSuggestingPro=I(()=>{let t=this.taskGoal().toLowerCase();return t.trim()?["monitor","polling","poll","wait until","loop","keep watching","crash","logcat","troubleshoot","diagnose","debug","investigate","compare","extract","summarize","report","\u76D1\u63A7","\u8F6E\u8BE2","\u7B49\u5F85","\u4E00\u76F4","\u76F4\u5230","\u5D29\u6E83","\u95EA\u9000","\u6392\u67E5","\u5206\u6790\u65E5\u5FD7","\u5BF9\u6BD4","\u603B\u7ED3"].some(o=>t.includes(o)):!1});showIntentSuggestion=I(()=>this.isIntentSuggestingPro()&&this.selectedProfile()==="flash");isReady=I(()=>this.systemService.isReady());hasReadinessReport=I(()=>this.systemService.hasReadinessReport());isLoading=I(()=>this.systemService.isLoading());isRestartingAdb=I(()=>this.systemService.isRestartingAdb());launchingAvd=I(()=>this.systemService.launchingAvd());emulatorLaunchState=I(()=>this.systemService.emulatorLaunchState());isEmulatorLaunching=I(()=>this.systemService.isEmulatorLaunching());showLaunchLogs=x(!1);pythonProbe=I(()=>this.systemService.pythonProbe());configProbe=I(()=>this.systemService.configProbe());adbProbe=I(()=>this.systemService.adbProbe());llmProbe=I(()=>this.systemService.llmProbe());geminiProbe=I(()=>this.systemService.geminiProbe());ocrProbe=I(()=>this.systemService.ocrProbe());toolchainProbe=I(()=>this.systemService.toolchainProbe());isEnvironmentReady=I(()=>this.systemService.isEnvironmentReady());isCredentialsReady=I(()=>this.systemService.isCredentialsReady());isSkipCredentialsCheck=I(()=>this.systemService.isSkipCredentialsCheck());isDeviceReady=I(()=>this.systemService.isDeviceReady());activeDevice=I(()=>this.systemService.activeDevice());connectedDevices=I(()=>this.systemService.connectedDevices());installedAvds=I(()=>this.systemService.installedAvds());emulatorPath=I(()=>this.systemService.emulatorPath());isEmulatorInPath=I(()=>this.systemService.isEmulatorInPath());totalStepCount=I(()=>this.systemService.totalStepCount());passedStepCount=I(()=>this.systemService.passedStepCount());blockerCount=I(()=>this.systemService.blockerCount());passedBlockerCount=I(()=>this.systemService.passedBlockerCount());configuredLlmProviders=I(()=>{let t=this.llmProbe()?.metadata;return t&&Array.isArray(t.providers)?t.providers:[]});selectedOs=x(null);effectiveOs=I(()=>this.selectedOs()||this.systemService.osType());oneClickSetupCmd=I(()=>this.effectiveOs()==="windows"?"powershell -ExecutionPolicy Bypass -File scripts/install_deps.ps1":"bash scripts/install_deps.sh");adbInstallCmd=I(()=>{let t=this.effectiveOs();return t==="windows"?"winget install Google.PlatformTools":t==="darwin"?"brew install android-platform-tools":"sudo apt-get install -y adb"});toolchainInstallCmd=I(()=>{let t=this.effectiveOs();return t==="windows"?"winget install Gyan.FFmpeg Genymobile.scrcpy":t==="darwin"?"brew install ffmpeg scrcpy":"sudo apt-get install -y ffmpeg scrcpy"});emuHypervisorTitle=I(()=>{let t=this.effectiveOs();return t==="windows"?"Enable Windows Hypervisor Platform (WHPX)":t==="darwin"?"Verify macOS Hypervisor / Install Tools":"Enable KVM Hardware Acceleration (Linux)"});emuHypervisorDesc=I(()=>{let t=this.effectiveOs();return t==="windows"?"Enable Windows Hypervisor Platform in PowerShell (Run as Administrator):":t==="darwin"?"macOS uses native Hypervisor.framework. Install SDK tools via Homebrew (or Studio):":"Ensure virtualization permissions are granted to your user account:"});emuHypervisorCmd=I(()=>{let t=this.effectiveOs();return t==="windows"?"Enable-WindowsOptionalFeature -Online -FeatureName HypervisorPlatform":t==="darwin"?"brew install --cask android-commandlinetools":"sudo apt-get install -y qemu-kvm libvirt-daemon-system && sudo adduser $USER kvm"});emuSdkInstallCmd=I(()=>this.effectiveOs()==="darwin"?'sdkmanager --install "system-images;android-34;google_apis;arm64-v8a" "emulator" "platform-tools"':'sdkmanager --install "system-images;android-34;google_apis;x86_64" "emulator" "platform-tools"');emuCreateAvdCmd=I(()=>this.effectiveOs()==="darwin"?'avdmanager create avd -n Pixel_8_API_34 -k "system-images;android-34;google_apis;arm64-v8a" --device "pixel_8"':'avdmanager create avd -n Pixel_8_API_34 -k "system-images;android-34;google_apis;x86_64" --device "pixel_8"');isOcrConfigured=I(()=>this.ocrProbe()?.metadata?.configured===!0);currentApiKey=I(()=>this.systemService.currentApiKey());apiKeysMap=I(()=>this.systemService.apiKeysMap());savedGeminiKey=I(()=>this.apiKeysMap().google||this.currentApiKey()||"");isGeminiModified=I(()=>this.geminiKeyInput().trim()!==this.savedGeminiKey().trim());savedOcrKey=I(()=>this.apiKeysMap().ocr||"");isOcrModified=I(()=>this.ocrKeyInput().trim()!==this.savedOcrKey().trim());allSuggestions=this.taskRecService.allTasks;selectedCategory=x("all");shuffleOffset=x(0);installedPackages=I(()=>{let t=this.activeDevice()?.installed_packages;return t&&Array.isArray(t)?new Set(t):new Set});filteredSuggestions=I(()=>this.taskRecService.filterAndRankTasks(this.installedPackages(),this.selectedCategory(),this.shuffleOffset()));focusListener=()=>{this.systemService.fetchReadiness().subscribe()};constructor(){Ye(()=>{let t=this.systemService.apiKeysMap(),e=this.systemService.currentApiKey(),o=t.google||e||"",i=t.ocr||"";this.isGeminiKeyEdited()||this.geminiKeyInput.set(o),this.isOcrKeyEdited()||this.ocrKeyInput.set(i)})}ngOnInit(){this.loadProTuningDefaults(),this.systemService.fetchReadiness().subscribe(),this.systemService.fetchModelConfigEnv().subscribe(),this.systemService.fetchAdbServerStatus().subscribe({next:t=>{t.endpoint.mode==="remote"&&(this.remoteAdbHost.set(t.endpoint.host),this.remoteAdbPort.set(String(t.endpoint.port)),this.activeAdbGuideTab.set("remote"))},error:()=>{}}),window.addEventListener("focus",this.focusListener)}ngOnDestroy(){window.removeEventListener("focus",this.focusListener)}setTab(t){this.activeTab.set(t),t==="diagnostics"&&(this.systemService.fetchReadiness().subscribe(),this.systemService.fetchModelConfigEnv().subscribe())}setSelectedOs(t){this.selectedOs.set(t)}setAdbGuideTab(t){this.activeAdbGuideTab.set(t),this.remoteAdbError.set(null),this.remoteAdbMessage.set(null)}setEmulatorSetupMode(t){this.emulatorSetupMode.set(t)}setModelSetupMode(t){this.modelSetupMode.set(t),t==="custom"?(this.systemService.setSkipCredentialsCheck(!0),this.systemService.fetchModelConfigEnv().subscribe()):this.systemService.setSkipCredentialsCheck(!1)}toggleFullConfigFile(){this.showFullConfigFile.update(t=>!t)}toggleGeminiKeyVisibility(){this.showGeminiKey.update(t=>!t)}toggleOcrKeyVisibility(){this.showOcrKey.update(t=>!t)}toggleOcrConfig(){this.showOcrConfig.update(t=>!t)}onGeminiKeyChange(t){this.geminiKeyInput.set(t),this.isGeminiKeyEdited.set(!0),this.geminiSaveError.set(null),this.geminiSaveMessage.set(null)}onOcrKeyChange(t){this.ocrKeyInput.set(t),this.isOcrKeyEdited.set(!0),this.ocrSaveError.set(null),this.ocrSaveMessage.set(null)}saveGeminiKey(){let t=this.geminiKeyInput().trim();t&&(this.isSavingGeminiKey.set(!0),this.geminiSaveError.set(null),this.geminiSaveMessage.set(null),this.systemService.updateApiKey("google",t,!0).subscribe({next:e=>{this.isSavingGeminiKey.set(!1),this.isGeminiKeyEdited.set(!1),this.geminiSaveMessage.set(e?.message||"\u2713 Gemini API key verified & saved successfully."),setTimeout(()=>this.geminiSaveMessage.set(null),5e3)},error:e=>{this.isSavingGeminiKey.set(!1),this.geminiSaveError.set(e?.error?.detail||e?.message||"Failed to update Gemini API key.")}}))}clearGeminiKey(){this.geminiKeyInput.set(""),this.isGeminiKeyEdited.set(!1),this.geminiSaveError.set(null),this.geminiSaveMessage.set(null),this.savedGeminiKey().trim()?(this.isSavingGeminiKey.set(!0),this.systemService.updateApiKey("google","",!0).subscribe({next:t=>{this.isSavingGeminiKey.set(!1),this.geminiSaveMessage.set(t?.message||"\u2713 Gemini API key cleared."),setTimeout(()=>this.geminiSaveMessage.set(null),5e3)},error:t=>{this.isSavingGeminiKey.set(!1),this.geminiSaveError.set(t?.error?.detail||t?.message||"Failed to clear Gemini API key.")}})):(this.geminiSaveMessage.set("\u2713 Gemini API key cleared."),setTimeout(()=>this.geminiSaveMessage.set(null),3e3))}saveOcrKey(){let t=this.ocrKeyInput().trim();t&&(this.isSavingOcrKey.set(!0),this.ocrSaveError.set(null),this.ocrSaveMessage.set(null),this.systemService.updateApiKey("ocr",t,!0).subscribe({next:e=>{this.isSavingOcrKey.set(!1),this.isOcrKeyEdited.set(!1),this.ocrSaveMessage.set(e?.message||"\u2713 Vision OCR API key verified & saved."),setTimeout(()=>this.ocrSaveMessage.set(null),5e3)},error:e=>{this.isSavingOcrKey.set(!1),this.ocrSaveError.set(e?.error?.detail||e?.message||"Failed to update Vision OCR key.")}}))}clearOcrKey(){this.ocrKeyInput.set(""),this.isOcrKeyEdited.set(!1),this.ocrSaveError.set(null),this.ocrSaveMessage.set(null),this.savedOcrKey().trim()?(this.isSavingOcrKey.set(!0),this.systemService.updateApiKey("ocr","",!0).subscribe({next:t=>{this.isSavingOcrKey.set(!1),this.ocrSaveMessage.set(t?.message||"\u2713 Vision OCR API key cleared."),setTimeout(()=>this.ocrSaveMessage.set(null),5e3)},error:t=>{this.isSavingOcrKey.set(!1),this.ocrSaveError.set(t?.error?.detail||t?.message||"Failed to clear Vision OCR key.")}})):(this.ocrSaveMessage.set("\u2713 Vision OCR API key cleared."),setTimeout(()=>this.ocrSaveMessage.set(null),3e3))}testGeminiKey(){let t=this.geminiKeyInput().trim();t&&(this.isTestingGeminiKey.set(!0),this.geminiSaveError.set(null),this.geminiSaveMessage.set(null),this.systemService.testApiKey("google",t).subscribe({next:e=>{this.isTestingGeminiKey.set(!1),e?.valid?this.geminiSaveMessage.set(e?.message||"\u2713 Gemini API key is valid!"):this.geminiSaveError.set(e?.message||"Gemini API key verification failed."),setTimeout(()=>this.geminiSaveMessage.set(null),5e3)},error:e=>{this.isTestingGeminiKey.set(!1),this.geminiSaveError.set(e?.error?.detail||e?.message||"Gemini API key test failed.")}}))}testOcrKey(){let t=this.ocrKeyInput().trim();t&&(this.isTestingOcrKey.set(!0),this.ocrSaveError.set(null),this.ocrSaveMessage.set(null),this.systemService.testApiKey("ocr",t).subscribe({next:e=>{this.isTestingOcrKey.set(!1),e?.valid?this.ocrSaveMessage.set(e?.message||"\u2713 Vision OCR API key is valid!"):this.ocrSaveError.set(e?.message||"Vision OCR API key verification failed."),setTimeout(()=>this.ocrSaveMessage.set(null),5e3)},error:e=>{this.isTestingOcrKey.set(!1),this.ocrSaveError.set(e?.error?.detail||e?.message||"Vision OCR API key test failed.")}}))}getProviderDisplayName(t){switch(t){case"gemini":return"Gemini";case"ocr":return"Vision OCR";default:return t}}getProviderEnvVar(t){switch(t){case"gemini":return"GEMINI_API_KEY";case"ocr":return"GOOGLE_VISION_API_KEY";default:return"API_KEY"}}getProviderHint(t){switch(t){case"gemini":return"For a quick start, Google Gemini provides a free API key. Artemis also supports other models (OpenAI, Claude, OpenRouter, etc.)\u2014you can configure your own API keys directly in .env or your environment.";case"ocr":return"Google Cloud Vision API key for on-screen OCR text detection and UI grounding.";default:return"Configure your API key or use environment definitions."}}skipCredentialsCheck(){this.systemService.skipCredentialsCheck()}getApiKeyPlaceholder(t){switch(t){case"gemini":return"Enter Gemini API Key (e.g. AIzaSy...)";case"ocr":return"Enter Vision OCR API Key (e.g. AIzaSy...)";default:return"Enter API Key..."}}isTabProviderActive(t){if(t==="ocr")return this.isOcrConfigured();let e=t==="gemini"?"google":t;return this.configuredLlmProviders().some(i=>i.provider===e)}refreshReadiness(){this.isRefreshingDiagnostics.set(!0),this.systemService.fetchReadiness(!1,!0).subscribe({next:()=>{this.systemService.fetchModelConfigEnv().subscribe({next:()=>{setTimeout(()=>this.isRefreshingDiagnostics.set(!1),450)},error:()=>this.isRefreshingDiagnostics.set(!1)})},error:()=>this.isRefreshingDiagnostics.set(!1)})}restartAdbServer(){this.adbRestartFeedback.set(null),this.systemService.restartAdb().subscribe({next:t=>{this.adbRestartFeedback.set(t?.restart_result?.skipped?"Devices Refreshed \u2713":"ADB Refreshed \u2713"),setTimeout(()=>this.adbRestartFeedback.set(null),2500)},error:()=>{this.adbRestartFeedback.set("Restart Failed"),setTimeout(()=>this.adbRestartFeedback.set(null),3e3)}})}connectWifiDevice(){if(this.isRemoteAdbServer()){this.wifiConnectError.set("Switch to local ADB before connecting a Wireless ADB device.");return}let t=this.wifiHost().trim(),e=this.wifiPort().trim()||"5555",o=parseInt(e,10)||5555;if(!t){this.wifiConnectError.set("Please enter a valid IP address.");return}this.isConnectingWifi.set(!0),this.wifiConnectError.set(null),this.wifiConnectMessage.set(null),this.systemService.connectWirelessAdb(t,o).subscribe({next:i=>{this.isConnectingWifi.set(!1);let r=i?.connect_result;r?.success?(this.wifiConnectMessage.set(`Connected to ${t}:${o}!`),setTimeout(()=>this.wifiConnectMessage.set(null),4e3)):this.wifiConnectError.set(r?.message||"Connection failed. Please check phone IP & Wi-Fi.")},error:i=>{this.isConnectingWifi.set(!1),this.wifiConnectError.set(i?.error?.detail||"Failed to connect. Please check adb connection.")}})}connectRemoteAdbServer(){let t=this.remoteAdbHost().trim(),e=Number(this.remoteAdbPort().trim());if(!t){this.remoteAdbError.set("Enter the host name or IP address of the ADB server.");return}if(!Number.isInteger(e)||e<1||e>65535){this.remoteAdbError.set("Enter a port between 1 and 65535.");return}this.isConnectingRemoteAdb.set(!0),this.remoteAdbError.set(null),this.remoteAdbMessage.set(null),this.remoteAdbDevices.set([]),this.remoteAdbProbeResult.set(null),this.systemService.probeAdbServer(t,e).subscribe({next:o=>{this.isConnectingRemoteAdb.set(!1);let i=o.connection_result;i.success?(this.remoteAdbProbeResult.set(i),this.remoteAdbDevices.set(i.devices||[]),this.remoteAdbMessage.set(i.message)):this.remoteAdbError.set(i.message)},error:o=>{this.isConnectingRemoteAdb.set(!1),this.remoteAdbError.set(o?.error?.detail||"Unable to test the ADB server endpoint.")}})}activateRemoteAdbServer(){let t=this.remoteAdbProbeResult();if(!t?.success){this.remoteAdbError.set("Test the endpoint before using it.");return}this.isActivatingRemoteAdb.set(!0),this.remoteAdbError.set(null),this.systemService.connectAdbServer(t.endpoint.host,t.endpoint.port,this.rememberRemoteAdb()).subscribe({next:e=>{this.isActivatingRemoteAdb.set(!1);let o=e.connection_result;o.success?(this.remoteAdbProbeResult.set(o),this.remoteAdbDevices.set(o.devices||[]),this.remoteAdbMessage.set(o.message)):this.remoteAdbError.set(o.message)},error:e=>{this.isActivatingRemoteAdb.set(!1),this.remoteAdbError.set(e?.error?.detail||"Unable to use the ADB server endpoint.")}})}updateRemoteAdbHost(t){this.remoteAdbHost.set(t),this.clearRemoteAdbProbe()}updateRemoteAdbPort(t){this.remoteAdbPort.set(t),this.clearRemoteAdbProbe()}clearRemoteAdbProbe(){this.remoteAdbProbeResult.set(null),this.remoteAdbDevices.set([]),this.remoteAdbMessage.set(null),this.remoteAdbError.set(null)}switchToLocalAdbServer(){this.isSwitchingToLocalAdb.set(!0),this.remoteAdbError.set(null),this.systemService.useLocalAdbServer(!0).subscribe({next:t=>{this.isSwitchingToLocalAdb.set(!1),this.remoteAdbDevices.set([]),this.remoteAdbMessage.set(t.connection_result.message)},error:t=>{this.isSwitchingToLocalAdb.set(!1),this.remoteAdbError.set(t?.error?.detail||"Unable to switch back to the local ADB server.")}})}toggleConnectionMethods(){this.showConnectionMethods.update(t=>!t)}launchAvdEmulator(t){this.isRemoteAdbServer()||this.systemService.launchEmulator(t).subscribe()}toggleLaunchLogs(){this.showLaunchLogs.update(t=>!t)}stopEmulator(){this.systemService.stopEmulator().subscribe()}dismissEmulatorStatus(){this.systemService.dismissEmulatorStatus().subscribe()}selectTargetDevice(t){this.systemService.selectDevice(t).subscribe()}getEmulatorCommand(t){let e=this.emulatorPath();return`${this.isEmulatorInPath()?"emulator":e||"~/Android/Sdk/emulator/emulator"} -avd ${t}`}copyToClipboard(t,e){navigator.clipboard&&navigator.clipboard.writeText(t).then(()=>{this.copiedId.set(e),setTimeout(()=>{this.copiedId()===e&&this.copiedId.set(null)},2e3)})}setProfile(t){this.selectedProfile.set(t)}setCategory(t){this.selectedCategory.set(t)}shuffleSuggestions(){this.shuffleOffset.update(t=>t+3)}getAppNamesDisplay(t){return t.map(e=>e.name).join(" + ")}applySuggestion(t){this.taskGoal.set(t.goal),this.selectedProfile.set(t.profile),this.errorMessage.set(null)}applyQuickPrompt(t,e){this.taskGoal.set(t),e&&this.selectedProfile.set(e),this.errorMessage.set(null)}proceedToLauncher(){this.activeTab.set("launcher")}runTask(){let t=this.taskGoal().trim();if(!t){this.errorMessage.set("Please enter a task goal before running.");return}if(!this.isReady()){this.errorMessage.set("System prerequisites are not satisfied. Please review System Setup first."),this.activeTab.set("diagnostics");return}this.isSubmitting.set(!0),this.errorMessage.set(null),this.agentService.runTask(t,this.selectedProfile(),this.selectedProfile()==="pro"&&this.expectedOutput().trim()?this.expectedOutput().trim():void 0,this.selectedProfile()==="pro"?this.enableOutputter():void 0,this.selectedProfile()==="pro"?{verificationLevel:this.verificationLevel().id,explorerMode:this.explorerMode().id}:void 0).subscribe({next:()=>{this.isSubmitting.set(!1),this.router.navigate(["/workspace"])},error:e=>{console.error("Failed to submit task from home page:",e),this.isSubmitting.set(!1),this.errorMessage.set(e?.error?.detail||"Failed to submit task. Please check server connection.")}})}static \u0275fac=function(e){return new(e||n)};static \u0275cmp=Ge({type:n,selectors:[["app-home"]],decls:26,vars:14,consts:[[1,"home-container"],[1,"aurora-background"],[1,"glow","glow-1"],[1,"glow","glow-2"],[1,"glow","glow-3"],[1,"glow","glow-pending"],[1,"glow","glow-ready"],[1,"glow","glow-pro"],[1,"home-content"],[1,"hero-section"],[1,"hero-title"],[1,"mode-tabs-container"],[1,"mode-tabs"],["type","button",1,"mode-tab-btn",3,"click"],[1,"material-symbols-outlined","tab-icon","icon-ready"],[1,"material-symbols-outlined","tab-icon"],[1,"tab-label"],[1,"material-symbols-outlined","tab-icon","icon-locked"],[1,"setup-guide-view"],[1,"task-launcher-view"],[1,"guide-card"],[1,"guide-card-header"],[1,"step-badge"],[1,"material-symbols-outlined","step-check-icon"],[1,"step-info"],[1,"step-title-row"],[1,"step-title"],[1,"step-desc"],["type","button","title","Re-check all system dependencies",1,"btn-header-action",3,"click","disabled"],[1,"material-symbols-outlined"],[1,"guide-card-body"],[1,"one-click-install-bar"],[1,"env-matrix-grid"],[1,"env-item-card"],[1,"item-header"],[1,"item-icon-box"],[1,"material-symbols-outlined","item-icon"],[1,"item-title-group"],[1,"item-name"],[1,"item-subtitle"],[1,"item-status"],[1,"status-dot"],[1,"item-body"],[1,"model-choice-cards-row"],[1,"choice-card",3,"click"],[1,"choice-card-top"],[1,"choice-radio"],[1,"radio-dot"],[1,"choice-icon-wrap"],["viewBox","0 0 24 24","fill","none",1,"brand-svg","gemini-svg"],["id","geminiChoiceGrad","x1","0%","y1","0%","x2","100%","y2","100%"],["offset","0%","stop-color","#4285F4"],["offset","50%","stop-color","#9B72CB"],["offset","100%","stop-color","#D96570"],["d","M12 2C12 7.523 7.523 12 2 12C7.523 12 12 16.477 12 22C12 16.477 16.477 12 22 12C16.477 12 12 7.523 12 2Z","fill","url(#geminiChoiceGrad)"],[1,"choice-text"],[1,"choice-title-row"],[1,"choice-title"],[1,"tag-badge","tag-rec"],[1,"choice-desc"],[1,"choice-icon-wrap","icon-gear"],[1,"custom-config-inspector"],["type","button",1,"btn-restart-adb","btn-connection-options",3,"click"],["type","button",1,"btn-restart-adb",3,"click","disabled","title"],[1,"connected-device-panel"],[1,"emulator-boot-tracker-container"],[1,"emulator-failed-container"],[1,"foolproof-guide-container"],[1,"device-locked-panel"],[1,"unauthorized-panel"],[1,"adb-missing-panel"],[1,"ready-banner-footer"],[1,"install-bar-left"],[1,"install-bar-icon-box"],[1,"material-symbols-outlined","bolt-icon"],[1,"install-bar-text"],[1,"install-bar-title-row"],[1,"install-bar-title"],[1,"os-selector-pills"],["type","button",1,"os-pill",3,"click"],[1,"install-bar-desc"],[1,"code-box","small-box"],["type","button",1,"btn-copy-code",3,"click"],[1,"item-action-box"],[1,"terminal-prefix"],[1,"code-text"],[1,"credentials-input-box"],[1,"input-box-header"],[1,"header-label"],[1,"material-symbols-outlined","label-icon"],["href","https://aistudio.google.com/app/apikey","target","_blank","rel","noopener noreferrer",1,"btn-header-link"],[1,"unified-key-row"],[1,"unified-key-wrapper"],[1,"material-symbols-outlined","key-prefix-icon"],["placeholder","Enter Gemini API Key (e.g. AIzaSy...)","spellcheck","false","autocomplete","off",1,"unified-key-input",3,"ngModelChange","type","ngModel"],["type","button",1,"btn-icon-action",3,"click","title"],["type","button","title","Copy API Key",1,"btn-icon-action"],["type","button",1,"btn-unified-save",3,"click","disabled"],["type","button","title","Test if Gemini API key is valid",1,"btn-unified-test",3,"click","disabled"],[1,"feedback-msg","success"],[1,"feedback-msg","error"],[1,"ocr-enhancement-card"],[1,"ocr-card-toggle-bar",3,"click"],[1,"toggle-bar-left"],[1,"material-symbols-outlined","icon-ocr"],[1,"toggle-bar-titles"],[1,"ocr-main-title"],[1,"toggle-bar-right"],["title","OCR Configured",1,"status-dot","dot-pass"],[1,"material-symbols-outlined","caret-icon"],[1,"ocr-content-body"],["type","button","title","Copy API Key",1,"btn-icon-action",3,"click"],[1,"material-symbols-outlined","spin"],[1,"ocr-help-desc"],[1,"credentials-input-box","ocr-box"],["placeholder","Enter Vision OCR API Key (e.g. AIzaSy...)","spellcheck","false","autocomplete","off",1,"unified-key-input",3,"ngModelChange","type","ngModel"],["type","button","title","Test if Vision OCR key is valid",1,"btn-unified-test",3,"click","disabled"],[1,"inspector-card"],[1,"inspector-card-header"],[1,"inspector-header-left"],[1,"material-symbols-outlined","header-icon"],[1,"inspector-header-titles"],[1,"inspector-title"],[1,"inspector-subtitle"],[1,"inspector-header-actions"],["type","button","title","Copy file path",1,"btn-file-action",3,"click"],["type","button",1,"btn-file-action","btn-toggle-code",3,"click"],[1,"inspector-summary-row"],[1,"summary-metric"],[1,"metric-label"],[1,"metric-val-pill"],[1,"provider-dot"],[1,"provider-name"],[1,"model-divider"],[1,"model-name"],[1,"jsonc-viewer-box"],[1,"env-keys-table"],[1,"env-key-row",3,"is-configured"],[1,"metric-val-pill","secondary"],[1,"jsonc-code"],[1,"env-key-row"],[1,"env-name-col"],[1,"env-key-name"],[1,"env-status-col"],[1,"badge-env-pass"],[1,"badge-env-unset"],[1,"key-preview"],[1,"material-symbols-outlined","spinning"],[1,"material-symbols-outlined","feedback-done"],[1,"feedback-text"],[1,"device-hero-card"],[1,"device-avatar"],[1,"material-symbols-outlined","device-hero-icon"],[1,"device-hero-details"],[1,"device-hero-name"],[1,"device-hero-meta"],[1,"device-hero-status"],[1,"status-dot","dot-pass"],[1,"status-text"],[1,"multi-device-switcher"],[1,"meta-dot"],[1,"remote-endpoint-label"],[1,"switcher-label"],[1,"switcher-chips"],["type","button",1,"device-chip-btn",3,"selected"],["type","button",1,"device-chip-btn",3,"click"],[1,"material-symbols-outlined","chip-icon"],[1,"active-check"],[1,"emulator-boot-tracker-card"],[1,"tracker-header"],[1,"tracker-title-group"],[1,"tracker-icon-box"],[1,"material-symbols-outlined","spinning-icon"],[1,"tracker-titles"],[1,"tracker-main-heading"],[1,"tracker-title"],[1,"avd-highlight"],[1,"stage-tag-badge"],[1,"tracker-subtitle"],[1,"tracker-actions"],["type","button",1,"btn-toggle-logs",3,"click"],["type","button","title","Terminate emulator process",1,"btn-stop-emu",3,"click"],[1,"boot-stepper"],[1,"step-item"],[1,"step-circle"],[1,"material-symbols-outlined","check-icon"],[1,"spinner-tiny"],[1,"step-label"],[1,"step-line"],[1,"progress-bar-container"],[1,"progress-bar-track"],[1,"progress-bar-fill"],[1,"progress-bar-meta"],[1,"progress-percent"],[1,"progress-timer"],[1,"material-symbols-outlined","timer-icon"],[1,"boot-tip-banner"],[1,"material-symbols-outlined","tip-icon"],[1,"boot-logs-drawer"],[1,"logs-header"],[1,"logs-header-left"],["type","button",1,"btn-copy-logs",3,"click"],[1,"logs-body"],[1,"log-empty"],[1,"log-line"],[1,"emulator-failed-card"],[1,"failed-header"],[1,"failed-header-left"],[1,"failed-icon-box"],[1,"material-symbols-outlined","failed-icon"],[1,"failed-titles"],[1,"failed-title"],[1,"failed-desc"],["type","button","title","Dismiss error",1,"btn-dismiss-failed",3,"click"],[1,"failed-logs-box"],[1,"failed-actions-bar"],["type","button",1,"btn-retry-launch",3,"disabled"],["type","button",1,"btn-restart-adb-sub",3,"click"],["type","button",1,"btn-dismiss-flat",3,"click"],[1,"failed-logs-pre"],["type","button",1,"btn-retry-launch",3,"click","disabled"],[1,"connection-methods-nav"],["type","button",1,"method-nav-card",3,"click"],[1,"method-card-left"],[1,"method-icon-box"],[1,"method-info"],[1,"method-title-row"],[1,"method-title"],[1,"method-desc"],[1,"method-card-right"],[1,"radio-indicator"],[1,"active-remote-notice"],[1,"method-details-panel"],[1,"method-details-panel","remote-adb-panel"],[1,"active-remote-copy"],["type","button",1,"btn-use-local-adb",3,"click","disabled"],[1,"avds-section"],[1,"emulator-setup-guide-container"],[1,"section-header-bar"],[1,"section-title-group"],[1,"section-title"],[1,"count-badge"],[1,"avds-list"],[1,"avd-card-item"],[1,"avd-item-main"],[1,"avd-avatar"],[1,"avd-meta"],[1,"avd-heading"],[1,"avd-name"],[1,"avd-type-pill"],[1,"avd-item-actions"],["type","button",1,"btn-avd-launch",3,"click","disabled","title"],[1,"spinner-icon"],[1,"material-symbols-outlined","action-icon"],[1,"empty-avd-banner"],[1,"banner-left"],[1,"empty-avd-icon-box"],[1,"banner-titles"],[1,"banner-title-row"],[1,"empty-title"],[1,"empty-desc"],[1,"mode-switch-pills"],["type","button",1,"pill-btn",3,"click"],[1,"material-symbols-outlined","pill-icon"],[1,"steps-flow-grid","emu-flow-grid"],[1,"cli-steps-list"],[1,"flow-step-card"],[1,"step-card-header"],[1,"step-number-badge"],[1,"material-symbols-outlined","step-card-icon"],[1,"step-card-title"],[1,"step-card-desc"],["href","https://developer.android.com/studio","target","_blank","rel","noopener",1,"guide-link"],[1,"cli-step-item"],[1,"step-badge-num"],[1,"step-content"],[1,"step-text"],[1,"usb-guide-intro"],[1,"material-symbols-outlined","intro-icon"],[1,"steps-flow-grid"],[1,"wifi-guide-intro"],[1,"wifi-interactive-box"],[1,"wifi-form-row"],[1,"wifi-input-col","ip-col"],[1,"wifi-label"],[1,"wifi-input-wrap"],[1,"material-symbols-outlined","wrap-icon"],["type","text","placeholder","e.g. 192.168.1.108","spellcheck","false",1,"wifi-input",3,"ngModelChange","ngModel"],[1,"wifi-input-col","port-col"],["type","text","placeholder","5555","spellcheck","false",1,"wifi-input",3,"ngModelChange","ngModel"],[1,"wifi-btn-col"],["type","button",1,"btn-wifi-connect",3,"click","disabled"],[1,"wifi-status-alert","success"],[1,"wifi-status-alert","error"],[1,"info-callout-box"],[1,"material-symbols-outlined","callout-icon"],[1,"callout-text"],[1,"remote-guide-intro"],[1,"remote-adb-interactive-box"],[1,"remote-adb-form-row"],[1,"remote-adb-input-col","host-col"],["for","remote-adb-host",1,"remote-adb-label"],[1,"remote-adb-input-wrap"],["id","remote-adb-host","type","text","placeholder","127.0.0.1","autocomplete","off","spellcheck","false",1,"remote-adb-input",3,"ngModelChange","ngModel"],[1,"remote-adb-input-col","port-col"],["for","remote-adb-port",1,"remote-adb-label"],["id","remote-adb-port","type","text","inputmode","numeric","placeholder","5038","autocomplete","off","spellcheck","false",1,"remote-adb-input",3,"ngModelChange","ngModel"],[1,"remote-adb-btn-col"],["type","button",1,"btn-remote-adb-connect",3,"click","disabled"],[1,"remember-remote-adb"],["type","checkbox",3,"ngModelChange","ngModel"],[1,"remote-adb-status-alert","success"],["role","alert",1,"remote-adb-status-alert","error"],[1,"remote-device-results"],["type","button",1,"btn-remote-adb-connect","btn-use-tested-endpoint",3,"disabled"],[1,"remote-adb-explanation"],[1,"remote-device-row"],[1,"remote-device-copy"],[1,"remote-device-state"],["type","button",1,"btn-remote-adb-connect","btn-use-tested-endpoint",3,"click","disabled"],[1,"device-locked-card"],[1,"material-symbols-outlined","alert-icon"],[1,"locked-details"],[1,"locked-title"],[1,"locked-desc"],["type","button",1,"btn-check-unlock",3,"click","disabled"],[1,"multi-device-switcher",2,"margin-top","12px"],[1,"unauthorized-card"],[1,"unauth-details"],[1,"unauth-title"],[1,"unauth-desc"],["type","button",1,"btn-retry-auth",3,"click"],[1,"error-msg-box"],[1,"material-symbols-outlined","err-icon"],[1,"install-commands-row"],[1,"install-card"],[1,"install-os"],[1,"detected-badge"],[1,"ready-banner-left"],[1,"step-badge","step-pass"],[1,"ready-text-group"],[1,"ready-title"],[1,"ready-desc"],["type","button",1,"btn-proceed-launch",3,"click"],[1,"material-symbols-outlined","rocket-icon"],[1,"launcher-warning-bar"],[1,"composer-card"],[1,"intent-suggestion-strip"],[1,"composer-input-area"],["id","task-input-field","rows","3","placeholder","Describe what you want to achieve on your phone (e.g. Open Gmail and search for latest emails)...",1,"composer-textarea",3,"ngModelChange","keydown.control.enter","keydown.meta.enter","ngModel","disabled"],[1,"composer-model-descriptor"],[1,"descriptor-icon-wrap"],[1,"descriptor-body"],[1,"desc-line"],[1,"error-banner"],[1,"composer-action-bar"],[1,"bar-left"],[1,"model-toggle-capsule"],["type","button","title","Flash: fast single-look loop for everyday app tasks (3\u20135s/step)",1,"model-toggle-btn","btn-flash",3,"click"],[1,"material-symbols-outlined","btn-icon"],[1,"btn-text"],["type","button","title","Pro: plans first, checks results and recovers from blocked steps. For long or complex tasks (15\u201340s/step)",1,"model-toggle-btn","btn-pro",3,"click"],[1,"bar-right"],[1,"char-count"],["type","button",1,"btn-run-composer",3,"click","disabled","title"],[1,"smart-suggestions-container"],[1,"suggestions-header-bar"],[1,"header-left"],[1,"section-label"],[1,"material-symbols-outlined","section-icon"],[1,"label-text"],[1,"category-tabs-group"],["type","button",1,"cat-tab-btn",3,"click"],["type","button",1,"cat-tab-btn","cat-flash",3,"click"],[1,"material-symbols-outlined","tab-mini-icon"],["type","button",1,"cat-tab-btn","cat-pro",3,"click"],[1,"header-right"],["type","button","title","Discover more tasks",1,"btn-shuffle-suggestions",3,"click"],[1,"material-symbols-outlined","shuffle-icon"],[1,"suggestions-cards-grid"],[1,"suggestion-card",3,"is-pro-card","is-flash-card"],[1,"launcher-warning-bar",3,"click"],[1,"material-symbols-outlined","warn-icon"],[1,"material-symbols-outlined","arrow"],[1,"suggestion-left"],[1,"material-symbols-outlined","sparkle-icon"],[1,"suggestion-text"],["type","button",1,"btn-switch-pro",3,"click"],[1,"material-symbols-outlined","btn-arrow"],[1,"mode-name"],[1,"bullet"],[1,"limitation-tag"],[1,"material-symbols-outlined","error-icon"],[1,"spinner"],[1,"material-symbols-outlined","run-icon"],[1,"pro-outputter-standalone-card"],[1,"outputter-card-header"],[1,"outputter-title"],[1,"outputter-optional-tag"],[1,"outputter-input-wrapper"],["type","text","placeholder","Specify expected output or schema (e.g. 'Extract SMS verification code as JSON' or 'Summary of discounts and total price')...","spellcheck","false",1,"outputter-standalone-input",3,"ngModelChange","ngModel"],["type","button","title","Clear expected output",1,"btn-clear-standalone"],[1,"pro-tuning-card"],[1,"tuning-card-header"],[1,"tuning-title"],["type","button","title","Back to the defaults",1,"btn-tuning-reset"],[1,"tuning-sliders"],[1,"tuning-slider",3,"verify","explore","dragging","--%NS%pct"],["type","button","title","Clear expected output",1,"btn-clear-standalone",3,"click"],["type","button","title","Back to the defaults",1,"btn-tuning-reset",3,"click"],[1,"tuning-slider"],[1,"slider-head"],[1,"slider-name"],[1,"slider-value"],["aria-hidden","true",1,"slider-ends"],[1,"slider-track-wrap",3,"pointerenter","pointerleave"],["aria-hidden","true",1,"slider-rail"],[1,"slider-fill"],[1,"slider-notch",3,"reached","--%NS%n"],["aria-hidden","true",1,"slider-thumb"],["aria-hidden","true",1,"thumb-ripple"],["type","range","min","0","step","1",1,"slider-input",3,"input","focus","blur","max","value"],[1,"slider-notch"],[1,"slider-solid"],["width","100%","height","6","aria-hidden","true",1,"slider-dither"],["width","2","height","2",3,"opacity","animation-delay"],["width","2","height","2"],["aria-hidden","true",1,"tuning-tip-arrow"],["role","tooltip",1,"tuning-tip"],[1,"tip-heading"],[1,"tip-title"],[1,"tip-latency"],[1,"tip-tagline"],[1,"tip-section"],[1,"tip-section-title"],[1,"tip-section","skips"],[1,"tip-best"],[1,"suggestion-card",3,"click"],[1,"card-top-row"],[1,"app-icons-cluster"],[1,"app-icon-badge",3,"title"],[1,"app-names-label"],[1,"mode-badge",3,"title"],[1,"card-body"],[1,"card-title"],[1,"card-desc"],[1,"card-footer-row"],[1,"tag-pill"],[1,"use-prompt-action"],[1,"material-symbols-outlined","action-arrow"]],template:function(e,o){e&1&&(l(0,"div",0)(1,"div",1),$(2,"div",2)(3,"div",3)(4,"div",4)(5,"div",5)(6,"div",6)(7,"div",7),d(),l(8,"main",8)(9,"section",9)(10,"h1",10),u(11," What would you like to do on your phone? "),d()(),l(12,"div",11)(13,"div",12)(14,"button",13),k("click",function(){return o.setTab("diagnostics")}),M(15,MD,2,0,"span",14)(16,bD,2,0,"span",15),l(17,"span",16),u(18,"System Setup & Prerequisites"),d()(),l(19,"button",13),k("click",function(){return o.setTab("launcher")}),M(20,PD,2,0,"span",17)(21,OD,2,0,"span",15),l(22,"span",16),u(23,"Launch Autonomous Task"),d()()()(),M(24,_T,155,87,"section",18),M(25,jT,74,32,"section",19),d()()),e&2&&(w("system-ready",o.isReady())("system-pending",!o.isReady())("pro-theme-active",o.selectedProfile()==="pro"),g(14),w("active",o.activeTab()==="diagnostics"),g(),b(o.isReady()?15:16),g(4),w("active",o.activeTab()==="launcher"),g(),b(o.isReady()?21:20),g(4),b(o.activeTab()==="diagnostics"?24:-1),g(),b(o.activeTab()==="launcher"?25:-1))},dependencies:[Qt,zr,em,Nd,ws],styles:['@charset "UTF-8";.os-selector-pills[_ngcontent-%COMP%]{display:inline-flex;align-items:center;padding:2px;gap:2px;background:#ffffffa6;border:1px solid rgba(226,232,240,.85);border-radius:8px;box-shadow:0 1px 3px #0f172a0a,inset 0 1px 1px #fffc;backdrop-filter:blur(12px) saturate(180%);-webkit-backdrop-filter:blur(12px) saturate(180%)}.os-selector-pills[_ngcontent-%COMP%] .os-pill[_ngcontent-%COMP%]{padding:3px 9px;border:0;border-radius:6px;background:transparent;color:#475569;font-family:inherit;font-size:11px;font-weight:600;line-height:1.35;cursor:pointer;transition:color .15s ease,background-color .15s ease,box-shadow .15s ease}.os-selector-pills[_ngcontent-%COMP%] .os-pill[_ngcontent-%COMP%]:hover{color:#0f172a;background:#ffffffb8}.os-selector-pills[_ngcontent-%COMP%] .os-pill.active[_ngcontent-%COMP%]{color:#fff;background:#1a73e8;box-shadow:0 1px 2px #0f172a1f}.os-selector-pills[_ngcontent-%COMP%] .os-pill[_ngcontent-%COMP%]:focus-visible{outline:2px solid rgba(26,115,232,.35);outline-offset:1px}[_nghost-%COMP%]{display:block;min-height:100vh;width:100%;background-color:#f8f9fa;font-family:Google Sans Text,Google Sans,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#0f172a;box-sizing:border-box;-webkit-font-smoothing:antialiased}*[_ngcontent-%COMP%], *[_ngcontent-%COMP%]:before, *[_ngcontent-%COMP%]:after{box-sizing:border-box}.home-container[_ngcontent-%COMP%]{position:relative;min-height:100vh;display:flex;flex-direction:column;background-color:#f8f9fa;overflow-x:hidden}.home-container[_ngcontent-%COMP%] .aurora-background[_ngcontent-%COMP%]{display:block;position:fixed;top:0;left:0;width:100vw;height:100vh;pointer-events:none;z-index:1;overflow:hidden;opacity:.9;transition:opacity .8s ease}.home-container[_ngcontent-%COMP%] .aurora-background[_ngcontent-%COMP%] .glow[_ngcontent-%COMP%]{position:absolute;border-radius:50%;transform:translateZ(0);will-change:transform,opacity;pointer-events:none}.home-container[_ngcontent-%COMP%] .aurora-background[_ngcontent-%COMP%] .glow-1[_ngcontent-%COMP%]{top:-140px;left:10%;width:520px;height:520px;background:radial-gradient(circle,rgba(59,130,246,.12) 0%,rgba(99,102,241,.04) 45%,transparent 82%);animation:_ngcontent-%COMP%_floatGlow1 22s ease-in-out infinite alternate}.home-container[_ngcontent-%COMP%] .aurora-background[_ngcontent-%COMP%] .glow-2[_ngcontent-%COMP%]{top:-100px;right:12%;width:560px;height:560px;background:radial-gradient(circle,rgba(147,51,234,.08) 0%,rgba(219,39,119,.03) 45%,transparent 82%);animation:_ngcontent-%COMP%_floatGlow2 26s ease-in-out infinite alternate}.home-container[_ngcontent-%COMP%] .aurora-background[_ngcontent-%COMP%] .glow-3[_ngcontent-%COMP%]{top:280px;left:30%;width:460px;height:460px;background:radial-gradient(circle,rgba(6,182,212,.08) 0%,rgba(59,130,246,.03) 45%,transparent 82%);animation:_ngcontent-%COMP%_floatGlow3 20s ease-in-out infinite alternate}.home-container[_ngcontent-%COMP%] .aurora-background[_ngcontent-%COMP%] .glow-pending[_ngcontent-%COMP%]{top:-120px;left:15%;right:15%;margin:0 auto;width:720px;height:520px;background:radial-gradient(circle,rgba(245,158,11,.09) 0%,rgba(217,119,6,.035) 38%,transparent 82%);opacity:0;transition:opacity .8s cubic-bezier(.4,0,.2,1);animation:_ngcontent-%COMP%_readyPulseGlow 9s ease-in-out infinite alternate;pointer-events:none}.home-container[_ngcontent-%COMP%] .aurora-background[_ngcontent-%COMP%] .glow-ready[_ngcontent-%COMP%]{top:-120px;left:15%;right:15%;margin:0 auto;width:700px;height:520px;background:radial-gradient(circle,rgba(16,185,129,.14) 0%,rgba(6,182,212,.08) 38%,transparent 82%);opacity:0;transition:opacity 1s cubic-bezier(.4,0,.2,1);animation:_ngcontent-%COMP%_readyPulseGlow 7s ease-in-out infinite alternate;pointer-events:none}.home-container[_ngcontent-%COMP%] .aurora-background[_ngcontent-%COMP%] .glow-pro[_ngcontent-%COMP%]{top:-100px;left:20%;right:20%;margin:0 auto;width:680px;height:520px;background:radial-gradient(circle,rgba(99,102,241,.18) 0%,rgba(147,51,234,.08) 38%,transparent 82%);opacity:0;transition:opacity .8s cubic-bezier(.4,0,.2,1);pointer-events:none}.home-container.system-pending[_ngcontent-%COMP%] .aurora-background[_ngcontent-%COMP%] .glow-pending[_ngcontent-%COMP%]{opacity:1}.home-container.system-pending[_ngcontent-%COMP%] .aurora-background[_ngcontent-%COMP%] .glow-1[_ngcontent-%COMP%]{background:radial-gradient(circle,rgba(245,158,11,.07) 0%,rgba(99,102,241,.03) 55%,transparent 75%)}.home-container.system-ready[_ngcontent-%COMP%] .aurora-background[_ngcontent-%COMP%] .glow-pending[_ngcontent-%COMP%]{opacity:0}.home-container.system-ready[_ngcontent-%COMP%] .aurora-background[_ngcontent-%COMP%] .glow-ready[_ngcontent-%COMP%]{opacity:1}.home-container.system-ready[_ngcontent-%COMP%] .aurora-background[_ngcontent-%COMP%] .glow-1[_ngcontent-%COMP%]{background:radial-gradient(circle,rgba(16,185,129,.1) 0%,rgba(59,130,246,.05) 55%,transparent 75%)}.home-container.pro-theme-active[_ngcontent-%COMP%] .aurora-background[_ngcontent-%COMP%] .glow-pro[_ngcontent-%COMP%]{opacity:1}.home-container.pro-theme-active[_ngcontent-%COMP%] .aurora-background[_ngcontent-%COMP%] .glow-ready[_ngcontent-%COMP%]{opacity:.35}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%]{position:relative;z-index:5;max-width:960px;width:100%;margin:0 auto;padding:36px 24px 60px;display:flex;flex-direction:column;gap:18px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .hero-section[_ngcontent-%COMP%]{text-align:center;padding:16px 0 6px;margin:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .hero-section[_ngcontent-%COMP%] .hero-title[_ngcontent-%COMP%]{font-family:Outfit,Google Sans,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-size:32px;font-weight:600;letter-spacing:-.035em;line-height:1.2;color:#0f172a;margin:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .hero-section[_ngcontent-%COMP%] .hero-subtitle[_ngcontent-%COMP%]{font-size:14.5px;color:#64748b;font-weight:400;margin:8px 0 0;letter-spacing:-.01em}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .mode-tabs-container[_ngcontent-%COMP%]{display:flex;justify-content:center;margin-bottom:6px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .mode-tabs-container[_ngcontent-%COMP%] .mode-tabs[_ngcontent-%COMP%]{display:grid;grid-template-columns:1fr 1fr;width:100%;max-width:520px;box-sizing:border-box;background:#ffffffb8;backdrop-filter:blur(12px) saturate(180%);-webkit-backdrop-filter:blur(12px) saturate(180%);padding:3px;border-radius:9999px;border:1px solid rgba(226,232,240,.85);gap:3px;box-shadow:0 2px 8px -1px #0f172a0f,0 1px 2px #00000005,inset 0 1px 1px #ffffffe6;-webkit-user-select:none;user-select:none;font-family:Google Sans,Google Sans Text,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;transition:background .25s cubic-bezier(.4,0,.2,1),box-shadow .25s cubic-bezier(.4,0,.2,1),border-color .25s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .mode-tabs-container[_ngcontent-%COMP%] .mode-tabs[_ngcontent-%COMP%]:hover{background:#ffffffd9;box-shadow:0 4px 14px -2px #0f172a14,0 2px 4px #00000005,inset 0 1px 1px #fff;border-color:#cbd5e1e6}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .mode-tabs-container[_ngcontent-%COMP%] .mode-tabs[_ngcontent-%COMP%] .mode-tab-btn[_ngcontent-%COMP%]{display:inline-flex;align-items:center;justify-content:center;gap:7px;height:32px;padding:0 14px;box-sizing:border-box;border:none;background:transparent;border-radius:9999px;cursor:pointer;font-family:inherit;font-size:13px;font-weight:600;color:#475569;line-height:1;transition:all .2s cubic-bezier(.4,0,.2,1);outline:none;white-space:nowrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .mode-tabs-container[_ngcontent-%COMP%] .mode-tabs[_ngcontent-%COMP%] .mode-tab-btn[_ngcontent-%COMP%] .tab-icon[_ngcontent-%COMP%]{font-size:16px;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;color:#64748b;transition:color .15s ease,transform .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .mode-tabs-container[_ngcontent-%COMP%] .mode-tabs[_ngcontent-%COMP%] .mode-tab-btn[_ngcontent-%COMP%] .tab-icon.icon-ready[_ngcontent-%COMP%]{color:#059669}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .mode-tabs-container[_ngcontent-%COMP%] .mode-tabs[_ngcontent-%COMP%] .mode-tab-btn[_ngcontent-%COMP%] .tab-icon.icon-locked[_ngcontent-%COMP%]{color:#94a3b8;font-size:15px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .mode-tabs-container[_ngcontent-%COMP%] .mode-tabs[_ngcontent-%COMP%] .mode-tab-btn[_ngcontent-%COMP%] .tab-label[_ngcontent-%COMP%]{color:#475569;transition:color .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .mode-tabs-container[_ngcontent-%COMP%] .mode-tabs[_ngcontent-%COMP%] .mode-tab-btn[_ngcontent-%COMP%]:hover:not(.active){background:#ffffffb3;color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .mode-tabs-container[_ngcontent-%COMP%] .mode-tabs[_ngcontent-%COMP%] .mode-tab-btn[_ngcontent-%COMP%]:hover:not(.active) .tab-icon[_ngcontent-%COMP%]{color:#1e293b}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .mode-tabs-container[_ngcontent-%COMP%] .mode-tabs[_ngcontent-%COMP%] .mode-tab-btn[_ngcontent-%COMP%]:hover:not(.active) .tab-icon.icon-ready[_ngcontent-%COMP%]{color:#059669}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .mode-tabs-container[_ngcontent-%COMP%] .mode-tabs[_ngcontent-%COMP%] .mode-tab-btn[_ngcontent-%COMP%]:hover:not(.active) .tab-label[_ngcontent-%COMP%]{color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .mode-tabs-container[_ngcontent-%COMP%] .mode-tabs[_ngcontent-%COMP%] .mode-tab-btn.active[_ngcontent-%COMP%]{background:#fff!important;color:#0f172a!important;font-weight:700;box-shadow:0 2px 8px -1px #0f172a14,0 1px 2px #0000000a,inset 0 1px #fff}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .mode-tabs-container[_ngcontent-%COMP%] .mode-tabs[_ngcontent-%COMP%] .mode-tab-btn.active[_ngcontent-%COMP%] .tab-icon[_ngcontent-%COMP%]{color:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .mode-tabs-container[_ngcontent-%COMP%] .mode-tabs[_ngcontent-%COMP%] .mode-tab-btn.active[_ngcontent-%COMP%] .tab-icon.icon-ready[_ngcontent-%COMP%]{color:#059669}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .mode-tabs-container[_ngcontent-%COMP%] .mode-tabs[_ngcontent-%COMP%] .mode-tab-btn.active[_ngcontent-%COMP%] .tab-label[_ngcontent-%COMP%]{color:#0f172a!important}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .mode-tabs-container[_ngcontent-%COMP%] .mode-tabs[_ngcontent-%COMP%] .mode-tab-btn[_ngcontent-%COMP%]:active{transform:scale(.98)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:16px;animation:_ngcontent-%COMP%_fadeIn .2s ease-out}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%]{background:#fff;border:1px solid #e2e8f0;border-radius:14px;box-shadow:0 1px 3px #00000008,0 4px 12px #00000005;overflow:hidden;transition:border-color .2s ease,box-shadow .2s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%]:hover{border-color:#cbd5e1}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:16px 20px;border-bottom:1px solid #f1f5f9;background:#fff}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .step-badge[_ngcontent-%COMP%]{width:32px;height:32px;border-radius:8px;display:inline-flex;align-items:center;justify-content:center;font-size:13.5px;font-weight:700;background:#f1f5f9;color:#475569;flex-shrink:0;transition:all .2s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .step-badge[_ngcontent-%COMP%] span[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .step-badge[_ngcontent-%COMP%] .step-check-icon[_ngcontent-%COMP%]{font-size:18px;font-weight:700;color:inherit}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .step-badge.step-pass[_ngcontent-%COMP%]{background:#ecfdf5;color:#059669}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .step-info[_ngcontent-%COMP%]{flex:1;min-width:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .step-info[_ngcontent-%COMP%] .step-title-row[_ngcontent-%COMP%]{display:flex;align-items:center;gap:10px;margin-bottom:2px;flex-wrap:wrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .step-info[_ngcontent-%COMP%] .step-title-row[_ngcontent-%COMP%] .step-title[_ngcontent-%COMP%]{margin:0;font-size:15px;font-weight:600;color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .step-info[_ngcontent-%COMP%] .step-title-row[_ngcontent-%COMP%] .status-pill[_ngcontent-%COMP%]{font-size:11px;font-weight:600;padding:2px 8px;border-radius:12px;line-height:1.3;height:22px;display:inline-flex;align-items:center}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .step-info[_ngcontent-%COMP%] .step-title-row[_ngcontent-%COMP%] .status-pill.pill-pass[_ngcontent-%COMP%]{background:#ecfdf5;color:#059669;border:1px solid #a7f3d0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .step-info[_ngcontent-%COMP%] .step-title-row[_ngcontent-%COMP%] .status-pill.pill-warn[_ngcontent-%COMP%]{background:#fffbeb;color:#b45309;border:1px solid #fde68a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .step-info[_ngcontent-%COMP%] .step-title-row[_ngcontent-%COMP%] .status-pill.pill-fail[_ngcontent-%COMP%]{background:#fef2f2;color:#b91c1c;border:1px solid #fecaca}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .step-info[_ngcontent-%COMP%] .step-desc[_ngcontent-%COMP%]{margin:0;font-size:12.5px;color:#475569;line-height:1.4}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .btn-restart-adb[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .btn-header-action[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:5px;padding:0 13px;height:32px;background:#ffffffb8;backdrop-filter:blur(10px) saturate(180%);-webkit-backdrop-filter:blur(10px) saturate(180%);border:1px solid rgba(226,232,240,.88);box-shadow:0 2px 8px -1px #0f172a0d,inset 0 1px 1px #fffffff2;color:#475569;transition:background .2s cubic-bezier(.4,0,.2,1),border-color .2s cubic-bezier(.4,0,.2,1),color .2s cubic-bezier(.4,0,.2,1),box-shadow .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1),opacity .2s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .btn-restart-adb[_ngcontent-%COMP%]:hover:not(:disabled), .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .btn-header-action[_ngcontent-%COMP%]:hover:not(:disabled){background:#ffffffeb;border-color:#bfdbfef2;color:#1a73e8;box-shadow:0 4px 12px -2px #0f172a14,inset 0 1px 1px #fff;transform:translateY(-.5px)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .btn-restart-adb[_ngcontent-%COMP%]:hover:not(:disabled) .material-symbols-outlined[_ngcontent-%COMP%]:not(.feedback-done), .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .btn-header-action[_ngcontent-%COMP%]:hover:not(:disabled) .material-symbols-outlined[_ngcontent-%COMP%]:not(.feedback-done){color:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .btn-restart-adb[_ngcontent-%COMP%]:active:not(:disabled), .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .btn-header-action[_ngcontent-%COMP%]:active:not(:disabled){transform:scale(.97)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .btn-restart-adb[_ngcontent-%COMP%]:disabled, .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .btn-header-action[_ngcontent-%COMP%]:disabled{opacity:.55;cursor:not-allowed;transform:none}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .btn-restart-adb[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .btn-header-action[_ngcontent-%COMP%]{border-radius:8px;font-family:inherit;font-size:12px;font-weight:600;cursor:pointer;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .btn-restart-adb[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .btn-header-action[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:15px;color:#64748b;transition:transform .2s ease,color .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .btn-restart-adb[_ngcontent-%COMP%] .feedback-done[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .btn-header-action[_ngcontent-%COMP%] .feedback-done[_ngcontent-%COMP%]{color:#059669!important;font-size:16px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .btn-restart-adb[_ngcontent-%COMP%] .feedback-text[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .btn-header-action[_ngcontent-%COMP%] .feedback-text[_ngcontent-%COMP%]{color:#059669;font-weight:600}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .btn-restart-adb[_ngcontent-%COMP%] .spinning[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-header[_ngcontent-%COMP%] .btn-header-action[_ngcontent-%COMP%] .spinning[_ngcontent-%COMP%]{animation:_ngcontent-%COMP%_spin 1s linear infinite}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .guide-card[_ngcontent-%COMP%] .guide-card-body[_ngcontent-%COMP%]{padding:18px 20px;display:flex;flex-direction:column;gap:14px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .one-click-install-bar[_ngcontent-%COMP%]{background:#f0f9ff;border:1px solid #bae6fd;border-radius:10px;padding:12px 16px;display:flex;align-items:center;justify-content:space-between;gap:14px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .one-click-install-bar[_ngcontent-%COMP%] .install-bar-left[_ngcontent-%COMP%]{display:flex;align-items:center;gap:12px;min-width:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .one-click-install-bar[_ngcontent-%COMP%] .install-bar-left[_ngcontent-%COMP%] .install-bar-icon-box[_ngcontent-%COMP%]{width:32px;height:32px;border-radius:8px;background:#0284c7;color:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .one-click-install-bar[_ngcontent-%COMP%] .install-bar-left[_ngcontent-%COMP%] .install-bar-icon-box[_ngcontent-%COMP%] .bolt-icon[_ngcontent-%COMP%]{font-size:18px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .one-click-install-bar[_ngcontent-%COMP%] .install-bar-left[_ngcontent-%COMP%] .install-bar-text[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:2px;min-width:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .one-click-install-bar[_ngcontent-%COMP%] .install-bar-left[_ngcontent-%COMP%] .install-bar-text[_ngcontent-%COMP%] .install-bar-title-row[_ngcontent-%COMP%]{display:flex;align-items:center;flex-wrap:wrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .one-click-install-bar[_ngcontent-%COMP%] .install-bar-left[_ngcontent-%COMP%] .install-bar-text[_ngcontent-%COMP%] .install-bar-title-row[_ngcontent-%COMP%] .install-bar-title[_ngcontent-%COMP%]{font-size:13px;font-weight:600;color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .one-click-install-bar[_ngcontent-%COMP%] .install-bar-left[_ngcontent-%COMP%] .install-bar-text[_ngcontent-%COMP%] .install-bar-title-row[_ngcontent-%COMP%] .os-selector-pills[_ngcontent-%COMP%]{display:inline-flex;align-items:center;background:#ffffffa6;backdrop-filter:blur(12px) saturate(180%);-webkit-backdrop-filter:blur(12px) saturate(180%);border:1px solid rgba(226,232,240,.85);border-radius:8px;padding:2px;gap:2px;box-shadow:0 1px 3px #0f172a0a,inset 0 1px 1px #fffc}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .one-click-install-bar[_ngcontent-%COMP%] .install-bar-left[_ngcontent-%COMP%] .install-bar-text[_ngcontent-%COMP%] .install-bar-title-row[_ngcontent-%COMP%] .os-selector-pills[_ngcontent-%COMP%] .os-pill[_ngcontent-%COMP%]{border:none;background:transparent;font-size:11px;font-weight:600;color:#475569;padding:3px 9px;border-radius:6px;cursor:pointer;transition:all .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .one-click-install-bar[_ngcontent-%COMP%] .install-bar-left[_ngcontent-%COMP%] .install-bar-text[_ngcontent-%COMP%] .install-bar-title-row[_ngcontent-%COMP%] .os-selector-pills[_ngcontent-%COMP%] .os-pill[_ngcontent-%COMP%]:hover{color:#0f172a;background:#ffffff80}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .one-click-install-bar[_ngcontent-%COMP%] .install-bar-left[_ngcontent-%COMP%] .install-bar-text[_ngcontent-%COMP%] .install-bar-title-row[_ngcontent-%COMP%] .os-selector-pills[_ngcontent-%COMP%] .os-pill.active[_ngcontent-%COMP%]{background:#1a73e8eb;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);color:#fff!important;box-shadow:0 2px 6px #1a73e84d,inset 0 1px 1px #ffffff59}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .one-click-install-bar[_ngcontent-%COMP%] .install-bar-left[_ngcontent-%COMP%] .install-bar-text[_ngcontent-%COMP%] .install-bar-desc[_ngcontent-%COMP%]{font-size:12px;color:#475569;line-height:1.4;margin-top:2px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .one-click-install-bar[_ngcontent-%COMP%] .code-box[_ngcontent-%COMP%]{margin:0;flex-shrink:0}@media(max-width:860px){.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .one-click-install-bar[_ngcontent-%COMP%]{flex-direction:column;align-items:stretch}}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .env-matrix-grid[_ngcontent-%COMP%]{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}@media(max-width:640px){.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .env-matrix-grid[_ngcontent-%COMP%]{grid-template-columns:1fr}}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .env-matrix-grid[_ngcontent-%COMP%] .env-item-card[_ngcontent-%COMP%]{background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:12px 14px;display:flex;flex-direction:column;justify-content:center;gap:8px;transition:border-color .18s ease,box-shadow .18s ease;min-width:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .env-matrix-grid[_ngcontent-%COMP%] .env-item-card[_ngcontent-%COMP%]:hover{border-color:#cbd5e1}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .env-matrix-grid[_ngcontent-%COMP%] .env-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%]{display:flex;align-items:center;gap:10px;min-width:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .env-matrix-grid[_ngcontent-%COMP%] .env-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-icon-box[_ngcontent-%COMP%]{width:32px;height:32px;border-radius:8px;background:#f8fafc;border:1px solid #e2e8f0;color:#475569;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .env-matrix-grid[_ngcontent-%COMP%] .env-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-icon-box[_ngcontent-%COMP%] .item-icon[_ngcontent-%COMP%]{font-size:17px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .env-matrix-grid[_ngcontent-%COMP%] .env-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-title-group[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:1px;flex:1;min-width:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .env-matrix-grid[_ngcontent-%COMP%] .env-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-title-group[_ngcontent-%COMP%] .item-name[_ngcontent-%COMP%]{font-size:13px;font-weight:600;color:#0f172a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .env-matrix-grid[_ngcontent-%COMP%] .env-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-title-group[_ngcontent-%COMP%] .item-subtitle[_ngcontent-%COMP%]{font-size:11.5px;color:#64748b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .env-matrix-grid[_ngcontent-%COMP%] .env-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-status[_ngcontent-%COMP%]{display:flex;align-items:center;margin-left:auto;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .env-matrix-grid[_ngcontent-%COMP%] .env-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-status[_ngcontent-%COMP%] .status-dot[_ngcontent-%COMP%]{width:7px;height:7px;border-radius:50%}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .env-matrix-grid[_ngcontent-%COMP%] .env-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-status[_ngcontent-%COMP%] .status-dot.dot-pass[_ngcontent-%COMP%]{background:#10b981}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .env-matrix-grid[_ngcontent-%COMP%] .env-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-status[_ngcontent-%COMP%] .status-dot.dot-fail[_ngcontent-%COMP%]{background:#ef4444}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .env-matrix-grid[_ngcontent-%COMP%] .env-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-status[_ngcontent-%COMP%] .status-dot.dot-warn[_ngcontent-%COMP%]{background:#f59e0b}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .env-matrix-grid[_ngcontent-%COMP%] .env-item-card[_ngcontent-%COMP%] .item-body[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .env-matrix-grid[_ngcontent-%COMP%] .env-item-card[_ngcontent-%COMP%] .item-body[_ngcontent-%COMP%] .item-action-box[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:4px;min-width:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .env-matrix-grid[_ngcontent-%COMP%] .env-item-card.item-fail[_ngcontent-%COMP%]{background:#fff8f8;border-color:#fecaca}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .env-matrix-grid[_ngcontent-%COMP%] .env-item-card.item-fail[_ngcontent-%COMP%] .item-icon-box[_ngcontent-%COMP%]{background:#fef2f2;color:#dc2626;border-color:#fecaca}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .model-choice-cards-row[_ngcontent-%COMP%]{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .model-choice-cards-row[_ngcontent-%COMP%] .choice-card[_ngcontent-%COMP%]{border:1px solid #e2e8f0;border-radius:10px;padding:14px 16px;background:#f8fafc;cursor:pointer;transition:all .18s ease;-webkit-user-select:none;user-select:none}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .model-choice-cards-row[_ngcontent-%COMP%] .choice-card[_ngcontent-%COMP%] .choice-card-top[_ngcontent-%COMP%]{display:flex;align-items:flex-start;gap:12px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .model-choice-cards-row[_ngcontent-%COMP%] .choice-card[_ngcontent-%COMP%] .choice-card-top[_ngcontent-%COMP%] .choice-radio[_ngcontent-%COMP%]{margin-top:2px;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .model-choice-cards-row[_ngcontent-%COMP%] .choice-card[_ngcontent-%COMP%] .choice-card-top[_ngcontent-%COMP%] .choice-radio[_ngcontent-%COMP%] .radio-dot[_ngcontent-%COMP%]{display:block;width:16px;height:16px;border-radius:50%;border:1.5px solid #cbd5e1;background:#fff;transition:all .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .model-choice-cards-row[_ngcontent-%COMP%] .choice-card[_ngcontent-%COMP%] .choice-card-top[_ngcontent-%COMP%] .choice-radio[_ngcontent-%COMP%] .radio-dot.checked[_ngcontent-%COMP%]{border-color:#1a73e8;background:#1a73e8;box-shadow:inset 0 0 0 3px #fff}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .model-choice-cards-row[_ngcontent-%COMP%] .choice-card[_ngcontent-%COMP%] .choice-card-top[_ngcontent-%COMP%] .choice-icon-wrap[_ngcontent-%COMP%]{width:34px;height:34px;border-radius:8px;background:#fff;border:1px solid #e2e8f0;display:flex;align-items:center;justify-content:center;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .model-choice-cards-row[_ngcontent-%COMP%] .choice-card[_ngcontent-%COMP%] .choice-card-top[_ngcontent-%COMP%] .choice-icon-wrap[_ngcontent-%COMP%] .brand-svg[_ngcontent-%COMP%]{width:18px;height:18px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .model-choice-cards-row[_ngcontent-%COMP%] .choice-card[_ngcontent-%COMP%] .choice-card-top[_ngcontent-%COMP%] .choice-icon-wrap.icon-gear[_ngcontent-%COMP%]{color:#475569}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .model-choice-cards-row[_ngcontent-%COMP%] .choice-card[_ngcontent-%COMP%] .choice-card-top[_ngcontent-%COMP%] .choice-icon-wrap.icon-gear[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:18px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .model-choice-cards-row[_ngcontent-%COMP%] .choice-card[_ngcontent-%COMP%] .choice-card-top[_ngcontent-%COMP%] .choice-text[_ngcontent-%COMP%]{flex:1;min-width:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .model-choice-cards-row[_ngcontent-%COMP%] .choice-card[_ngcontent-%COMP%] .choice-card-top[_ngcontent-%COMP%] .choice-text[_ngcontent-%COMP%] .choice-title-row[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .model-choice-cards-row[_ngcontent-%COMP%] .choice-card[_ngcontent-%COMP%] .choice-card-top[_ngcontent-%COMP%] .choice-text[_ngcontent-%COMP%] .choice-title-row[_ngcontent-%COMP%] .choice-title[_ngcontent-%COMP%]{font-size:13.5px;font-weight:600;color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .model-choice-cards-row[_ngcontent-%COMP%] .choice-card[_ngcontent-%COMP%] .choice-card-top[_ngcontent-%COMP%] .choice-text[_ngcontent-%COMP%] .choice-title-row[_ngcontent-%COMP%] .tag-badge[_ngcontent-%COMP%]{font-size:10.5px;font-weight:600;padding:1px 7px;border-radius:10px;line-height:1.3}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .model-choice-cards-row[_ngcontent-%COMP%] .choice-card[_ngcontent-%COMP%] .choice-card-top[_ngcontent-%COMP%] .choice-text[_ngcontent-%COMP%] .choice-title-row[_ngcontent-%COMP%] .tag-badge.tag-rec[_ngcontent-%COMP%]{background:#eff6ff;color:#1a73e8;border:1px solid #bfdbfe}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .model-choice-cards-row[_ngcontent-%COMP%] .choice-card[_ngcontent-%COMP%] .choice-card-top[_ngcontent-%COMP%] .choice-text[_ngcontent-%COMP%] .choice-title-row[_ngcontent-%COMP%] .tag-badge.tag-ready[_ngcontent-%COMP%]{background:#ecfdf5;color:#059669;border:1px solid #a7f3d0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .model-choice-cards-row[_ngcontent-%COMP%] .choice-card[_ngcontent-%COMP%] .choice-card-top[_ngcontent-%COMP%] .choice-text[_ngcontent-%COMP%] .choice-desc[_ngcontent-%COMP%]{margin:3px 0 0;font-size:12px;color:#475569;line-height:1.4}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .model-choice-cards-row[_ngcontent-%COMP%] .choice-card[_ngcontent-%COMP%] .choice-card-top[_ngcontent-%COMP%] .choice-text[_ngcontent-%COMP%] .choice-desc[_ngcontent-%COMP%] code[_ngcontent-%COMP%]{font-family:JetBrains Mono,Fira Code,Roboto Mono,SFMono-Regular,Consolas,monospace;background:#fff;border:1px solid #e2e8f0;padding:1px 4px;border-radius:4px;color:#1557b0;font-size:11px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .model-choice-cards-row[_ngcontent-%COMP%] .choice-card[_ngcontent-%COMP%]:hover:not(.selected){border-color:#cbd5e1;background:#fff}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .model-choice-cards-row[_ngcontent-%COMP%] .choice-card.selected[_ngcontent-%COMP%]{border-color:#1a73e8;background:#fff;box-shadow:0 0 0 1px #1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .model-choice-cards-row[_ngcontent-%COMP%] .choice-card.selected[_ngcontent-%COMP%] .choice-icon-wrap[_ngcontent-%COMP%]{border-color:#bfdbfe}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .model-choice-cards-row[_ngcontent-%COMP%] .choice-card.selected[_ngcontent-%COMP%] .choice-icon-wrap.icon-gear[_ngcontent-%COMP%]{color:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%]{background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;padding:14px 16px;display:flex;flex-direction:column;gap:10px;animation:_ngcontent-%COMP%_fadeIn .15s ease-out}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box.ocr-box[_ngcontent-%COMP%]{background:#fff;border:1px solid #e2e8f0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .input-box-header[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .input-box-header[_ngcontent-%COMP%] .header-label[_ngcontent-%COMP%]{display:flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600;color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .input-box-header[_ngcontent-%COMP%] .header-label[_ngcontent-%COMP%] .label-icon[_ngcontent-%COMP%]{font-size:16px;color:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .input-box-header[_ngcontent-%COMP%] .btn-header-link[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:4px;font-size:11.5px;font-weight:600;color:#1a73e8;text-decoration:none;padding:3px 10px;height:26px;background:#eff6ffbf;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid rgba(191,219,254,.85);border-radius:6px;box-shadow:0 1px 3px #2563eb0f,inset 0 1px 1px #fffc;transition:all .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .input-box-header[_ngcontent-%COMP%] .btn-header-link[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:13px;color:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .input-box-header[_ngcontent-%COMP%] .btn-header-link[_ngcontent-%COMP%]:hover{background:#dbeafee6;color:#1557b0;border-color:#1a73e8;box-shadow:0 2px 6px #2563eb26;transform:translateY(-.5px)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .unified-key-wrapper[_ngcontent-%COMP%]{flex:1 1 260px;min-width:0;display:flex;align-items:center;background:#ffffffbf;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid #e2e8f0;border-radius:8px;padding:0 10px;height:36px;transition:border-color .15s ease,box-shadow .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .unified-key-wrapper[_ngcontent-%COMP%]:focus-within{border-color:#1a73e8;box-shadow:0 0 0 2px #1a73e81f}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .unified-key-wrapper[_ngcontent-%COMP%] .key-prefix-icon[_ngcontent-%COMP%]{font-size:16px;color:#94a3b8;margin-right:6px;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .unified-key-wrapper[_ngcontent-%COMP%] .unified-key-input[_ngcontent-%COMP%]{flex:1;min-width:0;border:none;outline:none;font-family:JetBrains Mono,Fira Code,Roboto Mono,SFMono-Regular,Consolas,monospace;font-size:12.5px;color:#0f172a;background:transparent;padding:4px 0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .unified-key-wrapper[_ngcontent-%COMP%] .unified-key-input.is-masked[_ngcontent-%COMP%]{letter-spacing:2px;font-weight:700;font-size:13px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .unified-key-wrapper[_ngcontent-%COMP%] .unified-key-input[_ngcontent-%COMP%]::placeholder{color:#94a3b8;font-family:Google Sans Text,Google Sans,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:12px;letter-spacing:normal;font-weight:400}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .unified-key-wrapper[_ngcontent-%COMP%] .btn-icon-action[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:center;width:26px;height:26px;background:transparent;border:none;border-radius:4px;color:#64748b;cursor:pointer;transition:all .15s ease;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .unified-key-wrapper[_ngcontent-%COMP%] .btn-icon-action[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:15px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .unified-key-wrapper[_ngcontent-%COMP%] .btn-icon-action[_ngcontent-%COMP%] .text-emerald[_ngcontent-%COMP%]{color:#059669!important}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .unified-key-wrapper[_ngcontent-%COMP%] .btn-icon-action[_ngcontent-%COMP%]:hover{background:#f1f5f9cc;color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .btn-unified-save[_ngcontent-%COMP%]{display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:0 18px;height:36px;background:linear-gradient(135deg,#1a73e8eb,#2563ebd9);backdrop-filter:blur(10px) saturate(180%);-webkit-backdrop-filter:blur(10px) saturate(180%);border:1px solid rgba(255,255,255,.28);box-shadow:0 4px 14px #1a73e852,inset 0 1px 1px #ffffff73;color:#fff!important;transition:background .2s cubic-bezier(.4,0,.2,1),border-color .2s cubic-bezier(.4,0,.2,1),box-shadow .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1),opacity .2s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .btn-unified-save[_ngcontent-%COMP%] span[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .btn-unified-save[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .btn-unified-save[_ngcontent-%COMP%] .run-icon[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .btn-unified-save[_ngcontent-%COMP%] .action-icon[_ngcontent-%COMP%]{color:#fff!important}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .btn-unified-save[_ngcontent-%COMP%]:hover:not(:disabled){background:linear-gradient(135deg,#1a73e8,#2563ebf2);box-shadow:0 6px 20px #1a73e86b,inset 0 1px 1px #fff9;transform:translateY(-1px)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .btn-unified-save[_ngcontent-%COMP%]:active:not(:disabled){transform:scale(.97) translateY(0);box-shadow:0 2px 8px #1a73e859}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .btn-unified-save[_ngcontent-%COMP%]:disabled{background:#94a3b880;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.2);box-shadow:none;opacity:.65;cursor:not-allowed;transform:none}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .btn-unified-save[_ngcontent-%COMP%]{border-radius:8px;font-family:inherit;font-size:12.5px;font-weight:600;cursor:pointer;white-space:nowrap;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .btn-unified-save[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:16px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .btn-unified-save[_ngcontent-%COMP%] .spin[_ngcontent-%COMP%]{animation:_ngcontent-%COMP%_spin 1s linear infinite}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .btn-unified-test[_ngcontent-%COMP%]{display:inline-flex;align-items:center;justify-content:center;gap:4px;padding:0 15px;height:36px;background:#eff6ffb3;backdrop-filter:blur(14px) saturate(180%);-webkit-backdrop-filter:blur(14px) saturate(180%);color:#1a73e8;border:1px solid rgba(191,219,254,.85);border-radius:8px;box-shadow:0 2px 6px -1px #2563eb14,inset 0 1px 1px #ffffffe6;font-family:inherit;font-size:12.5px;font-weight:600;cursor:pointer;transition:all .2s cubic-bezier(.4,0,.2,1);white-space:nowrap;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .btn-unified-test[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:15px;color:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .btn-unified-test[_ngcontent-%COMP%]:hover:not(:disabled){background:#dbeafee6;border-color:#1a73e8;color:#1557b0;box-shadow:0 4px 12px -2px #2563eb26,inset 0 1px 1px #fff;transform:translateY(-.5px)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .btn-unified-test[_ngcontent-%COMP%]:active:not(:disabled){transform:scale(.97)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .btn-unified-test[_ngcontent-%COMP%]:disabled{opacity:.55;cursor:not-allowed;transform:none}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .unified-key-row[_ngcontent-%COMP%] .btn-unified-test[_ngcontent-%COMP%] .spin[_ngcontent-%COMP%]{animation:_ngcontent-%COMP%_spin 1s linear infinite}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .feedback-msg[_ngcontent-%COMP%]{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:6px;font-size:12px;font-weight:600}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .feedback-msg[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:15px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .feedback-msg.success[_ngcontent-%COMP%]{background:#ecfdf5;color:#047857;border:1px solid #a7f3d0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .credentials-input-box[_ngcontent-%COMP%] .feedback-msg.error[_ngcontent-%COMP%]{background:#fef2f2;color:#b91c1c;border:1px solid #fecaca}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ocr-enhancement-card[_ngcontent-%COMP%]{background:#fff;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden;margin-top:2px;transition:border-color .18s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ocr-enhancement-card[_ngcontent-%COMP%] .ocr-card-toggle-bar[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:#fff;cursor:pointer;-webkit-user-select:none;user-select:none;transition:background .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ocr-enhancement-card[_ngcontent-%COMP%] .ocr-card-toggle-bar[_ngcontent-%COMP%]:hover{background:#f8fafc}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ocr-enhancement-card[_ngcontent-%COMP%] .ocr-card-toggle-bar[_ngcontent-%COMP%] .toggle-bar-left[_ngcontent-%COMP%]{display:flex;align-items:center;gap:10px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ocr-enhancement-card[_ngcontent-%COMP%] .ocr-card-toggle-bar[_ngcontent-%COMP%] .toggle-bar-left[_ngcontent-%COMP%] .icon-ocr[_ngcontent-%COMP%]{font-size:18px;color:#1a73e8;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ocr-enhancement-card[_ngcontent-%COMP%] .ocr-card-toggle-bar[_ngcontent-%COMP%] .toggle-bar-left[_ngcontent-%COMP%] .toggle-bar-titles[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ocr-enhancement-card[_ngcontent-%COMP%] .ocr-card-toggle-bar[_ngcontent-%COMP%] .toggle-bar-left[_ngcontent-%COMP%] .toggle-bar-titles[_ngcontent-%COMP%] .ocr-main-title[_ngcontent-%COMP%]{font-size:13px;font-weight:600;color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ocr-enhancement-card[_ngcontent-%COMP%] .ocr-card-toggle-bar[_ngcontent-%COMP%] .toggle-bar-right[_ngcontent-%COMP%]{display:flex;align-items:center;gap:10px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ocr-enhancement-card[_ngcontent-%COMP%] .ocr-card-toggle-bar[_ngcontent-%COMP%] .toggle-bar-right[_ngcontent-%COMP%] .status-dot[_ngcontent-%COMP%]{width:7px;height:7px;border-radius:50%}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ocr-enhancement-card[_ngcontent-%COMP%] .ocr-card-toggle-bar[_ngcontent-%COMP%] .toggle-bar-right[_ngcontent-%COMP%] .status-dot.dot-pass[_ngcontent-%COMP%]{background:#10b981}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ocr-enhancement-card[_ngcontent-%COMP%] .ocr-card-toggle-bar[_ngcontent-%COMP%] .toggle-bar-right[_ngcontent-%COMP%] .caret-icon[_ngcontent-%COMP%]{font-size:18px;color:#64748b;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ocr-enhancement-card[_ngcontent-%COMP%] .ocr-content-body[_ngcontent-%COMP%]{padding:12px 14px;border-top:1px solid #f1f5f9;display:flex;flex-direction:column;gap:10px;background:#f8fafc;animation:_ngcontent-%COMP%_fadeIn .15s ease-out}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ocr-enhancement-card[_ngcontent-%COMP%] .ocr-content-body[_ngcontent-%COMP%] .ocr-help-desc[_ngcontent-%COMP%]{margin:0;font-size:12px;color:#475569;line-height:1.45}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ocr-enhancement-card[_ngcontent-%COMP%] .ocr-content-body[_ngcontent-%COMP%] .credentials-input-box.ocr-box[_ngcontent-%COMP%]{margin:0;padding:12px;background:#fff;border:1px solid #e2e8f0;border-radius:8px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:12px;animation:_ngcontent-%COMP%_fadeIn .15s ease-out}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%]{background:#fff;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-card-header[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;padding:10px 14px;background:#fff;border-bottom:1px solid #f1f5f9}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-card-header[_ngcontent-%COMP%] .inspector-header-left[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-card-header[_ngcontent-%COMP%] .inspector-header-left[_ngcontent-%COMP%] .header-icon[_ngcontent-%COMP%]{font-size:18px;color:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-card-header[_ngcontent-%COMP%] .inspector-header-left[_ngcontent-%COMP%] .inspector-header-titles[_ngcontent-%COMP%]{display:flex;flex-direction:column}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-card-header[_ngcontent-%COMP%] .inspector-header-left[_ngcontent-%COMP%] .inspector-header-titles[_ngcontent-%COMP%] .inspector-title[_ngcontent-%COMP%]{font-size:13px;font-weight:600;color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-card-header[_ngcontent-%COMP%] .inspector-header-left[_ngcontent-%COMP%] .inspector-header-titles[_ngcontent-%COMP%] .inspector-subtitle[_ngcontent-%COMP%]{font-size:11.5px;color:#475569}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-card-header[_ngcontent-%COMP%] .inspector-header-left[_ngcontent-%COMP%] .inspector-header-titles[_ngcontent-%COMP%] .inspector-subtitle[_ngcontent-%COMP%] code[_ngcontent-%COMP%]{font-family:JetBrains Mono,Fira Code,Roboto Mono,SFMono-Regular,Consolas,monospace;background:#f1f5f9;padding:1px 4px;border-radius:4px;color:#1557b0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-card-header[_ngcontent-%COMP%] .inspector-header-actions[_ngcontent-%COMP%]{display:flex;align-items:center;gap:6px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-card-header[_ngcontent-%COMP%] .btn-file-action[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:4px;padding:0 10px;height:28px;background:#ffffffb8;backdrop-filter:blur(10px) saturate(180%);-webkit-backdrop-filter:blur(10px) saturate(180%);border:1px solid rgba(226,232,240,.88);box-shadow:0 2px 8px -1px #0f172a0d,inset 0 1px 1px #fffffff2;color:#475569;transition:background .2s cubic-bezier(.4,0,.2,1),border-color .2s cubic-bezier(.4,0,.2,1),color .2s cubic-bezier(.4,0,.2,1),box-shadow .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1),opacity .2s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-card-header[_ngcontent-%COMP%] .btn-file-action[_ngcontent-%COMP%]:hover:not(:disabled){background:#ffffffeb;border-color:#bfdbfef2;color:#1a73e8;box-shadow:0 4px 12px -2px #0f172a14,inset 0 1px 1px #fff;transform:translateY(-.5px)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-card-header[_ngcontent-%COMP%] .btn-file-action[_ngcontent-%COMP%]:hover:not(:disabled) .material-symbols-outlined[_ngcontent-%COMP%]:not(.feedback-done){color:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-card-header[_ngcontent-%COMP%] .btn-file-action[_ngcontent-%COMP%]:active:not(:disabled){transform:scale(.97)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-card-header[_ngcontent-%COMP%] .btn-file-action[_ngcontent-%COMP%]:disabled{opacity:.55;cursor:not-allowed;transform:none}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-card-header[_ngcontent-%COMP%] .btn-file-action[_ngcontent-%COMP%]{border-radius:6px;font-family:inherit;font-size:11.5px;font-weight:600;cursor:pointer}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-card-header[_ngcontent-%COMP%] .btn-file-action[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:13px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-card-header[_ngcontent-%COMP%] .btn-file-action.btn-toggle-code[_ngcontent-%COMP%]{background:#eff6ffbf;color:#1a73e8;border-color:#bfdbfed9}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-card-header[_ngcontent-%COMP%] .btn-file-action.btn-toggle-code[_ngcontent-%COMP%]:hover{background:#dbeafee6;color:#1557b0;border-color:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-summary-row[_ngcontent-%COMP%]{display:flex;flex-wrap:wrap;gap:12px;padding:12px 14px;background:#fff}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-summary-row[_ngcontent-%COMP%] .summary-metric[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:4px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-summary-row[_ngcontent-%COMP%] .summary-metric[_ngcontent-%COMP%] .metric-label[_ngcontent-%COMP%]{font-size:10.5px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.5px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-summary-row[_ngcontent-%COMP%] .summary-metric[_ngcontent-%COMP%] .metric-val-pill[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;font-family:JetBrains Mono,Fira Code,Roboto Mono,SFMono-Regular,Consolas,monospace;font-size:12px;color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-summary-row[_ngcontent-%COMP%] .summary-metric[_ngcontent-%COMP%] .metric-val-pill[_ngcontent-%COMP%] .provider-dot[_ngcontent-%COMP%]{width:7px;height:7px;border-radius:50%;background:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-summary-row[_ngcontent-%COMP%] .summary-metric[_ngcontent-%COMP%] .metric-val-pill[_ngcontent-%COMP%] .provider-name[_ngcontent-%COMP%]{font-weight:700;color:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-summary-row[_ngcontent-%COMP%] .summary-metric[_ngcontent-%COMP%] .metric-val-pill[_ngcontent-%COMP%] .model-divider[_ngcontent-%COMP%]{color:#94a3b8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-summary-row[_ngcontent-%COMP%] .summary-metric[_ngcontent-%COMP%] .metric-val-pill[_ngcontent-%COMP%] .model-name[_ngcontent-%COMP%]{font-weight:600;color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .inspector-summary-row[_ngcontent-%COMP%] .summary-metric[_ngcontent-%COMP%] .metric-val-pill.secondary[_ngcontent-%COMP%]{background:#f8fafc;color:#475569;font-size:11.5px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .jsonc-viewer-box[_ngcontent-%COMP%]{border-top:1px solid #e2e8f0;background:#0f172a;max-height:260px;overflow-y:auto;padding:12px 14px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .jsonc-viewer-box[_ngcontent-%COMP%] .jsonc-code[_ngcontent-%COMP%]{margin:0;font-family:JetBrains Mono,Fira Code,Roboto Mono,SFMono-Regular,Consolas,monospace;font-size:11.5px;line-height:1.5;color:#e2e8f0;white-space:pre-wrap;word-break:break-all}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .env-keys-table[_ngcontent-%COMP%]{display:flex;flex-direction:column}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .env-keys-table[_ngcontent-%COMP%] .env-key-row[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;padding:8px 14px;border-bottom:1px solid #f1f5f9;transition:background .12s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .env-keys-table[_ngcontent-%COMP%] .env-key-row[_ngcontent-%COMP%]:last-child{border-bottom:none}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .env-keys-table[_ngcontent-%COMP%] .env-key-row[_ngcontent-%COMP%]:hover{background:#f8fafc}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .env-keys-table[_ngcontent-%COMP%] .env-key-row[_ngcontent-%COMP%] .env-name-col[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .env-keys-table[_ngcontent-%COMP%] .env-key-row[_ngcontent-%COMP%] .env-name-col[_ngcontent-%COMP%] .env-key-name[_ngcontent-%COMP%]{font-family:JetBrains Mono,Fira Code,Roboto Mono,SFMono-Regular,Consolas,monospace;font-size:12px;font-weight:600;color:#0f172a;background:#f1f5f9;padding:2px 6px;border-radius:4px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .env-keys-table[_ngcontent-%COMP%] .env-key-row[_ngcontent-%COMP%] .env-status-col[_ngcontent-%COMP%]{display:flex;align-items:center}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .env-keys-table[_ngcontent-%COMP%] .env-key-row[_ngcontent-%COMP%] .env-status-col[_ngcontent-%COMP%] .badge-env-pass[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;color:#059669;background:#ecfdf5;border:1px solid #a7f3d0;padding:2px 7px;border-radius:6px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .env-keys-table[_ngcontent-%COMP%] .env-key-row[_ngcontent-%COMP%] .env-status-col[_ngcontent-%COMP%] .badge-env-pass[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:13px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .env-keys-table[_ngcontent-%COMP%] .env-key-row[_ngcontent-%COMP%] .env-status-col[_ngcontent-%COMP%] .badge-env-pass[_ngcontent-%COMP%] .key-preview[_ngcontent-%COMP%]{font-family:JetBrains Mono,Fira Code,Roboto Mono,SFMono-Regular,Consolas,monospace;font-size:10.5px;color:#047857;margin-left:2px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .env-keys-table[_ngcontent-%COMP%] .env-key-row[_ngcontent-%COMP%] .env-status-col[_ngcontent-%COMP%] .badge-env-unset[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600;color:#94a3b8;background:#f8fafc;border:1px solid #e2e8f0;padding:2px 7px;border-radius:6px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .custom-config-inspector[_ngcontent-%COMP%] .inspector-card[_ngcontent-%COMP%] .env-keys-table[_ngcontent-%COMP%] .env-key-row[_ngcontent-%COMP%] .env-status-col[_ngcontent-%COMP%] .badge-env-unset[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:13px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .connected-device-panel[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:10px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .connected-device-panel[_ngcontent-%COMP%] .device-hero-card[_ngcontent-%COMP%]{display:flex;align-items:center;gap:16px;padding:16px 20px;background:#fff;border:1px solid #e2e8f0;border-radius:12px;box-shadow:0 1px 3px #00000005,0 4px 12px #00000005;transition:all .2s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .connected-device-panel[_ngcontent-%COMP%] .device-hero-card[_ngcontent-%COMP%]:hover{border-color:#cbd5e1;box-shadow:0 2px 6px #0000000a,0 8px 18px #00000008}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .connected-device-panel[_ngcontent-%COMP%] .device-hero-card[_ngcontent-%COMP%] .device-avatar[_ngcontent-%COMP%]{width:44px;height:44px;border-radius:10px;background:#f1f5f9;border:1px solid #e2e8f0;color:#334155;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .2s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .connected-device-panel[_ngcontent-%COMP%] .device-hero-card[_ngcontent-%COMP%] .device-avatar[_ngcontent-%COMP%] .device-hero-icon[_ngcontent-%COMP%]{font-size:22px;color:#334155!important}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .connected-device-panel[_ngcontent-%COMP%] .device-hero-card[_ngcontent-%COMP%] .device-hero-details[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:4px;min-width:0;flex:1}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .connected-device-panel[_ngcontent-%COMP%] .device-hero-card[_ngcontent-%COMP%] .device-hero-details[_ngcontent-%COMP%] .device-hero-name[_ngcontent-%COMP%]{font-size:14.5px;font-weight:600;color:#0f172a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;letter-spacing:-.01em}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .connected-device-panel[_ngcontent-%COMP%] .device-hero-card[_ngcontent-%COMP%] .device-hero-details[_ngcontent-%COMP%] .device-hero-meta[_ngcontent-%COMP%]{display:flex;align-items:center;gap:6px;font-size:12px;color:#64748b;flex-wrap:wrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .connected-device-panel[_ngcontent-%COMP%] .device-hero-card[_ngcontent-%COMP%] .device-hero-details[_ngcontent-%COMP%] .device-hero-meta[_ngcontent-%COMP%] .meta-dot[_ngcontent-%COMP%]{color:#94a3b8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .connected-device-panel[_ngcontent-%COMP%] .device-hero-card[_ngcontent-%COMP%] .device-hero-details[_ngcontent-%COMP%] .device-hero-meta[_ngcontent-%COMP%] .remote-endpoint-label[_ngcontent-%COMP%]{color:#1a73e8;font-family:JetBrains Mono,Fira Code,Roboto Mono,SFMono-Regular,Consolas,monospace;font-size:11px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .connected-device-panel[_ngcontent-%COMP%] .device-hero-card[_ngcontent-%COMP%] .device-hero-status[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:500;color:#047857;margin-left:auto;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .connected-device-panel[_ngcontent-%COMP%] .device-hero-card[_ngcontent-%COMP%] .device-hero-status[_ngcontent-%COMP%] .status-dot[_ngcontent-%COMP%]{width:7px;height:7px;border-radius:50%;background:#10b981;box-shadow:0 0 #10b981b3;animation:_ngcontent-%COMP%_statusDotPulse 2s infinite cubic-bezier(.4,0,.6,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .connected-device-panel[_ngcontent-%COMP%] .multi-device-switcher[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:5px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .connected-device-panel[_ngcontent-%COMP%] .multi-device-switcher[_ngcontent-%COMP%] .switcher-label[_ngcontent-%COMP%]{font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.5px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .connected-device-panel[_ngcontent-%COMP%] .multi-device-switcher[_ngcontent-%COMP%] .switcher-chips[_ngcontent-%COMP%]{display:flex;flex-wrap:wrap;gap:6px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .connected-device-panel[_ngcontent-%COMP%] .multi-device-switcher[_ngcontent-%COMP%] .switcher-chips[_ngcontent-%COMP%] .device-chip-btn[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:5px;padding:0 10px;height:30px;background:#fff;border:1px solid #e2e8f0;border-radius:6px;font-family:inherit;font-size:11.5px;font-weight:600;color:#475569;cursor:pointer;transition:all .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .connected-device-panel[_ngcontent-%COMP%] .multi-device-switcher[_ngcontent-%COMP%] .switcher-chips[_ngcontent-%COMP%] .device-chip-btn[_ngcontent-%COMP%] .chip-icon[_ngcontent-%COMP%]{font-size:14px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .connected-device-panel[_ngcontent-%COMP%] .multi-device-switcher[_ngcontent-%COMP%] .switcher-chips[_ngcontent-%COMP%] .device-chip-btn[_ngcontent-%COMP%] .active-check[_ngcontent-%COMP%]{color:#059669;font-size:10.5px;font-weight:700}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .connected-device-panel[_ngcontent-%COMP%] .multi-device-switcher[_ngcontent-%COMP%] .switcher-chips[_ngcontent-%COMP%] .device-chip-btn.selected[_ngcontent-%COMP%]{background:#ecfdf5;border-color:#059669;color:#059669}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .connected-device-panel[_ngcontent-%COMP%] .multi-device-switcher[_ngcontent-%COMP%] .switcher-chips[_ngcontent-%COMP%] .device-chip-btn[_ngcontent-%COMP%]:hover:not(.selected){background:#f8fafc;border-color:#cbd5e1}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-container[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-container[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:12px;margin-bottom:8px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:16px;padding:20px;background:linear-gradient(180deg,#fff,#f8fafc);border:1.5px solid rgba(37,99,235,.25);border-radius:12px;box-shadow:0 4px 16px #2563eb0f}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%]{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%] .tracker-title-group[_ngcontent-%COMP%]{display:flex;align-items:flex-start;gap:12px;flex:1;min-width:260px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%] .tracker-title-group[_ngcontent-%COMP%] .tracker-icon-box[_ngcontent-%COMP%]{width:36px;height:36px;border-radius:10px;background:#2563eb1a;color:#1a73e8;display:flex;align-items:center;justify-content:center;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%] .tracker-title-group[_ngcontent-%COMP%] .tracker-icon-box[_ngcontent-%COMP%] .spinning-icon[_ngcontent-%COMP%]{font-size:20px;animation:_ngcontent-%COMP%_spin 1.2s linear infinite}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%] .tracker-title-group[_ngcontent-%COMP%] .tracker-titles[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:4px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%] .tracker-title-group[_ngcontent-%COMP%] .tracker-titles[_ngcontent-%COMP%] .tracker-main-heading[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%] .tracker-title-group[_ngcontent-%COMP%] .tracker-titles[_ngcontent-%COMP%] .tracker-main-heading[_ngcontent-%COMP%] .tracker-title[_ngcontent-%COMP%]{margin:0;font-size:15px;font-weight:700;color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%] .tracker-title-group[_ngcontent-%COMP%] .tracker-titles[_ngcontent-%COMP%] .tracker-main-heading[_ngcontent-%COMP%] .tracker-title[_ngcontent-%COMP%] .avd-highlight[_ngcontent-%COMP%]{color:#1a73e8;font-family:JetBrains Mono,monospace,sans-serif}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%] .tracker-title-group[_ngcontent-%COMP%] .tracker-titles[_ngcontent-%COMP%] .tracker-main-heading[_ngcontent-%COMP%] .stage-tag-badge[_ngcontent-%COMP%]{display:inline-flex;align-items:center;padding:2px 8px;border-radius:12px;background:#2563eb1f;color:#1a73e8;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.5px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%] .tracker-title-group[_ngcontent-%COMP%] .tracker-titles[_ngcontent-%COMP%] .tracker-subtitle[_ngcontent-%COMP%]{margin:0;font-size:13px;color:#475569;line-height:1.4}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%] .tracker-actions[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%] .tracker-actions[_ngcontent-%COMP%] .btn-toggle-logs[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:6px;padding:6px 13px;background:#ffffffb8;backdrop-filter:blur(10px) saturate(180%);-webkit-backdrop-filter:blur(10px) saturate(180%);border:1px solid rgba(226,232,240,.88);box-shadow:0 2px 8px -1px #0f172a0d,inset 0 1px 1px #fffffff2;color:#475569;transition:background .2s cubic-bezier(.4,0,.2,1),border-color .2s cubic-bezier(.4,0,.2,1),color .2s cubic-bezier(.4,0,.2,1),box-shadow .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1),opacity .2s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%] .tracker-actions[_ngcontent-%COMP%] .btn-toggle-logs[_ngcontent-%COMP%]:hover:not(:disabled){background:#ffffffeb;border-color:#bfdbfef2;color:#1a73e8;box-shadow:0 4px 12px -2px #0f172a14,inset 0 1px 1px #fff;transform:translateY(-.5px)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%] .tracker-actions[_ngcontent-%COMP%] .btn-toggle-logs[_ngcontent-%COMP%]:hover:not(:disabled) .material-symbols-outlined[_ngcontent-%COMP%]:not(.feedback-done){color:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%] .tracker-actions[_ngcontent-%COMP%] .btn-toggle-logs[_ngcontent-%COMP%]:active:not(:disabled){transform:scale(.97)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%] .tracker-actions[_ngcontent-%COMP%] .btn-toggle-logs[_ngcontent-%COMP%]:disabled{opacity:.55;cursor:not-allowed;transform:none}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%] .tracker-actions[_ngcontent-%COMP%] .btn-toggle-logs[_ngcontent-%COMP%]{border-radius:6px;font-size:12px;font-weight:600;cursor:pointer}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%] .tracker-actions[_ngcontent-%COMP%] .btn-toggle-logs[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:15px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%] .tracker-actions[_ngcontent-%COMP%] .btn-toggle-logs.active[_ngcontent-%COMP%]{background:#eff6ffe6;color:#1a73e8;border-color:#2563eb66;box-shadow:0 2px 6px #2563eb1f}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%] .tracker-actions[_ngcontent-%COMP%] .btn-stop-emu[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:5px;padding:6px 13px;background:#fef2f2bf;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid rgba(254,202,202,.85);border-radius:6px;color:#dc2626;font-size:12px;font-weight:600;cursor:pointer;box-shadow:0 1px 3px #dc26260f,inset 0 1px 1px #fffc;transition:all .2s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%] .tracker-actions[_ngcontent-%COMP%] .btn-stop-emu[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:15px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%] .tracker-actions[_ngcontent-%COMP%] .btn-stop-emu[_ngcontent-%COMP%]:hover{background:#fee2e2f2;border-color:#dc2626;box-shadow:0 4px 12px -2px #dc262626;transform:translateY(-.5px)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .tracker-header[_ngcontent-%COMP%] .tracker-actions[_ngcontent-%COMP%] .btn-stop-emu[_ngcontent-%COMP%]:active{transform:scale(.97)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-stepper[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:#fff;border:1px solid #e2e8f0;border-radius:8px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-stepper[_ngcontent-%COMP%] .step-item[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:#64748b}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-stepper[_ngcontent-%COMP%] .step-item[_ngcontent-%COMP%] .step-circle[_ngcontent-%COMP%]{width:22px;height:22px;border-radius:50%;background:#f1f5f9;border:1.5px solid #e2e8f0;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#64748b;transition:all .2s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-stepper[_ngcontent-%COMP%] .step-item[_ngcontent-%COMP%] .step-circle[_ngcontent-%COMP%] .check-icon[_ngcontent-%COMP%]{font-size:14px;color:#fff}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-stepper[_ngcontent-%COMP%] .step-item[_ngcontent-%COMP%] .step-circle[_ngcontent-%COMP%] .spinner-tiny[_ngcontent-%COMP%]{width:11px;height:11px;border:2px solid rgba(37,99,235,.3);border-top-color:#1a73e8;border-radius:50%;animation:_ngcontent-%COMP%_spin .8s linear infinite}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-stepper[_ngcontent-%COMP%] .step-item[_ngcontent-%COMP%] .step-label[_ngcontent-%COMP%]{white-space:nowrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-stepper[_ngcontent-%COMP%] .step-item.active[_ngcontent-%COMP%]{color:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-stepper[_ngcontent-%COMP%] .step-item.active[_ngcontent-%COMP%] .step-circle[_ngcontent-%COMP%]{border-color:#1a73e8;background:#2563eb1a;color:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-stepper[_ngcontent-%COMP%] .step-item.done[_ngcontent-%COMP%]{color:#059669}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-stepper[_ngcontent-%COMP%] .step-item.done[_ngcontent-%COMP%] .step-circle[_ngcontent-%COMP%]{border-color:#059669;background:#059669;color:#fff}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-stepper[_ngcontent-%COMP%] .step-line[_ngcontent-%COMP%]{flex:1;height:2px;background:#e2e8f0;margin:0 10px;border-radius:2px;transition:all .3s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-stepper[_ngcontent-%COMP%] .step-line.done[_ngcontent-%COMP%]{background:#059669}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .progress-bar-container[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:6px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .progress-bar-container[_ngcontent-%COMP%] .progress-bar-track[_ngcontent-%COMP%]{width:100%;height:8px;background:#e2e8f0;border-radius:6px;overflow:hidden}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .progress-bar-container[_ngcontent-%COMP%] .progress-bar-track[_ngcontent-%COMP%] .progress-bar-fill[_ngcontent-%COMP%]{height:100%;background:linear-gradient(90deg,#3b82f6,#10b981);border-radius:6px;transition:width .4s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .progress-bar-container[_ngcontent-%COMP%] .progress-bar-meta[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;font-size:12px;font-weight:600}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .progress-bar-container[_ngcontent-%COMP%] .progress-bar-meta[_ngcontent-%COMP%] .progress-percent[_ngcontent-%COMP%]{color:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .progress-bar-container[_ngcontent-%COMP%] .progress-bar-meta[_ngcontent-%COMP%] .progress-timer[_ngcontent-%COMP%]{display:flex;align-items:center;gap:4px;color:#475569}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .progress-bar-container[_ngcontent-%COMP%] .progress-bar-meta[_ngcontent-%COMP%] .progress-timer[_ngcontent-%COMP%] .timer-icon[_ngcontent-%COMP%]{font-size:14px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-tip-banner[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px;padding:8px 12px;background:#2563eb0d;border-left:3px solid #1a73e8;border-radius:0 6px 6px 0;font-size:12px;color:#475569}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-tip-banner[_ngcontent-%COMP%] .tip-icon[_ngcontent-%COMP%]{font-size:16px;color:#1a73e8;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-logs-drawer[_ngcontent-%COMP%]{display:flex;flex-direction:column;border:1px solid #1e293b;border-radius:8px;background:#0f172a;overflow:hidden}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-logs-drawer[_ngcontent-%COMP%] .logs-header[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:#1e293b;color:#94a3b8;font-size:12px;font-weight:600}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-logs-drawer[_ngcontent-%COMP%] .logs-header[_ngcontent-%COMP%] .logs-header-left[_ngcontent-%COMP%]{display:flex;align-items:center;gap:6px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-logs-drawer[_ngcontent-%COMP%] .logs-header[_ngcontent-%COMP%] .logs-header-left[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:14px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-logs-drawer[_ngcontent-%COMP%] .logs-header[_ngcontent-%COMP%] .btn-copy-logs[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;background:#334155;border:none;border-radius:4px;color:#f8fafc;font-size:11px;font-weight:600;cursor:pointer}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-logs-drawer[_ngcontent-%COMP%] .logs-header[_ngcontent-%COMP%] .btn-copy-logs[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:12px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-logs-drawer[_ngcontent-%COMP%] .logs-header[_ngcontent-%COMP%] .btn-copy-logs[_ngcontent-%COMP%]:hover{background:#475569}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-logs-drawer[_ngcontent-%COMP%] .logs-body[_ngcontent-%COMP%]{margin:0;padding:12px;max-height:180px;overflow-y:auto;font-family:JetBrains Mono,monospace,sans-serif;font-size:11px;line-height:1.5;color:#cbd5e1;white-space:pre-wrap;word-break:break-all}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-logs-drawer[_ngcontent-%COMP%] .logs-body[_ngcontent-%COMP%] .log-line[_ngcontent-%COMP%]{margin-bottom:2px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-boot-tracker-card[_ngcontent-%COMP%] .boot-logs-drawer[_ngcontent-%COMP%] .logs-body[_ngcontent-%COMP%] .log-empty[_ngcontent-%COMP%]{color:#64748b;font-style:italic}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:14px;padding:18px 20px;background:#fff8f8;border:1.5px solid rgba(239,68,68,.3);border-radius:12px;box-shadow:0 4px 14px #ef44440f}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-header[_ngcontent-%COMP%]{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-header[_ngcontent-%COMP%] .failed-header-left[_ngcontent-%COMP%]{display:flex;align-items:flex-start;gap:12px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-header[_ngcontent-%COMP%] .failed-header-left[_ngcontent-%COMP%] .failed-icon-box[_ngcontent-%COMP%]{width:34px;height:34px;border-radius:8px;background:#ef44441f;color:#dc2626;display:flex;align-items:center;justify-content:center;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-header[_ngcontent-%COMP%] .failed-header-left[_ngcontent-%COMP%] .failed-icon-box[_ngcontent-%COMP%] .failed-icon[_ngcontent-%COMP%]{font-size:20px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-header[_ngcontent-%COMP%] .failed-header-left[_ngcontent-%COMP%] .failed-titles[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:2px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-header[_ngcontent-%COMP%] .failed-header-left[_ngcontent-%COMP%] .failed-titles[_ngcontent-%COMP%] .failed-title[_ngcontent-%COMP%]{margin:0;font-size:15px;font-weight:700;color:#dc2626}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-header[_ngcontent-%COMP%] .failed-header-left[_ngcontent-%COMP%] .failed-titles[_ngcontent-%COMP%] .failed-desc[_ngcontent-%COMP%]{margin:0;font-size:13px;color:#0f172a;line-height:1.4}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-header[_ngcontent-%COMP%] .btn-dismiss-failed[_ngcontent-%COMP%]{background:transparent;border:none;color:#64748b;cursor:pointer;padding:4px;border-radius:4px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-header[_ngcontent-%COMP%] .btn-dismiss-failed[_ngcontent-%COMP%]:hover{background:#0000000d;color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-logs-box[_ngcontent-%COMP%]{display:flex;flex-direction:column;background:#1e1e2e;border-radius:8px;overflow:hidden}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-logs-box[_ngcontent-%COMP%] .logs-header[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;padding:6px 12px;background:#282a36;color:#f55;font-size:11px;font-weight:600}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-logs-box[_ngcontent-%COMP%] .logs-header[_ngcontent-%COMP%] .btn-copy-logs[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:4px;padding:2px 6px;background:#44475a;border:none;border-radius:4px;color:#f8fafc;font-size:10px;cursor:pointer}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-logs-box[_ngcontent-%COMP%] .failed-logs-pre[_ngcontent-%COMP%]{margin:0;padding:10px 12px;max-height:120px;overflow-y:auto;font-family:JetBrains Mono,monospace;font-size:11px;line-height:1.4;color:#f8f8f2;white-space:pre-wrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-actions-bar[_ngcontent-%COMP%]{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-actions-bar[_ngcontent-%COMP%] .btn-retry-launch[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:6px;padding:6px 16px;background:linear-gradient(135deg,#1a73e8eb,#2563ebd9);backdrop-filter:blur(10px) saturate(180%);-webkit-backdrop-filter:blur(10px) saturate(180%);border:1px solid rgba(255,255,255,.28);box-shadow:0 4px 14px #1a73e852,inset 0 1px 1px #ffffff73;color:#fff!important;transition:background .2s cubic-bezier(.4,0,.2,1),border-color .2s cubic-bezier(.4,0,.2,1),box-shadow .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1),opacity .2s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-actions-bar[_ngcontent-%COMP%] .btn-retry-launch[_ngcontent-%COMP%] span[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-actions-bar[_ngcontent-%COMP%] .btn-retry-launch[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-actions-bar[_ngcontent-%COMP%] .btn-retry-launch[_ngcontent-%COMP%] .run-icon[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-actions-bar[_ngcontent-%COMP%] .btn-retry-launch[_ngcontent-%COMP%] .action-icon[_ngcontent-%COMP%]{color:#fff!important}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-actions-bar[_ngcontent-%COMP%] .btn-retry-launch[_ngcontent-%COMP%]:hover:not(:disabled){background:linear-gradient(135deg,#1a73e8,#2563ebf2);box-shadow:0 6px 20px #1a73e86b,inset 0 1px 1px #fff9;transform:translateY(-1px)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-actions-bar[_ngcontent-%COMP%] .btn-retry-launch[_ngcontent-%COMP%]:active:not(:disabled){transform:scale(.97) translateY(0);box-shadow:0 2px 8px #1a73e859}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-actions-bar[_ngcontent-%COMP%] .btn-retry-launch[_ngcontent-%COMP%]:disabled{background:#94a3b880;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.2);box-shadow:none;opacity:.65;cursor:not-allowed;transform:none}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-actions-bar[_ngcontent-%COMP%] .btn-retry-launch[_ngcontent-%COMP%]{border-radius:6px;font-size:12px;font-weight:600;cursor:pointer}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-actions-bar[_ngcontent-%COMP%] .btn-retry-launch[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:15px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-actions-bar[_ngcontent-%COMP%] .btn-restart-adb-sub[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;background:#ffffffb8;backdrop-filter:blur(10px) saturate(180%);-webkit-backdrop-filter:blur(10px) saturate(180%);border:1px solid rgba(226,232,240,.88);box-shadow:0 2px 8px -1px #0f172a0d,inset 0 1px 1px #fffffff2;color:#475569;transition:background .2s cubic-bezier(.4,0,.2,1),border-color .2s cubic-bezier(.4,0,.2,1),color .2s cubic-bezier(.4,0,.2,1),box-shadow .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1),opacity .2s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-actions-bar[_ngcontent-%COMP%] .btn-restart-adb-sub[_ngcontent-%COMP%]:hover:not(:disabled){background:#ffffffeb;border-color:#bfdbfef2;color:#1a73e8;box-shadow:0 4px 12px -2px #0f172a14,inset 0 1px 1px #fff;transform:translateY(-.5px)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-actions-bar[_ngcontent-%COMP%] .btn-restart-adb-sub[_ngcontent-%COMP%]:hover:not(:disabled) .material-symbols-outlined[_ngcontent-%COMP%]:not(.feedback-done){color:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-actions-bar[_ngcontent-%COMP%] .btn-restart-adb-sub[_ngcontent-%COMP%]:active:not(:disabled){transform:scale(.97)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-actions-bar[_ngcontent-%COMP%] .btn-restart-adb-sub[_ngcontent-%COMP%]:disabled{opacity:.55;cursor:not-allowed;transform:none}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-actions-bar[_ngcontent-%COMP%] .btn-restart-adb-sub[_ngcontent-%COMP%]{border-radius:6px;font-size:12px;font-weight:600;cursor:pointer}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-actions-bar[_ngcontent-%COMP%] .btn-restart-adb-sub[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:15px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-actions-bar[_ngcontent-%COMP%] .btn-dismiss-flat[_ngcontent-%COMP%]{background:transparent;border:none;color:#475569;font-size:12px;font-weight:600;cursor:pointer;padding:6px 10px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .emulator-failed-card[_ngcontent-%COMP%] .failed-actions-bar[_ngcontent-%COMP%] .btn-dismiss-flat[_ngcontent-%COMP%]:hover{text-decoration:underline}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:12px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .connection-methods-nav[_ngcontent-%COMP%]{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}@media(max-width:1100px){.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .connection-methods-nav[_ngcontent-%COMP%]{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:620px){.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .connection-methods-nav[_ngcontent-%COMP%]{grid-template-columns:1fr}}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .connection-methods-nav[_ngcontent-%COMP%] .method-nav-card[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;cursor:pointer;text-align:left;transition:all .18s ease;outline:none;min-width:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .connection-methods-nav[_ngcontent-%COMP%] .method-nav-card[_ngcontent-%COMP%] .method-card-left[_ngcontent-%COMP%]{display:flex;align-items:center;gap:10px;flex:1;min-width:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .connection-methods-nav[_ngcontent-%COMP%] .method-nav-card[_ngcontent-%COMP%] .method-card-left[_ngcontent-%COMP%] .method-icon-box[_ngcontent-%COMP%]{width:32px;height:32px;border-radius:8px;background:#fff;border:1px solid #e2e8f0;color:#475569;display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .connection-methods-nav[_ngcontent-%COMP%] .method-nav-card[_ngcontent-%COMP%] .method-card-left[_ngcontent-%COMP%] .method-icon-box[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:17px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .connection-methods-nav[_ngcontent-%COMP%] .method-nav-card[_ngcontent-%COMP%] .method-card-left[_ngcontent-%COMP%] .method-info[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .connection-methods-nav[_ngcontent-%COMP%] .method-nav-card[_ngcontent-%COMP%] .method-card-left[_ngcontent-%COMP%] .method-info[_ngcontent-%COMP%] .method-title-row[_ngcontent-%COMP%]{display:flex;align-items:center}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .connection-methods-nav[_ngcontent-%COMP%] .method-nav-card[_ngcontent-%COMP%] .method-card-left[_ngcontent-%COMP%] .method-info[_ngcontent-%COMP%] .method-title-row[_ngcontent-%COMP%] .method-title[_ngcontent-%COMP%]{font-size:13px;font-weight:600;color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .connection-methods-nav[_ngcontent-%COMP%] .method-nav-card[_ngcontent-%COMP%] .method-card-left[_ngcontent-%COMP%] .method-info[_ngcontent-%COMP%] .method-desc[_ngcontent-%COMP%]{font-size:11.5px;color:#64748b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .connection-methods-nav[_ngcontent-%COMP%] .method-nav-card[_ngcontent-%COMP%] .method-card-right[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-left:8px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .connection-methods-nav[_ngcontent-%COMP%] .method-nav-card[_ngcontent-%COMP%] .method-card-right[_ngcontent-%COMP%] .radio-indicator[_ngcontent-%COMP%]{width:16px;height:16px;border-radius:50%;border:1.5px solid #cbd5e1;background:#fff;transition:all .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .connection-methods-nav[_ngcontent-%COMP%] .method-nav-card[_ngcontent-%COMP%] .method-card-right[_ngcontent-%COMP%] .radio-indicator.checked[_ngcontent-%COMP%]{border-color:#1a73e8;background:#1a73e8;box-shadow:inset 0 0 0 3px #fff}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .connection-methods-nav[_ngcontent-%COMP%] .method-nav-card.active[_ngcontent-%COMP%]{background:#fff;border-color:#1a73e8;box-shadow:0 0 0 1px #1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .connection-methods-nav[_ngcontent-%COMP%] .method-nav-card.active[_ngcontent-%COMP%] .method-icon-box[_ngcontent-%COMP%]{background:#eff6ff;color:#1a73e8;border-color:#bfdbfe}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .connection-methods-nav[_ngcontent-%COMP%] .method-nav-card.active[_ngcontent-%COMP%] .method-title[_ngcontent-%COMP%]{color:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .connection-methods-nav[_ngcontent-%COMP%] .method-nav-card[_ngcontent-%COMP%]:hover:not(.active){background:#fff;border-color:#cbd5e1}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .active-remote-notice[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 12px;background:#fffbeb;border:1px solid #fde68a;border-radius:8px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .active-remote-notice[_ngcontent-%COMP%] .active-remote-copy[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px;min-width:0;color:#78350f;font-size:11.5px;line-height:1.45}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .active-remote-notice[_ngcontent-%COMP%] .active-remote-copy[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{color:#d97706;font-size:17px;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .active-remote-notice[_ngcontent-%COMP%] .active-remote-copy[_ngcontent-%COMP%] strong[_ngcontent-%COMP%]{font-family:JetBrains Mono,Fira Code,Roboto Mono,SFMono-Regular,Consolas,monospace;font-weight:600}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .active-remote-notice[_ngcontent-%COMP%] .btn-use-local-adb[_ngcontent-%COMP%]{display:inline-flex;align-items:center;justify-content:center;gap:5px;min-height:32px;padding:0 12px;background:#ffffffb8;backdrop-filter:blur(10px) saturate(180%);-webkit-backdrop-filter:blur(10px) saturate(180%);border:1px solid rgba(226,232,240,.88);box-shadow:0 2px 8px -1px #0f172a0d,inset 0 1px 1px #fffffff2;color:#475569;transition:background .2s cubic-bezier(.4,0,.2,1),border-color .2s cubic-bezier(.4,0,.2,1),color .2s cubic-bezier(.4,0,.2,1),box-shadow .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1),opacity .2s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .active-remote-notice[_ngcontent-%COMP%] .btn-use-local-adb[_ngcontent-%COMP%]:hover:not(:disabled){background:#ffffffeb;border-color:#bfdbfef2;color:#1a73e8;box-shadow:0 4px 12px -2px #0f172a14,inset 0 1px 1px #fff;transform:translateY(-.5px)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .active-remote-notice[_ngcontent-%COMP%] .btn-use-local-adb[_ngcontent-%COMP%]:hover:not(:disabled) .material-symbols-outlined[_ngcontent-%COMP%]:not(.feedback-done){color:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .active-remote-notice[_ngcontent-%COMP%] .btn-use-local-adb[_ngcontent-%COMP%]:active:not(:disabled){transform:scale(.97)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .active-remote-notice[_ngcontent-%COMP%] .btn-use-local-adb[_ngcontent-%COMP%]:disabled{opacity:.55;cursor:not-allowed;transform:none}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .active-remote-notice[_ngcontent-%COMP%] .btn-use-local-adb[_ngcontent-%COMP%]{border-radius:6px;font-family:inherit;font-size:11.5px;font-weight:600;cursor:pointer;white-space:nowrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .active-remote-notice[_ngcontent-%COMP%] .btn-use-local-adb[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:15px}@media(max-width:700px){.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .active-remote-notice[_ngcontent-%COMP%]{align-items:stretch;flex-direction:column}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .active-remote-notice[_ngcontent-%COMP%] .btn-use-local-adb[_ngcontent-%COMP%]{width:100%}}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%]{background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:14px 16px;display:flex;flex-direction:column;gap:12px;animation:_ngcontent-%COMP%_fadeIn .15s ease-out}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:10px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .section-header-bar[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:6px;padding-bottom:6px;border-bottom:1px solid #f1f5f9}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .section-header-bar[_ngcontent-%COMP%] .section-title-group[_ngcontent-%COMP%]{display:flex;align-items:center;gap:6px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .section-header-bar[_ngcontent-%COMP%] .section-title-group[_ngcontent-%COMP%] .header-icon[_ngcontent-%COMP%]{font-size:17px;color:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .section-header-bar[_ngcontent-%COMP%] .section-title-group[_ngcontent-%COMP%] .section-title[_ngcontent-%COMP%]{font-size:13px;font-weight:600;color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .section-header-bar[_ngcontent-%COMP%] .section-title-group[_ngcontent-%COMP%] .count-badge[_ngcontent-%COMP%]{font-size:10.5px;font-weight:600;padding:1.5px 6px;border-radius:6px;background:#f1f5f9;color:#475569}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .avds-list[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:8px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .avds-list[_ngcontent-%COMP%] .avd-card-item[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;gap:12px;min-width:0;transition:all .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .avds-list[_ngcontent-%COMP%] .avd-card-item[_ngcontent-%COMP%]:hover{border-color:#cbd5e1;background:#fff}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .avds-list[_ngcontent-%COMP%] .avd-card-item[_ngcontent-%COMP%] .avd-item-main[_ngcontent-%COMP%]{display:flex;align-items:center;gap:10px;flex:1;min-width:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .avds-list[_ngcontent-%COMP%] .avd-card-item[_ngcontent-%COMP%] .avd-item-main[_ngcontent-%COMP%] .avd-avatar[_ngcontent-%COMP%]{width:32px;height:32px;border-radius:6px;background:#fff;border:1px solid #e2e8f0;color:#1a73e8;display:flex;align-items:center;justify-content:center;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .avds-list[_ngcontent-%COMP%] .avd-card-item[_ngcontent-%COMP%] .avd-item-main[_ngcontent-%COMP%] .avd-avatar[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:17px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .avds-list[_ngcontent-%COMP%] .avd-card-item[_ngcontent-%COMP%] .avd-item-main[_ngcontent-%COMP%] .avd-meta[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:1px;flex:1;min-width:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .avds-list[_ngcontent-%COMP%] .avd-card-item[_ngcontent-%COMP%] .avd-item-main[_ngcontent-%COMP%] .avd-meta[_ngcontent-%COMP%] .avd-heading[_ngcontent-%COMP%]{display:flex;align-items:center;gap:6px;min-width:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .avds-list[_ngcontent-%COMP%] .avd-card-item[_ngcontent-%COMP%] .avd-item-main[_ngcontent-%COMP%] .avd-meta[_ngcontent-%COMP%] .avd-heading[_ngcontent-%COMP%] .avd-name[_ngcontent-%COMP%]{font-size:13px;font-weight:600;color:#0f172a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .avds-list[_ngcontent-%COMP%] .avd-card-item[_ngcontent-%COMP%] .avd-item-main[_ngcontent-%COMP%] .avd-meta[_ngcontent-%COMP%] .avd-heading[_ngcontent-%COMP%] .avd-type-pill[_ngcontent-%COMP%]{font-size:9.5px;font-weight:600;padding:1px 5px;border-radius:4px;background:#fff;border:1px solid #e2e8f0;color:#64748b;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .avds-list[_ngcontent-%COMP%] .avd-card-item[_ngcontent-%COMP%] .avd-item-actions[_ngcontent-%COMP%]{display:flex;align-items:center;gap:6px;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .avds-list[_ngcontent-%COMP%] .avd-card-item[_ngcontent-%COMP%] .avd-item-actions[_ngcontent-%COMP%] .btn-avd-launch[_ngcontent-%COMP%]{display:inline-flex;align-items:center;justify-content:center;gap:4px;padding:0 15px;height:30px;background:linear-gradient(135deg,#1a73e8eb,#2563ebd9);backdrop-filter:blur(10px) saturate(180%);-webkit-backdrop-filter:blur(10px) saturate(180%);border:1px solid rgba(255,255,255,.28);box-shadow:0 4px 14px #1a73e852,inset 0 1px 1px #ffffff73;color:#fff!important;transition:background .2s cubic-bezier(.4,0,.2,1),border-color .2s cubic-bezier(.4,0,.2,1),box-shadow .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1),opacity .2s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .avds-list[_ngcontent-%COMP%] .avd-card-item[_ngcontent-%COMP%] .avd-item-actions[_ngcontent-%COMP%] .btn-avd-launch[_ngcontent-%COMP%] span[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .avds-list[_ngcontent-%COMP%] .avd-card-item[_ngcontent-%COMP%] .avd-item-actions[_ngcontent-%COMP%] .btn-avd-launch[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .avds-list[_ngcontent-%COMP%] .avd-card-item[_ngcontent-%COMP%] .avd-item-actions[_ngcontent-%COMP%] .btn-avd-launch[_ngcontent-%COMP%] .run-icon[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .avds-list[_ngcontent-%COMP%] .avd-card-item[_ngcontent-%COMP%] .avd-item-actions[_ngcontent-%COMP%] .btn-avd-launch[_ngcontent-%COMP%] .action-icon[_ngcontent-%COMP%]{color:#fff!important}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .avds-list[_ngcontent-%COMP%] .avd-card-item[_ngcontent-%COMP%] .avd-item-actions[_ngcontent-%COMP%] .btn-avd-launch[_ngcontent-%COMP%]:hover:not(:disabled){background:linear-gradient(135deg,#1a73e8,#2563ebf2);box-shadow:0 6px 20px #1a73e86b,inset 0 1px 1px #fff9;transform:translateY(-1px)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .avds-list[_ngcontent-%COMP%] .avd-card-item[_ngcontent-%COMP%] .avd-item-actions[_ngcontent-%COMP%] .btn-avd-launch[_ngcontent-%COMP%]:active:not(:disabled){transform:scale(.97) translateY(0);box-shadow:0 2px 8px #1a73e859}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .avds-list[_ngcontent-%COMP%] .avd-card-item[_ngcontent-%COMP%] .avd-item-actions[_ngcontent-%COMP%] .btn-avd-launch[_ngcontent-%COMP%]:disabled{background:#94a3b880;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.2);box-shadow:none;opacity:.65;cursor:not-allowed;transform:none}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .avds-list[_ngcontent-%COMP%] .avd-card-item[_ngcontent-%COMP%] .avd-item-actions[_ngcontent-%COMP%] .btn-avd-launch[_ngcontent-%COMP%]{border-radius:6px;font-family:inherit;font-size:12px;font-weight:600;cursor:pointer}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .avds-list[_ngcontent-%COMP%] .avd-card-item[_ngcontent-%COMP%] .avd-item-actions[_ngcontent-%COMP%] .btn-avd-launch[_ngcontent-%COMP%] .action-icon[_ngcontent-%COMP%]{font-size:15px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .avds-section[_ngcontent-%COMP%] .avds-list[_ngcontent-%COMP%] .avd-card-item[_ngcontent-%COMP%] .avd-item-actions[_ngcontent-%COMP%] .btn-avd-launch[_ngcontent-%COMP%] .spinner-icon[_ngcontent-%COMP%]{width:13px;height:13px;border:2px solid rgba(255,255,255,.4);border-top-color:#fff;border-radius:50%;animation:_ngcontent-%COMP%_spin .8s linear infinite}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .emulator-setup-guide-container[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:12px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .emulator-setup-guide-container[_ngcontent-%COMP%] .empty-avd-banner[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;padding:12px 14px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .emulator-setup-guide-container[_ngcontent-%COMP%] .empty-avd-banner[_ngcontent-%COMP%] .banner-left[_ngcontent-%COMP%]{display:flex;align-items:center;gap:10px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .emulator-setup-guide-container[_ngcontent-%COMP%] .empty-avd-banner[_ngcontent-%COMP%] .banner-left[_ngcontent-%COMP%] .empty-avd-icon-box[_ngcontent-%COMP%]{width:34px;height:34px;border-radius:6px;background:#fff;border:1px solid #e2e8f0;color:#64748b;display:flex;align-items:center;justify-content:center;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .emulator-setup-guide-container[_ngcontent-%COMP%] .empty-avd-banner[_ngcontent-%COMP%] .banner-left[_ngcontent-%COMP%] .empty-avd-icon-box[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:19px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .emulator-setup-guide-container[_ngcontent-%COMP%] .empty-avd-banner[_ngcontent-%COMP%] .banner-left[_ngcontent-%COMP%] .banner-titles[_ngcontent-%COMP%] .banner-title-row[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .emulator-setup-guide-container[_ngcontent-%COMP%] .empty-avd-banner[_ngcontent-%COMP%] .banner-left[_ngcontent-%COMP%] .banner-titles[_ngcontent-%COMP%] .banner-title-row[_ngcontent-%COMP%] .empty-title[_ngcontent-%COMP%]{margin:0;font-size:13px;font-weight:600;color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .emulator-setup-guide-container[_ngcontent-%COMP%] .empty-avd-banner[_ngcontent-%COMP%] .banner-left[_ngcontent-%COMP%] .banner-titles[_ngcontent-%COMP%] .empty-desc[_ngcontent-%COMP%]{margin:2px 0 0;font-size:12px;color:#475569}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .emulator-setup-guide-container[_ngcontent-%COMP%] .empty-avd-banner[_ngcontent-%COMP%] .mode-switch-pills[_ngcontent-%COMP%]{display:inline-flex;background:#fff;border:1px solid #e2e8f0;padding:2px;border-radius:6px;gap:2px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .emulator-setup-guide-container[_ngcontent-%COMP%] .empty-avd-banner[_ngcontent-%COMP%] .mode-switch-pills[_ngcontent-%COMP%] .pill-btn[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:4px;padding:3px 8px;border-radius:4px;border:none;background:transparent;color:#475569;font-family:inherit;font-size:11.5px;font-weight:600;cursor:pointer;transition:all .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .emulator-setup-guide-container[_ngcontent-%COMP%] .empty-avd-banner[_ngcontent-%COMP%] .mode-switch-pills[_ngcontent-%COMP%] .pill-btn[_ngcontent-%COMP%] .pill-icon[_ngcontent-%COMP%]{font-size:14px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .emulator-setup-guide-container[_ngcontent-%COMP%] .empty-avd-banner[_ngcontent-%COMP%] .mode-switch-pills[_ngcontent-%COMP%] .pill-btn[_ngcontent-%COMP%]:hover{color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .emulator-setup-guide-container[_ngcontent-%COMP%] .empty-avd-banner[_ngcontent-%COMP%] .mode-switch-pills[_ngcontent-%COMP%] .pill-btn.active[_ngcontent-%COMP%]{background:#1a73e8;color:#fff}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .emulator-setup-guide-container[_ngcontent-%COMP%] .emu-flow-grid[_ngcontent-%COMP%] .flow-step-card[_ngcontent-%COMP%] .guide-link[_ngcontent-%COMP%]{color:#1a73e8;font-weight:600;text-decoration:none}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .emulator-setup-guide-container[_ngcontent-%COMP%] .emu-flow-grid[_ngcontent-%COMP%] .flow-step-card[_ngcontent-%COMP%] .guide-link[_ngcontent-%COMP%]:hover{text-decoration:underline}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .emulator-setup-guide-container[_ngcontent-%COMP%] .cli-steps-list[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:8px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .emulator-setup-guide-container[_ngcontent-%COMP%] .cli-steps-list[_ngcontent-%COMP%] .cli-step-item[_ngcontent-%COMP%]{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .emulator-setup-guide-container[_ngcontent-%COMP%] .cli-steps-list[_ngcontent-%COMP%] .cli-step-item[_ngcontent-%COMP%] .step-badge-num[_ngcontent-%COMP%]{width:22px;height:22px;border-radius:50%;background:#eff6ff;color:#1a73e8;font-size:11.5px;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:1px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .emulator-setup-guide-container[_ngcontent-%COMP%] .cli-steps-list[_ngcontent-%COMP%] .cli-step-item[_ngcontent-%COMP%] .step-content[_ngcontent-%COMP%]{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .emulator-setup-guide-container[_ngcontent-%COMP%] .cli-steps-list[_ngcontent-%COMP%] .cli-step-item[_ngcontent-%COMP%] .step-content[_ngcontent-%COMP%] .step-title[_ngcontent-%COMP%]{font-size:12.5px;font-weight:600;color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .emulator-setup-guide-container[_ngcontent-%COMP%] .cli-steps-list[_ngcontent-%COMP%] .cli-step-item[_ngcontent-%COMP%] .step-content[_ngcontent-%COMP%] .step-text[_ngcontent-%COMP%]{margin:0;font-size:11.5px;color:#475569}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .emulator-setup-guide-container[_ngcontent-%COMP%] .cli-steps-list[_ngcontent-%COMP%] .cli-step-item[_ngcontent-%COMP%] .step-content[_ngcontent-%COMP%] .small-box[_ngcontent-%COMP%]{margin-top:4px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .usb-guide-intro[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-guide-intro[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .remote-guide-intro[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px;font-size:12.5px;font-weight:600;color:#475569}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .usb-guide-intro[_ngcontent-%COMP%] .intro-icon[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-guide-intro[_ngcontent-%COMP%] .intro-icon[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .remote-guide-intro[_ngcontent-%COMP%] .intro-icon[_ngcontent-%COMP%]{font-size:17px;color:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .steps-flow-grid[_ngcontent-%COMP%]{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}@media(max-width:900px){.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .steps-flow-grid[_ngcontent-%COMP%]{grid-template-columns:repeat(2,1fr)}}@media(max-width:520px){.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .steps-flow-grid[_ngcontent-%COMP%]{grid-template-columns:1fr}}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .steps-flow-grid[_ngcontent-%COMP%] .flow-step-card[_ngcontent-%COMP%]{background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:12px 14px;display:flex;flex-direction:column;gap:6px;min-width:0;transition:all .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .steps-flow-grid[_ngcontent-%COMP%] .flow-step-card[_ngcontent-%COMP%]:hover{border-color:#cbd5e1;background:#fff}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .steps-flow-grid[_ngcontent-%COMP%] .flow-step-card[_ngcontent-%COMP%] .step-card-header[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .steps-flow-grid[_ngcontent-%COMP%] .flow-step-card[_ngcontent-%COMP%] .step-card-header[_ngcontent-%COMP%] .step-number-badge[_ngcontent-%COMP%]{width:20px;height:20px;border-radius:4px;background:#1a73e8;color:#fff!important;font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .steps-flow-grid[_ngcontent-%COMP%] .flow-step-card[_ngcontent-%COMP%] .step-card-header[_ngcontent-%COMP%] .step-card-icon[_ngcontent-%COMP%]{font-size:17px;color:#94a3b8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .steps-flow-grid[_ngcontent-%COMP%] .flow-step-card[_ngcontent-%COMP%] .step-card-title[_ngcontent-%COMP%]{font-size:12.5px;font-weight:600;color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .steps-flow-grid[_ngcontent-%COMP%] .flow-step-card[_ngcontent-%COMP%] .step-card-desc[_ngcontent-%COMP%]{font-size:11.5px;color:#64748b;line-height:1.4}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%]{background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:12px 14px;display:flex;flex-direction:column;gap:10px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-form-row[_ngcontent-%COMP%]{display:flex;align-items:flex-end;gap:8px;flex-wrap:wrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-form-row[_ngcontent-%COMP%] .wifi-input-col[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:3px;min-width:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-form-row[_ngcontent-%COMP%] .wifi-input-col.ip-col[_ngcontent-%COMP%]{flex:1 1 180px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-form-row[_ngcontent-%COMP%] .wifi-input-col.port-col[_ngcontent-%COMP%]{flex:0 1 90px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-form-row[_ngcontent-%COMP%] .wifi-input-col[_ngcontent-%COMP%] .wifi-label[_ngcontent-%COMP%]{font-size:11px;font-weight:600;color:#64748b}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-form-row[_ngcontent-%COMP%] .wifi-input-col[_ngcontent-%COMP%] .wifi-input-wrap[_ngcontent-%COMP%]{display:flex;align-items:center;background:#fff;border:1px solid #e2e8f0;border-radius:6px;padding:0 8px;height:34px;transition:border-color .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-form-row[_ngcontent-%COMP%] .wifi-input-col[_ngcontent-%COMP%] .wifi-input-wrap[_ngcontent-%COMP%]:focus-within{border-color:#1a73e8;box-shadow:0 0 0 2px #1a73e81a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-form-row[_ngcontent-%COMP%] .wifi-input-col[_ngcontent-%COMP%] .wifi-input-wrap[_ngcontent-%COMP%] .wrap-icon[_ngcontent-%COMP%]{font-size:15px;color:#94a3b8;margin-right:5px;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-form-row[_ngcontent-%COMP%] .wifi-input-col[_ngcontent-%COMP%] .wifi-input-wrap[_ngcontent-%COMP%] .wifi-input[_ngcontent-%COMP%]{flex:1;min-width:0;border:none;outline:none;background:transparent;font-family:JetBrains Mono,Fira Code,Roboto Mono,SFMono-Regular,Consolas,monospace;font-size:12px;color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-form-row[_ngcontent-%COMP%] .wifi-input-col[_ngcontent-%COMP%] .wifi-input-wrap[_ngcontent-%COMP%] .wifi-input[_ngcontent-%COMP%]::placeholder{color:#94a3b8;font-family:Google Sans Text,Google Sans,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:11.5px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-form-row[_ngcontent-%COMP%] .wifi-btn-col[_ngcontent-%COMP%]{flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-form-row[_ngcontent-%COMP%] .wifi-btn-col[_ngcontent-%COMP%] .btn-wifi-connect[_ngcontent-%COMP%]{display:inline-flex;align-items:center;justify-content:center;gap:4px;padding:0 16px;height:34px;background:linear-gradient(135deg,#1a73e8eb,#2563ebd9);backdrop-filter:blur(10px) saturate(180%);-webkit-backdrop-filter:blur(10px) saturate(180%);border:1px solid rgba(255,255,255,.28);box-shadow:0 4px 14px #1a73e852,inset 0 1px 1px #ffffff73;color:#fff!important;transition:background .2s cubic-bezier(.4,0,.2,1),border-color .2s cubic-bezier(.4,0,.2,1),box-shadow .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1),opacity .2s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-form-row[_ngcontent-%COMP%] .wifi-btn-col[_ngcontent-%COMP%] .btn-wifi-connect[_ngcontent-%COMP%] span[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-form-row[_ngcontent-%COMP%] .wifi-btn-col[_ngcontent-%COMP%] .btn-wifi-connect[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-form-row[_ngcontent-%COMP%] .wifi-btn-col[_ngcontent-%COMP%] .btn-wifi-connect[_ngcontent-%COMP%] .run-icon[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-form-row[_ngcontent-%COMP%] .wifi-btn-col[_ngcontent-%COMP%] .btn-wifi-connect[_ngcontent-%COMP%] .action-icon[_ngcontent-%COMP%]{color:#fff!important}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-form-row[_ngcontent-%COMP%] .wifi-btn-col[_ngcontent-%COMP%] .btn-wifi-connect[_ngcontent-%COMP%]:hover:not(:disabled){background:linear-gradient(135deg,#1a73e8,#2563ebf2);box-shadow:0 6px 20px #1a73e86b,inset 0 1px 1px #fff9;transform:translateY(-1px)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-form-row[_ngcontent-%COMP%] .wifi-btn-col[_ngcontent-%COMP%] .btn-wifi-connect[_ngcontent-%COMP%]:active:not(:disabled){transform:scale(.97) translateY(0);box-shadow:0 2px 8px #1a73e859}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-form-row[_ngcontent-%COMP%] .wifi-btn-col[_ngcontent-%COMP%] .btn-wifi-connect[_ngcontent-%COMP%]:disabled{background:#94a3b880;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.2);box-shadow:none;opacity:.65;cursor:not-allowed;transform:none}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-form-row[_ngcontent-%COMP%] .wifi-btn-col[_ngcontent-%COMP%] .btn-wifi-connect[_ngcontent-%COMP%]{border-radius:6px;font-family:inherit;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-form-row[_ngcontent-%COMP%] .wifi-btn-col[_ngcontent-%COMP%] .btn-wifi-connect[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:16px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-form-row[_ngcontent-%COMP%] .wifi-btn-col[_ngcontent-%COMP%] .btn-wifi-connect[_ngcontent-%COMP%] .spinning[_ngcontent-%COMP%]{animation:_ngcontent-%COMP%_spin 1s linear infinite}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-status-alert[_ngcontent-%COMP%]{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:6px;font-size:11.5px;font-weight:600}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-status-alert[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:15px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-status-alert.success[_ngcontent-%COMP%]{background:#ecfdf5;color:#047857;border:1px solid #a7f3d0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .wifi-interactive-box[_ngcontent-%COMP%] .wifi-status-alert.error[_ngcontent-%COMP%]{background:#fef2f2;color:#b91c1c;border:1px solid #fecaca}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .info-callout-box[_ngcontent-%COMP%]{display:flex;align-items:flex-start;gap:8px;padding:8px 12px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:6px;font-size:11.5px;color:#1557b0;line-height:1.45}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel[_ngcontent-%COMP%] .info-callout-box[_ngcontent-%COMP%] .callout-icon[_ngcontent-%COMP%]{font-size:16px;color:#1a73e8;flex-shrink:0;margin-top:1px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:10px;padding:12px 14px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%]{display:flex;align-items:flex-end;gap:8px;flex-wrap:wrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-input-col[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:3px;min-width:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-input-col.host-col[_ngcontent-%COMP%]{flex:1 1 220px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-input-col.port-col[_ngcontent-%COMP%]{flex:0 1 120px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-input-col[_ngcontent-%COMP%] .remote-adb-label[_ngcontent-%COMP%]{color:#64748b;font-size:11px;font-weight:600}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-input-col[_ngcontent-%COMP%] .remote-adb-input-wrap[_ngcontent-%COMP%]{display:flex;align-items:center;height:36px;padding:0 9px;background:#fff;border:1px solid #e2e8f0;border-radius:6px;transition:border-color .15s ease,box-shadow .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-input-col[_ngcontent-%COMP%] .remote-adb-input-wrap[_ngcontent-%COMP%]:focus-within{border-color:#1a73e8;box-shadow:0 0 0 2px #1a73e81a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-input-col[_ngcontent-%COMP%] .remote-adb-input-wrap[_ngcontent-%COMP%] .wrap-icon[_ngcontent-%COMP%]{margin-right:6px;color:#94a3b8;font-size:15px;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-input-col[_ngcontent-%COMP%] .remote-adb-input-wrap[_ngcontent-%COMP%] .remote-adb-input[_ngcontent-%COMP%]{flex:1;min-width:0;border:0;outline:0;background:transparent;color:#0f172a;font-family:JetBrains Mono,Fira Code,Roboto Mono,SFMono-Regular,Consolas,monospace;font-size:12px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-input-col[_ngcontent-%COMP%] .remote-adb-input-wrap[_ngcontent-%COMP%] .remote-adb-input[_ngcontent-%COMP%]::placeholder{color:#94a3b8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-btn-col[_ngcontent-%COMP%]{flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-btn-col[_ngcontent-%COMP%] .btn-remote-adb-connect[_ngcontent-%COMP%]{display:inline-flex;align-items:center;justify-content:center;gap:5px;height:36px;padding:0 16px;background:linear-gradient(135deg,#1a73e8eb,#2563ebd9);backdrop-filter:blur(10px) saturate(180%);-webkit-backdrop-filter:blur(10px) saturate(180%);border:1px solid rgba(255,255,255,.28);box-shadow:0 4px 14px #1a73e852,inset 0 1px 1px #ffffff73;color:#fff!important;transition:background .2s cubic-bezier(.4,0,.2,1),border-color .2s cubic-bezier(.4,0,.2,1),box-shadow .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1),opacity .2s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-btn-col[_ngcontent-%COMP%] .btn-remote-adb-connect[_ngcontent-%COMP%] span[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-btn-col[_ngcontent-%COMP%] .btn-remote-adb-connect[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-btn-col[_ngcontent-%COMP%] .btn-remote-adb-connect[_ngcontent-%COMP%] .run-icon[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-btn-col[_ngcontent-%COMP%] .btn-remote-adb-connect[_ngcontent-%COMP%] .action-icon[_ngcontent-%COMP%]{color:#fff!important}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-btn-col[_ngcontent-%COMP%] .btn-remote-adb-connect[_ngcontent-%COMP%]:hover:not(:disabled){background:linear-gradient(135deg,#1a73e8,#2563ebf2);box-shadow:0 6px 20px #1a73e86b,inset 0 1px 1px #fff9;transform:translateY(-1px)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-btn-col[_ngcontent-%COMP%] .btn-remote-adb-connect[_ngcontent-%COMP%]:active:not(:disabled){transform:scale(.97) translateY(0);box-shadow:0 2px 8px #1a73e859}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-btn-col[_ngcontent-%COMP%] .btn-remote-adb-connect[_ngcontent-%COMP%]:disabled{background:#94a3b880;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.2);box-shadow:none;opacity:.65;cursor:not-allowed;transform:none}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-btn-col[_ngcontent-%COMP%] .btn-remote-adb-connect[_ngcontent-%COMP%]{border-radius:6px;font-family:inherit;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-btn-col[_ngcontent-%COMP%] .btn-remote-adb-connect[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:16px}@media(max-width:640px){.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%]{align-items:stretch;flex-direction:column}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-input-col[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-input-col.host-col[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-input-col.port-col[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-btn-col[_ngcontent-%COMP%]{flex:1 1 auto;width:100%}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-form-row[_ngcontent-%COMP%] .remote-adb-btn-col[_ngcontent-%COMP%] .btn-remote-adb-connect[_ngcontent-%COMP%]{width:100%}}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remember-remote-adb[_ngcontent-%COMP%]{display:inline-flex;align-items:center;align-self:flex-start;gap:7px;color:#475569;font-size:11.5px;cursor:pointer}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remember-remote-adb[_ngcontent-%COMP%] input[_ngcontent-%COMP%]{width:15px;height:15px;margin:0;accent-color:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .btn-use-tested-endpoint[_ngcontent-%COMP%]{display:inline-flex;align-items:center;justify-content:center;align-self:flex-end;gap:5px;min-width:150px;height:36px;padding:0 16px;background:linear-gradient(135deg,#1a73e8eb,#2563ebd9);backdrop-filter:blur(10px) saturate(180%);-webkit-backdrop-filter:blur(10px) saturate(180%);border:1px solid rgba(255,255,255,.28);box-shadow:0 4px 14px #1a73e852,inset 0 1px 1px #ffffff73;color:#fff!important;transition:background .2s cubic-bezier(.4,0,.2,1),border-color .2s cubic-bezier(.4,0,.2,1),box-shadow .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1),opacity .2s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .btn-use-tested-endpoint[_ngcontent-%COMP%] span[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .btn-use-tested-endpoint[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .btn-use-tested-endpoint[_ngcontent-%COMP%] .run-icon[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .btn-use-tested-endpoint[_ngcontent-%COMP%] .action-icon[_ngcontent-%COMP%]{color:#fff!important}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .btn-use-tested-endpoint[_ngcontent-%COMP%]:hover:not(:disabled){background:linear-gradient(135deg,#1a73e8,#2563ebf2);box-shadow:0 6px 20px #1a73e86b,inset 0 1px 1px #fff9;transform:translateY(-1px)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .btn-use-tested-endpoint[_ngcontent-%COMP%]:active:not(:disabled){transform:scale(.97) translateY(0);box-shadow:0 2px 8px #1a73e859}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .btn-use-tested-endpoint[_ngcontent-%COMP%]:disabled{background:#94a3b880;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.2);box-shadow:none;opacity:.65;cursor:not-allowed;transform:none}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .btn-use-tested-endpoint[_ngcontent-%COMP%]{border-radius:6px;font-family:inherit;font-size:12px;font-weight:600;cursor:pointer}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .btn-use-tested-endpoint[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:16px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-status-alert[_ngcontent-%COMP%]{display:flex;align-items:flex-start;gap:6px;padding:7px 10px;border-radius:6px;font-size:11.5px;font-weight:600;line-height:1.4}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-status-alert[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:15px;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-status-alert.success[_ngcontent-%COMP%]{color:#047857;background:#ecfdf5;border:1px solid #a7f3d0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-adb-status-alert.error[_ngcontent-%COMP%]{color:#b91c1c;background:#fef2f2;border:1px solid #fecaca}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-device-results[_ngcontent-%COMP%]{display:flex;flex-direction:column;overflow:hidden;background:#fff;border:1px solid #e2e8f0;border-radius:7px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-device-results[_ngcontent-%COMP%] .remote-device-row[_ngcontent-%COMP%]{display:flex;align-items:center;gap:9px;min-width:0;padding:8px 10px;border-bottom:1px solid #f1f5f9}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-device-results[_ngcontent-%COMP%] .remote-device-row[_ngcontent-%COMP%]:last-child{border-bottom:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-device-results[_ngcontent-%COMP%] .remote-device-row[_ngcontent-%COMP%] > .material-symbols-outlined[_ngcontent-%COMP%]{color:#1a73e8;font-size:17px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-device-results[_ngcontent-%COMP%] .remote-device-row[_ngcontent-%COMP%] .remote-device-copy[_ngcontent-%COMP%]{display:flex;flex:1;flex-direction:column;min-width:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-device-results[_ngcontent-%COMP%] .remote-device-row[_ngcontent-%COMP%] .remote-device-copy[_ngcontent-%COMP%] strong[_ngcontent-%COMP%]{color:#0f172a;font-size:12px;font-weight:600}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-device-results[_ngcontent-%COMP%] .remote-device-row[_ngcontent-%COMP%] .remote-device-copy[_ngcontent-%COMP%] span[_ngcontent-%COMP%]{overflow:hidden;color:#64748b;font-family:JetBrains Mono,Fira Code,Roboto Mono,SFMono-Regular,Consolas,monospace;font-size:10.5px;text-overflow:ellipsis;white-space:nowrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-device-results[_ngcontent-%COMP%] .remote-device-row[_ngcontent-%COMP%] .remote-device-state[_ngcontent-%COMP%]{color:#b45309;font-size:10.5px;font-weight:600;text-transform:capitalize}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-interactive-box[_ngcontent-%COMP%] .remote-device-results[_ngcontent-%COMP%] .remote-device-row[_ngcontent-%COMP%] .remote-device-state.ready[_ngcontent-%COMP%]{color:#047857}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .foolproof-guide-container[_ngcontent-%COMP%] .method-details-panel.remote-adb-panel[_ngcontent-%COMP%] .remote-adb-explanation[_ngcontent-%COMP%]{margin:0;color:#64748b;font-size:11.5px;line-height:1.5}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%]{display:flex;align-items:center;gap:12px;padding:12px 16px;background:#fffbeb;border:1px solid #fde68a;border-radius:10px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .alert-icon[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .alert-icon[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .alert-icon[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .alert-icon[_ngcontent-%COMP%]{font-size:24px;color:#d97706;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .unauth-details[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .locked-details[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .unauth-details[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .locked-details[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .unauth-details[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .locked-details[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .unauth-details[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .locked-details[_ngcontent-%COMP%]{flex:1;min-width:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .unauth-details[_ngcontent-%COMP%] .unauth-title[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .unauth-details[_ngcontent-%COMP%] .locked-title[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .locked-details[_ngcontent-%COMP%] .unauth-title[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .locked-details[_ngcontent-%COMP%] .locked-title[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .unauth-details[_ngcontent-%COMP%] .unauth-title[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .unauth-details[_ngcontent-%COMP%] .locked-title[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .locked-details[_ngcontent-%COMP%] .unauth-title[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .locked-details[_ngcontent-%COMP%] .locked-title[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .unauth-details[_ngcontent-%COMP%] .unauth-title[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .unauth-details[_ngcontent-%COMP%] .locked-title[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .locked-details[_ngcontent-%COMP%] .unauth-title[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .locked-details[_ngcontent-%COMP%] .locked-title[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .unauth-details[_ngcontent-%COMP%] .unauth-title[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .unauth-details[_ngcontent-%COMP%] .locked-title[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .locked-details[_ngcontent-%COMP%] .unauth-title[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .locked-details[_ngcontent-%COMP%] .locked-title[_ngcontent-%COMP%]{font-size:13px;font-weight:600;color:#b45309;margin-bottom:2px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .unauth-details[_ngcontent-%COMP%] .unauth-desc[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .unauth-details[_ngcontent-%COMP%] .locked-desc[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .locked-details[_ngcontent-%COMP%] .unauth-desc[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .locked-details[_ngcontent-%COMP%] .locked-desc[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .unauth-details[_ngcontent-%COMP%] .unauth-desc[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .unauth-details[_ngcontent-%COMP%] .locked-desc[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .locked-details[_ngcontent-%COMP%] .unauth-desc[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .locked-details[_ngcontent-%COMP%] .locked-desc[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .unauth-details[_ngcontent-%COMP%] .unauth-desc[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .unauth-details[_ngcontent-%COMP%] .locked-desc[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .locked-details[_ngcontent-%COMP%] .unauth-desc[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .locked-details[_ngcontent-%COMP%] .locked-desc[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .unauth-details[_ngcontent-%COMP%] .unauth-desc[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .unauth-details[_ngcontent-%COMP%] .locked-desc[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .locked-details[_ngcontent-%COMP%] .unauth-desc[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .locked-details[_ngcontent-%COMP%] .locked-desc[_ngcontent-%COMP%]{font-size:11.5px;color:#78350f;line-height:1.4}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .btn-retry-auth[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .btn-check-unlock[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .btn-retry-auth[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .btn-check-unlock[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .btn-retry-auth[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .btn-check-unlock[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .btn-retry-auth[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .btn-check-unlock[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:4px;padding:0 12px;height:30px;background:#fef3c7bf;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid rgba(253,230,138,.85);border-radius:6px;box-shadow:0 1px 3px #d9770614,inset 0 1px 1px #fffc;font-family:inherit;font-size:11.5px;font-weight:600;color:#b45309;cursor:pointer;flex-shrink:0;transition:all .2s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .btn-retry-auth[_ngcontent-%COMP%]:hover:not(:disabled), .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .btn-check-unlock[_ngcontent-%COMP%]:hover:not(:disabled), .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .btn-retry-auth[_ngcontent-%COMP%]:hover:not(:disabled), .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .btn-check-unlock[_ngcontent-%COMP%]:hover:not(:disabled), .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .btn-retry-auth[_ngcontent-%COMP%]:hover:not(:disabled), .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .btn-check-unlock[_ngcontent-%COMP%]:hover:not(:disabled), .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .btn-retry-auth[_ngcontent-%COMP%]:hover:not(:disabled), .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .btn-check-unlock[_ngcontent-%COMP%]:hover:not(:disabled){background:#fde68af2;border-color:#d97706;transform:translateY(-.5px);box-shadow:0 3px 8px #d9770626}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .btn-retry-auth[_ngcontent-%COMP%]:active, .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .btn-check-unlock[_ngcontent-%COMP%]:active, .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .btn-retry-auth[_ngcontent-%COMP%]:active, .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .btn-check-unlock[_ngcontent-%COMP%]:active, .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .btn-retry-auth[_ngcontent-%COMP%]:active, .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .btn-check-unlock[_ngcontent-%COMP%]:active, .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .btn-retry-auth[_ngcontent-%COMP%]:active, .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .btn-check-unlock[_ngcontent-%COMP%]:active{transform:scale(.97)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .btn-retry-auth[_ngcontent-%COMP%]:disabled, .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .btn-check-unlock[_ngcontent-%COMP%]:disabled, .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .btn-retry-auth[_ngcontent-%COMP%]:disabled, .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .unauthorized-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .btn-check-unlock[_ngcontent-%COMP%]:disabled, .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .btn-retry-auth[_ngcontent-%COMP%]:disabled, .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .unauthorized-card[_ngcontent-%COMP%] .btn-check-unlock[_ngcontent-%COMP%]:disabled, .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .btn-retry-auth[_ngcontent-%COMP%]:disabled, .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .device-locked-panel[_ngcontent-%COMP%] .device-locked-card[_ngcontent-%COMP%] .btn-check-unlock[_ngcontent-%COMP%]:disabled{cursor:not-allowed;opacity:.65}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .adb-missing-panel[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:10px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .adb-missing-panel[_ngcontent-%COMP%] .error-msg-box[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px;padding:10px 12px;background:#fef2f2;border:1px solid #fecaca;border-radius:8px;font-size:12px;color:#b91c1c}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .adb-missing-panel[_ngcontent-%COMP%] .error-msg-box[_ngcontent-%COMP%] .err-icon[_ngcontent-%COMP%]{font-size:17px;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .adb-missing-panel[_ngcontent-%COMP%] .install-commands-row[_ngcontent-%COMP%]{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .adb-missing-panel[_ngcontent-%COMP%] .install-commands-row[_ngcontent-%COMP%] .install-card[_ngcontent-%COMP%]{background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:10px 12px;display:flex;flex-direction:column;gap:6px;transition:all .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .adb-missing-panel[_ngcontent-%COMP%] .install-commands-row[_ngcontent-%COMP%] .install-card.highlight-os[_ngcontent-%COMP%]{background:#eff6ff;border-color:#bfdbfe}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .adb-missing-panel[_ngcontent-%COMP%] .install-commands-row[_ngcontent-%COMP%] .install-card[_ngcontent-%COMP%] .install-os[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;font-size:11.5px;font-weight:600;color:#475569}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .adb-missing-panel[_ngcontent-%COMP%] .install-commands-row[_ngcontent-%COMP%] .install-card[_ngcontent-%COMP%] .install-os[_ngcontent-%COMP%] .detected-badge[_ngcontent-%COMP%]{font-size:9.5px;font-weight:700;color:#1a73e8;background:#dbeafe;padding:1px 5px;border-radius:4px;text-transform:uppercase}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ready-banner-footer[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 20px;background:#fff;border:1px solid #e2e8f0;border-radius:12px;box-shadow:0 1px 3px #00000008,0 4px 12px #00000005;transition:all .2s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ready-banner-footer[_ngcontent-%COMP%]:hover{border-color:#cbd5e1}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ready-banner-footer[_ngcontent-%COMP%] .ready-banner-left[_ngcontent-%COMP%]{display:flex;align-items:center;gap:14px;min-width:0;flex:1}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ready-banner-footer[_ngcontent-%COMP%] .ready-banner-left[_ngcontent-%COMP%] .step-badge[_ngcontent-%COMP%]{width:32px;height:32px;border-radius:8px;display:inline-flex;align-items:center;justify-content:center;font-size:13.5px;font-weight:700;background:#f1f5f9;color:#475569;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ready-banner-footer[_ngcontent-%COMP%] .ready-banner-left[_ngcontent-%COMP%] .step-badge[_ngcontent-%COMP%] .step-check-icon[_ngcontent-%COMP%]{font-size:18px;font-weight:700;color:inherit}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ready-banner-footer[_ngcontent-%COMP%] .ready-banner-left[_ngcontent-%COMP%] .step-badge.step-pass[_ngcontent-%COMP%]{background:#ecfdf5;color:#059669}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ready-banner-footer[_ngcontent-%COMP%] .ready-banner-left[_ngcontent-%COMP%] .ready-text-group[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:2px;min-width:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ready-banner-footer[_ngcontent-%COMP%] .ready-banner-left[_ngcontent-%COMP%] .ready-text-group[_ngcontent-%COMP%] .ready-title[_ngcontent-%COMP%]{margin:0;font-size:15px;font-weight:600;color:#0f172a;letter-spacing:-.01em}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ready-banner-footer[_ngcontent-%COMP%] .ready-banner-left[_ngcontent-%COMP%] .ready-text-group[_ngcontent-%COMP%] .ready-desc[_ngcontent-%COMP%]{margin:0;font-size:12.5px;color:#475569;line-height:1.4}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ready-banner-footer[_ngcontent-%COMP%] .btn-proceed-launch[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:6px;padding:0 18px;height:36px;background:linear-gradient(135deg,#1a73e8eb,#2563ebd9);backdrop-filter:blur(10px) saturate(180%);-webkit-backdrop-filter:blur(10px) saturate(180%);border:1px solid rgba(255,255,255,.28);box-shadow:0 4px 14px #1a73e852,inset 0 1px 1px #ffffff73;color:#fff!important;transition:background .2s cubic-bezier(.4,0,.2,1),border-color .2s cubic-bezier(.4,0,.2,1),box-shadow .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1),opacity .2s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ready-banner-footer[_ngcontent-%COMP%] .btn-proceed-launch[_ngcontent-%COMP%] span[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ready-banner-footer[_ngcontent-%COMP%] .btn-proceed-launch[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ready-banner-footer[_ngcontent-%COMP%] .btn-proceed-launch[_ngcontent-%COMP%] .run-icon[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ready-banner-footer[_ngcontent-%COMP%] .btn-proceed-launch[_ngcontent-%COMP%] .action-icon[_ngcontent-%COMP%]{color:#fff!important}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ready-banner-footer[_ngcontent-%COMP%] .btn-proceed-launch[_ngcontent-%COMP%]:hover:not(:disabled){background:linear-gradient(135deg,#1a73e8,#2563ebf2);box-shadow:0 6px 20px #1a73e86b,inset 0 1px 1px #fff9;transform:translateY(-1px)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ready-banner-footer[_ngcontent-%COMP%] .btn-proceed-launch[_ngcontent-%COMP%]:active:not(:disabled){transform:scale(.97) translateY(0);box-shadow:0 2px 8px #1a73e859}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ready-banner-footer[_ngcontent-%COMP%] .btn-proceed-launch[_ngcontent-%COMP%]:disabled{background:#94a3b880;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.2);box-shadow:none;opacity:.65;cursor:not-allowed;transform:none}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ready-banner-footer[_ngcontent-%COMP%] .btn-proceed-launch[_ngcontent-%COMP%]{border-radius:8px;font-family:inherit;font-size:13px;font-weight:600;cursor:pointer;white-space:nowrap;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ready-banner-footer[_ngcontent-%COMP%] .btn-proceed-launch[_ngcontent-%COMP%] .rocket-icon[_ngcontent-%COMP%]{font-size:16px;transition:transform .2s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ready-banner-footer[_ngcontent-%COMP%] .btn-proceed-launch[_ngcontent-%COMP%]:hover:not(:disabled) .rocket-icon[_ngcontent-%COMP%]{transform:translate(2px) translateY(-2px)}@media(max-width:768px){.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ready-banner-footer[_ngcontent-%COMP%]{flex-direction:column;align-items:stretch}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .setup-guide-view[_ngcontent-%COMP%] .ready-banner-footer[_ngcontent-%COMP%] .btn-proceed-launch[_ngcontent-%COMP%]{justify-content:center}}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .code-box[_ngcontent-%COMP%]{display:inline-flex;align-items:center;justify-content:space-between;gap:8px;padding:0 6px 0 10px;height:32px;background:#0f172a;color:#f8fafc;border-radius:6px;font-family:JetBrains Mono,Fira Code,Roboto Mono,SFMono-Regular,Consolas,monospace;font-size:11.5px;min-width:0;max-width:100%}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .code-box[_ngcontent-%COMP%] .terminal-prefix[_ngcontent-%COMP%]{color:#64748b;-webkit-user-select:none;user-select:none;flex-shrink:0;font-weight:700;font-size:11px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .code-box[_ngcontent-%COMP%] .code-text[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .code-box[_ngcontent-%COMP%] code[_ngcontent-%COMP%]{flex:1;min-width:0;color:#38bdf8!important;font-family:inherit;background:transparent!important;border:none!important;padding:0!important;font-size:inherit;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .code-box[_ngcontent-%COMP%] .btn-copy-code[_ngcontent-%COMP%]{display:inline-flex;align-items:center;justify-content:center;gap:3px;padding:0 8px;height:22px;background:#ffffff24;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:#fff!important;border:1px solid rgba(255,255,255,.2);border-radius:5px;box-shadow:inset 0 1px 1px #fff3;font-family:Google Sans Text,Google Sans,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:10.5px;font-weight:600;cursor:pointer;transition:all .18s ease;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .code-box[_ngcontent-%COMP%] .btn-copy-code[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .code-box[_ngcontent-%COMP%] .btn-copy-code[_ngcontent-%COMP%] span[_ngcontent-%COMP%]{color:#fff!important}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .code-box[_ngcontent-%COMP%] .btn-copy-code[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:12px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .code-box[_ngcontent-%COMP%] .btn-copy-code[_ngcontent-%COMP%]:hover{background:#ffffff42;border-color:#ffffff59;transform:translateY(-.5px)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .code-box[_ngcontent-%COMP%] .btn-copy-code[_ngcontent-%COMP%]:active{transform:scale(.96)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .code-box[_ngcontent-%COMP%] .btn-copy-code.copied[_ngcontent-%COMP%]{background:#10b98159;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);border-color:#10b98199;color:#34d399!important;box-shadow:0 0 10px #10b9814d,inset 0 1px 1px #fff6}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .code-box[_ngcontent-%COMP%] .btn-copy-code.copied[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .code-box[_ngcontent-%COMP%] .btn-copy-code.copied[_ngcontent-%COMP%] span[_ngcontent-%COMP%]{color:#34d399!important}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .code-box.small-box[_ngcontent-%COMP%]{height:30px;font-size:11.5px;padding:0 6px 0 10px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .code-box.small-box[_ngcontent-%COMP%] .btn-copy-code[_ngcontent-%COMP%]{height:22px;padding:0 8px;font-size:10.5px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .code-box.small-box[_ngcontent-%COMP%] .btn-copy-code[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:12px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:12px;animation:_ngcontent-%COMP%_fadeIn .22s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .launcher-warning-bar[_ngcontent-%COMP%]{display:flex;align-items:center;gap:10px;padding:10px 16px;background:#fffbeb;border:1px solid #fde68a;border-radius:12px;color:#92400e;font-size:13px;cursor:pointer;transition:all .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .launcher-warning-bar[_ngcontent-%COMP%] .warn-icon[_ngcontent-%COMP%]{font-size:18px;color:#d97706;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .launcher-warning-bar[_ngcontent-%COMP%] .arrow[_ngcontent-%COMP%]{font-size:16px;margin-left:auto}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .launcher-warning-bar[_ngcontent-%COMP%]:hover{background:#fef3c7;border-color:#fcd34d}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%]{background:#ffffffe0;backdrop-filter:blur(16px) saturate(190%);-webkit-backdrop-filter:blur(16px) saturate(190%);border:1px solid rgba(255,255,255,.95);box-shadow:0 20px 50px -12px #0f172a14,0 1px 3px #00000005,inset 0 1px 1px #fffffff2;border-radius:20px;padding:18px 20px 14px;transition:border-color .3s cubic-bezier(.4,0,.2,1),box-shadow .3s cubic-bezier(.4,0,.2,1),background .3s cubic-bezier(.4,0,.2,1);position:relative}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card.is-pro-active[_ngcontent-%COMP%]{border-color:#ddd6fef2;box-shadow:0 24px 60px -12px #6366f129,0 1px 3px #00000005,inset 0 1px 1px #fffffff2}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .intent-suggestion-strip[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 12px;background:linear-gradient(135deg,#eef2fff2,#f5f3fff2);border:1px solid rgba(199,210,254,.85);border-radius:10px;margin-bottom:12px;animation:_ngcontent-%COMP%_slideUpFade .22s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .intent-suggestion-strip[_ngcontent-%COMP%] .suggestion-left[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px;min-width:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .intent-suggestion-strip[_ngcontent-%COMP%] .suggestion-left[_ngcontent-%COMP%] .sparkle-icon[_ngcontent-%COMP%]{font-size:17px;color:#6366f1;flex-shrink:0;animation:_ngcontent-%COMP%_sparklePulse 2.5s ease-in-out infinite alternate}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .intent-suggestion-strip[_ngcontent-%COMP%] .suggestion-left[_ngcontent-%COMP%] .suggestion-text[_ngcontent-%COMP%]{font-size:12.5px;color:#3730a3;line-height:1.4}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .intent-suggestion-strip[_ngcontent-%COMP%] .suggestion-left[_ngcontent-%COMP%] .suggestion-text[_ngcontent-%COMP%] strong[_ngcontent-%COMP%]{color:#1e1b4b;font-weight:600}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .intent-suggestion-strip[_ngcontent-%COMP%] .btn-switch-pro[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:6px;border:none;background:#6366f1;color:#fff;font-family:inherit;font-size:11.5px;font-weight:600;cursor:pointer;flex-shrink:0;transition:all .18s cubic-bezier(.4,0,.2,1);box-shadow:0 1px 3px #6366f140}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .intent-suggestion-strip[_ngcontent-%COMP%] .btn-switch-pro[_ngcontent-%COMP%] .btn-arrow[_ngcontent-%COMP%]{font-size:14px;transition:transform .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .intent-suggestion-strip[_ngcontent-%COMP%] .btn-switch-pro[_ngcontent-%COMP%]:hover{background:#4f46e5;box-shadow:0 2px 6px #6366f159;transform:translateY(-.5px)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .intent-suggestion-strip[_ngcontent-%COMP%] .btn-switch-pro[_ngcontent-%COMP%]:hover .btn-arrow[_ngcontent-%COMP%]{transform:translate(2px)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .intent-suggestion-strip[_ngcontent-%COMP%] .btn-switch-pro[_ngcontent-%COMP%]:active{transform:scale(.96)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-input-area[_ngcontent-%COMP%]{width:100%}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-input-area[_ngcontent-%COMP%] .composer-textarea[_ngcontent-%COMP%]{width:100%;border:none;outline:none;background:transparent;padding:0;margin:0;font-family:inherit;font-size:15.5px;line-height:1.6;color:#0f172a;resize:vertical;min-height:90px;letter-spacing:-.01em}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-input-area[_ngcontent-%COMP%] .composer-textarea[_ngcontent-%COMP%]::placeholder{color:#94a3b8;font-weight:400}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-input-area[_ngcontent-%COMP%] .composer-textarea[_ngcontent-%COMP%]:disabled{color:#94a3b8;cursor:not-allowed}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-model-descriptor[_ngcontent-%COMP%]{display:flex;align-items:center;gap:9px;padding:8px 12px;border-radius:9px;background:#f8fafcbf;border:1px solid rgba(241,245,249,.95);margin-top:10px;margin-bottom:12px;transition:all .25s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-model-descriptor[_ngcontent-%COMP%] .descriptor-icon-wrap[_ngcontent-%COMP%]{width:22px;height:22px;border-radius:6px;display:inline-flex;align-items:center;justify-content:center;background:#1a73e81a;color:#1a73e8;flex-shrink:0;transition:all .25s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-model-descriptor[_ngcontent-%COMP%] .descriptor-icon-wrap[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:14px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-model-descriptor[_ngcontent-%COMP%] .descriptor-body[_ngcontent-%COMP%]{flex:1;min-width:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-model-descriptor[_ngcontent-%COMP%] .descriptor-body[_ngcontent-%COMP%] .desc-line[_ngcontent-%COMP%]{margin:0;font-size:12.5px;line-height:1.45;color:#475569;letter-spacing:-.005em}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-model-descriptor[_ngcontent-%COMP%] .descriptor-body[_ngcontent-%COMP%] .desc-line[_ngcontent-%COMP%] .mode-name[_ngcontent-%COMP%]{font-weight:700;color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-model-descriptor[_ngcontent-%COMP%] .descriptor-body[_ngcontent-%COMP%] .desc-line[_ngcontent-%COMP%] .bullet[_ngcontent-%COMP%]{color:#94a3b8;margin:0 3px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-model-descriptor[_ngcontent-%COMP%] .descriptor-body[_ngcontent-%COMP%] .desc-line[_ngcontent-%COMP%] strong[_ngcontent-%COMP%]{color:#0f172a;font-weight:600}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-model-descriptor[_ngcontent-%COMP%] .descriptor-body[_ngcontent-%COMP%] .desc-line[_ngcontent-%COMP%] .limitation-tag[_ngcontent-%COMP%]{color:#64748b;font-size:11.5px;margin-left:3px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-model-descriptor.is-pro[_ngcontent-%COMP%]{background:linear-gradient(135deg,#f5f3ffcc,#faf5ffcc);border-color:#ede9fef2}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-model-descriptor.is-pro[_ngcontent-%COMP%] .descriptor-icon-wrap[_ngcontent-%COMP%]{background:#7c3aed1f;color:#7c3aed}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-model-descriptor.is-pro[_ngcontent-%COMP%] .descriptor-body[_ngcontent-%COMP%] .desc-line[_ngcontent-%COMP%]{color:#334155}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-model-descriptor.is-pro[_ngcontent-%COMP%] .descriptor-body[_ngcontent-%COMP%] .desc-line[_ngcontent-%COMP%] .mode-name[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-model-descriptor.is-pro[_ngcontent-%COMP%] .descriptor-body[_ngcontent-%COMP%] .desc-line[_ngcontent-%COMP%] strong[_ngcontent-%COMP%]{color:#1e1b4b}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .error-banner[_ngcontent-%COMP%]{display:flex;align-items:center;gap:6px;padding:8px 12px;background:#fef2f2;border:1px solid #fecaca;border-radius:8px;color:#b91c1c;font-size:12px;margin-bottom:10px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .error-banner[_ngcontent-%COMP%] .error-icon[_ngcontent-%COMP%]{font-size:16px;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;padding-top:10px;border-top:1px solid rgba(241,245,249,.95);gap:12px;flex-wrap:wrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-left[_ngcontent-%COMP%]{display:flex;align-items:center}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-left[_ngcontent-%COMP%] .model-toggle-capsule[_ngcontent-%COMP%]{display:inline-flex;align-items:center;background:#f1f5f9d9;padding:3px;border-radius:18px;gap:3px;border:1px solid rgba(226,232,240,.88);width:172px;box-sizing:border-box}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-left[_ngcontent-%COMP%] .model-toggle-capsule[_ngcontent-%COMP%] .model-toggle-btn[_ngcontent-%COMP%]{flex:1 1 0;width:50%;height:32px;padding:0 10px;border-radius:15px;border:none;background:transparent;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:all .2s cubic-bezier(.4,0,.2,1);outline:none;box-sizing:border-box;white-space:nowrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-left[_ngcontent-%COMP%] .model-toggle-capsule[_ngcontent-%COMP%] .model-toggle-btn[_ngcontent-%COMP%] .btn-icon[_ngcontent-%COMP%]{font-size:16px;color:#64748b;flex-shrink:0;transition:color .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-left[_ngcontent-%COMP%] .model-toggle-capsule[_ngcontent-%COMP%] .model-toggle-btn[_ngcontent-%COMP%] .btn-text[_ngcontent-%COMP%]{font-size:13px;font-weight:500;color:#475569;line-height:1}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-left[_ngcontent-%COMP%] .model-toggle-capsule[_ngcontent-%COMP%] .model-toggle-btn[_ngcontent-%COMP%]:hover:not(.active){background:#ffffffb3}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-left[_ngcontent-%COMP%] .model-toggle-capsule[_ngcontent-%COMP%] .model-toggle-btn[_ngcontent-%COMP%]:hover:not(.active) .btn-icon[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-left[_ngcontent-%COMP%] .model-toggle-capsule[_ngcontent-%COMP%] .model-toggle-btn[_ngcontent-%COMP%]:hover:not(.active) .btn-text[_ngcontent-%COMP%]{color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-left[_ngcontent-%COMP%] .model-toggle-capsule[_ngcontent-%COMP%] .model-toggle-btn.active[_ngcontent-%COMP%]{background:#fff;box-shadow:0 1px 4px #00000014,0 1px 2px #0000000a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-left[_ngcontent-%COMP%] .model-toggle-capsule[_ngcontent-%COMP%] .model-toggle-btn.active[_ngcontent-%COMP%] .btn-icon[_ngcontent-%COMP%]{color:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-left[_ngcontent-%COMP%] .model-toggle-capsule[_ngcontent-%COMP%] .model-toggle-btn.active[_ngcontent-%COMP%] .btn-text[_ngcontent-%COMP%]{color:#0f172a;font-weight:600}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-left[_ngcontent-%COMP%] .model-toggle-capsule[_ngcontent-%COMP%] .model-toggle-btn.btn-pro.active[_ngcontent-%COMP%]{background:linear-gradient(135deg,#6366f1,#7c3aed);box-shadow:0 2px 8px #6366f159}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-left[_ngcontent-%COMP%] .model-toggle-capsule[_ngcontent-%COMP%] .model-toggle-btn.btn-pro.active[_ngcontent-%COMP%] .btn-icon[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-left[_ngcontent-%COMP%] .model-toggle-capsule[_ngcontent-%COMP%] .model-toggle-btn.btn-pro.active[_ngcontent-%COMP%] .btn-text[_ngcontent-%COMP%]{color:#fff!important}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-left[_ngcontent-%COMP%] .model-toggle-capsule[_ngcontent-%COMP%] .model-toggle-btn[_ngcontent-%COMP%]:active{transform:scale(.97)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-right[_ngcontent-%COMP%]{display:flex;align-items:center;gap:12px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-right[_ngcontent-%COMP%] .char-count[_ngcontent-%COMP%]{font-size:11.5px;color:#94a3b8;font-variant-numeric:tabular-nums}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-right[_ngcontent-%COMP%] .btn-run-composer[_ngcontent-%COMP%]{display:inline-flex;align-items:center;justify-content:center;gap:6px;height:38px;padding:0 20px;background:linear-gradient(135deg,#1a73e8eb,#2563ebd9);backdrop-filter:blur(10px) saturate(180%);-webkit-backdrop-filter:blur(10px) saturate(180%);border:1px solid rgba(255,255,255,.28);box-shadow:0 4px 14px #1a73e852,inset 0 1px 1px #ffffff73;color:#fff!important;transition:background .2s cubic-bezier(.4,0,.2,1),border-color .2s cubic-bezier(.4,0,.2,1),box-shadow .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1),opacity .2s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-right[_ngcontent-%COMP%] .btn-run-composer[_ngcontent-%COMP%] span[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-right[_ngcontent-%COMP%] .btn-run-composer[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-right[_ngcontent-%COMP%] .btn-run-composer[_ngcontent-%COMP%] .run-icon[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-right[_ngcontent-%COMP%] .btn-run-composer[_ngcontent-%COMP%] .action-icon[_ngcontent-%COMP%]{color:#fff!important}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-right[_ngcontent-%COMP%] .btn-run-composer[_ngcontent-%COMP%]:hover:not(:disabled){background:linear-gradient(135deg,#1a73e8,#2563ebf2);box-shadow:0 6px 20px #1a73e86b,inset 0 1px 1px #fff9;transform:translateY(-1px)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-right[_ngcontent-%COMP%] .btn-run-composer[_ngcontent-%COMP%]:active:not(:disabled){transform:scale(.97) translateY(0);box-shadow:0 2px 8px #1a73e859}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-right[_ngcontent-%COMP%] .btn-run-composer[_ngcontent-%COMP%]:disabled{background:#94a3b880;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.2);box-shadow:none;opacity:.65;cursor:not-allowed;transform:none}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-right[_ngcontent-%COMP%] .btn-run-composer[_ngcontent-%COMP%]{border-radius:12px;font-family:inherit;font-size:13.5px;font-weight:700;cursor:pointer;letter-spacing:-.01em}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-right[_ngcontent-%COMP%] .btn-run-composer.btn-run-pro[_ngcontent-%COMP%]{background:linear-gradient(135deg,#6366f1eb,#7c3aedd9);box-shadow:0 4px 14px #6366f152,inset 0 1px 1px #ffffff73}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-right[_ngcontent-%COMP%] .btn-run-composer.btn-run-pro[_ngcontent-%COMP%]:hover:not(:disabled){background:linear-gradient(135deg,#6366f1,#7c3aedf2);box-shadow:0 6px 20px #6366f16b,inset 0 1px 1px #fff9}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-right[_ngcontent-%COMP%] .btn-run-composer[_ngcontent-%COMP%] .run-icon[_ngcontent-%COMP%]{font-size:18px;line-height:1;transition:transform .2s cubic-bezier(.34,1.56,.64,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-right[_ngcontent-%COMP%] .btn-run-composer[_ngcontent-%COMP%]:hover:not(:disabled) .run-icon[_ngcontent-%COMP%]{transform:scale(1.15) rotate(15deg)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .composer-card[_ngcontent-%COMP%] .composer-action-bar[_ngcontent-%COMP%] .bar-right[_ngcontent-%COMP%] .btn-run-composer[_ngcontent-%COMP%] .spinner[_ngcontent-%COMP%]{width:14px;height:14px;border:2px solid rgba(255,255,255,.4);border-top-color:#fff;border-radius:50%;animation:_ngcontent-%COMP%_spin .8s linear infinite}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-outputter-standalone-card[_ngcontent-%COMP%]{background:#ffffffe0;backdrop-filter:blur(20px) saturate(180%);-webkit-backdrop-filter:blur(20px) saturate(180%);border:1px solid rgba(226,232,240,.95);border-radius:16px;padding:12px 18px 14px;box-shadow:0 1px 3px #00000005,0 8px 24px -6px #0f172a0a;display:flex;flex-direction:column;gap:8px;animation:_ngcontent-%COMP%_slideUpFade .2s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-outputter-standalone-card[_ngcontent-%COMP%] .outputter-card-header[_ngcontent-%COMP%]{display:flex;align-items:baseline;gap:6px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-outputter-standalone-card[_ngcontent-%COMP%] .outputter-card-header[_ngcontent-%COMP%] .outputter-title[_ngcontent-%COMP%]{font-size:13px;font-weight:700;color:#0f172a;letter-spacing:-.01em}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-outputter-standalone-card[_ngcontent-%COMP%] .outputter-card-header[_ngcontent-%COMP%] .outputter-optional-tag[_ngcontent-%COMP%]{font-size:11.5px;font-weight:500;color:#94a3b8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-outputter-standalone-card[_ngcontent-%COMP%] .outputter-input-wrapper[_ngcontent-%COMP%]{display:flex;align-items:center;background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;padding:0 12px;height:38px;transition:all .18s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-outputter-standalone-card[_ngcontent-%COMP%] .outputter-input-wrapper[_ngcontent-%COMP%]:focus-within{background:#fff;border-color:#7c3aed;box-shadow:0 0 0 3px #7c3aed1a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-outputter-standalone-card[_ngcontent-%COMP%] .outputter-input-wrapper[_ngcontent-%COMP%] .outputter-standalone-input[_ngcontent-%COMP%]{flex:1;border:none;outline:none;background:transparent;font-family:inherit;font-size:13px;color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-outputter-standalone-card[_ngcontent-%COMP%] .outputter-input-wrapper[_ngcontent-%COMP%] .outputter-standalone-input[_ngcontent-%COMP%]::placeholder{color:#94a3b8;font-weight:400}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-outputter-standalone-card[_ngcontent-%COMP%] .outputter-input-wrapper[_ngcontent-%COMP%] .btn-clear-standalone[_ngcontent-%COMP%]{border:none;background:transparent;color:#94a3b8;cursor:pointer;padding:3px;border-radius:4px;display:flex;align-items:center;justify-content:center;transition:color .15s ease,background .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-outputter-standalone-card[_ngcontent-%COMP%] .outputter-input-wrapper[_ngcontent-%COMP%] .btn-clear-standalone[_ngcontent-%COMP%]:hover{color:#475569;background:#0000000d}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-outputter-standalone-card[_ngcontent-%COMP%] .outputter-input-wrapper[_ngcontent-%COMP%] .btn-clear-standalone[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:16px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%]{--%NS%tip-w: 320px;--%NS%thumb-w: 22px;--%NS%thumb-h: 14px;--%NS%col-gap: 28px;--%NS%accent: #6366f1;position:relative;background:#ffffffe0;backdrop-filter:blur(20px) saturate(180%);-webkit-backdrop-filter:blur(20px) saturate(180%);border:1px solid rgba(226,232,240,.95);border-radius:16px;padding:12px 18px 14px;box-shadow:0 1px 3px #00000005,0 8px 24px -6px #0f172a0a;display:flex;flex-direction:column;gap:10px;animation:_ngcontent-%COMP%_slideUpFade .2s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card.tip-open[_ngcontent-%COMP%]{z-index:30}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-card-header[_ngcontent-%COMP%]{display:flex;align-items:baseline;justify-content:space-between;gap:10px;min-height:16px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-card-header[_ngcontent-%COMP%] .tuning-title[_ngcontent-%COMP%]{font-size:13px;font-weight:700;color:#0f172a;letter-spacing:-.01em}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-card-header[_ngcontent-%COMP%] .btn-tuning-reset[_ngcontent-%COMP%]{border:none;background:transparent;padding:0;font-family:inherit;font-size:12px;font-weight:500;color:#94a3b8;cursor:pointer;transition:color .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-card-header[_ngcontent-%COMP%] .btn-tuning-reset[_ngcontent-%COMP%]:hover{color:#475569}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-card-header[_ngcontent-%COMP%] .btn-tuning-reset[_ngcontent-%COMP%]:focus-visible{outline:2px solid var(--%NS%accent);outline-offset:3px;border-radius:3px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-sliders[_ngcontent-%COMP%]{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px var(--%NS%col-gap)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:4px;min-width:0;--%NS%tip-min: 0px;--%NS%tip-max: calc(100% - var(--%NS%tip-w))}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider.verify[_ngcontent-%COMP%]{--%NS%tip-max: calc(200% + var(--%NS%col-gap) - var(--%NS%tip-w))}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider.explore[_ngcontent-%COMP%]{--%NS%accent: #0ea5e9;--%NS%tip-min: calc(-100% - var(--%NS%col-gap))}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .slider-head[_ngcontent-%COMP%]{display:flex;align-items:baseline;gap:8px;font-size:13px;line-height:18px;color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .slider-head[_ngcontent-%COMP%] .slider-name[_ngcontent-%COMP%]{font-weight:400;color:#334155}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .slider-head[_ngcontent-%COMP%] .slider-value[_ngcontent-%COMP%]{font-weight:600}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .slider-ends[_ngcontent-%COMP%]{display:flex;justify-content:space-between;font-size:11.5px;line-height:14px;color:#94a3b8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .slider-track-wrap[_ngcontent-%COMP%]{position:relative;height:26px;display:flex;align-items:center}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .slider-rail[_ngcontent-%COMP%]{position:relative;width:100%;height:6px;border-radius:3px;background:#e2e8f0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .slider-fill[_ngcontent-%COMP%]{position:absolute;top:0;bottom:0;left:0;width:calc(var(--%NS%pct) * (100% - var(--%NS%thumb-w)) + var(--%NS%thumb-w) / 2);border-radius:3px;background:color-mix(in srgb,var(--%NS%accent) 24%,#e2e8f0);transition:width .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .slider-notch[_ngcontent-%COMP%]{position:absolute;top:50%;left:calc(var(--%NS%n) * (100% - var(--%NS%thumb-w)) + var(--%NS%thumb-w) / 2);width:4px;height:4px;border-radius:50%;background:#94a3b8;transform:translate(-50%,-50%);transition:background .15s ease;z-index:1}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .slider-notch.reached[_ngcontent-%COMP%]{background:#fffffff2}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .slider-thumb[_ngcontent-%COMP%]{position:absolute;top:50%;left:calc(var(--%NS%pct) * (100% - var(--%NS%thumb-w)) + var(--%NS%thumb-w) / 2);width:var(--%NS%thumb-w);height:var(--%NS%thumb-h);border-radius:5px;background:#fff;border:1px solid rgba(15,23,42,.1);box-shadow:0 1px 2px #0f172a1f,0 2px 6px #0f172a14;transform:translate(-50%,-50%);pointer-events:none;transition:left .15s ease,box-shadow .15s ease,border-color .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .slider-solid[_ngcontent-%COMP%]{position:absolute;inset:0;border-radius:3px;background:var(--%NS%accent);transform-origin:right center;transform:scaleX(1);animation:_ngcontent-%COMP%_solidSweep 1s cubic-bezier(.4,0,.2,1) .2s both;pointer-events:none}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .slider-dither[_ngcontent-%COMP%]{position:absolute;inset:0;display:block;overflow:hidden;border-radius:3px;pointer-events:none}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .slider-dither[_ngcontent-%COMP%] rect[_ngcontent-%COMP%]{fill:var(--%NS%accent);animation:_ngcontent-%COMP%_ditherIn .3s ease-out both}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .thumb-ripple[_ngcontent-%COMP%]{position:absolute;top:50%;left:calc(var(--%NS%pct) * (100% - var(--%NS%thumb-w)) + var(--%NS%thumb-w) / 2);width:var(--%NS%thumb-w);height:var(--%NS%thumb-w);border-radius:50%;border:1.5px solid var(--%NS%accent);transform:translate(-50%,-50%) scale(.6);opacity:0;pointer-events:none;animation:_ngcontent-%COMP%_thumbRipple .6s ease-out forwards}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider.dragging[_ngcontent-%COMP%] .slider-thumb[_ngcontent-%COMP%]{border-color:color-mix(in srgb,var(--%NS%accent) 45%,transparent);box-shadow:0 1px 2px #0f172a1f,0 2px 8px #0f172a1a,0 0 0 4px color-mix(in srgb,var(--%NS%accent) 14%,transparent)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .slider-input[_ngcontent-%COMP%]{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:pointer;-webkit-appearance:none;appearance:none;background:transparent}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .slider-input[_ngcontent-%COMP%]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:var(--%NS%thumb-w);height:26px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .slider-input[_ngcontent-%COMP%]::-moz-range-thumb{width:var(--%NS%thumb-w);height:26px;border:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .slider-input[_ngcontent-%COMP%]:focus-visible ~ .slider-thumb[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%]:has(.slider-input:focus-visible) .slider-thumb[_ngcontent-%COMP%]{box-shadow:0 1px 2px #0f172a1f,0 0 0 2px #fff,0 0 0 4px var(--%NS%accent)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .tuning-tip-arrow[_ngcontent-%COMP%]{position:absolute;bottom:calc(100% + 2px);left:calc(var(--%NS%pct) * (100% - var(--%NS%thumb-w)) + var(--%NS%thumb-w) / 2);width:10px;height:10px;background:#fff;border-right:1px solid rgb(226,232,240);border-bottom:1px solid rgb(226,232,240);transform:translate(-50%,50%) rotate(45deg);z-index:41;pointer-events:none}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .tuning-tip[_ngcontent-%COMP%]{position:absolute;bottom:calc(100% + 7px);left:clamp(var(--%NS%tip-min),var(--%NS%pct) * (100% - var(--%NS%thumb-w)) + var(--%NS%thumb-w) / 2 - var(--%NS%tip-w) / 2,var(--%NS%tip-max));width:var(--%NS%tip-w);max-width:calc(100vw - 32px);padding:12px 14px;background:#fff;border:1px solid rgb(226,232,240);border-radius:12px;box-shadow:0 12px 32px -8px #0f172a2e,0 2px 6px #0f172a0f;z-index:40;pointer-events:none;animation:_ngcontent-%COMP%_tipPop .16s ease-out;font-size:12px;line-height:1.5;color:#334155;display:flex;flex-direction:column;gap:8px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .tuning-tip[_ngcontent-%COMP%] .tip-heading[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:1px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .tuning-tip[_ngcontent-%COMP%] .tip-heading[_ngcontent-%COMP%] .tip-title[_ngcontent-%COMP%]{font-size:13.5px;font-weight:600;color:#0f172a;letter-spacing:-.01em}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .tuning-tip[_ngcontent-%COMP%] .tip-heading[_ngcontent-%COMP%] .tip-latency[_ngcontent-%COMP%]{font-size:11.5px;color:#94a3b8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .tuning-tip[_ngcontent-%COMP%] .tip-tagline[_ngcontent-%COMP%]{margin:0;color:#0f172a}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .tuning-tip[_ngcontent-%COMP%] .tip-section[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:2px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .tuning-tip[_ngcontent-%COMP%] .tip-section[_ngcontent-%COMP%] ul[_ngcontent-%COMP%]{margin:0;padding-left:16px;display:flex;flex-direction:column;gap:2px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .tuning-tip[_ngcontent-%COMP%] .tip-section[_ngcontent-%COMP%] li[_ngcontent-%COMP%]::marker{color:#cbd5e1}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .tuning-tip[_ngcontent-%COMP%] .tip-section.skips[_ngcontent-%COMP%] li[_ngcontent-%COMP%]{color:#64748b}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .tuning-tip[_ngcontent-%COMP%] .tip-section-title[_ngcontent-%COMP%]{font-size:10.5px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#94a3b8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider[_ngcontent-%COMP%] .tuning-tip[_ngcontent-%COMP%] .tip-best[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:2px;color:#475569}@media(max-width:760px){.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-sliders[_ngcontent-%COMP%]{grid-template-columns:minmax(0,1fr);gap:10px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider.verify[_ngcontent-%COMP%], .home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .pro-tuning-card[_ngcontent-%COMP%] .tuning-slider.explore[_ngcontent-%COMP%]{--%NS%tip-min: 0px;--%NS%tip-max: calc(100% - var(--%NS%tip-w))}}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:12px;margin-top:10px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-header-bar[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-header-bar[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%]{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-header-bar[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%] .section-label[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:6px;font-family:inherit;font-size:13px;font-weight:700;color:#0f172a;letter-spacing:-.01em}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-header-bar[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%] .section-label[_ngcontent-%COMP%] .section-icon[_ngcontent-%COMP%]{font-size:17px;color:#6366f1;animation:_ngcontent-%COMP%_sparklePulse 3s ease-in-out infinite alternate}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-header-bar[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%] .section-label[_ngcontent-%COMP%] .device-context-pill[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:12px;background:#10b9811a;border:1px solid rgba(16,185,129,.25);color:#059669;font-size:11px;font-weight:600;line-height:1.3}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-header-bar[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%] .section-label[_ngcontent-%COMP%] .device-context-pill[_ngcontent-%COMP%] .dev-icon[_ngcontent-%COMP%]{font-size:13px;color:#059669}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-header-bar[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%] .category-tabs-group[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:3px;background:#ffffffa6;backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);padding:3px;border-radius:16px;border:1px solid rgba(226,232,240,.85);box-shadow:0 1px 3px #00000005}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-header-bar[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%] .category-tabs-group[_ngcontent-%COMP%] .cat-tab-btn[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:12px;border:none;background:transparent;font-family:inherit;font-size:11.5px;font-weight:600;color:#64748b;cursor:pointer;transition:all .18s ease;white-space:nowrap}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-header-bar[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%] .category-tabs-group[_ngcontent-%COMP%] .cat-tab-btn[_ngcontent-%COMP%] .tab-mini-icon[_ngcontent-%COMP%]{font-size:13px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-header-bar[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%] .category-tabs-group[_ngcontent-%COMP%] .cat-tab-btn[_ngcontent-%COMP%]:hover:not(.active){color:#0f172a;background:#fff9}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-header-bar[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%] .category-tabs-group[_ngcontent-%COMP%] .cat-tab-btn.active[_ngcontent-%COMP%]{color:#0f172a;background:#fff;box-shadow:0 2px 6px #0f172a14,inset 0 1px #fff}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-header-bar[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%] .category-tabs-group[_ngcontent-%COMP%] .cat-tab-btn.active.cat-device[_ngcontent-%COMP%]{color:#059669}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-header-bar[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%] .category-tabs-group[_ngcontent-%COMP%] .cat-tab-btn.active.cat-flash[_ngcontent-%COMP%]{color:#1a73e8}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-header-bar[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%] .category-tabs-group[_ngcontent-%COMP%] .cat-tab-btn.active.cat-pro[_ngcontent-%COMP%]{color:#7c3aed}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-header-bar[_ngcontent-%COMP%] .header-right[_ngcontent-%COMP%] .btn-shuffle-suggestions[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:4px;padding:5px 12px;border-radius:14px;background:#ffffffb8;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border:1px solid rgba(226,232,240,.85);color:#64748b;font-family:inherit;font-size:11.5px;font-weight:600;cursor:pointer;transition:all .2s cubic-bezier(.4,0,.2,1)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-header-bar[_ngcontent-%COMP%] .header-right[_ngcontent-%COMP%] .btn-shuffle-suggestions[_ngcontent-%COMP%] .shuffle-icon[_ngcontent-%COMP%]{font-size:14px;transition:transform .2s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-header-bar[_ngcontent-%COMP%] .header-right[_ngcontent-%COMP%] .btn-shuffle-suggestions[_ngcontent-%COMP%]:hover{background:#fff;color:#0f172a;border-color:#cbd5e1e6;transform:translateY(-1px);box-shadow:0 2px 8px #0f172a0f}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-header-bar[_ngcontent-%COMP%] .header-right[_ngcontent-%COMP%] .btn-shuffle-suggestions[_ngcontent-%COMP%]:hover .shuffle-icon[_ngcontent-%COMP%]{transform:rotate(45deg)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-header-bar[_ngcontent-%COMP%] .header-right[_ngcontent-%COMP%] .btn-shuffle-suggestions[_ngcontent-%COMP%]:active{transform:scale(.97)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-cards-grid[_ngcontent-%COMP%]{display:grid;grid-template-columns:repeat(auto-fill,minmax(285px,1fr));gap:12px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-cards-grid[_ngcontent-%COMP%] .suggestion-card[_ngcontent-%COMP%]{display:flex;flex-direction:column;justify-content:space-between;padding:14px 16px;border-radius:16px;background:#ffffffb8;backdrop-filter:blur(10px) saturate(180%);-webkit-backdrop-filter:blur(10px) saturate(180%);border:1px solid rgba(226,232,240,.85);box-shadow:0 2px 8px -1px #0f172a0a,inset 0 1px 1px #fffffff2;cursor:pointer;transition:background .22s cubic-bezier(.4,0,.2,1),transform .22s cubic-bezier(.4,0,.2,1),box-shadow .22s cubic-bezier(.4,0,.2,1),border-color .22s cubic-bezier(.4,0,.2,1);-webkit-user-select:none;user-select:none;position:relative;overflow:hidden;min-height:128px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-cards-grid[_ngcontent-%COMP%] .suggestion-card[_ngcontent-%COMP%]:hover{background:#fffffff2;transform:translateY(-2px);box-shadow:0 8px 24px -4px #0f172a14,0 2px 6px #00000005,inset 0 1px 1px #fff;border-color:#bfdbfef2}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-cards-grid[_ngcontent-%COMP%] .suggestion-card.is-pro-card[_ngcontent-%COMP%]:hover{border-color:#c7d2fef2;box-shadow:0 8px 24px -4px #6366f11f,inset 0 1px 1px #fff}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-cards-grid[_ngcontent-%COMP%] .suggestion-card[_ngcontent-%COMP%]:active{transform:scale(.985)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-cards-grid[_ngcontent-%COMP%] .suggestion-card[_ngcontent-%COMP%] .card-top-row[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;gap:8px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-cards-grid[_ngcontent-%COMP%] .suggestion-card[_ngcontent-%COMP%] .card-top-row[_ngcontent-%COMP%] .app-icons-cluster[_ngcontent-%COMP%]{display:flex;align-items:center;gap:6px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-cards-grid[_ngcontent-%COMP%] .suggestion-card[_ngcontent-%COMP%] .card-top-row[_ngcontent-%COMP%] .app-icons-cluster[_ngcontent-%COMP%] .app-icon-badge[_ngcontent-%COMP%]{width:26px;height:26px;border-radius:8px;background:#f1f5f9;display:flex;align-items:center;justify-content:center;color:#475569;border:1px solid rgba(226,232,240,.9);flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-cards-grid[_ngcontent-%COMP%] .suggestion-card[_ngcontent-%COMP%] .card-top-row[_ngcontent-%COMP%] .app-icons-cluster[_ngcontent-%COMP%] .app-icon-badge[_ngcontent-%COMP%] span[_ngcontent-%COMP%]{font-size:14px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-cards-grid[_ngcontent-%COMP%] .suggestion-card[_ngcontent-%COMP%] .card-top-row[_ngcontent-%COMP%] .app-icons-cluster[_ngcontent-%COMP%] .app-names-label[_ngcontent-%COMP%]{font-size:11.5px;font-weight:600;color:#475569;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:140px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-cards-grid[_ngcontent-%COMP%] .suggestion-card[_ngcontent-%COMP%] .card-top-row[_ngcontent-%COMP%] .mode-badge[_ngcontent-%COMP%]{padding:2px 7px;border-radius:8px;font-size:10.5px;font-weight:700;line-height:1.2;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-cards-grid[_ngcontent-%COMP%] .suggestion-card[_ngcontent-%COMP%] .card-top-row[_ngcontent-%COMP%] .mode-badge.mode-flash[_ngcontent-%COMP%]{background:#1a73e81a;color:#1a73e8;border:1px solid rgba(26,115,232,.2)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-cards-grid[_ngcontent-%COMP%] .suggestion-card[_ngcontent-%COMP%] .card-top-row[_ngcontent-%COMP%] .mode-badge.mode-pro[_ngcontent-%COMP%]{background:#6366f11f;color:#6366f1;border:1px solid rgba(99,102,241,.25)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-cards-grid[_ngcontent-%COMP%] .suggestion-card[_ngcontent-%COMP%] .card-body[_ngcontent-%COMP%]{margin:8px 0 6px}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-cards-grid[_ngcontent-%COMP%] .suggestion-card[_ngcontent-%COMP%] .card-body[_ngcontent-%COMP%] .card-title[_ngcontent-%COMP%]{font-size:13.5px;font-weight:600;color:#0f172a;margin:0 0 3px;line-height:1.3;letter-spacing:-.01em}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-cards-grid[_ngcontent-%COMP%] .suggestion-card[_ngcontent-%COMP%] .card-body[_ngcontent-%COMP%] .card-desc[_ngcontent-%COMP%]{font-size:12px;color:#64748b;margin:0;line-height:1.45;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-cards-grid[_ngcontent-%COMP%] .suggestion-card[_ngcontent-%COMP%] .card-footer-row[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;gap:6px;margin-top:6px;padding-top:8px;border-top:1px solid rgba(241,245,249,.85)}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-cards-grid[_ngcontent-%COMP%] .suggestion-card[_ngcontent-%COMP%] .card-footer-row[_ngcontent-%COMP%] .tag-pill[_ngcontent-%COMP%]{font-size:11px;font-weight:600;color:#64748b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-cards-grid[_ngcontent-%COMP%] .suggestion-card[_ngcontent-%COMP%] .card-footer-row[_ngcontent-%COMP%] .use-prompt-action[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:3px;font-size:11.5px;font-weight:600;color:#1a73e8;opacity:.85;transition:all .15s ease;flex-shrink:0}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-cards-grid[_ngcontent-%COMP%] .suggestion-card[_ngcontent-%COMP%] .card-footer-row[_ngcontent-%COMP%] .use-prompt-action[_ngcontent-%COMP%] .action-arrow[_ngcontent-%COMP%]{font-size:13px;transition:transform .15s ease}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-cards-grid[_ngcontent-%COMP%] .suggestion-card[_ngcontent-%COMP%]:hover .use-prompt-action[_ngcontent-%COMP%]{opacity:1}.home-container[_ngcontent-%COMP%] .home-content[_ngcontent-%COMP%] .task-launcher-view[_ngcontent-%COMP%] .smart-suggestions-container[_ngcontent-%COMP%] .suggestions-cards-grid[_ngcontent-%COMP%] .suggestion-card[_ngcontent-%COMP%]:hover .use-prompt-action[_ngcontent-%COMP%] .action-arrow[_ngcontent-%COMP%]{transform:translate(2px)}@keyframes _ngcontent-%COMP%_spin{to{transform:rotate(360deg)}}@keyframes _ngcontent-%COMP%_fadeIn{0%{opacity:0;transform:translateY(3px)}to{opacity:1;transform:translateY(0)}}@keyframes _ngcontent-%COMP%_slideUpFade{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}@keyframes _ngcontent-%COMP%_slideDownFade{0%{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:translateY(0)}}@keyframes _ngcontent-%COMP%_floatGlow1{0%{transform:translate(0) scale(1)}50%{transform:translate(35px,45px) scale(1.06)}to{transform:translate(-25px,25px) scale(.96)}}@keyframes _ngcontent-%COMP%_floatGlow2{0%{transform:translate(0) scale(1)}50%{transform:translate(-40px,40px) scale(1.08)}to{transform:translate(20px,60px) scale(.94)}}@keyframes _ngcontent-%COMP%_floatGlow3{0%{transform:translate(0) scale(1)}50%{transform:translate(25px,-30px) scale(1.04)}to{transform:translate(-30px,15px) scale(.97)}}@keyframes _ngcontent-%COMP%_readyPulseGlow{0%{transform:scale(.97)}50%{transform:scale(1.06)}to{transform:scale(.98)}}@keyframes _ngcontent-%COMP%_statusDotPulse{0%{transform:scale(.95);box-shadow:0 0 #10b981b3}70%{transform:scale(1);box-shadow:0 0 0 5px #10b98100}to{transform:scale(.95);box-shadow:0 0 #10b98100}}@keyframes _ngcontent-%COMP%_sparklePulse{0%{transform:scale(.92) rotate(0);opacity:.8}50%{transform:scale(1.12) rotate(8deg);opacity:1}to{transform:scale(.92) rotate(0);opacity:.8}}@media(prefers-reduced-motion:reduce){.home-container[_ngcontent-%COMP%] .aurora-background[_ngcontent-%COMP%] .glow[_ngcontent-%COMP%], .sparkle-icon[_ngcontent-%COMP%], .status-dot[_ngcontent-%COMP%], .slider-dither[_ngcontent-%COMP%] rect[_ngcontent-%COMP%], .slider-solid[_ngcontent-%COMP%], .thumb-ripple[_ngcontent-%COMP%]{animation:none!important}}@keyframes _ngcontent-%COMP%_ditherIn{0%{opacity:0}}@keyframes _ngcontent-%COMP%_solidSweep{0%{transform:scaleX(0)}to{transform:scaleX(1)}}@keyframes _ngcontent-%COMP%_thumbRipple{0%{opacity:.7;transform:translate(-50%,-50%) scale(.6)}to{opacity:0;transform:translate(-50%,-50%) scale(2.6)}}@keyframes _ngcontent-%COMP%_tipPop{0%{opacity:0;transform:translateY(6px) scale(.96)}to{opacity:1;transform:translateY(0) scale(1)}}'],changeDetection:1})};function $n(n){return n?n.split(/(\*\*.*?\*\*|`.*?`)/g).filter(e=>e!=="").map(e=>e.startsWith("**")&&e.endsWith("**")?{text:e.slice(2,-2),bold:!0,code:!1}:e.startsWith("`")&&e.endsWith("`")?{text:e.slice(1,-1),bold:!1,code:!0}:{text:e,bold:!1,code:!1}):[]}function Ss(n){return n?n.split(` -`).map(t=>{let e=t.trim(),o=t.length-t.trimStart().length;if(e.startsWith("- [x] ")||e.startsWith("- [X] ")){let a=e.substring(6);return{type:"checked",segments:$n(a),indent:o}}if(e.startsWith("- [/] ")){let a=e.substring(6);return{type:"progress",segments:$n(a),indent:o}}if(e.startsWith("- [ ] ")){let a=e.substring(6);return{type:"unchecked",segments:$n(a),indent:o}}let i=e.match(/^[-*]\s*(verify|assert)(@end)?\s*:\s*(.*)$/i);if(i){let a=i[1].toLowerCase(),s=!!i[2],c=i[3].trim();return{type:"verify",checkKind:a,atEnd:s,segments:$n(c),indent:o}}let r=e.match(/^[-*]\s*finding\s*:\s*(.*)$/i);if(r){let a=r[1].trim();return{type:"finding",checkKind:"finding",segments:$n(a),indent:o}}if(e.startsWith("- ")||e.startsWith("* ")){let a=e.substring(2);return{type:"list-item",segments:$n(a),indent:o}}if(e.startsWith("# ")){let a=e.substring(2);return{type:"h1",segments:$n(a),indent:o}}if(e.startsWith("## ")){let a=e.substring(3);return{type:"h2",segments:$n(a),indent:o}}if(e.startsWith("### ")){let a=e.substring(4);return{type:"h3",segments:$n(a),indent:o}}return e?{type:"text",segments:$n(t),indent:o}:{type:"empty",segments:[],indent:0}}):[]}function ks(n){let t=Ss(n),e=[],o=[],i=null,r=null,a=0;for(let s of t){if(s.type==="empty")continue;if((s.type==="h1"||s.type==="h2")&&!i){i=s.segments.map(p=>p.text).join("");continue}["checked","progress","unchecked"].includes(s.type)&&(!r||s.indent<=(r._indent??0))?(a++,r={index:a,type:s.type,segments:s.segments,subSteps:[],checks:[],_indent:s.indent},e.push(r)):r&&s.indent>(r._indent??0)?s.type==="verify"||s.type==="assert"||s.type==="finding"?r.checks.push(s):r.subSteps.push(s):o.push(s)}return{title:i,milestones:e,otherLines:o}}function WP(n){if(!n)return null;let t=n.trim();if(t.includes("```json")){let e=t.split("```json");e.length>1&&(t=e[1].split("```")[0].trim())}else if(t.includes("```")){let e=t.split("```");e.length>1&&(t=e[1].split("```")[0].trim())}try{let e=JSON.parse(t);if(e&&typeof e=="object"&&"success"in e)return{success:!!e.success,reason:e.reason||"No reason provided."}}catch(e){}return null}function Vi(n){return n?n.replace(/`([^`]+)`/g,'$1').replace(/\*\*\*([^*]+)\*\*\*/g,"$1").replace(/___([^_]+)___/g,"$1").replace(/\*\*([^*]+)\*\*/g,"$1").replace(/__([^_]+)__/g,"$1").replace(/\*([^*]+)\*/g,"$1").replace(/\b_([^_]+)_\b/g,"$1").replace(/~~([^~]+)~~/g,"$1"):""}function Om(n){if(!n)return"";let o=n.replace(/<\/?(thought|short_term_memory|strong_term_memory|reasoning|plan|task_plan|call_tool)[^\n>]*>?/gi,"").replace(/\r\n/g,` -`).replace(/\r/g,` -`).replace(/\n{3,}/g,` - -`).trim().replace(/&/g,"&").replace(//g,">").split(` -`),i=[],r=null,a=!1,s="",c=[],p=()=>{r&&(i.push(``),r=null)};for(let f=0;f${A}`),a=!1,c=[],s=""}else p(),a=!0,s=C.substring(3).trim(),c=[];continue}if(a){c.push(_);continue}let h=C.match(/^(#{1,6})\s+(.*)$/);if(h){p();let A=h[1].length,N=Vi(h[2]);i.push(`${N}`);continue}if(/^(\-{3,}|\*{3,}|_{3,})$/.test(C)){p(),i.push('
      ');continue}if(C.startsWith("> ")||C.startsWith(">")){p();let A=Vi(C.replace(/^>\s?/,""));i.push(`
      ${A}
      `);continue}let P=_.match(/^(\s*)([\*\-])\s+(.*)$/);if(P){r!=="ul"&&(p(),r="ul",i.push("
        "));let A=P[1].length,E=Math.max(0,Math.floor((A-2)/2))*12,U=E?` style="margin-left: ${E}px"`:"",ne=P[3],J=ne.match(/^(verify|assert)(@end)?\s*:\s*(.*)$/i),X=ne.match(/^finding\s*:\s*(.*)$/i);if(J){let W=J[1].toLowerCase(),Me=J[2]?"@end":"",Ee=W==="assert"?" assert":"",ft=Vi(J[3].trim());i.push(`${W}${Me}${ft}`)}else if(X){let W=Vi(X[1].trim());i.push(`finding${W}`)}else{let W=Vi(ne);i.push(`${W}`)}continue}let T=_.match(/^(\s*)(\d+)\.\s+(.*)$/);if(T){r!=="ol"&&(p(),r="ol",i.push("
          "));let A=T[1].length,E=Math.max(0,Math.floor((A-2)/2))*12,U=E?` style="margin-left: ${E}px"`:"",ne=Vi(T[3]);i.push(`${ne}`);continue}if(p(),C==="")i.push('
          ');else{let A=Vi(_);i.push(`
          ${A}
          `)}}if(a){let f=c.join(` -`);i.push(`
          ${f}
          `)}return p(),i.join(` -`)}function Gr(n,t=0){if(n!=null){if(typeof n=="number")return n<1e11?n*1e3:n;let e=new Date(n).getTime();if(!isNaN(e)&&e>0)return e}return t}function KP(n,t){if(!t)return-1;let e=String(t);return n.findIndex(o=>o.type==="checker"&&o.data?.trace_id&&String(o.data.trace_id)===e)}function zT(n){for(let t=n.length-1;t>=0;t--)if(n[t].type==="checker"&&n[t].data?.isCompleted===!1)return t;return-1}function BT(n,t){let e=t.data||{};if(!e.event)return;if(e.event==="run_outcome"){let s="checker-outcome",c=n.findIndex(_=>_.id===s),p=F(S(S({},c>-1?n[c].data:{}),e),{phase:"outcome",status:"done",isCompleted:!0,generic_tools:[]}),f={id:s,type:"checker",timestamp:t.timestamp,data:p};c>-1?n[c]=f:n.push(f);return}if(!e.attempt_id)return;let o=`checker-${e.attempt_id}`,i=n.findIndex(s=>s.id===o),r=i>-1?n[i]:null,a=typeof e.ts=="number"?e.ts:t.timestamp?new Date(t.timestamp).getTime()/1e3:void 0;if(e.event==="attempt_started"){let s=F(S(S({},r?.data||{}),e),{status:r?.data?.isCompleted?r.data.status:"running",isCompleted:r?.data?.isCompleted??!1,started_at:r?.data?.started_at??a,verdicts:r?.data?.verdicts||[],findings:r?.data?.findings||[],generic_tools:r?.data?.generic_tools||[]}),c={id:o,type:"checker",timestamp:r?.timestamp||t.timestamp,data:s};r?n[i]=c:n.push(c);return}if(e.event==="attempt_finished"){let s=r?.data?.started_at??a,c=Array.isArray(e.verdicts)?e.verdicts:r?.data?.verdicts||[],p=r?.data?.items?.length?r.data.items:c.map(C=>({kind:C.kind,text:C.item_text,when:C.when})),f=F(S(S({},r?.data||{}),e),{status:e.status||"done",verdicts:c,items:p,findings:Array.isArray(e.findings)?e.findings:r?.data?.findings||[],generic_tools:r?.data?.generic_tools||[],started_at:s,finished_at:a,duration:s!==void 0&&a!==void 0?Math.max(0,a-s):void 0,isCompleted:!0}),_={id:o,type:"checker",timestamp:r?.timestamp||t.timestamp,data:f};r?n[i]=_:n.push(_)}}function HT(n){let t=new Map;n.forEach((e,o)=>{e.type==="checker"&&e.data?.trace_id&&t.set(String(e.data.trace_id),o)}),t.size!==0&&n.forEach((e,o)=>{if(e.type==="checker")return;let i=e.data?.generic_tools||[];if(i.length===0)return;let r=[],a=!1;for(let s of i){let c=s?.trace_id?String(s.trace_id):"",p=s?.parent_trace_id?String(s.parent_trace_id):"";if(c&&t.has(c)&&s?.type==="agent"){a=!0;continue}let f=p?t.get(p):void 0;if(f===void 0){r.push(s);continue}let _=n[f],C=_.data.generic_tools||[];C.some(h=>h.trace_id&&h.trace_id===s.trace_id)||(n[f]=F(S({},_),{data:F(S({},_.data),{generic_tools:[...C,s]})})),a=!0}a&&(n[o]=F(S({},e),{data:F(S({},e.data),{generic_tools:r})}))})}function ZP(n){if(!n||n.length===0)return[];let t=[];return n.forEach(e=>{if(e){if(e.type==="checker_event"){BT(t,e);return}if(e.type==="llm_stream"){let o=e.data?.execution_id||e.id,i=e.data?.step_id,r=e.data?.stream_type||"text",a=e.data?.text||"",s=e.data?.isCompleted??!1,c=KP(t,e.data?.parent_trace_id);if(c>-1){let f=t[c],_=S({},f.data),C=Array.isArray(_.stream_segments)?[..._.stream_segments]:[],h=C.findIndex(A=>A.execution_id===o&&A.stream_type===r),P={execution_id:o,stream_type:r==="thinking"?"thinking":"text",text:a,timestamp:h>-1?C[h].timestamp:e.timestamp,isCompleted:s};h>-1?C[h]=P:C.push(P),_.stream_segments=C;let T=A=>C.filter(N=>N.stream_type===A&&N.text.trim()).map(N=>N.text).join(` - -`);_.operator_native_thinking=T("thinking"),_.operator_raw_thinking=T("text"),r==="thinking"?_.operator_native_thinking_timestamp=_.operator_native_thinking_timestamp||e.timestamp:_.operator_raw_thinking_timestamp=_.operator_raw_thinking_timestamp||e.timestamp,o&&!_.execution_id&&(_.execution_id=o),t[c]=F(S({},f),{data:_});return}let p=-1;if(i&&(p=t.findIndex(f=>f.id===`step-${i}`||f.data?.step_id===i)),p===-1&&o&&(p=t.findIndex(f=>f.data?.execution_id===o||f.id===`stream-${o}`)),p>-1){let f=t[p],_=S({},f.data);r==="thinking"?(_.operator_native_thinking=a,_.operator_native_thinking_timestamp=_.operator_native_thinking_timestamp||e.timestamp):(_.operator_raw_thinking=a,_.operator_raw_thinking_timestamp=_.operator_raw_thinking_timestamp||e.timestamp),i&&!_.step_id&&(_.step_id=i),o&&!_.execution_id&&(_.execution_id=o),_.isCompleted=s,t[p]=F(S({},f),{data:_})}else{let f=i?`step-${i}`:`stream-${o}`,_={execution_id:o,step_id:i,isCompleted:s,generic_tools:[]};r==="thinking"?(_.operator_native_thinking=a,_.operator_native_thinking_timestamp=e.timestamp):(_.operator_raw_thinking=a,_.operator_raw_thinking_timestamp=e.timestamp),t.push({id:f,type:i?"step":"llm_stream",timestamp:e.timestamp,data:_})}}else if(e.type==="step_recorded"||e.type==="step_updated"){let o=e.data.step_id||e.data.step_number||"unknown",i=t.findIndex(r=>r.id===`step-${o}`||r.data?.step_id===o);if(i>-1){let r=t[i],a=r.data.generic_tools||[],s=e.data.generic_tools||[],c=[...a];s.forEach(f=>{let _=c.findIndex(C=>C.trace_id&&f.trace_id&&C.trace_id===f.trace_id);_>-1?c[_]=S(S({},c[_]),f):c.push(f)});let p=F(S(S({},r.data),e.data),{operator_native_thinking:e.data.operator_native_thinking||r.data.operator_native_thinking||void 0,operator_raw_thinking:e.data.operator_raw_thinking||r.data.operator_raw_thinking||void 0,operator_native_thinking_timestamp:r.data.operator_native_thinking_timestamp||e.data.operator_native_thinking_timestamp||(e.data.operator_native_thinking?e.timestamp:void 0),operator_raw_thinking_timestamp:r.data.operator_raw_thinking_timestamp||e.data.operator_raw_thinking_timestamp||(e.data.operator_raw_thinking?e.timestamp:void 0),action_taken:e.data.action_taken||r.data.action_taken||void 0,last_execution_result:e.data.last_execution_result||r.data.last_execution_result||void 0,generic_tools:c,isCompleted:!0});t[i]=F(S({},r),{id:`step-${o}`,type:"step",timestamp:r.timestamp||e.timestamp,data:p})}else t.push({id:`step-${o}`,type:"step",timestamp:e.timestamp,data:F(S({},e.data),{isCompleted:!0,operator_native_thinking_timestamp:e.data.operator_native_thinking_timestamp||(e.data.operator_native_thinking?e.timestamp:void 0),operator_raw_thinking_timestamp:e.data.operator_raw_thinking_timestamp||(e.data.operator_raw_thinking?e.timestamp:void 0),generic_tools:e.data.generic_tools||[]})})}else if(e.type==="trace_recorded"){let o=e.data.type==="action",i=e.data.type==="tool"||o,r=e.data.type==="llm_call"&&(e.data.status==="failed"||e.data.status==="retrying");if(!i&&!r)return;let a=e.data.step_id,s=-1,c=KP(t,e.data.parent_trace_id);if(c===-1&&String(e.data.agent_name||"").toLowerCase()==="checker"&&(c=zT(t)),c>-1&&(s=c),s===-1&&e.data.trace_id&&(s=t.findIndex(p=>p.data?.generic_tools?.some(f=>f.trace_id===e.data.trace_id))),s===-1&&a&&(s=t.findIndex(p=>p.id===`step-${a}`||p.data?.step_id===a)),s===-1&&!a&&(s=t.findIndex(p=>p.id==="step-pre-planning"||p.data?.step_id==="pre-planning")),s===-1&&!a){let p=e.timestamp?new Date(e.timestamp).getTime():0,f=-1;for(let _=0;_=f&&(f=h,s=_)}}if(s>-1){let p=t[s].data,f=p.generic_tools?[...p.generic_tools]:[],_=e.data.trace_id?f.findIndex(C=>C.trace_id===e.data.trace_id):-1;_>-1?f[_]=S(S({},f[_]),e.data):f.push(e.data),t[s]=F(S({},t[s]),{data:F(S({},p),{generic_tools:f})})}else{let p=a||"pre-planning";t.push({id:`step-${p}`,type:"step",timestamp:e.timestamp,data:{step_id:p,isCompleted:!0,generic_tools:[e.data]}})}}}}),HT(t),t.sort((e,o)=>{let i=e.timestamp?new Date(e.timestamp).getTime():0,r=o.timestamp?new Date(o.timestamp).getTime():0;if(Math.abs(i-r)>50)return i-r;let a=p=>p.data?.step_number!==void 0&&p.data.step_number!==null?Number(p.data.step_number):p.data?.step_id==="pre-planning"||p.data?.step_type==="planning"?0:99999,s=a(e),c=a(o);return s!==c&&s!==99999&&c!==99999?s-c:i-r}),t}var YP=new WeakMap;function UT(n){if(typeof n=="string")return{isImage:n.includes("data:image/")||n.includes("base64,"),length:n.length};if(n===null||typeof n!="object")return{isImage:!1,length:String(n).length};let t=YP.get(n);if(t)return t;let e="";try{e=JSON.stringify(n)||""}catch(i){e=String(n)}let o={isImage:e.includes("data:image/")||e.includes("base64,"),length:e.length};return YP.set(n,o),o}function GT(n){if(!n||!n.data)return{total:0,prompt:0,completion:0};let t=n.data;if(t.total_tokens!==void 0&&t.total_tokens!==null&&Number(t.total_tokens)>0){let s=t.token_usage?.prompt_tokens||t.token_usage?.input_tokens||0,c=t.token_usage?.completion_tokens||t.token_usage?.output_tokens||0;return{total:Number(t.total_tokens),prompt:Number(s),completion:Number(c)}}if(t.token_usage&&typeof t.token_usage=="object"){let s=t.token_usage.prompt_tokens||t.token_usage.input_tokens||0,c=t.token_usage.completion_tokens||t.token_usage.output_tokens||0,p=t.token_usage.total_tokens||s+c;if(p>0)return{total:Number(p),prompt:Number(s),completion:Number(c)}}if(t.extra_metadata?.token_usage&&typeof t.extra_metadata.token_usage=="object"){let s=t.extra_metadata.token_usage,c=s.prompt_tokens||s.prompt_token_count||s.input_tokens||0,p=s.completion_tokens||s.candidates_token_count||s.output_tokens||0,f=s.total_tokens||s.total_token_count||c+p;if(f>0)return{total:Number(f),prompt:Number(c),completion:Number(p)}}let e=0,o=0,i=0;if(t.generic_tools&&Array.isArray(t.generic_tools)&&t.generic_tools.forEach(s=>{if(s&&s.payload){let c=s.payload.token_usage||s.payload.usage_metadata;if(c){let p=c.prompt_tokens||c.prompt_token_count||c.input_tokens||0,f=c.completion_tokens||c.candidates_token_count||c.output_tokens||0,_=c.total_tokens||c.total_token_count||p+f;e+=Number(p),o+=Number(f),i+=Number(_)}}}),i>0)return{total:i,prompt:e,completion:o};let r=0,a=0;if(t.operator_native_thinking&&(r+=t.operator_native_thinking.length),t.operator_raw_thinking&&(r+=t.operator_raw_thinking.length),t.generic_tools&&Array.isArray(t.generic_tools)&&t.generic_tools.forEach(s=>{if(s.payload){let c=UT(s.payload);c.isImage?a+=1:r+=c.length}}),r>20||a>0){let s=Math.round(r/4)+a*258;return{total:s,prompt:0,completion:s}}return{total:0,prompt:0,completion:0}}function QP(n){return!n||n<=0?"0 tokens":n<1e3?`${n} tokens`:n<1e6?`${(n/1e3).toFixed(1).replace(/\.0$/,"")}k tokens`:`${(n/1e6).toFixed(2).replace(/\.?0+$/,"")}M tokens`}function JP(n,t){if(!n||n.length===0)return[];let e=[],o=t?t*1e3:0;return n.forEach((i,r)=>{let a=i.timestamp?new Date(i.timestamp).getTime():Date.now();o===0&&a>0&&(o=a);let s=0;i.data?.duration&&typeof i.data.duration=="number"&&i.data.duration>0?s=Math.max(1,Math.round(i.data.duration)):a>=o&&o>0?s=Math.max(1,Math.round((a-o)/1e3)):s=1;let c=GT(i);e.push({id:`phase-${i.id||r}`,durationSeconds:s,tokens:c.total>0?c.total:void 0,promptTokens:c.prompt>0?c.prompt:void 0,completionTokens:c.completion>0?c.completion:void 0,blocks:[i]}),o=a}),e}function XP(n,t){if(!n||typeof n!="object")return[];let e=n.generic_tools?n.generic_tools.length:0,o=n.action_taken?n.action_taken.timestamp||n.action_taken.start_time||n.action_taken.created_at:null,i=typeof n.operator_native_thinking=="string"?n.operator_native_thinking.trim():"",r=typeof n.operator_raw_thinking=="string"?n.operator_raw_thinking.trim():"",a=Array.isArray(n.stream_segments)&&n.stream_segments.length>0?n.stream_segments:null,s=[e,o??"",n.operator_native_thinking_timestamp??"",n.operator_raw_thinking_timestamp??"",i.length,r.length,a?a.map(h=>`${h.execution_id}:${h.stream_type}:${h.text.length}`).join(","):""].join("|");if(t){let h=t.get(n);if(h?.signature===s)return h.events}let c=[],p=Gr(n.timestamp,0),f=0;if(a?a.forEach(h=>{!h.text||!h.text.trim()||c.push({type:h.stream_type==="thinking"?"thinking":"text",data:{text:h.text,execution_id:h.execution_id,segment:!0},timestamp:Gr(h.timestamp,p),sequence:f++})}):(i&&c.push({type:"thinking",data:{text:n.operator_native_thinking},timestamp:Gr(n.operator_native_thinking_timestamp,p),sequence:f++}),r&&c.push({type:"text",data:{text:n.operator_raw_thinking},timestamp:Gr(n.operator_raw_thinking_timestamp,p),sequence:f++})),n.action_taken&&(jn(n.action_taken)||jo(n.action_taken))){let h=Gr(n.action_taken.timestamp??n.action_taken.start_time??n.action_taken.created_at,p);c.push({type:"action",data:n.action_taken,timestamp:h,sequence:f++})}Vd(n.generic_tools).forEach(h=>{let P=Gr(h.timestamp??h.start_time??h.created_at,p);c.push({type:"tool",data:h,timestamp:P,sequence:f++})}),c.sort((h,P)=>h.timestamp-P.timestamp||h.sequence-P.sequence);let C=c.map(h=>({type:h.type,data:h.data,timestamp:h.timestamp}));return t&&t.set(n,{signature:s,events:C}),C}function eO(n){if(!n||n.length===0)return"No logs available.";for(let t=n.length-1;t>=0;t--){let e=n[t];if(e.type==="session_ended"&&(e.data?.status==="cancelled"||e.data?.was_stopped_manually))return"Task stopped manually.";if(e.type==="llm_stream"&&e.data?.text){let o=e.data.text;if(o.includes("```json")&&o.includes('"success"'))return"Execution completed and verified by checker."}if(e.type==="step_recorded"||e.type==="step_updated"){let o=e.data?.action_taken;if(o&&jo(o)){let i=$d(o);if(i)return i}if(e.data?.status==="completed")return e.data.message||"Task completed successfully."}}return"Execution session in progress or ended."}function tO(n,t){if(!n||!t||t.length===0)return 0;let e=n*1e3,o=t[t.length-1],i=o?.timestamp?new Date(o.timestamp).getTime():Date.now();return Math.max(0,Math.round((i-e)/1e3))}function nO(n,t,e=!0){if(!t||!e)return!1;if(!n||n.length===0)return!0;for(let i=n.length-1;i>=Math.max(0,n.length-3);i--){let r=n[i];if(r.type==="session_ended")return!1;if(r.type==="step_recorded"||r.type==="step_updated"){let a=r.data?.action_taken;if(a&&(a.action==="report_task_status"||a==="report_task_status"||jo(a))||r.data?.status==="completed"||r.data?.status==="failed")return!1}}return!n.some(i=>i&&i.type==="llm_stream"&&i.data&&i.data.isCompleted===!1)}var qT=n=>[n];function WT(n,t){n&1&&(l(0,"div",25),u(1," No active or queued tasks "),d())}function KT(n,t){n&1&&$(0,"span",33)}function YT(n,t){if(n&1&&(l(0,"span",35)(1,"span",44),u(2,"phone_android"),d(),l(3,"span",45),u(4),d()()),n&2){let e=m().$implicit,o=m(2);R("title","Device: "+o.getDeviceSerial(e)),g(4),O(o.getDeviceSerial(e))}}function ZT(n,t){n&1&&(l(0,"span",36),u(1,"\xB7"),d())}function QT(n,t){if(n&1){let e=L();l(0,"button",46),k("click",function(i){v(e);let r=m().$implicit,a=m(2);return y(a.stopTask(r.session_id,i))}),l(1,"span",22),u(2,"stop_circle"),d()()}}function JT(n,t){if(n&1){let e=L();l(0,"button",47),k("click",function(i){v(e);let r=m().$implicit,a=m(2);return y(a.deleteTask(r.session_id,i))}),l(1,"span",22),u(2,"delete"),d()()}}function XT(n,t){if(n&1){let e=L();l(0,"div",30),k("click",function(i){let r=v(e).$implicit,a=m(2);return y(a.selectTask(r.session_id,i))}),l(1,"div",31)(2,"span",32),M(3,KT,1,0,"span",33),u(4),it(5,"uppercase"),d(),l(6,"div",34),M(7,YT,5,2,"span",35),M(8,ZT,2,0,"span",36),l(9,"span",37),u(10),it(11,"date"),d(),M(12,QT,3,0,"button",38),M(13,JT,3,0,"button",39),d()(),l(14,"div",40),u(15),d(),l(16,"div",41)(17,"span",42),u(18),d(),l(19,"span",43),u(20),it(21,"date"),d()()()}if(n&2){let e=t.$implicit,o=m(2);w("selected",o.agentService.currentSessionId()===e.session_id)("running",o.getTaskStatus(e)==="running"),g(2),R("ngClass",o.getTaskStatus(e)),g(),b(o.getTaskStatus(e)==="running"?3:-1),g(),V(" ",wi(5,18,o.getTaskStatus(e))," "),g(3),b(o.getDeviceSerial(e)?7:-1),g(),b(o.getDeviceSerial(e)?8:-1),g(2),O(Et(11,20,e.start_time*1e3,"shortTime")),g(2),b(o.getTaskStatus(e)==="running"||o.getTaskStatus(e)==="paused"?12:-1),g(),b(o.getTaskStatus(e)!=="running"&&o.getTaskStatus(e)!=="paused"?13:-1),g(),R("title",xi(e.initial_goal)),g(),V(" ",e.initial_goal||"Unnamed Task"," "),g(2),R("title",e.session_id),g(),V("ID: ",e.session_id),g(2),O(Et(21,23,e.start_time*1e3,"MM/dd/yy"))}}function eA(n,t){n&1&&(l(0,"div",25),u(1," No past task history "),d())}function tA(n,t){n&1&&$(0,"span",33)}function nA(n,t){if(n&1&&(l(0,"span",35)(1,"span",44),u(2,"phone_android"),d(),l(3,"span",45),u(4),d()()),n&2){let e=m().$implicit,o=m(2);R("title","Device: "+o.getDeviceSerial(e)),g(4),O(o.getDeviceSerial(e))}}function oA(n,t){n&1&&(l(0,"span",36),u(1,"\xB7"),d())}function iA(n,t){if(n&1){let e=L();l(0,"button",47),k("click",function(i){v(e);let r=m().$implicit,a=m(2);return y(a.deleteTask(r.session_id,i))}),l(1,"span",22),u(2,"delete"),d()()}}function rA(n,t){if(n&1){let e=L();l(0,"div",30),k("click",function(i){let r=v(e).$implicit,a=m(2);return y(a.selectTask(r.session_id,i))}),l(1,"div",31)(2,"span",32),M(3,tA,1,0,"span",33),u(4),it(5,"uppercase"),d(),l(6,"div",34),M(7,nA,5,2,"span",35),M(8,oA,2,0,"span",36),l(9,"span",37),u(10),it(11,"date"),d(),M(12,iA,3,0,"button",39),d()(),l(13,"div",40),u(14),d(),l(15,"div",41)(16,"span",42),u(17),d(),l(18,"span",43),u(19),it(20,"date"),d()()()}if(n&2){let e=t.$implicit,o=m(2);w("selected",o.agentService.currentSessionId()===e.session_id)("running",o.getTaskStatus(e)==="running"),g(2),R("ngClass",o.getTaskStatus(e)),g(),b(o.getTaskStatus(e)==="running"?3:-1),g(),V(" ",wi(5,17,o.getTaskStatus(e))," "),g(3),b(o.getDeviceSerial(e)?7:-1),g(),b(o.getDeviceSerial(e)?8:-1),g(2),O(Et(11,19,e.start_time*1e3,"shortTime")),g(2),b(o.getTaskStatus(e)!=="running"&&o.getTaskStatus(e)!=="paused"?12:-1),g(),R("title",xi(e.initial_goal)),g(),V(" ",e.initial_goal||"Unnamed Task"," "),g(2),R("title",e.session_id),g(),V("ID: ",e.session_id),g(2),O(Et(20,22,e.start_time*1e3,"MM/dd/yy"))}}function aA(n,t){if(n&1&&(l(0,"div",13)(1,"div",21)(2,"span",22),u(3,"list_alt"),d(),l(4,"span"),u(5,"Task Queue"),d()(),l(6,"div",23)(7,"span",24),u(8,"bolt"),d(),l(9,"span"),u(10),d()(),M(11,WT,2,0,"div",25),G(12,XT,22,26,"div",26,De().trackSession,!0),l(14,"div",27)(15,"span",28),u(16,"history"),d(),l(17,"span"),u(18),d()(),M(19,eA,2,0,"div",25),l(20,"div",29),G(21,rA,21,25,"div",26,De().trackSession,!0),d()()),n&2){let e=m();g(10),V("Active Queue (",e.activeQueue().length,")"),g(),b(e.activeQueue().length===0?11:-1),g(),q(e.activeQueue()),g(6),V("Past Runs & History (",e.historyTasks().length,")"),g(),b(e.historyTasks().length===0?19:-1),g(2),q(e.historyTasks())}}function sA(n,t){if(n&1){let e=L();l(0,"button",52),k("click",function(i){let r=v(e).$implicit,a=m(3);return y(a.selectNote(r,i))}),l(1,"span",53),u(2,"draft"),d(),u(3),d()}if(n&2){let e=t.$implicit,o=m(3);w("active",o.agentService.selectedNoteKey()===e),g(3),V(" ",e," ")}}function cA(n,t){if(n&1&&(l(0,"div",48)(1,"div",50),G(2,sA,4,3,"button",51,De().trackNoteKey,!0),d()()),n&2){let e=m(2);g(2),q(e.noteKeys())}}function lA(n,t){n&1&&(l(0,"div",25)(1,"span",54),u(2,"description"),d(),l(3,"span"),u(4,"No notes available for this session."),d()())}function dA(n,t){if(n&1&&(l(0,"h3",55),u(1),d()),n&2){let e=m(3);g(),O(e.parsedNote().title)}}function uA(n,t){n&1&&(l(0,"span",60),u(1,"check_box"),d())}function gA(n,t){n&1&&(l(0,"span",61),u(1,"indeterminate_check_box"),d())}function pA(n,t){n&1&&(l(0,"span",62),u(1,"check_box_outline_blank"),d())}function fA(n,t){if(n&1&&(l(0,"span"),u(1),d()),n&2){let e=t.$implicit;w("bold-segment",e.bold)("code-segment",e.code),g(),O(e.text)}}function mA(n,t){n&1&&(l(0,"span",69),u(1,"check_box"),d())}function _A(n,t){n&1&&(l(0,"span",70),u(1,"indeterminate_check_box"),d())}function hA(n,t){n&1&&(l(0,"span",71),u(1,"check_box_outline_blank"),d())}function CA(n,t){n&1&&(l(0,"span",72),u(1,"\u2022"),d())}function MA(n,t){if(n&1&&(l(0,"span"),u(1),d()),n&2){let e=t.$implicit;w("bold-segment",e.bold)("code-segment",e.code),g(),O(e.text)}}function bA(n,t){if(n&1&&(l(0,"span",73),G(1,MA,2,5,"span",64,Ie),d()),n&2){let e=m().$implicit;g(),q(e.segments)}}function PA(n,t){if(n&1&&(l(0,"div",68),M(1,mA,2,0,"span",69)(2,_A,2,0,"span",70)(3,hA,2,0,"span",71)(4,CA,2,0,"span",72),M(5,bA,3,0,"span",73),d()),n&2){let e,o=t.$implicit;Se("padding-left",o.indent>2?(o.indent-2)*12:0,"px"),R("ngClass",o.type),g(),b((e=o.type)==="checked"?1:e==="progress"?2:e==="unchecked"?3:e==="list-item"?4:-1),g(4),b(o.type!=="empty"?5:-1)}}function OA(n,t){if(n&1&&(l(0,"div",65),G(1,PA,6,5,"div",67,De().trackMarkdownLine,!0),d()),n&2){let e=m().$implicit;g(),q(e.subSteps)}}function vA(n,t){if(n&1&&(l(0,"span"),u(1),d()),n&2){let e=t.$implicit;w("bold-segment",e.bold)("code-segment",e.code),g(),O(e.text)}}function yA(n,t){if(n&1&&(l(0,"div",75)(1,"div",76)(2,"span",77),u(3),d()(),l(4,"div",78),G(5,vA,2,5,"span",64,Ie),d()()),n&2){let e=t.$implicit;Se("margin-left",e.indent>2?(e.indent-2)*12:0,"px"),R("ngClass",e.type),g(2),w("assert",e.checkKind==="assert"),g(),We("",e.checkKind||"verify","",e.atEnd?" @end":""),g(2),q(e.segments)}}function xA(n,t){if(n&1&&(l(0,"div",66),G(1,yA,7,7,"div",74,De().trackMarkdownLine,!0),d()),n&2){let e=m().$implicit;g(),q(e.checks)}}function wA(n,t){if(n&1&&(l(0,"div",58)(1,"div",59),M(2,uA,2,0,"span",60)(3,gA,2,0,"span",61)(4,pA,2,0,"span",62),l(5,"div",63),G(6,fA,2,5,"span",64,Ie),d()(),M(8,OA,3,0,"div",65),M(9,xA,3,0,"div",66),d()),n&2){let e,o=t.$implicit;R("ngClass",o.type),g(2),b((e=o.type)==="checked"?2:e==="progress"?3:e==="unchecked"?4:-1),g(4),q(o.segments),g(2),b(o.subSteps.length>0?8:-1),g(),b(o.checks.length>0?9:-1)}}function SA(n,t){if(n&1&&(l(0,"div",56),G(1,wA,10,4,"div",58,De().trackMilestone,!0),d()),n&2){let e=m(3);g(),q(e.parsedNote().milestones)}}function kA(n,t){if(n&1&&(l(0,"span"),u(1),d()),n&2){let e=t.$implicit;w("bold-segment",e.bold)("code-segment",e.code),g(),O(e.text)}}function EA(n,t){if(n&1&&(l(0,"div",81)(1,"div",76)(2,"span",77),u(3),d()(),l(4,"div",78),G(5,kA,2,5,"span",64,Ie),d()()),n&2){let e=m().$implicit;Se("margin-left",e.indent*12,"px"),R("ngClass",e.type),g(2),w("assert",e.checkKind==="assert"),g(),We("",e.checkKind||"verify","",e.atEnd?" @end":""),g(2),q(e.segments)}}function DA(n,t){n&1&&(l(0,"span",69),u(1,"check_box"),d())}function IA(n,t){n&1&&(l(0,"span",70),u(1,"indeterminate_check_box"),d())}function TA(n,t){n&1&&(l(0,"span",71),u(1,"check_box_outline_blank"),d())}function AA(n,t){n&1&&(l(0,"span",72),u(1,"\u2022"),d())}function NA(n,t){if(n&1&&(l(0,"span"),u(1),d()),n&2){let e=t.$implicit;w("bold-segment",e.bold)("code-segment",e.code),g(),O(e.text)}}function RA(n,t){if(n&1&&(l(0,"span",73),G(1,NA,2,5,"span",64,Ie),d()),n&2){let e=m(2).$implicit;g(),q(e.segments)}}function FA(n,t){if(n&1&&(l(0,"div",82),M(1,DA,2,0,"span",69)(2,IA,2,0,"span",70)(3,TA,2,0,"span",71)(4,AA,2,0,"span",72),M(5,RA,3,0,"span",73),d()),n&2){let e,o=m().$implicit;Se("margin-left",o.indent*12,"px"),R("ngClass",o.type),g(),b((e=o.type)==="checked"?1:e==="progress"?2:e==="unchecked"?3:e==="list-item"?4:-1),g(4),b(o.type!=="empty"?5:-1)}}function LA(n,t){if(n&1&&M(0,EA,7,7,"div",79)(1,FA,6,5,"div",80),n&2){let e=t.$implicit;b(e.type==="verify"||e.type==="finding"?0:1)}}function VA(n,t){if(n&1&&(l(0,"div",57),G(1,LA,2,1,null,null,De().trackMarkdownLine,!0),d()),n&2){let e=m(3);g(),q(e.parsedNote().otherLines)}}function jA(n,t){if(n&1&&(l(0,"div",49),M(1,dA,2,1,"h3",55),M(2,SA,3,0,"div",56),M(3,VA,3,0,"div",57),d()),n&2){let e=m(2);g(),b(e.parsedNote().title?1:-1),g(),b(e.parsedNote().milestones.length>0?2:-1),g(),b(e.parsedNote().otherLines.length>0?3:-1)}}function $A(n,t){if(n&1&&(l(0,"div",14)(1,"div",21)(2,"span",22),u(3,"description"),d(),l(4,"span"),u(5,"Notes & Plans"),d()(),M(6,cA,4,0,"div",48),M(7,lA,5,0,"div",25),M(8,jA,4,3,"div",49),d()),n&2){let e=m();g(6),b(e.noteKeys().length>1?6:-1),g(),b(e.noteKeys().length===0?7:-1),g(),b(e.noteKeys().length>0?8:-1)}}function zA(n,t){if(n&1&&(l(0,"div",83)(1,"span",93),u(2),d(),l(3,"span",94),u(4,"Architecture:"),d(),l(5,"span",95),u(6),d()()),n&2){let e=t,o=m(2);R("ngClass",o.getModelClass(e.name))("title",o.getArchitectureTooltip(e)),g(2),O(o.getModelIcon(e.name)),g(4),O(o.getModelDisplayName(e.name))}}function BA(n,t){n&1&&(l(0,"span",85),u(1," videocam "),d())}function HA(n,t){n&1&&(l(0,"span",86),u(1," progress_activity "),d())}function UA(n,t){n&1&&(l(0,"span",87),u(1," smart_display "),d())}function GA(n,t){n&1&&(l(0,"span",88),u(1," slideshow "),d())}function qA(n,t){n&1&&(l(0,"span",89),u(1," videocam_off "),d())}function WA(n,t){n&1&&(l(0,"span",85),u(1," videocam "),d())}function KA(n,t){n&1&&(l(0,"span",90),u(1,"Recording..."),d())}function YA(n,t){n&1&&(l(0,"span",90),u(1,"Preparing..."),d())}function ZA(n,t){n&1&&(l(0,"span",90),u(1,"Play Recording"),d())}function QA(n,t){n&1&&(l(0,"span",90),u(1,"Step Replay"),d())}function JA(n,t){n&1&&(l(0,"span",90),u(1,"Recording Failed"),d())}function XA(n,t){n&1&&(l(0,"span",90),u(1,"Screen Recording"),d())}function eN(n,t){n&1&&$(0,"span",91)}function tN(n,t){if(n&1&&(l(0,"span",92),u(1),d()),n&2){let e=m(2);g(),V(" ",e.agentService.currentSessionStepFrames().length," ")}}function nN(n,t){if(n&1){let e=L();l(0,"div",15),M(1,zA,7,4,"div",83),l(2,"button",84),k("click",function(){v(e);let i=m();return y(i.agentService.toggleVideoPlayer())}),M(3,BA,2,0,"span",85),M(4,HA,2,0,"span",86),M(5,UA,2,0,"span",87),M(6,GA,2,0,"span",88),M(7,qA,2,0,"span",89),M(8,WA,2,0,"span",85),M(9,KA,2,0,"span",90),M(10,YA,2,0,"span",90),M(11,ZA,2,0,"span",90),M(12,QA,2,0,"span",90),M(13,JA,2,0,"span",90),M(14,XA,2,0,"span",90),M(15,eN,1,0,"span",91),M(16,tN,2,1,"span",92),d()()}if(n&2){let e,o=m();g(),b((e=o.agentService.activeModel())?1:-1,e),g(),w("active",o.agentService.isVideoWindowOpen())("recording",o.agentService.isCurrentSessionRunning())("processing",!o.agentService.isCurrentSessionRunning()&&o.agentService.currentSessionRecordingStatus()==="processing")("has-video",!o.agentService.isCurrentSessionRunning()&&!!o.agentService.currentSessionVideoUrl())("has-steps",!o.agentService.isCurrentSessionRunning()&&!o.agentService.currentSessionVideoUrl()&&o.agentService.hasCurrentSessionStepFrames())("failed",!o.agentService.isCurrentSessionRunning()&&!o.agentService.currentSessionVideoUrl()&&!o.agentService.hasCurrentSessionStepFrames()&&o.agentService.currentSessionRecordingStatus()==="failed"),R("title",o.getScreenRecordingButtonTitle()),g(),b(o.agentService.isCurrentSessionRunning()?3:-1),g(),b(!o.agentService.isCurrentSessionRunning()&&o.agentService.currentSessionRecordingStatus()==="processing"?4:-1),g(),b(!o.agentService.isCurrentSessionRunning()&&o.agentService.currentSessionRecordingStatus()!=="processing"&&o.agentService.currentSessionVideoUrl()?5:-1),g(),b(!o.agentService.isCurrentSessionRunning()&&o.agentService.currentSessionRecordingStatus()!=="processing"&&!o.agentService.currentSessionVideoUrl()&&o.agentService.hasCurrentSessionStepFrames()?6:-1),g(),b(!o.agentService.isCurrentSessionRunning()&&o.agentService.currentSessionRecordingStatus()!=="processing"&&!o.agentService.currentSessionVideoUrl()&&!o.agentService.hasCurrentSessionStepFrames()&&o.agentService.currentSessionRecordingStatus()==="failed"?7:-1),g(),b(!o.agentService.isCurrentSessionRunning()&&o.agentService.currentSessionRecordingStatus()!=="processing"&&!o.agentService.currentSessionVideoUrl()&&!o.agentService.hasCurrentSessionStepFrames()&&o.agentService.currentSessionRecordingStatus()!=="failed"?8:-1),g(),b(o.agentService.isCurrentSessionRunning()?9:-1),g(),b(!o.agentService.isCurrentSessionRunning()&&o.agentService.currentSessionRecordingStatus()==="processing"?10:-1),g(),b(!o.agentService.isCurrentSessionRunning()&&o.agentService.currentSessionRecordingStatus()!=="processing"&&o.agentService.currentSessionVideoUrl()?11:-1),g(),b(!o.agentService.isCurrentSessionRunning()&&o.agentService.currentSessionRecordingStatus()!=="processing"&&!o.agentService.currentSessionVideoUrl()&&o.agentService.hasCurrentSessionStepFrames()?12:-1),g(),b(!o.agentService.isCurrentSessionRunning()&&o.agentService.currentSessionRecordingStatus()!=="processing"&&!o.agentService.currentSessionVideoUrl()&&!o.agentService.hasCurrentSessionStepFrames()&&o.agentService.currentSessionRecordingStatus()==="failed"?13:-1),g(),b(!o.agentService.isCurrentSessionRunning()&&o.agentService.currentSessionRecordingStatus()!=="processing"&&!o.agentService.currentSessionVideoUrl()&&!o.agentService.hasCurrentSessionStepFrames()&&o.agentService.currentSessionRecordingStatus()!=="failed"?14:-1),g(),b(o.agentService.isCurrentSessionRunning()?15:-1),g(),b(!o.agentService.isCurrentSessionRunning()&&!o.agentService.currentSessionVideoUrl()&&o.agentService.hasCurrentSessionStepFrames()?16:-1)}}function oN(n,t){n&1&&(l(0,"div",17)(1,"span",96),u(2,"progress_activity"),d(),l(3,"span"),u(4,"Loading run history..."),d()())}function iN(n,t){n&1&&(l(0,"div",18)(1,"div",97)(2,"span",22),u(3,"sensors"),d()(),l(4,"h3"),u(5,"No Stream Activity Selected"),d(),l(6,"p"),u(7,"Please select a session from the Task List on the right to view its real-time log payloads."),d()())}function rN(n,t){if(n&1&&(l(0,"div",109),$(1,"span",110),l(2,"div",111)(3,"span",112),u(4),d()()()),n&2){let e=t.$implicit;g(),w("streaming",e.isActive),g(3),mr(" ",e.message," \xB7 ",e.isActive?"Waiting ":"Waited ","",e.elapsed," ")}}function aN(n,t){if(n&1&&(l(0,"div",98)(1,"div",102)(2,"span",103),u(3),d()(),l(4,"div",104)(5,"div",105)(6,"div",106)(7,"div",107)(8,"div",108),G(9,rN,5,5,"div",109,Ie),d()()()()()()),n&2){let e=m(2);g(3),V("Worked for ",e.startupPreparationDuration(),"s"),g(6),q(e.startupWorkItems())}}function sN(n,t){if(n&1&&(l(0,"span",116)(1,"span",117),u(2,"\xB7"),d(),l(3,"span",118),u(4),d()()),n&2){let e=m(2).$implicit,o=m(2);R("title",o.getTokenTooltip(e)),g(4),O(o.formatTokenCount(e.tokens))}}function cN(n,t){if(n&1&&(l(0,"div",115)(1,"span",103),u(2),d(),M(3,sN,5,2,"span",116),d()),n&2){let e=m(),o=e.$implicit,i=e.$index,r=m(2);w("first-phase",i===0&&r.startupWorkItems().length===0),g(2),We("",r.isCheckerPhase(o)?"Checked":"Worked"," for ",o.durationSeconds,"s"),g(),b(o.tokens?3:-1)}}function lN(n,t){if(n&1&&(l(0,"span",124),u(1),d()),n&2){let e=m(3).$implicit;R("title",e.data.subgoal_text),g(),O(e.data.subgoal_text)}}function dN(n,t){if(n&1&&(l(0,"li",127)(1,"span",128),u(2),d(),l(3,"span",129),u(4),d(),l(5,"span",130),u(6),d()()),n&2){let e=t.$implicit,o=t.$index,i=m(4).$implicit,r=m(4);g(2),O(o+1),g(2),O(e.text),g(2),O(r.getCheckMethodLabel(e,i))}}function uN(n,t){if(n&1&&(l(0,"ol",126),G(1,dN,7,3,"li",127,Do),d()),n&2){let e=m(3).$implicit,o=m(4);g(),q(o.getCheckerItems(e))}}function gN(n,t){if(n&1&&(l(0,"div",120)(1,"div",122),$(2,"span",110),l(3,"span",123),u(4),d(),M(5,lN,2,2,"span",124),l(6,"span",125),u(7),d()(),M(8,uN,3,0,"ol",126),d()),n&2){let e=m(2).$implicit,o=m(4);R("ngClass","tone-"+o.getCheckerTone(e)),g(2),w("streaming",o.isBlockRunning(e)),g(2),O(o.getCheckerPhaseLabel(e)),g(),b(e.data.phase!=="outcome"&&e.data.subgoal_text?5:-1),g(2),O(o.getCheckerStatusLabel(e)),g(),b(!o.isCheckerMuted(e)&&o.getCheckerItems(e).length>0?8:-1)}}function pN(n,t){if(n&1){let e=L();l(0,"div",135)(1,"div",136),k("click",function(){v(e);let i=m().$index,r=m(3).$implicit,a=m(4);return y(a.toggleCheckerSegment(r,i))}),$(2,"span",110),u(3," Thought "),d(),l(4,"div",137)(5,"div",138),$(6,"div",139),d()()()}if(n&2){let e=m(),o=e.$implicit,i=e.$index,r=m(3).$implicit,a=m(4);w("is-running",a.isCheckerSegmentRunning(r,i))("collapsed",a.isCheckerSegmentCollapsed(r,i)),g(2),w("streaming",a.isCheckerSegmentRunning(r,i)),g(4),R("innerHTML",a.renderMarkdown(o.data.text,r.id+"-seg-"+i),Pi)}}function fN(n,t){if(n&1&&(l(0,"div",140)(1,"div",141),$(2,"span",110),u(3," Work "),d(),l(4,"div",142),$(5,"div",139),d()()),n&2){let e=m(),o=e.$implicit,i=e.$index,r=m(3).$implicit,a=m(4);w("is-running",a.isCheckerSegmentRunning(r,i)),g(2),w("streaming",a.isCheckerSegmentRunning(r,i)),g(3),R("innerHTML",a.renderMarkdown(o.data.text,r.id+"-seg-"+i),Pi)}}function mN(n,t){if(n&1){let e=L();l(0,"div",135)(1,"div",136),k("click",function(){v(e);let i=m(4).$implicit,r=m(4);return y(r.toggleCollapse(i.data.execution_id||i.data.step_id||i.id))}),$(2,"span",110),u(3," Thought "),d(),l(4,"div",137)(5,"div",138),$(6,"div",139),d()()()}if(n&2){let e=m().$implicit,o=m(3).$implicit,i=m(4);w("is-running",i.isBlockRunning(o))("collapsed",i.collapsedStreams().has(o.data.execution_id||o.data.step_id||o.id)),g(2),w("streaming",i.isBlockRunning(o)),g(4),R("innerHTML",i.renderMarkdown(i.typedTextsSignal()[o.id+"-native"]||e.data.text,o.id+"-native"),Pi)}}function _N(n,t){if(n&1&&(l(0,"div",140)(1,"div",141),$(2,"span",110),u(3," Work "),d(),l(4,"div",142),$(5,"div",139),d()()),n&2){let e=m().$implicit,o=m(3).$implicit,i=m(4);w("is-running",i.isBlockRunning(o)),g(2),w("streaming",i.isBlockRunning(o)),g(3),R("innerHTML",i.renderMarkdown(i.typedTextsSignal()[o.id]||e.data.text,o.id),Pi)}}function hN(n,t){if(n&1&&(l(0,"div",150)(1,"div",152)(2,"span",153),u(3,"Summary:"),d(),l(4,"span",154),u(5),d()()()),n&2){let e=m(2).$implicit,o=m(7);g(5),V('"',o.getReportStatusExplanation(e.data),'"')}}function CN(n,t){if(n&1&&(l(0,"div",157)(1,"span",159),u(2,"Report & Findings"),d(),$(3,"span",160),d()),n&2){let e=m(3).$index,o=m(3).$implicit,i=m(4);g(3),R("innerHTML",i.renderMarkdown(t,"report-"+o.id+"-"+e),Pi)}}function MN(n,t){if(n&1){let e=L();l(0,"div",161)(1,"span",162),u(2,"Final Verified State"),d(),l(3,"div",163),k("click",function(i){v(e);let r=m(4).$implicit,a=m(3).$implicit,s=m(4);return y(s.openImageModal(s.getStepPreImageUrl(a.data,r.data),r.data,i))}),l(4,"div",164),$(5,"img",165),d()()()}if(n&2){let e=m(4).$implicit,o=m(3).$implicit,i=m(4);g(5),R("src",i.getStepPreImageUrl(o.data,e.data),kt)}}function bN(n,t){if(n&1){let e=L();l(0,"div",161)(1,"span",162),u(2,"Post Verification"),d(),l(3,"div",163),k("click",function(i){v(e);let r=m(4).$implicit,a=m(3).$implicit,s=m(4);return y(s.openImageModal(s.getStepPostImageUrl(a.data,r.data),null,i))}),l(4,"div",164),$(5,"img",166),d()()()}if(n&2){let e=m(4).$implicit,o=m(3).$implicit,i=m(4);g(5),R("src",i.getStepPostImageUrl(o.data,e.data),kt)}}function PN(n,t){if(n&1&&(l(0,"div",158),M(1,MN,6,1,"div",161),M(2,bN,6,1,"div",161),d()),n&2){let e=m(3).$implicit,o=m(3).$implicit,i=m(4);g(),b(i.getStepPreImageUrl(o.data,e.data)?1:-1),g(),b(i.getStepPostImageUrl(o.data,e.data)?2:-1)}}function ON(n,t){if(n&1&&(l(0,"div",155),k("click",function(o){return o.stopPropagation()}),l(1,"div",156),M(2,CN,4,1,"div",157),d(),M(3,PN,3,2,"div",158),d()),n&2){let e,o=m(2).$implicit,i=m(3).$implicit,r=m(4);g(2),b((e=r.getReportStatusExplanation(o.data))?2:-1,e),g(),b(r.getStepPreImageUrl(i.data,o.data)||r.getStepPostImageUrl(i.data,o.data)?3:-1)}}function vN(n,t){if(n&1){let e=L();l(0,"div",143)(1,"div",144),k("click",function(i){v(e);let r=m().$index,a=m(3).$implicit,s=m(4);return y(s.toggleActionExpanded("action-"+a.id+"-"+r,i))}),l(2,"span",145),u(3),d(),l(4,"span",146),u(5),d(),l(6,"span",147)(7,"span",148),u(8),d(),u(9),d(),l(10,"span",149),u(11),d()(),M(12,hN,6,1,"div",150),M(13,ON,4,2,"div",151),d()}if(n&2){let e=m(),o=e.$implicit,i=e.$index,r=m(3).$implicit,a=m(4);w("is-completed",a.getReportStatusValue(o.data)==="completed"||a.getReportStatusValue(o.data)==="success")("is-failed",a.getReportStatusValue(o.data)==="failed")("is-expanded",a.isActionExpanded("action-"+r.id+"-"+i)),g(2),w("is-failed",a.getReportStatusValue(o.data)==="failed"),g(),V(" ",a.getReportStatusValue(o.data)==="failed"?"cancel":"task_alt"," "),g(2),V(" ",a.getReportStatusValue(o.data)==="failed"?"Task Execution Failed":"Task Completed"," "),g(),w("is-failed",a.getReportStatusValue(o.data)==="failed")("is-completed",a.getReportStatusValue(o.data)!=="failed"),g(2),V(" ",a.getReportStatusValue(o.data)==="failed"?"error":"check_circle"," "),g(),V(" ",a.getReportStatusValue(o.data)==="failed"?"Failed":"Completed"," "),g(2),V(" ",a.isActionExpanded("action-"+r.id+"-"+i)?"expand_less":"expand_more"," "),g(),b(!a.isActionExpanded("action-"+r.id+"-"+i)&&a.getReportStatusExplanation(o.data)?12:-1),g(),b(a.isActionExpanded("action-"+r.id+"-"+i)?13:-1)}}function yN(n,t){n&1&&(l(0,"span",169)(1,"span",148),u(2,"error"),d(),u(3," Failed "),d())}function xN(n,t){if(n&1&&(l(0,"div",152)(1,"span",153),u(2,"Target:"),d(),l(3,"span",154),u(4),d()()),n&2){let e=m(3).$implicit,o=m(7);g(4),V('"',o.getActionTargetText(e.data),'"')}}function wN(n,t){if(n&1&&(l(0,"div",152)(1,"span",153),u(2),d(),l(3,"span",154),u(4),d()()),n&2){let e=m(3).$implicit,o=m(7);g(2),V("",o.getActionInputLabel(e.data),":"),g(2),V('"',o.getActionInputText(e.data),'"')}}function SN(n,t){if(n&1&&(l(0,"div",152)(1,"span",153),u(2,"Coordinates:"),d(),l(3,"span",170),u(4),d()()),n&2){let e=m(3).$implicit,o=m(7);g(4),O(o.getActionCoords(e.data))}}function kN(n,t){if(n&1&&(l(0,"div",150),M(1,xN,5,1,"div",152),M(2,wN,5,2,"div",152),M(3,SN,5,1,"div",152),d()),n&2){let e=m(2).$implicit,o=m(7);g(),b(o.getActionTargetText(e.data)?1:-1),g(),b(o.getActionInputText(e.data)?2:-1),g(),b(o.getActionCoords(e.data)?3:-1)}}function EN(n,t){if(n&1&&(l(0,"div",171)(1,"span",159),u(2,"Target"),d(),l(3,"span",173),u(4),d()()),n&2){let e=m(3).$implicit,o=m(7);g(4),V('"',o.getActionTargetText(e.data),'"')}}function DN(n,t){if(n&1&&(l(0,"div",171)(1,"span",159),u(2),d(),l(3,"span",173),u(4),d()()),n&2){let e=m(3).$implicit,o=m(7);g(2),O(o.getActionInputLabel(e.data)),g(2),V('"',o.getActionInputText(e.data),'"')}}function IN(n,t){if(n&1&&(l(0,"div",171)(1,"span",159),u(2,"Coordinates"),d(),l(3,"span",174),u(4),d()()),n&2){let e=m(3).$implicit,o=m(7);g(4),O(o.getActionCoords(e.data))}}function TN(n,t){if(n&1&&(l(0,"div",171)(1,"span",159),u(2,"Target Bounds"),d(),l(3,"span",174),u(4),d()()),n&2){let e=m(3).$implicit,o=m(7);g(4),V("[",o.getActionBounds(e.data),"]")}}function AN(n,t){if(n&1&&(l(0,"div",171)(1,"span",159),u(2,"Target Class"),d(),l(3,"span",174),u(4),d()()),n&2){let e=m(3).$implicit,o=m(7);g(4),O(o.getActionClass(e.data))}}function NN(n,t){if(n&1&&(l(0,"div",171)(1,"span",159),u(2),d(),l(3,"span",174),u(4),d()()),n&2){let e=t.$implicit;g(2),O(e.key),g(2),O(e.value)}}function RN(n,t){if(n&1&&(l(0,"div",171)(1,"span",159),u(2,"Action"),d(),l(3,"span",173),u(4),d()()),n&2){let e=m(3).$implicit,o=m(7);g(4),O(o.getActionTitle(e.data))}}function FN(n,t){if(n&1){let e=L();l(0,"div",161)(1,"span",162),u(2,"Pre-Action (Before)"),d(),l(3,"div",163),k("click",function(i){v(e);let r=m(4).$implicit,a=m(3).$implicit,s=m(4);return y(s.openImageModal(s.getStepPreImageUrl(a.data,r.data),r.data,i))}),l(4,"div",164)(5,"img",175,0),k("load",function(){v(e);let i=Io(6),r=Io(8),a=m(4).$implicit,s=m(7);return y(s.onImageLoad(i,r,a.data))}),d(),$(7,"div",176,1),d()()()}if(n&2){let e=m(4).$implicit,o=m(3).$implicit,i=m(4);g(5),R("src",i.getStepPreImageUrl(o.data,e.data),kt)}}function LN(n,t){if(n&1){let e=L();l(0,"div",161)(1,"span",162),u(2,"Post-Action (After)"),d(),l(3,"div",163),k("click",function(i){v(e);let r=m(4).$implicit,a=m(3).$implicit,s=m(4);return y(s.openImageModal(s.getStepPostImageUrl(a.data,r.data),null,i))}),l(4,"div",164),$(5,"img",177),d()()()}if(n&2){let e=m(4).$implicit,o=m(3).$implicit,i=m(4);g(5),R("src",i.getStepPostImageUrl(o.data,e.data),kt)}}function VN(n,t){if(n&1&&(l(0,"div",158),M(1,FN,9,1,"div",161),M(2,LN,6,1,"div",161),d()),n&2){let e=m(3).$implicit,o=m(3).$implicit,i=m(4);g(),b(i.getStepPreImageUrl(o.data,e.data)?1:-1),g(),b(i.getStepPostImageUrl(o.data,e.data)?2:-1)}}function jN(n,t){if(n&1&&(l(0,"div",172)(1,"span",178),u(2,"error"),d(),l(3,"span",179),u(4),d()()),n&2){let e=m(3).$implicit,o=m(3).$implicit,i=m(4);g(4),O(i.getActionErrorMessage(e.data,o.data))}}function $N(n,t){if(n&1&&(l(0,"div",155),k("click",function(o){return o.stopPropagation()}),l(1,"div",156),M(2,EN,5,1,"div",171),M(3,DN,5,2,"div",171),M(4,IN,5,1,"div",171),M(5,TN,5,1,"div",171),M(6,AN,5,1,"div",171),G(7,NN,5,2,"div",171,De().trackParam,!0),M(9,RN,5,1,"div",171),d(),M(10,VN,3,2,"div",158),M(11,jN,5,1,"div",172),d()),n&2){let e=m(2).$implicit,o=m(3).$implicit,i=m(4);g(2),b(i.getActionTargetText(e.data)?2:-1),g(),b(i.getActionInputText(e.data)?3:-1),g(),b(i.getActionCoords(e.data)?4:-1),g(),b(i.getActionBounds(e.data)?5:-1),g(),b(i.getActionClass(e.data)?6:-1),g(),q(i.getActionExtraParams(e.data)),g(2),b(!i.getActionTargetText(e.data)&&!i.getActionInputText(e.data)&&!i.getActionCoords(e.data)&&!i.getActionBounds(e.data)&&!i.getActionClass(e.data)&&i.getActionExtraParams(e.data).length===0?9:-1),g(),b(i.getStepPreImageUrl(o.data,e.data)||i.getStepPostImageUrl(o.data,e.data)?10:-1),g(),b(i.isActionFailed(e.data,o.data)?11:-1)}}function zN(n,t){if(n&1){let e=L();l(0,"div",167)(1,"div",144),k("click",function(i){v(e);let r=m().$index,a=m(3).$implicit,s=m(4);return y(s.toggleActionExpanded("action-"+a.id+"-"+r,i))}),l(2,"span",168),u(3),d(),l(4,"span",146),u(5),d(),M(6,yN,4,0,"span",169),l(7,"span",149),u(8),d()(),M(9,kN,4,3,"div",150),M(10,$N,12,8,"div",151),d()}if(n&2){let e=m(),o=e.$implicit,i=e.$index,r=m(3).$implicit,a=m(4);w("is-running",a.isItemRunning(o,r))("is-failed",a.isActionFailed(o.data,r.data))("is-expanded",a.isActionExpanded("action-"+r.id+"-"+i)),g(2),w("is-failed",a.isActionFailed(o.data,r.data)),g(),O(a.getActionIcon(o.data)),g(2),O(a.getActionTitle(o.data)),g(),b(a.isActionFailed(o.data,r.data)?6:-1),g(2),V(" ",a.isActionExpanded("action-"+r.id+"-"+i)?"expand_less":"expand_more"," "),g(),b(!a.isActionExpanded("action-"+r.id+"-"+i)&&(a.getActionTargetText(o.data)||a.getActionInputText(o.data)||a.getActionCoords(o.data))?9:-1),g(),b(a.isActionExpanded("action-"+r.id+"-"+i)?10:-1)}}function BN(n,t){n&1&&u(0),n&2&&V(" \xB7 Retrying in ",t,"\u2026 ")}function HN(n,t){if(n&1&&(l(0,"span",112),u(1," LLM temporarily unavailable "),M(2,BN,1,1),d(),l(3,"span",183),u(4," Frequent retries may indicate a low API rate limit. Consider increasing the limit or reducing request concurrency. "),d()),n&2){let e,o=m().$implicit,i=m(10);g(2),b((e=i.getLLMRetryEntryDelay(o))?2:-1,e)}}function UN(n,t){if(n&1&&(l(0,"span",112),u(1),d()),n&2){let e=m().$implicit,o=m(10);g(),V(" Retrying LLM request \xB7 Waited ",o.getLLMRetryEntryWaited(e)," ")}}function GN(n,t){if(n&1&&(l(0,"div",109),$(1,"span",110),l(2,"div",111),M(3,HN,5,1),M(4,UN,2,1,"span",112),d()()),n&2){let e=t.$implicit,o=t.$index,i=m(3).$implicit,r=m(7);g(),w("streaming",o===r.getLLMRetryEntries(i.data).length-1&&r.isLLMRetryEntryWaiting(e)),g(2),b(r.isLLMRetryEntryWaiting(e)?3:-1),g(),b(r.isLLMRetryEntryWaiting(e)?-1:4)}}function qN(n,t){if(n&1&&(l(0,"div",108),G(1,GN,5,4,"div",109,Ie),d()),n&2){let e=m(2).$implicit,o=m(7);g(),q(o.getLLMRetryEntries(e.data))}}function WN(n,t){n&1&&(l(0,"span",187)(1,"span",148),u(2,"pause_circle"),d(),u(3," Paused "),d())}function KN(n,t){n&1&&(l(0,"span",169)(1,"span",148),u(2,"warning"),d(),u(3," Failed "),d())}function YN(n,t){if(n&1&&(l(0,"span",149),u(1),d()),n&2){let e=m(3).$index,o=m(3).$implicit,i=m(4);g(),V(" ",i.isActionExpanded("llm-failure-"+o.id+"-"+e)?"expand_less":"expand_more"," ")}}function ZN(n,t){n&1&&(l(0,"div",152)(1,"span",153),u(2,"Waited:"),d(),l(3,"span",154),u(4),d()()),n&2&&(g(4),O(t))}function QN(n,t){if(n&1&&(l(0,"div",191)(1,"span"),u(2),d(),l(3,"span"),u(4),d()()),n&2){let e=t.$implicit,o=t.$index,i=m(11);g(2),V("Retry ",o+1),g(2),We(" Planned ",i.getLLMRetryEntryDelay(e)||"0s"," \xB7 Waited ",i.getLLMRetryEntryWaited(e)," ")}}function JN(n,t){if(n&1&&(l(0,"div",188)(1,"div",190),u(2,"Retry details"),d(),G(3,QN,5,3,"div",191,Ie),d()),n&2){let e=m(3).$implicit,o=m(7);g(3),q(o.getLLMFailureRetryEntries(e.data))}}function XN(n,t){if(n&1){let e=L();l(0,"div",189)(1,"button",192),k("click",function(i){v(e);let r=m(10);return y(r.resumePausedTask(i))}),l(2,"span",193),u(3,"play_arrow"),d(),l(4,"span"),u(5,"Continue task"),d(),l(6,"span",194),u(7,"arrow_forward"),d()(),l(8,"span",195),u(9,"Task paused \xB7 no steps are running"),d()()}}function eR(n,t){if(n&1){let e=L();l(0,"div",180)(1,"div",184),k("click",function(i){v(e);let r=m(2),a=r.$implicit,s=r.$index,c=m(3).$implicit,p=m(4);return y(p.hasLLMFailureRetryEntries(a.data)&&p.toggleActionExpanded("llm-failure-"+c.id+"-"+s,i))}),l(2,"div",185)(3,"span",186),u(4,"error"),d(),l(5,"span",146),u(6,"LLM Request Failed"),d(),M(7,WN,4,0,"span",187)(8,KN,4,0,"span",169),M(9,YN,2,1,"span",149),d(),l(10,"div",150)(11,"div",152)(12,"span",153),u(13,"What happened:"),d(),l(14,"span",154),u(15,"The AI request could not be completed after automatic retries."),d()(),l(16,"div",152)(17,"span",153),u(18,"Failure reason:"),d(),l(19,"span",154),u(20),d()(),M(21,ZN,5,1,"div",152),M(22,JN,5,0,"div",188),d()(),M(23,XN,10,0,"div",189),d()}if(n&2){let e,o=m(2),i=o.$implicit,r=o.$index,a=m(3).$implicit,s=m(4);g(),w("has-details",s.hasLLMFailureRetryEntries(i.data))("is-expanded",s.isActionExpanded("llm-failure-"+a.id+"-"+r)),g(6),b(s.isTerminalLLMFailure(i.data)?7:8),g(2),b(s.hasLLMFailureRetryEntries(i.data)?9:-1),g(11),O(s.getLLMErrorText(i.data)),g(),b((e=s.getLLMFailureWaited(i.data))?21:-1,e),g(),b(s.isActionExpanded("llm-failure-"+a.id+"-"+r)?22:-1),g(),b(s.isTerminalLLMFailure(i.data)?23:-1)}}function tR(n,t){if(n&1){let e=L();l(0,"span",200),k("click",function(){let i=v(e),r=m(10);return y(r.onToolKeyClick(i))}),l(1,"span",53),u(2,"draft"),d(),u(3),d()}n&2&&(g(3),V(" ",t," "))}function nR(n,t){n&1&&(l(0,"span",202),u(1),d()),n&2&&(g(),V("\xB7 ",t))}function oR(n,t){if(n&1){let e=L();l(0,"span",201),k("click",function(){v(e);let i=m(3).$implicit,r=m(7);return y(r.onVideoToolClick(i.data))}),l(1,"span",53),u(2,"videocam"),d(),u(3),d(),M(4,nR,2,1,"span",202)}if(n&2){let e,o=m(3).$implicit,i=m(7);R("title","Open recording at "+i.getVideoAnalysisRangeLabel(o.data)),g(3),V(" ",i.getVideoAnalysisRangeLabel(o.data)," "),g(),b((e=i.getVideoAnalysisDetail(o.data))?4:-1,e)}}function iR(n,t){if(n&1&&(l(0,"div",196)(1,"div",197),$(2,"span",110),l(3,"div",198)(4,"span"),u(5),d(),M(6,tR,4,1,"span",199),M(7,oR,5,3),d()()()),n&2){let e,o=m(2).$implicit,i=m(3).$implicit,r=m(4);w("is-running",r.isItemRunning(o,i))("is-note-tool",r.isNoteTool(o.data))("is-video-tool",r.isVideoTool(o.data))("is-video-attention",r.isVideoTool(o.data)&&r.isVideoAnalysisAttention(o.data)),g(2),w("streaming",r.isItemRunning(o,i)),g(3),We("",r.isVideoTool(o.data)?r.getVideoAnalysisLabel(o.data):r.getToolDisplayLabel(o.data),"",r.isNoteTool(o.data)&&r.getToolKey(o.data)||r.isVideoTool(o.data)?":":""),g(),b((e=r.isNoteTool(o.data)&&r.getToolKey(o.data))?6:-1,e),g(),b(r.isVideoTool(o.data)?7:-1)}}function rR(n,t){if(n&1&&(l(0,"span",205),u(1),d()),n&2){let e=m(3).$implicit,o=m(7);w("is-outputter",o.getToolAgentName(e.data)==="Outputter"),g(),O(o.getToolAgentName(e.data))}}function aR(n,t){n&1&&(l(0,"span",169)(1,"span",148),u(2,"error"),d(),u(3," Failed "),d())}function sR(n,t){if(n&1&&(l(0,"div",152)(1,"span",153),u(2,"Command:"),d(),l(3,"span",206)(4,"code"),u(5),d()()()),n&2){let e=m(5).$implicit,o=m(7);g(5),O(o.getAdbCommandLine(e.data))}}function cR(n,t){if(n&1&&(l(0,"div",152)(1,"span",153),u(2,"Cwd:"),d(),l(3,"span",154),u(4),d()()),n&2){let e=m(5).$implicit,o=m(7);g(4),V('"',o.getAdbCwd(e.data),'"')}}function lR(n,t){if(n&1&&(l(0,"div",152)(1,"span",153),u(2,"Terminal ID:"),d(),l(3,"span",154),u(4),d()()),n&2){let e=m(5).$implicit,o=m(7);g(4),V('"',o.getAdbTerminalId(e.data),'"')}}function dR(n,t){if(n&1&&(M(0,sR,6,1,"div",152),M(1,cR,5,1,"div",152),M(2,lR,5,1,"div",152)),n&2){let e=m(4).$implicit,o=m(7);b(o.getAdbCommandLine(e.data)?0:-1),g(),b(o.getAdbCwd(e.data)?1:-1),g(),b(o.getAdbTerminalId(e.data)?2:-1)}}function uR(n,t){if(n&1&&(l(0,"div",152)(1,"span",153),u(2,"Target:"),d(),l(3,"span",154),u(4),d()()),n&2){let e=m(5).$implicit,o=m(7);g(4),V('"',o.getToolTargetText(e.data),'"')}}function gR(n,t){if(n&1&&(l(0,"div",152)(1,"span",153),u(2),d(),l(3,"span",154),u(4),d()()),n&2){let e=m(5).$implicit,o=m(7);g(2),V("",o.getToolInputLabel(e.data),":"),g(2),V('"',o.getToolInputText(e.data),'"')}}function pR(n,t){if(n&1&&(l(0,"div",152)(1,"span",153),u(2,"Coordinates:"),d(),l(3,"span",170),u(4),d()()),n&2){let e=m(5).$implicit,o=m(7);g(4),O(o.getToolCoords(e.data))}}function fR(n,t){if(n&1&&(l(0,"div",152)(1,"span",153),u(2,"Analysis:"),d(),l(3,"span",154),u(4),d()()),n&2){let e=m(5).$implicit,o=m(7);g(4),V('"',o.getToolAnalysisText(e.data),'"')}}function mR(n,t){if(n&1&&(l(0,"div",152)(1,"span",153),u(2,"Details:"),d(),l(3,"span",154),u(4),d()()),n&2){let e=m(5).$implicit,o=m(7);g(4),V('"',o.getToolGenericDetails(e.data),'"')}}function _R(n,t){if(n&1&&(M(0,uR,5,1,"div",152),M(1,gR,5,2,"div",152),M(2,pR,5,1,"div",152),M(3,fR,5,1,"div",152),M(4,mR,5,1,"div",152)),n&2){let e=m(4).$implicit,o=m(7);b(o.getToolTargetText(e.data)?0:-1),g(),b(o.getToolInputText(e.data)?1:-1),g(),b(o.getToolCoords(e.data)?2:-1),g(),b(o.getToolAnalysisText(e.data)?3:-1),g(),b(o.getToolGenericDetails(e.data)?4:-1)}}function hR(n,t){if(n&1&&(l(0,"div",150),M(1,dR,3,3),M(2,_R,5,5),d()),n&2){let e=m(3).$implicit,o=m(7);g(),b(o.isAdbCommandTool(e.data)?1:-1),g(),b(o.isAdbCommandTool(e.data)?-1:2)}}function CR(n,t){if(n&1&&(l(0,"div",157)(1,"span",159),u(2,"Command"),d(),l(3,"span",207)(4,"code"),u(5),d()()()),n&2){let e=m(5).$implicit,o=m(7);g(5),O(o.getAdbCommandLine(e.data))}}function MR(n,t){if(n&1&&(l(0,"div",171)(1,"span",159),u(2,"Working Directory"),d(),l(3,"span",173),u(4),d()()),n&2){let e=m(5).$implicit,o=m(7);g(4),V('"',o.getAdbCwd(e.data),'"')}}function bR(n,t){if(n&1&&(l(0,"div",171)(1,"span",159),u(2,"Terminal ID"),d(),l(3,"span",174),u(4),d()()),n&2){let e=m(5).$implicit,o=m(7);g(4),O(o.getAdbTerminalId(e.data))}}function PR(n,t){if(n&1&&(M(0,CR,6,1,"div",157),M(1,MR,5,1,"div",171),M(2,bR,5,1,"div",171)),n&2){let e=m(4).$implicit,o=m(7);b(o.getAdbCommandLine(e.data)?0:-1),g(),b(o.getAdbCwd(e.data)?1:-1),g(),b(o.getAdbTerminalId(e.data)?2:-1)}}function OR(n,t){if(n&1&&(l(0,"div",171)(1,"span",159),u(2,"Target"),d(),l(3,"span",173),u(4),d()()),n&2){let e=m(5).$implicit,o=m(7);g(4),V('"',o.getToolTargetText(e.data),'"')}}function vR(n,t){if(n&1&&(l(0,"div",171)(1,"span",159),u(2),d(),l(3,"span",173),u(4),d()()),n&2){let e=m(5).$implicit,o=m(7);g(2),O(o.getToolInputLabel(e.data)),g(2),V('"',o.getToolInputText(e.data),'"')}}function yR(n,t){if(n&1&&(l(0,"div",171)(1,"span",159),u(2,"Coordinates"),d(),l(3,"span",174),u(4),d()()),n&2){let e=m(5).$implicit,o=m(7);g(4),O(o.getToolCoords(e.data))}}function xR(n,t){if(n&1&&(l(0,"div",171)(1,"span",159),u(2,"Analysis"),d(),l(3,"span",173),u(4),d()()),n&2){let e=m(5).$implicit,o=m(7);g(4),V('"',o.getToolAnalysisText(e.data),'"')}}function wR(n,t){if(n&1&&(l(0,"div",171)(1,"span",159),u(2,"Details"),d(),l(3,"span",173),u(4),d()()),n&2){let e=m(5).$implicit,o=m(7);g(4),V('"',o.getToolGenericDetails(e.data),'"')}}function SR(n,t){if(n&1&&(l(0,"div",171)(1,"span",159),u(2),d(),l(3,"span",174),u(4),d()()),n&2){let e=t.$implicit;g(2),O(e.key),g(2),O(e.value)}}function kR(n,t){if(n&1&&(M(0,OR,5,1,"div",171),M(1,vR,5,2,"div",171),M(2,yR,5,1,"div",171),M(3,xR,5,1,"div",171),M(4,wR,5,1,"div",171),G(5,SR,5,2,"div",171,De().trackParam,!0)),n&2){let e=m(4).$implicit,o=m(7);b(o.getToolTargetText(e.data)?0:-1),g(),b(o.getToolInputText(e.data)?1:-1),g(),b(o.getToolCoords(e.data)?2:-1),g(),b(o.getToolAnalysisText(e.data)?3:-1),g(),b(o.getToolGenericDetails(e.data)?4:-1),g(),q(o.getToolExtraParams(e.data))}}function ER(n,t){if(n&1){let e=L();l(0,"div",161)(1,"span",162),u(2,"Pre-Action (Before)"),d(),l(3,"div",163),k("click",function(i){v(e);let r=m(5).$implicit,a=m(3).$implicit,s=m(4);return y(s.openImageModal(s.getStepPreImageUrl(a.data,r.data),r.data,i))}),l(4,"div",164)(5,"img",175,2),k("load",function(){v(e);let i=Io(6),r=Io(8),a=m(5).$implicit,s=m(7);return y(s.onImageLoad(i,r,a.data))}),d(),$(7,"div",176,3),d()()()}if(n&2){let e=m(5).$implicit,o=m(3).$implicit,i=m(4);g(5),R("src",i.getStepPreImageUrl(o.data,e.data),kt)}}function DR(n,t){if(n&1){let e=L();l(0,"div",161)(1,"span",162),u(2,"Post-Action (After)"),d(),l(3,"div",163),k("click",function(i){v(e);let r=m(5).$implicit,a=m(3).$implicit,s=m(4);return y(s.openImageModal(s.getStepPostImageUrl(a.data,r.data),null,i))}),l(4,"div",164),$(5,"img",177),d()()()}if(n&2){let e=m(5).$implicit,o=m(3).$implicit,i=m(4);g(5),R("src",i.getStepPostImageUrl(o.data,e.data),kt)}}function IR(n,t){if(n&1&&(l(0,"div",158),M(1,ER,9,1,"div",161),M(2,DR,6,1,"div",161),d()),n&2){let e=m(4).$implicit,o=m(3).$implicit,i=m(4);g(),b(i.getStepPreImageUrl(o.data,e.data)?1:-1),g(),b(i.getStepPostImageUrl(o.data,e.data)?2:-1)}}function TR(n,t){if(n&1&&(l(0,"div",172)(1,"span",178),u(2,"error"),d(),l(3,"span",179),u(4),d()()),n&2){let e=m(4).$implicit,o=m(7);g(4),O(o.getToolErrorMessage(e.data))}}function AR(n,t){if(n&1&&(l(0,"div",155),k("click",function(o){return o.stopPropagation()}),l(1,"div",156),M(2,PR,3,3),M(3,kR,7,5),d(),M(4,IR,3,2,"div",158),M(5,TR,5,1,"div",172),d()),n&2){let e=m(3).$implicit,o=m(3).$implicit,i=m(4);g(2),b(i.isAdbCommandTool(e.data)?2:-1),g(),b(i.isAdbCommandTool(e.data)?-1:3),g(),b(i.getStepPreImageUrl(o.data,e.data)||i.getStepPostImageUrl(o.data,e.data)?4:-1),g(),b(i.isToolFailed(e.data)?5:-1)}}function NR(n,t){if(n&1){let e=L();l(0,"div",203)(1,"div",144),k("click",function(i){v(e);let r=m(2).$index,a=m(3).$implicit,s=m(4);return y(s.toggleActionExpanded("tool-"+a.id+"-"+r,i))}),l(2,"span",168),u(3),d(),l(4,"span",146),u(5),d(),M(6,rR,2,3,"span",204),M(7,aR,4,0,"span",169),l(8,"span",149),u(9),d()(),M(10,hR,3,2,"div",150),M(11,AR,6,4,"div",151),d()}if(n&2){let e=m(2),o=e.$implicit,i=e.$index,r=m(3).$implicit,a=m(4);w("is-running",a.isItemRunning(o,r))("has-no-details",a.isAdbCommandTool(o.data)?!a.getAdbCommandLine(o.data):!a.getToolTargetText(o.data)&&!a.getToolInputText(o.data)&&!a.getToolCoords(o.data)&&!a.getToolAnalysisText(o.data)&&!a.getToolGenericDetails(o.data)&&a.getToolExtraParams(o.data).length===0)("is-failed",a.isToolFailed(o.data))("is-expanded",a.isActionExpanded("tool-"+r.id+"-"+i)),g(2),w("is-failed",a.isToolFailed(o.data)),g(),O(a.getToolIcon(o.data)),g(2),O(a.getToolTitle(o.data)),g(),b(a.getToolAgentName(o.data)?6:-1),g(),b(a.isToolFailed(o.data)?7:-1),g(2),V(" ",a.isActionExpanded("tool-"+r.id+"-"+i)?"expand_less":"expand_more"," "),g(),b(!a.isActionExpanded("tool-"+r.id+"-"+i)&&(a.isAdbCommandTool(o.data)?a.getAdbCommandLine(o.data):a.getToolTargetText(o.data)||a.getToolInputText(o.data)||a.getToolCoords(o.data)||a.getToolAnalysisText(o.data)||a.getToolGenericDetails(o.data))?10:-1),g(),b(a.isActionExpanded("tool-"+r.id+"-"+i)?11:-1)}}function RR(n,t){if(n&1&&(M(0,qN,3,0,"div",108),M(1,eR,24,10,"div",180),M(2,iR,8,14,"div",181),M(3,NR,12,17,"div",182)),n&2){let e=m().$implicit,o=m(3).$implicit,i=m(4);b(i.isLLMRetry(e.data)?0:-1),g(),b(i.isDisplayableLLMFailure(e.data)?1:-1),g(),b(i.shouldShowTool(e.data,o.data)&&!i.isDeviceActionTool(e.data)?2:-1),g(),b(i.shouldShowTool(e.data,o.data)&&i.isDeviceActionTool(e.data)?3:-1)}}function FR(n,t){if(n&1&&(M(0,pN,7,7,"div",131),M(1,fN,6,5,"div",132),M(2,mN,7,7,"div",131),M(3,_N,6,5,"div",132),M(4,vN,14,19,"div",133),M(5,zN,11,14,"div",134),M(6,RR,4,4)),n&2){let e=t.$implicit,o=m(3).$implicit,i=m(4);b(i.isCheckerBlock(o)&&e.type==="thinking"?0:-1),g(),b(i.isCheckerBlock(o)&&e.type==="text"&&i.isCheckerTextVisible(e)?1:-1),g(),b(!i.isCheckerBlock(o)&&e.type==="thinking"?2:-1),g(),b(!i.isCheckerBlock(o)&&e.type==="text"&&i.getRawThinking(o)?3:-1),g(),b(i.isReportStatusAction(e.data)?4:-1),g(),b(e.type==="action"&&i.isAndroidAction(e.data)?5:-1),g(),b(e.type==="tool"?6:-1)}}function LR(n,t){if(n&1&&(l(0,"div",107),G(1,FR,7,7,null,null,De().trackStepEvent,!0),d()),n&2){let e=m(2).$implicit,o=m(4);g(),q(o.getSortedStepEvents(e.data))}}function VR(n,t){if(n&1&&(l(0,"span",213),u(1),d()),n&2){let e=m().$implicit;g(),O(e.suggestion)}}function jR(n,t){if(n&1&&(l(0,"div",208)(1,"span",128),u(2),d(),l(3,"span",210),u(4),d(),l(5,"span",211)(6,"span",212),u(7),d(),M(8,VR,2,1,"span",213),d()()),n&2){let e=t.$implicit,o=t.$index,i=m(3).$implicit,r=m(4);R("ngClass",e.status),g(2),O(r.getVerdictItemNumber(i,e,o)),g(2),O(r.getVerdictIcon(e.status)),g(3),O(e.evidence||r.getVerdictStatusText(e.status)),g(),b(e.suggestion?8:-1)}}function $R(n,t){if(n&1&&(l(0,"div",209),u(1),d()),n&2){let e=t.$implicit;g(),O(e)}}function zR(n,t){if(n&1&&(l(0,"div",121),G(1,jR,9,5,"div",208,Do),G(3,$R,2,1,"div",209,Ie),d()),n&2){let e=m(2).$implicit,o=m(4);R("ngClass","tone-"+o.getCheckerTone(e)),g(),q(o.getCheckerVerdicts(e)),g(2),q(o.getCheckerNotes(e))}}function BR(n,t){if(n&1&&(l(0,"div",119)(1,"div",106),M(2,gN,9,7,"div",120),M(3,LR,3,0,"div",107),M(4,zR,5,1,"div",121),d()()),n&2){let e=m().$implicit,o=m(4);R("ngClass",Up(4,qT,"type-"+e.type)),g(2),b(o.isCheckerBlock(e)?2:-1),g(),b(o.getSortedStepEvents(e.data).length>0?3:-1),g(),b(o.hasCheckerResult(e)?4:-1)}}function HR(n,t){if(n&1&&M(0,BR,5,6,"div",119),n&2){let e=t.$implicit,o=m(4);b(o.hasVisibleContent(e)?0:-1)}}function UR(n,t){if(n&1&&(l(0,"div",114),G(1,HR,1,1,null,null,De().trackBlock,!0),d()),n&2){let e=m().$implicit;g(),q(e.blocks)}}function GR(n,t){if(n&1&&(l(0,"div",99),M(1,cN,4,5,"div",113),M(2,UR,3,0,"div",114),d()),n&2){let e=t.$implicit,o=m(2);g(),b(o.hasVisibleBlocks(e)?1:-1),g(),b(o.hasVisibleBlocks(e)?2:-1)}}function qR(n,t){if(n&1&&(l(0,"h3",222),u(1),d()),n&2){let e=m();g(),O(e.title)}}function WR(n,t){if(n&1&&(l(0,"strong"),u(1),d()),n&2){let e=m().$implicit;g(),O(e.text)}}function KR(n,t){if(n&1&&(l(0,"code"),u(1),d()),n&2){let e=m().$implicit;g(),O(e.text)}}function YR(n,t){if(n&1&&(l(0,"span"),u(1),d()),n&2){let e=m().$implicit;g(),O(e.text)}}function ZR(n,t){if(n&1&&(M(0,WR,2,1,"strong"),M(1,KR,2,1,"code"),M(2,YR,2,1,"span")),n&2){let e=t.$implicit;b(e.bold?0:-1),g(),b(e.code?1:-1),g(),b(!e.bold&&!e.code?2:-1)}}function QR(n,t){if(n&1&&(l(0,"div",226)(1,"span",148),u(2),d(),l(3,"span",63),G(4,ZR,3,3,null,null,Ie),d()()),n&2){let e=t.$implicit;Dl("status-"+e.type),g(2),V(" ",e.type==="checked"?"check_circle":e.type==="progress"?"pending":"radio_button_unchecked"," "),g(2),q(e.segments)}}function JR(n,t){if(n&1&&(l(0,"div",223),G(1,QR,6,3,"div",225,De().trackMilestone,!0),d()),n&2){let e=m();g(),q(e.milestones)}}function XR(n,t){if(n&1&&(l(0,"span",231),u(1),d()),n&2){let e=m().$implicit;w("assert",e.checkKind==="assert"),g(),We("",e.checkKind||"verify","",e.atEnd?"@end":"")}}function e3(n,t){n&1&&(l(0,"span",230),u(1,"finding"),d())}function t3(n,t){if(n&1&&(l(0,"strong"),u(1),d()),n&2){let e=m().$implicit;g(),O(e.text)}}function n3(n,t){if(n&1&&(l(0,"code"),u(1),d()),n&2){let e=m().$implicit;g(),O(e.text)}}function o3(n,t){if(n&1&&(l(0,"span"),u(1),d()),n&2){let e=m().$implicit;g(),O(e.text)}}function i3(n,t){if(n&1&&(M(0,t3,2,1,"strong"),M(1,n3,2,1,"code"),M(2,o3,2,1,"span")),n&2){let e=t.$implicit;b(e.bold?0:-1),g(),b(e.code?1:-1),g(),b(!e.bold&&!e.code?2:-1)}}function r3(n,t){if(n&1&&(l(0,"p",228),M(1,XR,2,4,"span",229),M(2,e3,2,0,"span",230),G(3,i3,3,3,null,null,Ie),d()),n&2){let e=t.$implicit;Dl("line-"+e.type),g(),b(e.type==="verify"?1:-1),g(),b(e.type==="finding"?2:-1),g(),q(e.segments)}}function a3(n,t){if(n&1&&(l(0,"div",224),G(1,r3,5,4,"p",227,De().trackMarkdownLine,!0),d()),n&2){let e=m();g(),q(e.otherLines)}}function s3(n,t){if(n&1&&(l(0,"div",221),M(1,qR,2,1,"h3",222),M(2,JR,3,0,"div",223),M(3,a3,3,0,"div",224),d()),n&2){let e=t;g(),b(e.title?1:-1),g(),b(e.milestones.length>0?2:-1),g(),b(e.otherLines.length>0?3:-1)}}function c3(n,t){if(n&1){let e=L();l(0,"div",100)(1,"div",214)(2,"div",215)(3,"span",216),u(4,"assignment_turned_in"),d(),l(5,"span",217),u(6,"Task Report"),d()(),l(7,"div",218)(8,"button",219),k("click",function(i){v(e);let r=m(2);return y(r.openOutputterNote(i))}),l(9,"span",22),u(10,"visibility"),d(),l(11,"span"),u(12,"View in Notes"),d()()()(),l(13,"div",220),M(14,s3,4,3,"div",221),d()()}if(n&2){let e,o=m(2);g(14),b((e=o.parsedOutputReport())?14:-1,e)}}function l3(n,t){if(n&1&&(l(0,"div",101)(1,"div",232)(2,"span",96),u(3,"progress_activity"),d(),l(4,"span",233),u(5),d()()()),n&2){let e=m(2);g(5),O(e.currentPlanningText())}}function d3(n,t){if(n&1&&(l(0,"div",19),M(1,aN,11,1,"div",98),G(2,GR,3,2,"div",99,De().trackPhase,!0),M(4,c3,15,1,"div",100),M(5,l3,6,1,"div",101),d()),n&2){let e,o=m();g(),b(o.startupWorkItems().length>0?1:-1),g(),q(o.phases()),g(2),b((e=o.outputterReport())?4:-1,e),g(),b(o.showPlanningLoader()&&(o.startupWorkItems().length===0||o.startupPreparationIsComplete()||o.consolidatedBlocks().length>0)?5:-1)}}function u3(n,t){if(n&1){let e=L();l(0,"div",234),k("click",function(i){v(e);let r=m();return y(r.closeImageModal(i))}),l(1,"div",235),k("click",function(i){return i.stopPropagation()}),l(2,"button",236),k("click",function(i){v(e);let r=m();return y(r.closeImageModal(i))}),l(3,"span",22),u(4,"close"),d()(),l(5,"div",164)(6,"img",237,4),k("load",function(){v(e);let i=Io(7),r=Io(9),a=m();return y(a.onImageLoad(i,r,a.selectedZoomAction()))}),d(),$(8,"div",176,5),d()()()}if(n&2){let e=m();g(6),R("src",e.selectedZoomImage(),kt)}}var Es=["Planning next step...","Analyzing screen coordinates...","Consulting neural network...","Formulating tactical action plan...","Deciphering UI state & elements...","Synthesizing decision pathways...","Calibrating next move...","Aligning logical vectors...","Evaluating optimal sub-goals...","Gathering sensory inputs...","Computing next interaction...","Optimizing execution strategy...","Simulating probable outcomes...","Summoning AI intuition...","Brewing the next command...","Strategizing tactical moves..."],oO=[{started:"device_check",completed:"device_ready",completedMessage:"Android device connected"},{started:"uiautomator",completed:"uiautomator_ready",completedMessage:"UI Automator is ready"},{started:"environment",completed:"environment_ready",completedMessage:"Device environment is ready"}];function g3(n){let t=Math.max(0,n);return t<10?`${t.toFixed(1)}s`:`${Math.round(t)}s`}function p3(n,t,e,o){let i=new Map(n.map(a=>[a.stage,a])),r=i.get("first_response");return oO.flatMap((a,s)=>{let c=i.get(a.started),p=i.get(a.completed);if(!c&&!p)return[];let f=oO.slice(s+1).map(A=>i.get(A.started)||i.get(A.completed)).find(A=>!!A),_=a.started==="environment"?r:f,C=p||_,h=!C&&!e&&o,P=c?.timestamp||p?.timestamp||t,T=C?.timestamp||(h?t:n[n.length-1]?.timestamp||P);return[F(S({},p||c),{message:p?.message||(C?a.completedMessage:c.message),isActive:h,elapsed:g3(T-P)})]})}var Wd=class n{agentService=D(It);http=D(Ro);destroyRef=D(Ae);zone=D(ge);isUserAtBottom=!0;resizeObserver=null;streamLogsContainer=null;autoScrollTimer=null;autoScrollStreamBoxes=!1;onContainerScroll=()=>{let t=this.streamLogsContainer;if(!t)return;let e=150,o=t.scrollHeight-t.scrollTop-t.clientHeight;this.isUserAtBottom=o<=e};currentPlanningText=x(Es[0]);planningIntervalId=null;currentPhraseIndex=0;isTaskDropdownOpen=x(!1);isNotesDropdownOpen=x(!1);selectedZoomImage=x(null);selectedZoomAction=x(null);collapsedStreams=x(new Set);scheduledCollapses=new Set;typedTextsSignal=x({});typingTargets=new Map;typingRafId=null;lastTypingTimestamp=0;static TYPING_CHARS_PER_SECOND=667;static TYPING_MIN_FRAME_MS=28;markdownHtmlCache=new Map;thinkingTextCache=new WeakMap;deviceSerialCache=new WeakMap;expandedActionCards=x(new Set);collapsedActionCards=x(new Set);retryClock=x(Date.now());actionParamsCache=new WeakMap;toolParamsCache=new WeakMap;sortedEventsCache=new WeakMap;activeQueue=I(()=>this.agentService.sessions().filter(e=>{let o=this.getTaskStatus(e);return o==="running"||o==="paused"||o==="pending"}).sort((e,o)=>{let i=this.getTaskStatus(e),r=this.getTaskStatus(o),a=i==="running"||i==="paused",s=r==="running"||r==="paused";return a&&!s?-1:!a&&s?1:(e.start_time||0)-(o.start_time||0)}));historyTasks=I(()=>this.agentService.sessions().filter(t=>{let e=this.getTaskStatus(t);return e==="completed"||e==="failed"||e==="cancelled"}));currentNoteContent=I(()=>{let t=this.agentService.currentNotes(),e=this.agentService.selectedNoteKey();return t[e]||""});noteKeys=I(()=>Object.keys(this.agentService.currentNotes()).filter(t=>t.toLowerCase().endsWith(".md")));outputterReport=I(()=>this.agentService.currentNotes()["output.md"]||null);parsedOutputReport=I(()=>{let t=this.outputterReport();return t?ks(t):null});parsedNote=I(()=>this.getParsedNote(this.currentNoteContent()));filteredLogs=I(()=>{let t=this.agentService.sessionLogs(),e=this.agentService.currentSessionId();return e?t.filter(o=>{let i=o.session_id||o.data?.session_id;return!i||i===e}):t});sessionSummary=I(()=>eO(this.filteredLogs()));showPlanningLoader=I(()=>{let t=this.agentService.agentStatus()==="running",e=this.agentService.currentSessionId(),o=this.agentService.runningSessionId(),i=!e||e===o;return nO(this.filteredLogs(),t,i)});isViewingPausedTask=I(()=>{if(!this.agentService.isPaused()||this.agentService.agentStatus()!=="paused")return!1;let t=this.agentService.currentSessionId(),e=this.agentService.runningSessionId();return!t||t===e});sessionDuration=I(()=>{let t=this.agentService.sessions().find(e=>e.session_id===this.agentService.currentSessionId());return tO(t?.start_time||0,this.filteredLogs())});consolidatedBlocks=I(()=>ZP(this.filteredLogs()));phases=I(()=>{let t=this.agentService.sessions().find(e=>e.session_id===this.agentService.currentSessionId());return JP(this.consolidatedBlocks(),t?.start_time||0)});startupWorkItems=I(()=>{this.retryClock();let t=this.agentService.currentStartupProgress(),e=["running","paused"].includes(this.agentService.agentStatus());return p3(t,Date.now()/1e3,this.consolidatedBlocks().length>0,e)});startupPreparationIsRunning=I(()=>this.startupWorkItems().some(t=>t.isActive));startupPreparationIsComplete=I(()=>{let t=this.startupWorkItems();return t.length>0&&!t.some(e=>e.isActive)});startupPreparationDuration=I(()=>{let t=this.startupWorkItems();if(t.length===0)return 0;let e=this.agentService.currentStartupProgress(),o=e[0]?.timestamp||t[0].timestamp,i=e.find(a=>a.stage==="environment_ready")?.timestamp||e.find(a=>a.stage==="first_response")?.timestamp,r=this.startupPreparationIsRunning()?Date.now()/1e3:i||Math.max(...t.map(a=>a.timestamp));return Math.max(1,Math.round(r-o))});retryablePauseTrace=I(()=>{if(!this.isViewingPausedTask())return null;let t=this.consolidatedBlocks();for(let e=t.length-1;e>=0;e--){let o=t[e]?.data?.generic_tools;if(Array.isArray(o))for(let i=o.length-1;i>=0;i--){let r=o[i];if(this.isDisplayableLLMFailure(r))return r}}return null});hasVisibleBlocks(t){return!t||!t.blocks?!1:t.blocks.some(e=>this.hasVisibleContent(e))}constructor(){this.zone.runOutsideAngular(()=>{let t=setInterval(()=>{let e=this.agentService.agentStatus();(e==="running"||e==="paused"||this.agentService.isRetrying())&&this.retryClock.set(Date.now())},1e3);this.destroyRef.onDestroy(()=>clearInterval(t))}),Ye(()=>{this.filteredLogs().find(o=>o.type==="llm_stream"&&!o.data?.isCompleted)&&this.scheduleAutoScroll(!0)}),Ye(()=>{this.consolidatedBlocks(),this.typedTextsSignal(),this.scheduleAutoScroll(!1)}),Ye(()=>{this.consolidatedBlocks().forEach(e=>{if(e.type==="checker")return;let o=e.id,i=this.getRawThinking(e)||"",r=this.getNativeThinking(e)||"";de(()=>{let a=this.typedTextsSignal();i&&(o in a?(a[o]||"").lengthF(S({},p),{[o]:i})):this.startTyping(o,i,!1)):e.data?.isCompleted?this.typedTextsSignal.update(c=>F(S({},c),{[o]:i})):(this.typedTextsSignal.update(c=>F(S({},c),{[o]:""})),this.startTyping(o,i,!1)));let s=o+"-native";if(r){let c=e.data?.execution_id||e.data?.step_id||o;s in a?(a[s]||"").lengthF(S({},f),{[s]:r})),this.collapsedStreams.update(f=>{let _=new Set(f);return _.add(c),_})):this.startTyping(s,r,!0,c)):e.data?.isCompleted?(this.typedTextsSignal.update(p=>F(S({},p),{[s]:r})),this.collapsedStreams.update(p=>{let f=new Set(p);return f.add(c),f})):(this.typedTextsSignal.update(p=>F(S({},p),{[s]:""})),this.startTyping(s,r,!0,c))}})})}),Ye(()=>{this.agentService.currentSessionId(),de(()=>{this.stopTypingLoop(),this.typedTextsSignal.set({}),this.markdownHtmlCache.clear(),this.scheduledCollapses.clear(),this.collapsedStreams.set(new Set),this.isUserAtBottom=!0,setTimeout(()=>{let t=document.querySelector(".stream-logs-content");t&&(t.scrollTop=t.scrollHeight)},100)})}),Ye(()=>{this.showPlanningLoader()?this.planningIntervalId||(this.currentPhraseIndex=Math.floor(Math.random()*Es.length),this.currentPlanningText.set(Es[this.currentPhraseIndex]),this.planningIntervalId=setInterval(()=>{this.currentPhraseIndex=(this.currentPhraseIndex+1)%Es.length,this.currentPlanningText.set(Es[this.currentPhraseIndex])},2800)):this.planningIntervalId&&(clearInterval(this.planningIntervalId),this.planningIntervalId=null)}),this.destroyRef.onDestroy(()=>{this.planningIntervalId&&(clearInterval(this.planningIntervalId),this.planningIntervalId=null),this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.stopTypingLoop(),this.autoScrollTimer&&(clearTimeout(this.autoScrollTimer),this.autoScrollTimer=null),this.streamLogsContainer&&(this.streamLogsContainer.removeEventListener("scroll",this.onContainerScroll),this.streamLogsContainer=null)})}ngAfterViewInit(){let t=document.querySelector(".stream-logs-content");this.streamLogsContainer=t,t&&(this.zone.runOutsideAngular(()=>{t.addEventListener("scroll",this.onContainerScroll,{passive:!0})}),typeof ResizeObserver<"u"&&(this.resizeObserver=new ResizeObserver(()=>{this.isUserAtBottom&&this.scheduleAutoScroll(!1)}),this.resizeObserver.observe(t)))}scheduleAutoScroll(t){this.autoScrollStreamBoxes=this.autoScrollStreamBoxes||t,!this.autoScrollTimer&&this.zone.runOutsideAngular(()=>{this.autoScrollTimer=setTimeout(()=>{this.autoScrollTimer=null;let e=this.autoScrollStreamBoxes;this.autoScrollStreamBoxes=!1;let o=e?Array.from(document.getElementsByClassName("stream-box")):[],i=o.map(s=>s.scrollHeight),r=this.streamLogsContainer,a=r&&this.isUserAtBottom?r.scrollHeight:null;o.forEach((s,c)=>{s.scrollTop=i[c]}),r&&a!==null&&(r.scrollTop=a)},50)})}toggleTaskDropdown(t){t.stopPropagation(),this.isTaskDropdownOpen.update(e=>!e),this.isTaskDropdownOpen()&&this.isNotesDropdownOpen.set(!1)}toggleNotesDropdown(t){t.stopPropagation(),this.isNotesDropdownOpen.update(e=>!e),this.isNotesDropdownOpen()&&this.isTaskDropdownOpen.set(!1)}openOutputterNote(t){t&&t.stopPropagation(),this.agentService.selectedNoteKey.set("output.md"),this.isNotesDropdownOpen.set(!0),this.isTaskDropdownOpen.set(!1)}closeAllDropdowns(){this.isTaskDropdownOpen.set(!1),this.isNotesDropdownOpen.set(!1)}getTaskStatus(t){if(t.status){let e=t.status.toLowerCase();if(e==="completed"||e==="success"||e==="failed"||e==="cancelled")return e==="success"?"completed":e;if(e==="running"||e==="paused"||e==="pending")return e}return t.session_id===this.agentService.runningSessionId()&&(this.agentService.agentStatus()==="running"||this.agentService.agentStatus()==="paused")?this.agentService.agentStatus():"completed"}getDeviceSerial(t){if(this.deviceSerialCache.has(t))return this.deviceSerialCache.get(t)??null;let e=null,o=t.device_serial||t.device_id;if(o&&o!=="pending"&&o!=="null"&&o!=="undefined")e=o;else if(t.device_info)try{let i=typeof t.device_info=="string"?JSON.parse(t.device_info):t.device_info,r=i?.device_id||i?.device_serial;r&&r!=="pending"&&r!=="null"&&r!=="undefined"&&(e=r)}catch(i){}return this.deviceSerialCache.set(t,e),e}selectTask(t,e){e&&e.stopPropagation(),this.agentService.selectSession(t,!0),this.isTaskDropdownOpen.set(!1)}stopTask(t,e){e&&e.stopPropagation(),this.agentService.stopTask(t,!1)}deleteTask(t,e){e&&e.stopPropagation(),confirm("Are you sure you want to delete this task? This cannot be undone.")&&this.agentService.deleteSession(t).subscribe({error:o=>{console.error(`Failed to delete session ${t}:`,o)}})}selectNote(t,e){e&&e.stopPropagation(),this.agentService.selectedNoteKey.set(t)}getParsedNote(t){return ks(t)}getParsedNoteLines(t){return Ss(t)}startTyping(t,e,o=!1,i){let r=t.endsWith("-native")?t.replace("-native",""):t;this.typingTargets.set(t,{blockId:r,isThinking:o,execId:i,fallbackText:e}),this.ensureTypingLoop()}ensureTypingLoop(){this.typingRafId===null&&(this.lastTypingTimestamp=performance.now(),this.zone.runOutsideAngular(()=>{let t=e=>{this.typingRafId=null;let o=e-this.lastTypingTimestamp;o>=n.TYPING_MIN_FRAME_MS&&(this.lastTypingTimestamp=e,this.advanceTyping(o)),this.typingTargets.size>0&&(this.typingRafId=requestAnimationFrame(t))};this.typingRafId=requestAnimationFrame(t)}))}stopTypingLoop(){this.typingTargets.clear(),this.typingRafId!==null&&(cancelAnimationFrame(this.typingRafId),this.typingRafId=null)}advanceTyping(t){let e=de(()=>this.consolidatedBlocks()),o=de(()=>this.typedTextsSignal()),i=Math.max(1,Math.round(n.TYPING_CHARS_PER_SECOND*t/1e3)),r={},a=!1;for(let[s,c]of this.typingTargets){let p=e.find(C=>C.id===c.blockId),f=c.fallbackText;p&&(f=(c.isThinking?this.getNativeThinking(p):this.getRawThinking(p))||c.fallbackText);let _=o[s]||"";_.lengthS(S({},s),r))}triggerDelayedCollapse(t){this.scheduledCollapses.has(t)||(this.scheduledCollapses.add(t),setTimeout(()=>{this.collapsedStreams.update(e=>{let o=new Set(e);return o.add(t),o})},1e3))}onDetailsToggle(t,e){let i=t.target.open;this.collapsedStreams.update(r=>{let a=new Set(r);return i?a.delete(e):a.add(e),a})}toggleCollapse(t){this.collapsedStreams.update(e=>{let o=new Set(e);return o.has(t)?o.delete(t):o.add(t),o})}getActionObject(t){return ke(t)}isAndroidAction(t){return jn(t)}getActionIcon(t){return zd(t)}getActionTitle(t){return mm(t)}getActionTargetText(t){return _m(t)}getActionInputLabel(t){return AP(t)}getActionInputText(t){return NP(t)}getActionCoords(t){return hm(t)}isActionFailed(t,e){return Bd(t,e)}getActionErrorMessage(t,e){return RP(t,e)}isActionExpanded(t,e=!1){return this.collapsedActionCards().has(t)?!1:this.expandedActionCards().has(t)?!0:e}toggleActionExpanded(t,e,o=!1){e&&e.stopPropagation(),this.isActionExpanded(t,o)?(this.expandedActionCards.update(r=>{let a=new Set(r);return a.delete(t),a}),this.collapsedActionCards.update(r=>{let a=new Set(r);return a.add(t),a})):(this.collapsedActionCards.update(r=>{let a=new Set(r);return a.delete(t),a}),this.expandedActionCards.update(r=>{let a=new Set(r);return a.add(t),a}))}getActionBounds(t){return FP(t)}getActionResourceId(t){return LP(t)}getActionClass(t){return VP(t)}getActionExtraParams(t){return jP(t,this.actionParamsCache)}getToolExtraParams(t){return kP(t,this.toolParamsCache)}getStepPreImageUrl(t,e){return Cm(t,e)}getStepPostImageUrl(t,e){return Mm(t,e)}openImageModal(t,e,o){o&&o.stopPropagation(),this.selectedZoomImage.set(t),this.selectedZoomAction.set(e||null)}closeImageModal(t){t&&t.stopPropagation(),this.selectedZoomImage.set(null),this.selectedZoomAction.set(null)}onImageLoad(t,e,o){if(!(!t||!e))try{this.drawActionCoordinatesOnOverlay(t,e,o)}catch(i){console.warn("Failed to draw action overlay:",i)}}drawActionCoordinatesOnOverlay(t,e,o){Rd(t,e,o)}firstSaveNoteByKey=I(()=>{let t=new Map;for(let e of this.consolidatedBlocks())if(e.type==="step"&&e.data.generic_tools){for(let o of this.getUniqueGenericTools(e.data.generic_tools))if(o.name&&o.name.toLowerCase()==="save_note"){let i=this.getToolKey(o);i&&!t.has(i)&&t.set(i,o)}}return t});isFirstSaveNoteForKey(t){if(!t||!t.name||t.name.toLowerCase()!=="save_note")return!1;let e=this.getToolKey(t);if(!e)return!0;let o=this.firstSaveNoteByKey().get(e);return o?t.trace_id&&o.trace_id?t.trace_id===o.trace_id:t===o:!0}getToolDisplayLabel(t){return CP(t,this.isFirstSaveNoteForKey(t))}isNoteTool(t){return pP(t)}isVideoTool(t){return Fd(t)}getVideoToolTarget(t){return fP(t)}getVideoAnalysisLabel(t){return Fi(t)?.title||"Analyzing screen recording"}getVideoAnalysisRangeLabel(t){let e=Fi(t)?.requestedRange;return e?`${cm(e.start)}\u2013${cm(e.end)}`:"Screen recording"}getVideoAnalysisDetail(t){let e=Fi(t);return!e||e.totalCount<=1?"":e.outcome==="running"||e.outcome==="recovering"?e.completedCount>0?`${e.completedCount}/${e.totalCount} segments saved`:"":e.outcome==="partial"?`${e.completedCount}/${e.totalCount} segments saved`:""}isVideoAnalysisAttention(t){let e=Fi(t)?.outcome;return e==="partial"||e==="failed"}onVideoToolClick(t){let e=this.agentService.currentSessionId();if(e){let o=Fi(t)?.requestedRange?.start;this.agentService.openVideoPlayer(e,void 0,void 0,o)}}getScreenRecordingButtonTitle(){return this.agentService.isCurrentSessionRunning()?"Task is currently running (Recording screen)":this.agentService.currentSessionRecordingStatus()==="processing"?"Preparing screen recording...":this.agentService.currentSessionVideoUrl()?"Play Screen Recording Video":this.agentService.hasCurrentSessionStepFrames()?this.agentService.currentSessionRecordingStatus()==="failed"?"Video recording failed \u2014 Click to replay step-by-step screenshots":"Play step-by-step screenshots replay":this.agentService.currentSessionRecordingStatus()==="failed"?"Screen recording generation failed":"Screen Recording"}isDeviceActionTool(t){return Ld(t)}isFailureAnalyzerActionTool(t){return Ld(t)}getToolAgentName(t){return _P(t)}shouldShowTool(t,e){return mP(t,e)}getUniqueGenericTools(t){return Vd(t)}getSortedStepEvents(t){return XP(t,this.sortedEventsCache)}getThinkingTexts(t){let e=this.thinkingTextCache.get(t);if(!e){let o=t.operator_native_thinking||(t.stream_type==="thinking"?t.text:null),i=t.operator_raw_thinking||(t.stream_type==="text"?t.text:null);e={native:o&&o.trim()?o:null,raw:i&&i.trim()&&this.isHumanThinking(i)?i:null},this.thinkingTextCache.set(t,e)}return e}getNativeThinking(t){return!t||!t.data?null:this.getThinkingTexts(t.data).native}getRawThinking(t){return!t||!t.data?null:this.getThinkingTexts(t.data).raw}isCheckerBlock(t){return t?.type==="checker"}isCheckerPhase(t){let e=Array.isArray(t?.blocks)?t.blocks:[];return e.length>0&&e.every(o=>this.isCheckerBlock(o))}getCheckerPhaseLabel(t){switch(t?.data?.phase){case"final":return"Final check";case"outcome":return"Result";default:return"Check"}}getCheckerVerdicts(t){return Array.isArray(t?.data?.verdicts)?t.data.verdicts:[]}getCheckerItems(t){let e=t?.data||{};return Array.isArray(e.items)&&e.items.length>0?e.items:this.getCheckerVerdicts(t).map(o=>({kind:o.kind,text:o.item_text,when:o.when}))}getCheckMethodLabel(t,e){let o=[t?.kind==="assert"?"Test assertion":"Must pass"];return t?.when==="at_end"&&e?.data?.phase!=="final"&&o.push("at the end"),o.join(" \xB7 ")}isCheckerMuted(t){let e=t?.data?.status;return e==="superseded"||e==="unchecked"}getVerdictItemNumber(t,e,o){let i=this.getCheckerItems(t).findIndex(r=>r.text===e?.item_text);return(i>-1?i:o)+1}getVerdictStatusText(t){switch(t){case"passed":return"Passed";case"failed":return"Failed";case"inconclusive":return"Could not be confirmed";case"superseded":return"Superseded by a newer check";case"unchecked":return"Not checked";default:return t||""}}hasCheckerResult(t){return!this.isCheckerBlock(t)||this.isBlockRunning(t)||this.isCheckerMuted(t)?!1:t.data?.phase==="outcome"?this.getCheckerNotes(t).length>0:this.getCheckerVerdicts(t).length>0||this.getCheckerNotes(t).length>0}getCheckerNotes(t){let e=t?.data||{};if(e.phase==="outcome")return Array.isArray(e.last_findings)?e.last_findings:[];let o=[];if(Array.isArray(e.unmet_subgoals))for(let i of e.unmet_subgoals)o.push(`Not finished yet: ${i}`);return e.reverted&&o.push("The step was sent back to be redone."),e.error&&o.push(String(e.error)),o}openCheckSegments=x(new Set);isCheckerSegmentRunning(t,e){return this.isBlockRunning(t)?e===this.getSortedStepEvents(t.data).length-1:!1}isCheckerSegmentCollapsed(t,e){return this.isCheckerSegmentRunning(t,e)?!1:!this.openCheckSegments().has(`${t.id}-${e}`)}toggleCheckerSegment(t,e){let o=`${t.id}-${e}`;this.openCheckSegments.update(i=>{let r=new Set(i);return r.has(o)?r.delete(o):r.add(o),r})}isCheckerTextVisible(t){let e=t?.data?.text;return typeof e=="string"&&e.trim().length>0&&this.isHumanThinking(e)}getCheckerTone(t){let e=t?.data||{};if(e.phase==="outcome")return e.task_status==="completed"?"passed":e.task_status==="blocked"?"failed":"inconclusive";if(e.isCompleted===!1)return"running";if(e.status==="superseded"||e.status==="unchecked")return"muted";if(e.status==="error")return"inconclusive";let o=this.getCheckerVerdicts(t);return o.some(i=>i.status==="failed")?"failed":o.some(i=>i.status==="inconclusive")?"inconclusive":"passed"}getCheckerStatusLabel(t){let e=t?.data||{};if(e.phase==="outcome"){let r=e.tests||{},a=e.task_status==="completed"?"Goal completed":e.task_status==="blocked"?"Blocked":"Partially completed",s=[[r.passed,"passed"],[r.failed,"failed"],[r.inconclusive,"inconclusive"],[r.unchecked,"not checked"]].filter(([c])=>Number(c)>0).map(([c,p])=>`${c} ${p}`);return s.length>0?`${a} \xB7 ${s.join(" \xB7 ")}`:a}if(e.isCompleted===!1)return"Checking\u2026";switch(e.status){case"superseded":return"Superseded";case"unchecked":return"Not checked";case"error":return"No verdict"}let o=this.getCheckerVerdicts(t),i=o.filter(r=>r.status==="failed").length;return i>0?`${i} failed`:o.some(r=>r.status==="inconclusive")?"Inconclusive":o.length>0?"Passed":"Done"}getVerdictIcon(t){switch(t){case"passed":return"check_circle";case"failed":return"cancel";case"inconclusive":return"help";case"superseded":return"history";case"unchecked":return"radio_button_unchecked";default:return"pending"}}hasVisibleContent(t){if(!t)return!1;if(t.type==="checker")return!0;let e=!!this.getNativeThinking(t),o=!!this.getRawThinking(t),i=!!t.data?.generic_tools&&t.data.generic_tools.some(a=>this.shouldShowTool(a,t.data)||this.isDisplayableLLMFailure(a)||this.isLLMRetry(a)||this.isReportStatusAction(a)),r=!!t.data?.action_taken&&(this.isAndroidAction(t.data.action_taken)||this.isReportStatusAction(t.data.action_taken));return e||o||i||r}isReportStatusAction(t){return jo(t)}getReportStatusValue(t){return TP(t)}getReportStatusExplanation(t){return $d(t)}isHumanThinking(t){return EP(t)}isTerminalLLMFailure(t){return!!t&&this.retryablePauseTrace()===t}isDisplayableLLMFailure(t){return t?.type==="llm_call"&&t?.status==="failed"&&(t?.name==="llm_pause"||t?.payload?.pause===!0)}resumePausedTask(t){t.stopPropagation(),this.agentService.resumeTask()}getLLMErrorText(t){let e="The AI provider did not return error details after the request failed.";if(!t)return e;let o=t.payload?.error||t.error;if(!o)return e;let i=this.cleanErrorMessage(o);return i==="Unknown error"?e:i}isLLMRetry(t){return t?.type==="llm_call"&&t?.status==="retrying"&&t?.payload?.source==="provider_sdk"&&["google","gemini"].includes(String(t?.payload?.provider||"").toLowerCase())}getLLMRetryEntries(t){let e=t?.payload?.retries;return Array.isArray(e)&&e.length>0?e:[t?.payload||t]}getLLMFailureRetryEntries(t){let e=t?.payload?.retries;return Array.isArray(e)?e.filter(o=>o?.source==="provider_sdk"&&["google","gemini"].includes(String(o?.provider||"").toLowerCase())):[]}hasLLMFailureRetryEntries(t){return this.getLLMFailureRetryEntries(t).length>0}getLLMRetryEntryDelay(t){let e=Number(t?.delay);return!Number.isFinite(e)||e<=0?null:`${e.toFixed(2).replace(/\.00$/,"")}s`}getLLMRetryEntryWaited(t){let e=Number(t?.delay);if(!Number.isFinite(e)||e<=0)return"0s";let o=Number(t?.scheduled_at??t?.timestamp);if(!Number.isFinite(o)||o<=0)return this.formatLLMDuration(e);let i=o<1e11?o*1e3:o,r=Math.min(e,Math.max(0,(this.retryClock()-i)/1e3));return this.formatLLMDuration(r)}isLLMRetryEntryWaiting(t){let e=Number(t?.delay),o=Number(t?.scheduled_at??t?.timestamp);if(!Number.isFinite(e)||e<=0||!Number.isFinite(o))return!1;let i=o<1e11?o*1e3:o;return this.retryClock()r.session_id===e);return!(i&&(i.status==="completed"||i.status==="failed"||i.status==="cancelled"))}isItemRunning(t,e){if(!t||!t.data||!this.isCurrentSessionActive()||t.data.status==="success"||t.data.status==="failed"||t.data.status==="cancelled"||this.isReportStatusAction(t.data)||this.isReportStatusAction(t.data?.action_taken))return!1;if(t.data.status==="running")return!0;let o=this.consolidatedBlocks(),i=o.length>0?o[o.length-1]:null;if(i&&(i.id===e?.id||!e?.data?.isCompleted)){let r=this.getSortedStepEvents(e.data),a=r.length>0?r[r.length-1]:null;if(a&&(a.data?.trace_id===t.data?.trace_id||a===t))return!0}return!1}isBlockRunning(t){if(!t||!t.data||!this.isCurrentSessionActive())return!1;if(t.type==="checker")return t.data.isCompleted===!1;if(this.isReportStatusAction(t.data.action_taken)||t.data?.status==="completed"||t.data?.status==="failed"||t.data?.status==="cancelled")return!1;if(t.data.isCompleted===!1)return!0;let e=this.consolidatedBlocks();return(e.length>0?e[e.length-1]:null)?.id===t.id}static \u0275fac=function(e){return new(e||n)};static \u0275cmp=Ge({type:n,selectors:[["app-agent-stream"]],decls:21,vars:11,consts:[["preImg",""],["preOverlay",""],["preImgTool",""],["preOverlayTool",""],["modalImg",""],["modalOverlay",""],[1,"agent-stream-container"],[1,"vscode-nav-header"],[1,"vscode-tab-container"],["title","Task Queue","aria-label","Task Queue",1,"vscode-tab-btn",3,"click"],[1,"material-symbols-outlined","tab-icon"],[1,"tab-label"],["title","Notes & Plans","aria-label","Notes & Plans",1,"vscode-tab-btn",3,"click"],[1,"vscode-dropdown-menu","task-dropdown"],[1,"vscode-dropdown-menu","notes-dropdown"],[1,"stream-floating-toolbar"],[1,"stream-logs-content"],["aria-live","polite",1,"session-loading-state"],[1,"empty-state"],[1,"logs-list-container"],[1,"image-modal-backdrop"],[1,"popover-title-header"],[1,"material-symbols-outlined"],[1,"dropdown-section-label"],[1,"material-symbols-outlined","icon-bolt"],[1,"dropdown-empty-state"],[1,"dropdown-item-card",3,"selected","running"],[1,"dropdown-section-label","history-label"],[1,"material-symbols-outlined","icon-history"],[1,"dropdown-history-list"],[1,"dropdown-item-card",3,"click"],[1,"item-header"],[1,"task-badge",3,"ngClass"],[1,"status-dot"],[1,"item-header-actions"],[1,"item-device",3,"title"],[1,"item-time-divider"],[1,"item-time"],["type","button","title","Stop this task","aria-label","Stop this task",1,"btn-stop-card"],["type","button","title","Delete this task","aria-label","Delete this task",1,"btn-delete-card"],[1,"item-goal",3,"title"],[1,"item-footer"],[1,"item-id",3,"title"],[1,"item-date"],[1,"material-symbols-outlined","device-icon"],[1,"device-name"],["type","button","title","Stop this task","aria-label","Stop this task",1,"btn-stop-card",3,"click"],["type","button","title","Delete this task","aria-label","Delete this task",1,"btn-delete-card",3,"click"],[1,"notes-selector-container"],[1,"dropdown-notes-content"],[1,"notes-selector-list"],[1,"note-selector-pill",3,"active"],[1,"note-selector-pill",3,"click"],[1,"material-symbols-outlined","pill-icon"],[1,"material-symbols-outlined","empty-icon"],[1,"note-title"],[1,"milestones-container"],[1,"other-lines-container"],[1,"milestone-block",3,"ngClass"],[1,"milestone-title-row"],[1,"material-symbols-outlined","milestone-status-icon","checked"],[1,"material-symbols-outlined","milestone-status-icon","progress"],[1,"material-symbols-outlined","milestone-status-icon","unchecked"],[1,"milestone-text"],[3,"bold-segment","code-segment"],[1,"milestone-substeps"],[1,"milestone-checks-container"],[1,"substep-row",3,"ngClass","padding-left"],[1,"substep-row",3,"ngClass"],[1,"material-symbols-outlined","check-icon","checked"],[1,"material-symbols-outlined","check-icon","progress"],[1,"material-symbols-outlined","check-icon","unchecked"],[1,"bullet"],[1,"line-text"],[1,"verification-card",3,"ngClass","margin-left"],[1,"verification-card",3,"ngClass"],[1,"card-header"],[1,"card-tag"],[1,"card-body"],[1,"verification-card","standalone",3,"ngClass","margin-left"],[1,"md-line",3,"ngClass","margin-left"],[1,"verification-card","standalone",3,"ngClass"],[1,"md-line",3,"ngClass"],[1,"model-info-chip",3,"ngClass","title"],[1,"btn-screen-recording",3,"click","title"],[1,"material-symbols-outlined","video-icon"],[1,"material-symbols-outlined","video-icon","spin-animation"],[1,"material-symbols-outlined","video-icon","play-icon"],[1,"material-symbols-outlined","video-icon","steps-icon"],[1,"material-symbols-outlined","video-icon","error-icon"],[1,"btn-text"],[1,"video-pulse-dot","red"],[1,"steps-count-badge"],[1,"material-symbols-outlined","model-icon"],[1,"model-label"],[1,"model-name"],[1,"material-symbols-outlined","spin-animation"],[1,"empty-icon"],["aria-live","polite",1,"phase-container","startup-phase"],[1,"phase-container"],[1,"outputter-synthesis-stream-card"],[1,"planning-loader-container"],[1,"phase-header","first-phase"],[1,"phase-worked-time"],["aria-label","Task startup progress",1,"phase-body"],[1,"block-item","type-stream"],[1,"step-body"],[1,"step-actions-container"],[1,"llm-retry-notes"],[1,"llm-retry-note"],[1,"stream-status-dot"],[1,"llm-retry-note-text"],[1,"llm-retry-status-line"],[1,"phase-header",3,"first-phase"],[1,"phase-body"],[1,"phase-header"],[1,"phase-token-usage",3,"title"],[1,"token-dot"],[1,"token-text"],[1,"block-item",3,"ngClass"],[1,"check-lane",3,"ngClass"],[1,"check-result",3,"ngClass"],[1,"check-header"],[1,"check-title"],[1,"check-subject",3,"title"],[1,"check-status"],[1,"check-items"],[1,"check-item"],[1,"check-index"],[1,"check-text"],[1,"check-method"],[1,"thinking-stream-details","native-thinking-details",3,"is-running","collapsed"],[1,"step-raw-thinking-message",3,"is-running"],[1,"step-android-action-card","task-report-card",3,"is-completed","is-failed","is-expanded"],[1,"step-android-action-card",3,"is-running","is-failed","is-expanded"],[1,"thinking-stream-details","native-thinking-details"],[1,"details-summary",3,"click"],[1,"details-content-wrapper"],[1,"details-content","stream-box"],[1,"stream-text",3,"innerHTML"],[1,"step-raw-thinking-message"],[1,"report-header"],[1,"report-content","stream-box"],[1,"step-android-action-card","task-report-card"],[1,"action-card-header",3,"click"],[1,"material-symbols-outlined","action-icon","report-icon"],[1,"action-title"],[1,"action-status-badge"],[1,"material-symbols-outlined","status-icon"],[1,"action-expand-icon","material-symbols-outlined"],[1,"action-card-body"],[1,"action-card-expanded-details"],[1,"action-detail-row"],[1,"detail-label"],[1,"detail-value"],[1,"action-card-expanded-details",3,"click"],[1,"expanded-grid"],[1,"grid-item","full-width"],[1,"expanded-screenshots-container"],[1,"grid-label"],[1,"grid-value","report-markdown-body",3,"innerHTML"],[1,"screenshot-column"],[1,"screenshot-label"],[1,"image-wrapper",3,"click"],[1,"img-relative-box"],["alt","Final Screen Screenshot",3,"src"],["alt","Post Screenshot",3,"src"],[1,"step-android-action-card"],[1,"material-symbols-outlined","action-icon"],[1,"action-status-badge","is-failed"],[1,"detail-value","coordinate-badge"],[1,"grid-item"],[1,"expanded-error-banner"],[1,"grid-value"],[1,"grid-value","code-pill"],["alt","Pre-Action Screenshot",3,"load","src"],[1,"overlay-layer"],["alt","Post-Action Screenshot",3,"src"],[1,"material-symbols-outlined","icon"],[1,"error-text"],[1,"llm-failure-stack"],[1,"step-generic-tool",3,"is-running","is-note-tool","is-video-tool","is-video-attention"],[1,"step-android-action-card","tool-action-card",3,"is-running","has-no-details","is-failed","is-expanded"],[1,"llm-retry-guidance"],[1,"step-android-action-card","tool-action-card","is-failed",3,"click"],[1,"action-card-header","llm-failure-header"],[1,"material-symbols-outlined","action-icon","is-failed"],[1,"action-status-badge","is-paused"],[1,"failure-retry-details"],[1,"llm-resume-row"],[1,"failure-retry-title"],[1,"failure-retry-row"],["type","button","title","Continue the paused task","aria-label","Continue the paused task",1,"btn-continue-task",3,"click"],[1,"material-symbols-outlined","continue-icon"],[1,"material-symbols-outlined","continue-arrow"],[1,"llm-resume-hint"],[1,"step-generic-tool"],[1,"tool-call-label"],[1,"tool-call-text"],["title","Click to view note content",1,"tool-key-pill"],["title","Click to view note content",1,"tool-key-pill",3,"click"],[1,"tool-key-pill",3,"click","title"],[1,"video-status-detail"],[1,"step-android-action-card","tool-action-card"],[1,"action-agent-badge",3,"is-outputter"],[1,"action-agent-badge"],[1,"detail-value","command-value"],[1,"grid-value","command-value"],[1,"check-verdict",3,"ngClass"],[1,"check-note"],[1,"material-symbols-outlined","verdict-icon"],[1,"verdict-body"],[1,"verdict-evidence"],[1,"verdict-suggestion"],[1,"synthesis-card-header"],[1,"header-left"],[1,"material-symbols-outlined","icon-sparkle"],[1,"main-title"],[1,"header-right"],["type","button",1,"btn-open-note",3,"click"],[1,"synthesis-card-preview"],[1,"parsed-note-container"],[1,"parsed-note-title"],[1,"parsed-milestones"],[1,"parsed-other-lines"],[1,"milestone-item",3,"class"],[1,"milestone-item"],[1,"other-line",3,"class"],[1,"other-line"],[1,"verify-badge",3,"assert"],[1,"finding-badge"],[1,"verify-badge"],[1,"planning-loader"],[1,"loader-text"],[1,"image-modal-backdrop",3,"click"],[1,"image-modal-content",3,"click"],["aria-label","Close image preview",1,"image-modal-close",3,"click"],["alt","Zoomed Screenshot",3,"load","src"]],template:function(e,o){e&1&&(l(0,"div",6)(1,"header",7)(2,"div",8)(3,"button",9),k("click",function(r){return o.toggleTaskDropdown(r)}),l(4,"span",10),u(5,"list_alt"),d(),l(6,"span",11),u(7,"Task Queue"),d()(),l(8,"button",12),k("click",function(r){return o.toggleNotesDropdown(r)}),l(9,"span",10),u(10,"description"),d(),l(11,"span",11),u(12,"Notes & Plans"),d()()(),M(13,aA,23,4,"div",13),M(14,$A,9,3,"div",14),d(),M(15,nN,17,28,"div",15),l(16,"main",16),M(17,oN,5,0,"div",17),M(18,iN,8,0,"div",18),M(19,d3,6,3,"div",19),d(),M(20,u3,10,1,"div",20),d()),e&2&&(g(3),w("active",o.isTaskDropdownOpen()),g(5),w("active",o.isNotesDropdownOpen()),g(5),b(o.isTaskDropdownOpen()?13:-1),g(),b(o.isNotesDropdownOpen()?14:-1),g(),b(o.consolidatedBlocks().length>0||o.showPlanningLoader()?15:-1),g(2),b(o.agentService.isSessionContentLoading()?17:-1),g(),b(!o.agentService.isSessionContentLoading()&&o.consolidatedBlocks().length===0&&!o.showPlanningLoader()&&o.startupWorkItems().length===0?18:-1),g(),b(!o.agentService.isSessionContentLoading()&&(o.phases().length>0||o.showPlanningLoader()||o.startupWorkItems().length>0)?19:-1),g(),b(o.selectedZoomImage()?20:-1))},dependencies:[No,ki,Qt,Ya,Ei],styles:['@charset "UTF-8";[_nghost-%COMP%]{display:flex;flex-direction:column;height:100%;width:100%;overflow:hidden}.agent-stream-container[_ngcontent-%COMP%]{display:flex;flex-direction:column;height:100%;background:#f8f9fa;box-sizing:border-box;position:relative}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%]{position:relative;height:60px;background:#fff;border-bottom:1px solid rgba(0,0,0,.08);display:flex;align-items:center;justify-content:flex-end;padding:0 16px;box-sizing:border-box;-webkit-user-select:none;user-select:none;box-shadow:none;z-index:10}@media(min-width:1151px){.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%]{display:none!important}}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-tab-container[_ngcontent-%COMP%]{display:flex;height:36px;box-sizing:border-box;background:#f1f5f9b8;backdrop-filter:blur(16px) saturate(180%);-webkit-backdrop-filter:blur(16px) saturate(180%);border:1px solid rgba(226,232,240,.85);border-radius:18px;padding:3px;gap:3px;align-items:center;flex-shrink:0;box-shadow:0 2px 8px -1px #0f172a0a,inset 0 1px 1px #fffc}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-tab-btn[_ngcontent-%COMP%]{display:inline-flex;align-items:center;justify-content:center;gap:6px;height:30px;padding:0 14px;box-sizing:border-box;border:none;background:transparent;border-radius:15px;font-size:13px;font-weight:600;color:#64748b;font-family:Google Sans,Google Sans Text,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;text-decoration:none;cursor:pointer;position:relative;line-height:1;transition:all .2s cubic-bezier(.4,0,.2,1);outline:none;white-space:nowrap}@media(max-width:1150px){.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-tab-btn[_ngcontent-%COMP%]{padding:0 9px;gap:0}}@media(max-width:1150px){.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-tab-btn[_ngcontent-%COMP%] .tab-label[_ngcontent-%COMP%]{display:none!important}}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-tab-btn[_ngcontent-%COMP%] .tab-icon[_ngcontent-%COMP%]{font-size:16px;color:inherit;transition:color .15s ease}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-tab-btn[_ngcontent-%COMP%] .badge-count[_ngcontent-%COMP%]{background:#2563eb;color:#fff;font-size:10px;font-weight:700;padding:1px 6px;border-radius:10px;line-height:1}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-tab-btn[_ngcontent-%COMP%] .dropdown-chevron[_ngcontent-%COMP%]{font-size:16px;color:#94a3b8;margin-left:2px;transition:transform .2s ease,color .15s ease}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-tab-btn[_ngcontent-%COMP%] .dropdown-chevron.open[_ngcontent-%COMP%]{transform:rotate(180deg);color:#1e293b}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-tab-btn[_ngcontent-%COMP%]:hover:not(.active){color:#0f172a;background:#fff9}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-tab-btn.active[_ngcontent-%COMP%]{background:#fff;color:#2563eb;box-shadow:0 2px 8px #0000000f,0 1px 2px #00000005}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-tab-btn.active[_ngcontent-%COMP%] .tab-icon[_ngcontent-%COMP%]{color:#2563eb}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-tab-btn.video-replay-tab-btn[_ngcontent-%COMP%] .video-indicator-dot[_ngcontent-%COMP%]{width:7px;height:7px;border-radius:50%;background:#10b981;box-shadow:0 0 6px #10b981;margin-left:-2px;flex-shrink:0}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-tab-btn.video-replay-tab-btn.has-video[_ngcontent-%COMP%] .tab-icon[_ngcontent-%COMP%]{color:#0284c7}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .header-right-actions[_ngcontent-%COMP%]{margin-left:auto;display:flex;align-items:center}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .header-right-actions[_ngcontent-%COMP%] .btn-resume-task[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .header-right-actions[_ngcontent-%COMP%] .btn-stop-task[_ngcontent-%COMP%]{padding:5px 10px;background:#2563eb;border:1px solid #2563eb;border-radius:16px;font-size:13px;font-weight:600;color:#fff;cursor:pointer;display:flex;align-items:center;gap:4px;transition:all .2s;box-shadow:0 2px 4px #2563eb33}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .header-right-actions[_ngcontent-%COMP%] .btn-resume-task[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .header-right-actions[_ngcontent-%COMP%] .btn-stop-task[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:15px;color:inherit}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .header-right-actions[_ngcontent-%COMP%] .btn-resume-task[_ngcontent-%COMP%]:hover, .agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .header-right-actions[_ngcontent-%COMP%] .btn-stop-task[_ngcontent-%COMP%]:hover{background:#1d4ed8;border-color:#1d4ed8;box-shadow:0 4px 8px #2563eb4d}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .header-right-actions[_ngcontent-%COMP%] .btn-stop-task[_ngcontent-%COMP%]{background:#ef4444;border-color:#ef4444;box-shadow:0 2px 4px #ef444433}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .header-right-actions[_ngcontent-%COMP%] .btn-stop-task[_ngcontent-%COMP%]:hover{background:#dc2626;border-color:#dc2626;box-shadow:0 4px 8px #ef44444d}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%]{position:absolute;top:48px;right:12px;left:auto;width:360px;max-width:calc(100vw - 24px);max-height:480px;background:#fff;border:1px solid #cbd5e1;border-radius:12px;box-shadow:0 12px 32px #00000024,0 4px 12px #0000000d;z-index:100;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:10px;animation:_ngcontent-%COMP%_dropdownFadeIn .15s cubic-bezier(.16,1,.3,1)}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .popover-title-header[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px;padding-bottom:8px;border-bottom:1px solid #e2e8f0;font-size:14px;font-weight:700;color:#0f172a}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .popover-title-header[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:18px;color:#2563eb}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%]{right:12px;left:auto;width:380px;max-height:520px}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .notes-selector-container[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:6px;margin-bottom:4px}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .notes-selector-container[_ngcontent-%COMP%] .notes-selector-list[_ngcontent-%COMP%]{display:flex;flex-wrap:wrap;gap:6px}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .notes-selector-container[_ngcontent-%COMP%] .notes-selector-list[_ngcontent-%COMP%] .note-selector-pill[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;border:1px solid #e2e8f0;background:#fff;border-radius:16px;font-size:11.5px;font-weight:500;color:#475569;cursor:pointer;transition:all .15s ease}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .notes-selector-container[_ngcontent-%COMP%] .notes-selector-list[_ngcontent-%COMP%] .note-selector-pill[_ngcontent-%COMP%] .pill-icon[_ngcontent-%COMP%]{font-size:14px}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .notes-selector-container[_ngcontent-%COMP%] .notes-selector-list[_ngcontent-%COMP%] .note-selector-pill[_ngcontent-%COMP%]:hover{background:#f8fafc;border-color:#cbd5e1;color:#1e293b}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .notes-selector-container[_ngcontent-%COMP%] .notes-selector-list[_ngcontent-%COMP%] .note-selector-pill.active[_ngcontent-%COMP%]{background:#eff6ff;border-color:#bfdbfe;color:#1d4ed8;font-weight:600}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:10px;padding:4px 2px}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .note-title[_ngcontent-%COMP%]{font-size:13.5px;font-weight:700;color:#1f1f1f;margin:0 0 4px;font-family:Google Sans,Google Sans Text,Inter,sans-serif}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .code-segment[_ngcontent-%COMP%]{font-family:Fira Code,Roboto Mono,Consolas,monospace;background-color:#f1f3f4;color:#1967d2;padding:1px 5px;border-radius:4px;font-size:12px;margin:0 2px;word-break:break-all}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestones-container[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:8px}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%]{display:flex;flex-direction:column}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block.checked[_ngcontent-%COMP%] .milestone-title-row[_ngcontent-%COMP%] .milestone-status-icon.checked[_ngcontent-%COMP%]{opacity:.7}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block.checked[_ngcontent-%COMP%] .milestone-title-row[_ngcontent-%COMP%] .milestone-text[_ngcontent-%COMP%]{color:#a1a1aa}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-title-row[_ngcontent-%COMP%]{display:flex;align-items:flex-start;gap:8px}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-title-row[_ngcontent-%COMP%] .milestone-status-icon[_ngcontent-%COMP%]{font-size:18px;-webkit-user-select:none;user-select:none;margin-top:2px;font-variation-settings:"FILL" 1,"wght" 400,"GRAD" 0,"opsz" 24}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-title-row[_ngcontent-%COMP%] .milestone-status-icon.checked[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-title-row[_ngcontent-%COMP%] .milestone-status-icon.progress[_ngcontent-%COMP%]{color:#1a73e8}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-title-row[_ngcontent-%COMP%] .milestone-status-icon.unchecked[_ngcontent-%COMP%]{color:#cbd5e1}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-title-row[_ngcontent-%COMP%] .milestone-text[_ngcontent-%COMP%]{font-family:Google Sans,Google Sans Text,Inter,sans-serif;color:#1f1f1f;font-size:13px;line-height:1.5;word-break:break-word;flex:1}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%]{margin-top:4px;display:flex;flex-direction:column;gap:5px;padding-left:26px}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row[_ngcontent-%COMP%]{display:flex;align-items:flex-start;gap:8px;font-family:Google Sans,Google Sans Text,Inter,sans-serif;color:#1f1f1f;font-size:12.5px;line-height:1.5}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row.checked[_ngcontent-%COMP%]{color:#a1a1aa}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row.checked[_ngcontent-%COMP%] .check-icon.checked[_ngcontent-%COMP%]{opacity:.7}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row.verify[_ngcontent-%COMP%]{color:#334155;gap:8px;align-items:flex-start}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row.verify[_ngcontent-%COMP%] .verify-badge[_ngcontent-%COMP%]{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;font-family:Google Sans Text,Inter,-apple-system,sans-serif;font-size:10.5px;font-weight:550;line-height:1.2;letter-spacing:.2px;text-transform:lowercase;color:#475569;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:4px;padding:1.5px 6px;margin-top:1.5px;-webkit-user-select:none;user-select:none}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row.verify[_ngcontent-%COMP%] .verify-badge.assert[_ngcontent-%COMP%]{color:#0369a1;background:#f0f9ff;border-color:#e0f2fe}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row.verify[_ngcontent-%COMP%] .line-text[_ngcontent-%COMP%]{color:#334155;font-size:12.5px;line-height:1.5}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row.finding[_ngcontent-%COMP%]{color:#92400e;gap:8px;align-items:flex-start}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row.finding[_ngcontent-%COMP%] .finding-badge[_ngcontent-%COMP%]{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;font-family:Google Sans Text,Inter,-apple-system,sans-serif;font-size:10.5px;font-weight:550;line-height:1.2;letter-spacing:.2px;text-transform:lowercase;color:#b45309;background:#fef3c7;border:1px solid #fde68a;border-radius:4px;padding:1.5px 6px;margin-top:1.5px;-webkit-user-select:none;user-select:none}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row.finding[_ngcontent-%COMP%] .line-text[_ngcontent-%COMP%]{color:#92400e;font-size:12.5px;line-height:1.5}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row[_ngcontent-%COMP%] .check-icon[_ngcontent-%COMP%]{font-size:16px;-webkit-user-select:none;user-select:none;margin-top:2px;font-variation-settings:"FILL" 1,"wght" 400,"GRAD" 0,"opsz" 24}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row[_ngcontent-%COMP%] .check-icon.checked[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row[_ngcontent-%COMP%] .check-icon.progress[_ngcontent-%COMP%]{color:#1a73e8}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row[_ngcontent-%COMP%] .check-icon.unchecked[_ngcontent-%COMP%]{color:#cbd5e1}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row[_ngcontent-%COMP%] .bullet[_ngcontent-%COMP%]{color:#9ca3af;font-weight:700;-webkit-user-select:none;user-select:none}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row[_ngcontent-%COMP%] .line-text[_ngcontent-%COMP%]{word-break:break-word;color:inherit}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-checks-container[_ngcontent-%COMP%]{margin-top:6px;padding-left:26px;display:flex;flex-direction:column;gap:6px}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .verification-card[_ngcontent-%COMP%]{background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;padding:7px 10px;display:flex;flex-direction:column;gap:3px}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .verification-card.standalone[_ngcontent-%COMP%]{margin-top:6px;margin-bottom:6px}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .verification-card[_ngcontent-%COMP%] .card-header[_ngcontent-%COMP%]{display:flex;align-items:center}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .verification-card[_ngcontent-%COMP%] .card-header[_ngcontent-%COMP%] .card-tag[_ngcontent-%COMP%]{font-family:Google Sans Text,Inter,-apple-system,sans-serif;font-size:9.5px;font-weight:600;line-height:1;letter-spacing:.6px;text-transform:uppercase;color:#64748b}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .verification-card[_ngcontent-%COMP%] .card-header[_ngcontent-%COMP%] .card-tag.assert[_ngcontent-%COMP%]{color:#0284c7}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .verification-card[_ngcontent-%COMP%] .card-body[_ngcontent-%COMP%]{font-family:inherit;font-size:12px;line-height:1.5;color:#334155;word-break:break-word}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .verification-card[_ngcontent-%COMP%] .card-body[_ngcontent-%COMP%] .bold-segment[_ngcontent-%COMP%]{font-weight:600;color:#1e293b}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .verification-card[_ngcontent-%COMP%] .card-body[_ngcontent-%COMP%] .code-segment[_ngcontent-%COMP%]{font-family:Fira Code,Roboto Mono,Consolas,monospace;font-size:11px;background:#f1f5f9;color:#0f172a;padding:1px 5px;border-radius:4px;border:1px solid #e2e8f0}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .verification-card.finding[_ngcontent-%COMP%]{background:#fffbeb;border-color:#fef3c7}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .verification-card.finding[_ngcontent-%COMP%] .card-header[_ngcontent-%COMP%] .card-tag[_ngcontent-%COMP%]{color:#b45309}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .verification-card.finding[_ngcontent-%COMP%] .card-body[_ngcontent-%COMP%]{color:#92400e}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .other-lines-container[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:6px;margin-top:6px;padding-top:6px;border-top:1px dashed #e2e8f0}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .other-lines-container[_ngcontent-%COMP%] .md-line[_ngcontent-%COMP%]{display:flex;align-items:flex-start;gap:6px;font-size:12.5px;color:#334155;line-height:1.5}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu.notes-dropdown[_ngcontent-%COMP%] .dropdown-notes-content[_ngcontent-%COMP%] .other-lines-container[_ngcontent-%COMP%] .md-line[_ngcontent-%COMP%] .line-text[_ngcontent-%COMP%]{word-break:break-word;color:inherit}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%]::-webkit-scrollbar{width:6px}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%]::-webkit-scrollbar-thumb{background:#cbd5e1;border-radius:4px}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-header[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;padding-bottom:8px;border-bottom:1px solid #e2e8f0}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-header[_ngcontent-%COMP%] .dropdown-title[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:700;color:#0f172a}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-header[_ngcontent-%COMP%] .dropdown-title[_ngcontent-%COMP%] .icon-main[_ngcontent-%COMP%]{font-size:18px;color:#2563eb}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-section-label[_ngcontent-%COMP%]{display:flex;align-items:center;gap:6px;font-size:11px;font-weight:700;color:#475569;text-transform:uppercase;letter-spacing:.5px;padding:4px 2px 2px}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-section-label.history-label[_ngcontent-%COMP%]{margin-top:6px}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-section-label[_ngcontent-%COMP%] .icon-bolt[_ngcontent-%COMP%]{font-size:15px;color:#d97706}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-section-label[_ngcontent-%COMP%] .icon-history[_ngcontent-%COMP%]{font-size:15px;color:#64748b}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-empty-state[_ngcontent-%COMP%]{padding:20px 14px;text-align:center;font-size:12px;color:#94a3b8;background:#f8fafc;border:1px dashed #e2e8f0;border-radius:6px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-empty-state[_ngcontent-%COMP%] .empty-icon[_ngcontent-%COMP%]{font-size:22px;color:#94a3b8}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-history-list[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:8px}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%]{padding:10px 12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;cursor:pointer;transition:all .15s ease;display:flex;flex-direction:column;gap:6px}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%]:hover{background:#f1f5f9;border-color:#cbd5e1;transform:translateY(-1px)}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%]:hover .item-header[_ngcontent-%COMP%] .item-header-actions[_ngcontent-%COMP%]:has(.btn-delete-card) .item-time[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%]:hover .item-header[_ngcontent-%COMP%] .item-header-actions[_ngcontent-%COMP%]:has(.btn-delete-card) .item-time-divider[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%]:hover .item-header[_ngcontent-%COMP%] .item-header-actions[_ngcontent-%COMP%]:has(.btn-stop-card) .item-time[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%]:hover .item-header[_ngcontent-%COMP%] .item-header-actions[_ngcontent-%COMP%]:has(.btn-stop-card) .item-time-divider[_ngcontent-%COMP%]{opacity:0}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%]:hover .item-header[_ngcontent-%COMP%] .item-header-actions[_ngcontent-%COMP%] .btn-delete-card[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%]:hover .item-header[_ngcontent-%COMP%] .item-header-actions[_ngcontent-%COMP%] .btn-stop-card[_ngcontent-%COMP%]{opacity:1;pointer-events:auto}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card.selected[_ngcontent-%COMP%]{background:#eff6ff;border-color:#93c5fd;box-shadow:0 0 0 1px #2563eb}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card.selected[_ngcontent-%COMP%] .item-goal[_ngcontent-%COMP%]{color:#1e3a8a;font-weight:600}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card.running[_ngcontent-%COMP%]{border-left:3.5px solid #2563eb}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;gap:8px;min-width:0}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .task-badge[_ngcontent-%COMP%]{flex-shrink:0;display:inline-flex;align-items:center;font-size:10.5px;font-weight:700;padding:2px 5px;border-radius:4px;letter-spacing:.05em;border:1px solid #e2e8f0;background:#fafafa;color:#64748b}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .task-badge[_ngcontent-%COMP%]:before{content:"";width:4px;height:4px;border-radius:50%;display:inline-block;margin-right:4px}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .task-badge.running[_ngcontent-%COMP%]{background:#fafafa;color:#1f1f1f;border-color:#1f1f1f}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .task-badge.running[_ngcontent-%COMP%]:before{background-color:#1f1f1f;animation:_ngcontent-%COMP%_pulse-black-dot-small 1.5s infinite ease-in-out}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .task-badge.paused[_ngcontent-%COMP%]{background:#fff7ed;color:#c2410c;border-color:#fdba74}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .task-badge.paused[_ngcontent-%COMP%]:before{background-color:#f97316}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .task-badge.completed[_ngcontent-%COMP%]{background:#fafafa;color:#1f1f1f;border-color:#e2e8f0}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .task-badge.completed[_ngcontent-%COMP%]:before{background-color:#1f1f1f}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .task-badge.pending[_ngcontent-%COMP%]{background:#fafafa;color:#64748b;border-color:#e2e8f0}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .task-badge.pending[_ngcontent-%COMP%]:before{background-color:#94a3b8}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .task-badge.failed[_ngcontent-%COMP%]{background:#fafafa;color:#ef4444;border-color:#fee2e2}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .task-badge.failed[_ngcontent-%COMP%]:before{background-color:#ef4444}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .task-badge.cancelled[_ngcontent-%COMP%]{background:#fafafa;color:#d97706;border-color:#fef3c7}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .task-badge.cancelled[_ngcontent-%COMP%]:before{background-color:#f59e0b}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .task-badge[_ngcontent-%COMP%] .status-dot[_ngcontent-%COMP%]{display:none}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-header-actions[_ngcontent-%COMP%]{display:flex;align-items:center;gap:4px;position:relative;min-width:0;flex-shrink:1;justify-content:flex-end}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-header-actions[_ngcontent-%COMP%] .item-device[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:2.5px;min-width:0;flex-shrink:1;font-size:10.5px;color:#64748b;line-height:1;cursor:default;transition:color .15s ease,opacity .15s ease}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-header-actions[_ngcontent-%COMP%] .item-device[_ngcontent-%COMP%] .device-icon[_ngcontent-%COMP%]{font-size:12px;width:12px;height:12px;line-height:12px;color:#64748b;opacity:.75;flex-shrink:0}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-header-actions[_ngcontent-%COMP%] .item-device[_ngcontent-%COMP%] .device-name[_ngcontent-%COMP%]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;font-family:inherit;letter-spacing:-.15px}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-header-actions[_ngcontent-%COMP%] .item-device[_ngcontent-%COMP%]:hover{color:#334155}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-header-actions[_ngcontent-%COMP%] .item-device[_ngcontent-%COMP%]:hover .device-icon[_ngcontent-%COMP%]{color:#334155;opacity:1}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-header-actions[_ngcontent-%COMP%] .item-time-divider[_ngcontent-%COMP%]{font-size:10px;color:#94a3b8;line-height:1;-webkit-user-select:none;user-select:none;flex-shrink:0;transition:opacity .15s ease}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-header-actions[_ngcontent-%COMP%] .item-time[_ngcontent-%COMP%]{font-size:11px;color:#64748b;white-space:nowrap;flex-shrink:0;line-height:1;transition:opacity .15s ease}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-header-actions[_ngcontent-%COMP%] .btn-delete-card[_ngcontent-%COMP%]{position:absolute;right:0;top:50%;transform:translateY(-50%);width:18px;height:18px;padding:0;border:none;background:transparent;border-radius:3px;color:#94a3b8;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:all .15s ease}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-header-actions[_ngcontent-%COMP%] .btn-delete-card[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:13px;line-height:1}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-header-actions[_ngcontent-%COMP%] .btn-delete-card[_ngcontent-%COMP%]:hover{color:#ef4444;background:#fee2e2}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-header-actions[_ngcontent-%COMP%] .btn-stop-card[_ngcontent-%COMP%]{position:absolute;right:0;top:50%;transform:translateY(-50%);width:18px;height:18px;padding:0;border:none;background:transparent;border-radius:3px;color:#ef4444;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:all .15s ease}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-header-actions[_ngcontent-%COMP%] .btn-stop-card[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:14px;line-height:1}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-header[_ngcontent-%COMP%] .item-header-actions[_ngcontent-%COMP%] .btn-stop-card[_ngcontent-%COMP%]:hover{color:#dc2626;background:#fee2e2;transform:translateY(-50%) scale(1.1)}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-goal[_ngcontent-%COMP%]{font-size:12.5px;color:#334155;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-footer[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:10.5px;color:#94a3b8}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-footer[_ngcontent-%COMP%] .item-id[_ngcontent-%COMP%]{font-family:Fira Code,Roboto Mono,Consolas,monospace;font-size:10.5px;color:#94a3b8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;flex:1;transition:color .15s ease}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-footer[_ngcontent-%COMP%] .item-id[_ngcontent-%COMP%]:hover{color:#475569}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-item-card[_ngcontent-%COMP%] .item-footer[_ngcontent-%COMP%] .item-date[_ngcontent-%COMP%]{flex-shrink:0}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-note-item[_ngcontent-%COMP%]{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:6px;cursor:pointer;font-size:13px;color:#334155;transition:background .15s ease}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-note-item[_ngcontent-%COMP%] .note-icon[_ngcontent-%COMP%]{font-size:18px;color:#64748b}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-note-item[_ngcontent-%COMP%] .note-key-text[_ngcontent-%COMP%]{flex:1;font-weight:500}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-note-item[_ngcontent-%COMP%] .check-icon[_ngcontent-%COMP%]{font-size:18px;color:#2563eb}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-note-item[_ngcontent-%COMP%]:hover{background:#f1f5f9;color:#0f172a}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-note-item.selected[_ngcontent-%COMP%]{background:#eff6ff;color:#2563eb;font-weight:600}.agent-stream-container[_ngcontent-%COMP%] .vscode-nav-header[_ngcontent-%COMP%] .vscode-dropdown-menu[_ngcontent-%COMP%] .dropdown-note-item.selected[_ngcontent-%COMP%] .note-icon[_ngcontent-%COMP%]{color:#2563eb}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%]{position:absolute;top:12px;right:20px;height:36px;display:inline-flex;align-items:center;gap:8px;z-index:40;pointer-events:auto}@media(max-width:1150px){.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%]{top:72px}}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .model-info-chip[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:5px;height:30px;padding:0 11px;box-sizing:border-box;background:#ffffffb8;backdrop-filter:blur(16px) saturate(180%);-webkit-backdrop-filter:blur(16px) saturate(180%);border:1px solid rgba(226,232,240,.85);border-radius:15px;cursor:default;-webkit-user-select:none;user-select:none;box-shadow:0 2px 6px -1px #0f172a0d,inset 0 1px 1px #ffffffe6;font-family:Google Sans,Google Sans Text,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;transition:all .2s cubic-bezier(.4,0,.2,1)}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .model-info-chip[_ngcontent-%COMP%] .model-icon[_ngcontent-%COMP%]{font-size:15px;width:15px;height:15px;display:inline-flex;align-items:center;justify-content:center;color:#64748b}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .model-info-chip[_ngcontent-%COMP%] .model-label[_ngcontent-%COMP%]{font-size:12px;font-weight:500;color:#64748b;line-height:1}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .model-info-chip[_ngcontent-%COMP%] .model-name[_ngcontent-%COMP%]{font-size:12px;font-weight:700;color:#1e293b;letter-spacing:.1px;line-height:1}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:5px;height:30px;padding:0 12px;box-sizing:border-box;background:#ffffffb8;backdrop-filter:blur(16px) saturate(180%);-webkit-backdrop-filter:blur(16px) saturate(180%);border:1px solid rgba(226,232,240,.85);border-radius:15px;box-shadow:0 2px 6px -1px #0f172a0d,inset 0 1px 1px #ffffffe6;cursor:pointer;transition:all .2s cubic-bezier(.16,1,.3,1);-webkit-user-select:none;user-select:none;outline:none;font-family:Google Sans,Google Sans Text,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording[_ngcontent-%COMP%] .video-icon[_ngcontent-%COMP%]{font-size:15px;width:15px;height:15px;display:inline-flex;align-items:center;justify-content:center;color:#64748b;transition:transform .2s ease,color .2s ease}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording[_ngcontent-%COMP%] .btn-text[_ngcontent-%COMP%]{font-size:12px;font-weight:600;color:#475569;letter-spacing:.1px;line-height:1;transition:color .15s ease}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording[_ngcontent-%COMP%] .video-pulse-dot[_ngcontent-%COMP%]{width:6px;height:6px;border-radius:50%;background:#ef4444;box-shadow:0 0 6px #ef4444;animation:pulseRed 1.5s infinite}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording.recording[_ngcontent-%COMP%]{background:#fef2f2e6;border-color:#f8717199;box-shadow:0 2px 6px -1px #dc262626,inset 0 1px 1px #ffffffe6}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording.recording[_ngcontent-%COMP%] .video-icon[_ngcontent-%COMP%]{color:#dc2626}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording.recording[_ngcontent-%COMP%] .btn-text[_ngcontent-%COMP%]{color:#b91c1c}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording.processing[_ngcontent-%COMP%] .video-icon[_ngcontent-%COMP%]{color:#2563eb;animation:_ngcontent-%COMP%_spin 1s linear infinite}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording[_ngcontent-%COMP%]:hover:not(.active){background:#ffffffe0;border-color:#cbd5e1f2;box-shadow:0 4px 12px -2px #0f172a14,inset 0 1px 1px #fff;color:#0f172a}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording[_ngcontent-%COMP%]:hover:not(.active) .video-icon[_ngcontent-%COMP%]{color:#1a73e8;transform:scale(1.08)}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording[_ngcontent-%COMP%]:hover:not(.active) .btn-text[_ngcontent-%COMP%]{color:#0f172a}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording[_ngcontent-%COMP%]:active{transform:translateY(0);box-shadow:0 1px 3px #0f172a14}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording.has-steps[_ngcontent-%COMP%]{background:#f0fdfae6;border-color:#5eead4b3;box-shadow:0 2px 6px -1px #0d94881f,inset 0 1px 1px #ffffffe6}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording.has-steps[_ngcontent-%COMP%] .steps-icon[_ngcontent-%COMP%]{color:#0d9488}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording.has-steps[_ngcontent-%COMP%] .btn-text[_ngcontent-%COMP%]{color:#0f766e}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording.has-steps[_ngcontent-%COMP%]:hover:not(.active){background:#ccfbf1f2;border-color:#2dd4bfd9}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording.has-steps[_ngcontent-%COMP%]:hover:not(.active) .steps-icon[_ngcontent-%COMP%]{color:#0f766e}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording.failed[_ngcontent-%COMP%]{background:#fef2f2cc;border-color:#fecacae6}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording.failed[_ngcontent-%COMP%] .error-icon[_ngcontent-%COMP%]{color:#ef4444}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording.failed[_ngcontent-%COMP%] .btn-text[_ngcontent-%COMP%]{color:#dc2626}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording[_ngcontent-%COMP%] .steps-count-badge[_ngcontent-%COMP%]{display:inline-flex;align-items:center;justify-content:center;padding:1px 5px;border-radius:10px;background:#0d9488;color:#fff;font-size:10px;font-weight:700;line-height:1;margin-left:2px}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording.active[_ngcontent-%COMP%]{background:#1a73e8f2;border-color:#1a73e8f2;box-shadow:0 2px 8px #1a73e859}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording.active[_ngcontent-%COMP%] .video-icon[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording.active[_ngcontent-%COMP%] .btn-text[_ngcontent-%COMP%]{color:#fff}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording.active[_ngcontent-%COMP%] .video-pulse-dot[_ngcontent-%COMP%]{background:#fff;box-shadow:0 0 6px #ffffffe6}.agent-stream-container[_ngcontent-%COMP%] .stream-floating-toolbar[_ngcontent-%COMP%] .btn-screen-recording.active[_ngcontent-%COMP%] .steps-count-badge[_ngcontent-%COMP%]{background:#fff;color:#1a73e8}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%]{flex:1;overflow-y:auto;padding:72px 24px 100px;position:relative}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .session-loading-state[_ngcontent-%COMP%]{min-height:180px;display:flex;align-items:center;justify-content:center;gap:9px;color:#64748b;font-size:13px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .session-loading-state[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:18px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%]{display:flex;flex-direction:column}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .floating-model-badge[_ngcontent-%COMP%]{position:sticky;top:0;float:right;margin-top:-2px;margin-right:4px;display:inline-flex;align-items:center;gap:4px;padding:3px 9px;background:#ffffffeb;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border:1px solid rgba(99,102,241,.2);border-radius:12px;box-shadow:0 1px 4px #6366f114,0 1px 2px #0000000a;z-index:35;cursor:default;transition:all .2s ease;-webkit-user-select:none;user-select:none}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .floating-model-badge[_ngcontent-%COMP%]:hover{background:#fff;border-color:#6366f166;box-shadow:0 2px 8px #6366f126;transform:translateY(-1px)}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .floating-model-badge[_ngcontent-%COMP%]:hover .model-icon[_ngcontent-%COMP%]{color:#4f46e5;transform:rotate(15deg) scale(1.05)}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .floating-model-badge[_ngcontent-%COMP%] .model-icon[_ngcontent-%COMP%]{font-size:13px;color:#6366f1;transition:all .3s ease}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .floating-model-badge[_ngcontent-%COMP%] .model-name[_ngcontent-%COMP%]{font-size:11px;font-weight:600;color:#334155;letter-spacing:normal;font-family:Google Sans,Google Sans Text,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%]::-webkit-scrollbar{width:6px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%]::-webkit-scrollbar-track{background:transparent}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%]::-webkit-scrollbar-thumb{background:#00000026;border-radius:10px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%]::-webkit-scrollbar-thumb:hover{background:#00000040}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .empty-state[_ngcontent-%COMP%]{flex:1 1 auto;min-height:100%;width:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;box-sizing:border-box;padding:30px;margin:auto 0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .empty-state[_ngcontent-%COMP%] .empty-icon[_ngcontent-%COMP%]{margin-bottom:12px;opacity:.8}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .empty-state[_ngcontent-%COMP%] .empty-icon[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:40px;color:#94a3b8}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .empty-state[_ngcontent-%COMP%] h3[_ngcontent-%COMP%]{margin:0 0 6px;font-size:12px;font-weight:600;color:#1f1f1f}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .empty-state[_ngcontent-%COMP%] p[_ngcontent-%COMP%]{margin:0;font-size:12px;color:#1f1f1f;max-width:320px;line-height:1.5}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list-container[_ngcontent-%COMP%]{display:flex;flex-direction:column}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list-container[_ngcontent-%COMP%] .planning-loader-container[_ngcontent-%COMP%]{display:flex;align-items:center;margin-top:10px;margin-bottom:10px;padding-left:2px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list-container[_ngcontent-%COMP%] .planning-loader-container[_ngcontent-%COMP%] .planning-loader[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:8px;color:#475569;font-size:13px;font-weight:500;-webkit-user-select:none;user-select:none}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list-container[_ngcontent-%COMP%] .planning-loader-container[_ngcontent-%COMP%] .planning-loader[_ngcontent-%COMP%] .spin-animation[_ngcontent-%COMP%]{font-size:16px;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;animation:_ngcontent-%COMP%_spin-loader 1s linear infinite;color:#2563eb;flex-shrink:0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list-container[_ngcontent-%COMP%] .planning-loader-container[_ngcontent-%COMP%] .planning-loader[_ngcontent-%COMP%] .loader-text[_ngcontent-%COMP%]{color:#475569;font-size:13px;font-weight:500;line-height:1.4}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-container[_ngcontent-%COMP%]{display:flex;flex-direction:column}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-header[_ngcontent-%COMP%]{font-size:12px;font-weight:400;line-height:1.5;min-height:20px;box-sizing:border-box;color:#71717a;margin-top:16px;margin-bottom:8px;-webkit-user-select:none;user-select:none;display:flex;align-items:center;gap:4px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-header.first-phase[_ngcontent-%COMP%]{margin-top:0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-header[_ngcontent-%COMP%] .phase-worked-time[_ngcontent-%COMP%]{color:#71717a;font-weight:400}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-header[_ngcontent-%COMP%] .phase-token-usage[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:4px;color:#8e8e93;cursor:default}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-header[_ngcontent-%COMP%] .phase-token-usage[_ngcontent-%COMP%] .token-dot[_ngcontent-%COMP%]{color:#a1a1aa;font-weight:700}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-header[_ngcontent-%COMP%] .phase-token-usage[_ngcontent-%COMP%] .token-text[_ngcontent-%COMP%]{color:#71717a;font-variant-numeric:tabular-nums}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:8px;padding-left:12px;border-left:1px solid rgba(0,0,0,.04);margin-left:6px;position:relative;z-index:1}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:8px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%]{border-radius:8px;padding:0;overflow:visible;box-shadow:0 1px 3px #00000005;border:1px solid transparent;background:transparent;display:flex;flex-direction:column}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .details-content-wrapper[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .details-content-wrapper[_ngcontent-%COMP%]{display:grid;grid-template-rows:1fr;transition:grid-template-rows .3s cubic-bezier(.4,0,.2,1);overflow:hidden}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .details-content-wrapper[_ngcontent-%COMP%] .details-content[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .details-content-wrapper[_ngcontent-%COMP%] .details-content[_ngcontent-%COMP%]{min-height:0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details.native-thinking-details[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details.native-thinking-details[_ngcontent-%COMP%]{border-color:transparent;background:transparent;box-shadow:none}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details.native-thinking-details[_ngcontent-%COMP%]:not(.collapsed), .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details.native-thinking-details[_ngcontent-%COMP%]:not(.collapsed){background:transparent}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details.native-thinking-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details.native-thinking-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]{color:#71717a}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details.native-thinking-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]:after, .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details.native-thinking-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]:after{color:#d4d4d8}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details.native-thinking-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]:hover, .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details.native-thinking-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]:hover{color:#1f1f1f}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details.native-thinking-details.is-running[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details.native-thinking-details.is-running[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]{background:linear-gradient(90deg,#71717a,#2563eb,#7c3aed,#0284c7,#71717a);background-size:200% 100%;-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;animation:_ngcontent-%COMP%_subtle-color-flow 2.8s linear infinite;font-weight:500}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details.native-thinking-details.is-running[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%] .stream-status-dot[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details.native-thinking-details.is-running[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%] .stream-status-dot[_ngcontent-%COMP%]{background:#3b82f6;box-shadow:0 0 #3b82f673;animation:_ngcontent-%COMP%_pulse-stream-dot 1.8s cubic-bezier(.4,0,.6,1) infinite}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details.native-thinking-details[_ngcontent-%COMP%] .stream-status-dot[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details.native-thinking-details[_ngcontent-%COMP%] .stream-status-dot[_ngcontent-%COMP%]{background:#d4d4d8}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details.native-thinking-details[_ngcontent-%COMP%] .stream-status-dot.streaming[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details.native-thinking-details[_ngcontent-%COMP%] .stream-status-dot.streaming[_ngcontent-%COMP%]{background:#3b82f6;box-shadow:0 0 #3b82f673;animation:_ngcontent-%COMP%_pulse-stream-dot 1.8s cubic-bezier(.4,0,.6,1) infinite}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details.native-thinking-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details.native-thinking-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%]{color:#1f1f1f}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details.raw-thinking-details[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details.raw-thinking-details[_ngcontent-%COMP%]{border-color:#dbeafe;background:#f8fafc}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details.raw-thinking-details[_ngcontent-%COMP%]:not(.collapsed), .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details.raw-thinking-details[_ngcontent-%COMP%]:not(.collapsed){background:#f8fafc}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details.raw-thinking-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details.raw-thinking-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]{color:#1e40af}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details.raw-thinking-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]:after, .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details.raw-thinking-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]:after{color:#93c5fd}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details.raw-thinking-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]:hover, .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details.raw-thinking-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]:hover{color:#1e3a8a}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details.raw-thinking-details[_ngcontent-%COMP%] .stream-status-dot[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details.raw-thinking-details[_ngcontent-%COMP%] .stream-status-dot[_ngcontent-%COMP%]{background:#93c5fd}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details.raw-thinking-details[_ngcontent-%COMP%] .stream-status-dot.streaming[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details.raw-thinking-details[_ngcontent-%COMP%] .stream-status-dot.streaming[_ngcontent-%COMP%]{background:#2563eb;animation:_ngcontent-%COMP%_pulse-stream-blue 1.5s infinite}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%]:not(.collapsed), .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%]:not(.collapsed){box-shadow:0 1px 3px #00000005}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%]:not(.collapsed) .details-summary[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%]:not(.collapsed) .details-summary[_ngcontent-%COMP%]{margin-bottom:8px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%]:not(.collapsed) .details-summary[_ngcontent-%COMP%]:after, .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%]:not(.collapsed) .details-summary[_ngcontent-%COMP%]:after{transform:rotate(90deg)}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]{padding:0;margin-bottom:0;font-family:Google Sans,Google Sans Text,Inter,Arial,sans-serif;font-weight:400;font-size:12px;line-height:1.5;min-height:20px;box-sizing:border-box;letter-spacing:.5px;cursor:pointer;-webkit-user-select:none;user-select:none;display:flex;align-items:center;gap:8px;outline:none;list-style:none}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]::-webkit-details-marker, .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]::-webkit-details-marker{display:none}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]:after, .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]:after{content:"\\276f";display:inline-block;font-size:9px;margin-left:6px;transition:transform .2s cubic-bezier(.4,0,.2,1),color .2s ease}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]:hover .stream-status-dot[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]:hover .stream-status-dot[_ngcontent-%COMP%]{transform:scale(1.25);background:#71717a}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]:hover:after, .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]:hover:after{transform:translate(3px);color:#71717a}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-status-dot[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-status-dot[_ngcontent-%COMP%]{width:8px;height:8px;border-radius:50%;display:inline-block;transition:transform .2s cubic-bezier(.4,0,.2,1),background-color .2s ease}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-box[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-box[_ngcontent-%COMP%]{max-height:250px;overflow-y:auto;background:transparent;padding:4px 0;box-sizing:border-box}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-box[_ngcontent-%COMP%]::-webkit-scrollbar, .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-box[_ngcontent-%COMP%]::-webkit-scrollbar{width:6px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-box[_ngcontent-%COMP%]::-webkit-scrollbar-track, .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-box[_ngcontent-%COMP%]::-webkit-scrollbar-track{background:transparent}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-box[_ngcontent-%COMP%]::-webkit-scrollbar-thumb, .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-box[_ngcontent-%COMP%]::-webkit-scrollbar-thumb{background:#00000014;border-radius:999px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-box[_ngcontent-%COMP%]::-webkit-scrollbar-thumb:hover, .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-box[_ngcontent-%COMP%]::-webkit-scrollbar-thumb:hover{background:#00000029}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%]{font-family:inherit;font-size:12.5px;line-height:1.65;color:#1f1f1f;white-space:normal;word-break:break-word}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] strong[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] strong[_ngcontent-%COMP%]{font-weight:600;color:#111827}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] div[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] p[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] div[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] p[_ngcontent-%COMP%]{font-size:12.5px;line-height:1.65;color:#1f1f1f;margin:4px 0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h1[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h2[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h3[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h4[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h5[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h6[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h1[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h2[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h3[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h4[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h5[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h6[_ngcontent-%COMP%]{font-size:12.5px;font-weight:600;color:#1f1f1f;margin-top:8px;margin-bottom:4px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] .inline-code[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] .inline-code[_ngcontent-%COMP%]{font-family:Fira Code,Roboto Mono,Consolas,monospace;font-size:11px;background:#f4f4f5;color:#b91c1c;padding:2px 6px;border-radius:4px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ul[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ol[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ul[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ol[_ngcontent-%COMP%]{margin:4px 0;padding-left:16px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ul[_ngcontent-%COMP%] li[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ol[_ngcontent-%COMP%] li[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ul[_ngcontent-%COMP%] li[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ol[_ngcontent-%COMP%] li[_ngcontent-%COMP%]{font-size:12.5px;line-height:1.65;color:#1f1f1f;margin:4px 0;padding-left:2px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] .md-para-gap[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] .md-para-gap[_ngcontent-%COMP%]{height:6px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details.collapsed[_ngcontent-%COMP%] .details-content-wrapper[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details.collapsed[_ngcontent-%COMP%] .details-content-wrapper[_ngcontent-%COMP%]{grid-template-rows:0fr;display:none}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-details.collapsed[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-details.collapsed[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]{margin-bottom:0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%]{background:transparent;border:1px solid transparent;border-radius:8px;padding:0;margin-bottom:0;box-shadow:none}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .report-header[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .report-header[_ngcontent-%COMP%]{font-family:Google Sans,Google Sans Text,Inter,Arial,sans-serif;font-weight:400;font-size:12px;line-height:1.5;min-height:20px;box-sizing:border-box;letter-spacing:.5px;color:#71717a;margin-bottom:8px;-webkit-user-select:none;user-select:none;display:flex;align-items:center;gap:8px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .report-header[_ngcontent-%COMP%] .stream-status-dot[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .report-header[_ngcontent-%COMP%] .stream-status-dot[_ngcontent-%COMP%]{width:8px;height:8px;border-radius:50%;display:inline-block;background:#d4d4d8}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .report-header[_ngcontent-%COMP%] .stream-status-dot.streaming[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .report-header[_ngcontent-%COMP%] .stream-status-dot.streaming[_ngcontent-%COMP%]{background:#71717a;animation:_ngcontent-%COMP%_pulse-stream-grey 1.5s infinite}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%]{font-size:12.5px;line-height:1.65;color:#1f1f1f}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-box[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-box[_ngcontent-%COMP%]{max-height:250px;overflow-y:auto;background:transparent;padding:4px 0;box-sizing:border-box}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-box[_ngcontent-%COMP%]::-webkit-scrollbar, .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-box[_ngcontent-%COMP%]::-webkit-scrollbar{width:6px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-box[_ngcontent-%COMP%]::-webkit-scrollbar-track, .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-box[_ngcontent-%COMP%]::-webkit-scrollbar-track{background:transparent}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-box[_ngcontent-%COMP%]::-webkit-scrollbar-thumb, .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-box[_ngcontent-%COMP%]::-webkit-scrollbar-thumb{background:#00000014;border-radius:999px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-box[_ngcontent-%COMP%]::-webkit-scrollbar-thumb:hover, .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-box[_ngcontent-%COMP%]::-webkit-scrollbar-thumb:hover{background:#00000029}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%]{font-family:inherit;font-size:12.5px;line-height:1.65;color:#1f1f1f;white-space:normal;word-break:break-word}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] strong[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] strong[_ngcontent-%COMP%]{font-weight:600;color:#111827}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] div[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] p[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] div[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] p[_ngcontent-%COMP%]{font-size:12.5px;line-height:1.65;color:#1f1f1f;margin:4px 0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h1[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h1[_ngcontent-%COMP%]{font-size:14px;font-weight:700;color:#0f172a;margin-top:12px;margin-bottom:6px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h2[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h2[_ngcontent-%COMP%]{font-size:13.5px;font-weight:700;color:#0f172a;margin-top:10px;margin-bottom:5px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h3[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h3[_ngcontent-%COMP%]{font-size:13px;font-weight:600;color:#1e293b;margin-top:8px;margin-bottom:4px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h4[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h5[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h6[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h4[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h5[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] h6[_ngcontent-%COMP%]{font-size:12.5px;font-weight:600;color:#334155;margin-top:6px;margin-bottom:3px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] em[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] em[_ngcontent-%COMP%]{font-style:italic;color:inherit}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] blockquote[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] blockquote[_ngcontent-%COMP%]{margin:6px 0;padding:4px 10px;border-left:3px solid #cbd5e1;color:#475569;background:#f1f5f980;border-radius:0 4px 4px 0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] .code-block[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] .code-block[_ngcontent-%COMP%]{margin:6px 0;padding:8px 12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;overflow-x:auto;font-family:Fira Code,Roboto Mono,Consolas,monospace;font-size:11.5px;line-height:1.5;color:#1e293b}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] .md-hr[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] .md-hr[_ngcontent-%COMP%]{border:none;border-top:1px solid #e2e8f0;margin:10px 0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] .inline-code[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] .inline-code[_ngcontent-%COMP%]{font-family:Fira Code,Roboto Mono,Consolas,monospace;font-size:11px;background:#f4f4f5;color:#b91c1c;padding:2px 6px;border-radius:4px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ul[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ol[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ul[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ol[_ngcontent-%COMP%]{margin:4px 0;padding-left:16px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ul[_ngcontent-%COMP%] li[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ol[_ngcontent-%COMP%] li[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ul[_ngcontent-%COMP%] li[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ol[_ngcontent-%COMP%] li[_ngcontent-%COMP%]{font-size:12.5px;line-height:1.65;color:#1f1f1f;margin:4px 0;padding-left:2px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ul[_ngcontent-%COMP%] li.md-verify-item[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ol[_ngcontent-%COMP%] li.md-verify-item[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ul[_ngcontent-%COMP%] li.md-verify-item[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ol[_ngcontent-%COMP%] li.md-verify-item[_ngcontent-%COMP%]{color:#334155}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ul[_ngcontent-%COMP%] li.md-finding-item[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ol[_ngcontent-%COMP%] li.md-finding-item[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ul[_ngcontent-%COMP%] li.md-finding-item[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] ol[_ngcontent-%COMP%] li.md-finding-item[_ngcontent-%COMP%]{color:#92400e}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] .md-para-gap[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .thinking-stream-flat-card[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%] .md-para-gap[_ngcontent-%COMP%]{height:6px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .checker-stream-flat-card[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .checker-stream-flat-card[_ngcontent-%COMP%]{background:#f5f3ff;border:1px solid #e9d5ff;border-radius:8px;padding:12px 16px;margin-bottom:8px;box-shadow:0 1px 3px #00000003}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .checker-stream-flat-card[_ngcontent-%COMP%] .report-header[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .checker-stream-flat-card[_ngcontent-%COMP%] .report-header[_ngcontent-%COMP%]{font-family:Google Sans,Google Sans Text,Inter,Arial,Liberation Sans,sans-serif;font-weight:700;font-size:12px;line-height:1.5;letter-spacing:.5px;color:#6b21a8;margin-bottom:8px;-webkit-user-select:none;user-select:none;display:flex;align-items:center;gap:8px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .checker-stream-flat-card[_ngcontent-%COMP%] .report-header[_ngcontent-%COMP%] .stream-status-dot[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .checker-stream-flat-card[_ngcontent-%COMP%] .report-header[_ngcontent-%COMP%] .stream-status-dot[_ngcontent-%COMP%]{width:8px;height:8px;border-radius:50%;display:inline-block;background:#c084fc}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .checker-stream-flat-card[_ngcontent-%COMP%] .report-header[_ngcontent-%COMP%] .stream-status-dot.streaming[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .checker-stream-flat-card[_ngcontent-%COMP%] .report-header[_ngcontent-%COMP%] .stream-status-dot.streaming[_ngcontent-%COMP%]{background:#a855f7;animation:_ngcontent-%COMP%_pulse-stream-purple 1.5s infinite}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .checker-stream-flat-card[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .checker-stream-flat-card[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%]{font-size:12px;line-height:1.6;color:#27272a}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .checker-stream-flat-card[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .checker-stream-flat-card[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] .stream-text[_ngcontent-%COMP%]{font-family:Fira Code,Roboto Mono,Consolas,monospace;font-size:11.5px;white-space:pre-wrap;word-break:break-all;color:#5b21b6;background:#ffffff80;padding:8px;border-radius:6px;border:1px dashed rgba(168,85,247,.2)}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .checker-stream-flat-card[_ngcontent-%COMP%] .checker-result-badge[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .checker-stream-flat-card[_ngcontent-%COMP%] .checker-result-badge[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:6px;font-weight:700;font-size:11.5px;padding:4px 10px;border-radius:6px;margin-bottom:10px;border:1px solid transparent}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .checker-stream-flat-card[_ngcontent-%COMP%] .checker-result-badge.success[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .checker-stream-flat-card[_ngcontent-%COMP%] .checker-result-badge.success[_ngcontent-%COMP%]{background:#ecfdf5;color:#065f46;border-color:#a7f3d0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .checker-stream-flat-card[_ngcontent-%COMP%] .checker-result-badge.failed[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .checker-stream-flat-card[_ngcontent-%COMP%] .checker-result-badge.failed[_ngcontent-%COMP%]{background:#fef2f2;color:#991b1b;border-color:#fca5a5}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .llm-stream-body[_ngcontent-%COMP%] .checker-stream-flat-card[_ngcontent-%COMP%] .checker-reason[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .checker-stream-flat-card[_ngcontent-%COMP%] .checker-reason[_ngcontent-%COMP%]{font-size:12px;line-height:1.6;color:#3f3f46;font-weight:500}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:8px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-raw-thinking-message[_ngcontent-%COMP%]{background:transparent;border:1px solid transparent;border-radius:8px;padding:0;margin-bottom:0;box-shadow:none}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-raw-thinking-message.is-running[_ngcontent-%COMP%] .report-header[_ngcontent-%COMP%]{background:linear-gradient(90deg,#71717a,#2563eb,#7c3aed,#0284c7,#71717a);background-size:200% 100%;-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;animation:_ngcontent-%COMP%_subtle-color-flow 2.8s linear infinite;font-weight:500}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-raw-thinking-message.is-running[_ngcontent-%COMP%] .report-header[_ngcontent-%COMP%] .stream-status-dot[_ngcontent-%COMP%]{background:#3b82f6;box-shadow:0 0 #3b82f673;animation:_ngcontent-%COMP%_pulse-stream-dot 1.8s cubic-bezier(.4,0,.6,1) infinite}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-raw-thinking-message[_ngcontent-%COMP%] .report-header[_ngcontent-%COMP%]{font-family:Google Sans,Google Sans Text,Inter,Arial,sans-serif;font-weight:400;font-size:12px;line-height:1.5;min-height:20px;box-sizing:border-box;letter-spacing:.5px;color:#71717a;margin-bottom:8px;-webkit-user-select:none;user-select:none;display:flex;align-items:center;gap:8px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-raw-thinking-message[_ngcontent-%COMP%] .report-header[_ngcontent-%COMP%] .stream-status-dot[_ngcontent-%COMP%]{width:8px;height:8px;border-radius:50%;display:inline-block;background:#d4d4d8}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-raw-thinking-message[_ngcontent-%COMP%] .report-header[_ngcontent-%COMP%] .stream-status-dot.streaming[_ngcontent-%COMP%]{background:#3b82f6;box-shadow:0 0 #3b82f673;animation:_ngcontent-%COMP%_pulse-stream-dot 1.8s cubic-bezier(.4,0,.6,1) infinite}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-raw-thinking-message[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%]{font-size:12.5px;line-height:1.65;color:#1f1f1f}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-raw-thinking-message[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] strong[_ngcontent-%COMP%]{font-weight:600;color:#111827}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-raw-thinking-message[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] div[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-raw-thinking-message[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] p[_ngcontent-%COMP%]{font-size:12.5px;line-height:1.65;color:#1f1f1f;margin:4px 0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-raw-thinking-message[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] h1[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-raw-thinking-message[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] h2[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-raw-thinking-message[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] h3[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-raw-thinking-message[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] h4[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-raw-thinking-message[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] h5[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-raw-thinking-message[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] h6[_ngcontent-%COMP%]{font-size:12.5px;font-weight:600;color:#1f1f1f;margin-top:8px;margin-bottom:4px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-raw-thinking-message[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] .inline-code[_ngcontent-%COMP%]{font-family:Fira Code,Roboto Mono,Consolas,monospace;font-size:11px;background:#f4f4f5;color:#b91c1c;padding:2px 6px;border-radius:4px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-raw-thinking-message[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] ul[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-raw-thinking-message[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] ol[_ngcontent-%COMP%]{margin:4px 0;padding-left:16px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-raw-thinking-message[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] ul[_ngcontent-%COMP%] li[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-raw-thinking-message[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] ol[_ngcontent-%COMP%] li[_ngcontent-%COMP%]{font-size:12.5px;line-height:1.65;color:#1f1f1f;margin:4px 0;padding-left:2px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-raw-thinking-message[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] .md-para-gap[_ngcontent-%COMP%]{height:6px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-action-summary-card[_ngcontent-%COMP%]{background:#f4fbf7;border:1px solid #d1fae5;border-radius:8px;padding:12px 16px;margin-bottom:8px;box-shadow:0 1px 3px #00000003}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-action-summary-card[_ngcontent-%COMP%] .report-header[_ngcontent-%COMP%]{font-family:Google Sans,Google Sans Text,Inter,Arial,Liberation Sans,sans-serif;font-weight:700;font-size:12px;line-height:1.5;letter-spacing:.5px;color:#065f46;margin-bottom:8px;-webkit-user-select:none;user-select:none}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-action-summary-card[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%]{font-size:12px;line-height:1.6;color:#27272a}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-action-summary-card[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] strong[_ngcontent-%COMP%]{font-weight:600;color:#09090b}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-action-summary-card[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] .inline-code[_ngcontent-%COMP%]{font-family:Fira Code,Roboto Mono,Consolas,monospace;font-size:11px;background:#e6f4ea;color:#137333;padding:2px 6px;border-radius:4px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-action-summary-card[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] ul[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-action-summary-card[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] ol[_ngcontent-%COMP%]{margin:2px 0;padding-left:20px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-action-summary-card[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] ul[_ngcontent-%COMP%] li[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-action-summary-card[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] ol[_ngcontent-%COMP%] li[_ngcontent-%COMP%]{margin-bottom:2px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-action-summary-card[_ngcontent-%COMP%] .report-content[_ngcontent-%COMP%] .md-para-gap[_ngcontent-%COMP%]{height:4px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:8px;margin-top:0;margin-bottom:0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .llm-retry-notes[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:8px;padding:0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .llm-retry-notes[_ngcontent-%COMP%] .llm-retry-note[_ngcontent-%COMP%]{min-height:25px;box-sizing:border-box;display:flex;align-items:center;gap:8px;color:#71717a;font-family:Google Sans,Google Sans Text,Inter,Arial,sans-serif;font-size:12px;line-height:1.5;letter-spacing:.2px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .llm-retry-notes[_ngcontent-%COMP%] .llm-retry-note[_ngcontent-%COMP%] .stream-status-dot[_ngcontent-%COMP%]{width:8px;height:8px;border-radius:50%;background:#d4d4d8;flex:0 0 auto}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .llm-retry-notes[_ngcontent-%COMP%] .llm-retry-note[_ngcontent-%COMP%] .stream-status-dot.streaming[_ngcontent-%COMP%]{background:#3b82f6;box-shadow:0 0 #3b82f673;animation:_ngcontent-%COMP%_pulse-stream-dot 1.8s cubic-bezier(.4,0,.6,1) infinite}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .llm-retry-notes[_ngcontent-%COMP%] .llm-retry-note[_ngcontent-%COMP%] .llm-retry-note-text[_ngcontent-%COMP%]{color:#71717a;display:flex;flex-direction:column;min-width:0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .llm-retry-notes[_ngcontent-%COMP%] .llm-retry-note[_ngcontent-%COMP%] .llm-retry-note-text[_ngcontent-%COMP%] .llm-retry-status-line[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .llm-retry-notes[_ngcontent-%COMP%] .llm-retry-note[_ngcontent-%COMP%] .llm-retry-note-text[_ngcontent-%COMP%] .llm-retry-guidance[_ngcontent-%COMP%]{display:block}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .llm-retry-notes[_ngcontent-%COMP%] .llm-retry-note[_ngcontent-%COMP%] .llm-retry-note-text[_ngcontent-%COMP%] .llm-retry-guidance[_ngcontent-%COMP%]{color:#a1a1aa}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-generic-tool[_ngcontent-%COMP%]{border:1px solid transparent}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-generic-tool.is-video-attention[_ngcontent-%COMP%]:not(.is-running) .tool-call-label[_ngcontent-%COMP%] .stream-status-dot[_ngcontent-%COMP%]{background:#f59e0b;box-shadow:none;animation:none}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-generic-tool.is-llm-error[_ngcontent-%COMP%] .tool-call-label[_ngcontent-%COMP%]{display:flex;align-items:flex-start;color:#71717a;line-height:1.5;-webkit-user-select:text;user-select:text}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-generic-tool.is-llm-error[_ngcontent-%COMP%] .tool-call-label[_ngcontent-%COMP%] .error-icon[_ngcontent-%COMP%]{font-size:14px;color:#94a3b8;margin-top:1px;margin-left:-3px;margin-right:-3px;flex-shrink:0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-generic-tool.is-llm-error[_ngcontent-%COMP%] .tool-call-label[_ngcontent-%COMP%] .tool-call-text[_ngcontent-%COMP%]{display:block;color:inherit}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-generic-tool.is-llm-error[_ngcontent-%COMP%] .tool-call-label[_ngcontent-%COMP%] .error-title-text[_ngcontent-%COMP%]{font-weight:500;color:#71717a;margin-right:4px;flex-shrink:0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-generic-tool.is-llm-error[_ngcontent-%COMP%] .tool-call-label[_ngcontent-%COMP%] .error-message-text[_ngcontent-%COMP%]{color:#71717a;word-break:break-word}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-generic-tool.is-running[_ngcontent-%COMP%] .tool-call-label[_ngcontent-%COMP%] .stream-status-dot[_ngcontent-%COMP%]{background:#3b82f6;box-shadow:0 0 #3b82f673;animation:_ngcontent-%COMP%_pulse-stream-dot 1.8s cubic-bezier(.4,0,.6,1) infinite}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-generic-tool.is-running[_ngcontent-%COMP%] .tool-call-label[_ngcontent-%COMP%] .tool-call-text[_ngcontent-%COMP%] > span[_ngcontent-%COMP%]:first-child{background:linear-gradient(90deg,#71717a,#2563eb,#7c3aed,#0284c7,#71717a);background-size:200% 100%;-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;animation:_ngcontent-%COMP%_subtle-color-flow 2.8s linear infinite;font-weight:500}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-generic-tool[_ngcontent-%COMP%] .tool-call-label[_ngcontent-%COMP%]{font-size:12px;font-weight:400;color:#71717a;font-family:Google Sans,Google Sans Text,Inter,Arial,sans-serif;letter-spacing:.5px;line-height:1.5;min-height:22px;box-sizing:border-box;display:inline-flex;align-items:center;gap:8px;-webkit-user-select:none;user-select:none;padding:0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-generic-tool[_ngcontent-%COMP%] .tool-call-label[_ngcontent-%COMP%] span[_ngcontent-%COMP%]:not(.tool-key-pill){color:inherit}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-generic-tool[_ngcontent-%COMP%] .tool-call-label[_ngcontent-%COMP%] .stream-status-dot[_ngcontent-%COMP%]{width:8px;height:8px;border-radius:50%;background:#d4d4d8;display:inline-block;flex-shrink:0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-generic-tool[_ngcontent-%COMP%] .tool-call-label[_ngcontent-%COMP%] .stream-status-dot.streaming[_ngcontent-%COMP%]{background:#3b82f6;box-shadow:0 0 #3b82f673;animation:_ngcontent-%COMP%_pulse-stream-dot 1.8s cubic-bezier(.4,0,.6,1) infinite}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-generic-tool[_ngcontent-%COMP%] .tool-call-label[_ngcontent-%COMP%] .is-failed-dot[_ngcontent-%COMP%]{font-size:13px;color:#94a3b8;display:inline-flex;align-items:center;justify-content:center;width:8px;height:8px;flex-shrink:0;line-height:1}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-generic-tool[_ngcontent-%COMP%] .tool-call-label[_ngcontent-%COMP%] .tool-call-text[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:2px;color:inherit}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-generic-tool[_ngcontent-%COMP%] .tool-call-label[_ngcontent-%COMP%] .tool-key-pill[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:4px;padding:2px 6px;border:none;background:transparent;font-size:12px;font-weight:500;color:#1a73e8;cursor:pointer;border-radius:4px;transition:all .2s ease;vertical-align:middle;text-decoration:none!important}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-generic-tool[_ngcontent-%COMP%] .tool-call-label[_ngcontent-%COMP%] .tool-key-pill[_ngcontent-%COMP%] .pill-icon[_ngcontent-%COMP%]{font-size:14px;color:inherit}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-generic-tool[_ngcontent-%COMP%] .tool-call-label[_ngcontent-%COMP%] .tool-key-pill[_ngcontent-%COMP%]:hover{color:#1557b0;background:#1a73e814}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-generic-tool[_ngcontent-%COMP%] .tool-call-label[_ngcontent-%COMP%] .video-status-detail[_ngcontent-%COMP%]{color:inherit;margin-left:2px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-llm-error-card[_ngcontent-%COMP%]{background:#fef2f2;border:1px solid #fecaca;border-radius:10px;padding:10px 12px;margin-top:0;margin-bottom:0;margin-left:-19px;box-shadow:0 4px 12px #ef444408;width:fit-content;min-width:260px;max-width:480px;display:flex;flex-direction:column;gap:8px;transition:all .2s ease;position:relative}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-llm-error-card[_ngcontent-%COMP%]:hover{border-color:#fca5a5;box-shadow:0 4px 16px #ef444414}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-llm-error-card[_ngcontent-%COMP%] .error-card-header[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px;border-bottom:1px solid #fee2e2;padding-bottom:6px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-llm-error-card[_ngcontent-%COMP%] .error-card-header[_ngcontent-%COMP%] .error-icon[_ngcontent-%COMP%]{font-size:18px;color:#ef4444}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-llm-error-card[_ngcontent-%COMP%] .error-card-header[_ngcontent-%COMP%] .error-title[_ngcontent-%COMP%]{font-family:Google Sans,Google Sans Text,Inter,Arial,sans-serif;font-weight:700;font-size:12px;color:#991b1b}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-llm-error-card[_ngcontent-%COMP%] .error-card-body[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:6px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-llm-error-card[_ngcontent-%COMP%] .error-card-body[_ngcontent-%COMP%] .error-detail-row[_ngcontent-%COMP%]{display:flex;font-size:12px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-llm-error-card[_ngcontent-%COMP%] .error-card-body[_ngcontent-%COMP%] .error-detail-row[_ngcontent-%COMP%] .detail-value[_ngcontent-%COMP%]{color:#b91c1c;font-weight:500;word-break:break-word;line-height:1.4}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .llm-failure-stack[_ngcontent-%COMP%]{width:380px;max-width:100%;margin-left:-19px;display:flex;flex-direction:column;align-items:flex-start;gap:10px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .llm-failure-stack[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%]{width:100%;margin-left:0;box-sizing:border-box}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .llm-failure-stack[_ngcontent-%COMP%] .llm-resume-row[_ngcontent-%COMP%]{min-height:36px;display:flex;align-items:center;gap:10px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .llm-failure-stack[_ngcontent-%COMP%] .btn-continue-task[_ngcontent-%COMP%]{min-height:36px;padding:7px 12px 7px 10px;display:inline-flex;align-items:center;justify-content:center;gap:7px;box-sizing:border-box;border:1px solid #1d4ed8;border-radius:9px;background:#2563eb;color:#fff;font-family:inherit;font-size:12px;font-weight:700;line-height:1;cursor:pointer;box-shadow:0 2px 7px #2563eb38;transition:background .18s ease,border-color .18s ease,box-shadow .18s ease,transform .18s ease}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .llm-failure-stack[_ngcontent-%COMP%] .btn-continue-task[_ngcontent-%COMP%] .continue-icon[_ngcontent-%COMP%]{width:22px;height:22px;display:inline-flex;align-items:center;justify-content:center;margin:-2px 0 -2px -2px;border-radius:50%;background:#ffffff29;font-size:16px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .llm-failure-stack[_ngcontent-%COMP%] .btn-continue-task[_ngcontent-%COMP%] .continue-arrow[_ngcontent-%COMP%]{font-size:15px;opacity:.72;transition:transform .18s ease,opacity .18s ease}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .llm-failure-stack[_ngcontent-%COMP%] .btn-continue-task[_ngcontent-%COMP%]:hover{background:#1d4ed8;border-color:#1e40af;box-shadow:0 4px 11px #2563eb47;transform:translateY(-1px)}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .llm-failure-stack[_ngcontent-%COMP%] .btn-continue-task[_ngcontent-%COMP%]:hover .continue-arrow[_ngcontent-%COMP%]{opacity:1;transform:translate(2px)}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .llm-failure-stack[_ngcontent-%COMP%] .btn-continue-task[_ngcontent-%COMP%]:active{box-shadow:0 1px 4px #2563eb33;transform:translateY(0) scale(.98)}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .llm-failure-stack[_ngcontent-%COMP%] .btn-continue-task[_ngcontent-%COMP%]:focus-visible{outline:3px solid rgba(59,130,246,.25);outline-offset:2px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .llm-failure-stack[_ngcontent-%COMP%] .llm-resume-hint[_ngcontent-%COMP%]{color:#94a3b8;font-size:11px;line-height:1.35;white-space:nowrap}@media(max-width:560px){.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .llm-failure-stack[_ngcontent-%COMP%] .llm-resume-row[_ngcontent-%COMP%]{width:100%}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .llm-failure-stack[_ngcontent-%COMP%] .btn-continue-task[_ngcontent-%COMP%]{flex:1}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .llm-failure-stack[_ngcontent-%COMP%] .llm-resume-hint[_ngcontent-%COMP%]{display:none}}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%]{background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;padding:10px 12px;margin-top:0;margin-bottom:0;margin-left:-19px;box-shadow:0 4px 12px #00000005;width:380px;max-width:100%;display:flex;flex-direction:column;gap:8px;transition:all .25s cubic-bezier(.16,1,.3,1);position:relative;cursor:pointer;-webkit-user-select:none;user-select:none}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.is-running[_ngcontent-%COMP%]{border-color:#3b82f659;box-shadow:0 2px 10px #3b82f614;animation:_ngcontent-%COMP%_subtle-card-glow 2.5s ease-in-out infinite alternate}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.is-running[_ngcontent-%COMP%] .action-icon[_ngcontent-%COMP%]{color:#3b82f6}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.is-running[_ngcontent-%COMP%] .action-title[_ngcontent-%COMP%]{background:linear-gradient(90deg,#0f172a,#2563eb 30%,#7c3aed,#0284c7 70%,#0f172a);background-size:200% 100%;-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;animation:_ngcontent-%COMP%_subtle-color-flow 2.8s linear infinite}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%]:hover{border-color:#cbd5e1;box-shadow:0 4px 16px #0000000a;transform:translateY(-1px)}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%]:hover .action-expand-icon[_ngcontent-%COMP%]{color:#1e293b;background:#e2e8f0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.is-expanded[_ngcontent-%COMP%]{border-color:#94a3b8;background:#fff;box-shadow:0 8px 24px #0000000f}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.is-expanded[_ngcontent-%COMP%] .action-expand-icon[_ngcontent-%COMP%]{color:#0f172a;background:#e2e8f0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.is-failed[_ngcontent-%COMP%]{background:#f8fafc;border-color:#fca5a5}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.is-failed.has-details[_ngcontent-%COMP%]{cursor:pointer}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.is-failed[_ngcontent-%COMP%]:hover{border-color:#f87171}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.is-failed.is-expanded[_ngcontent-%COMP%]{background:#fff;border-color:#f87171}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.task-report-card[_ngcontent-%COMP%]{background:#f0fdf4;border:1px solid #bbf7d0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.task-report-card.is-completed[_ngcontent-%COMP%]{background:#f0fdf4;border-color:#86efac}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.task-report-card.is-completed[_ngcontent-%COMP%] .report-icon[_ngcontent-%COMP%]{color:#16a34a;font-variation-settings:"FILL" 1}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.task-report-card.is-completed[_ngcontent-%COMP%] .action-title[_ngcontent-%COMP%]{color:#14532d;font-weight:700}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.task-report-card.is-completed[_ngcontent-%COMP%] .action-status-badge.is-completed[_ngcontent-%COMP%]{background:#dcfce7;color:#15803d;border:1px solid #bbf7d0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.task-report-card.is-failed[_ngcontent-%COMP%]{background:#fef2f2;border-color:#fecaca}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.task-report-card.is-failed[_ngcontent-%COMP%] .report-icon[_ngcontent-%COMP%]{color:#dc2626;font-variation-settings:"FILL" 1}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.task-report-card.is-failed[_ngcontent-%COMP%] .action-title[_ngcontent-%COMP%]{color:#7f1d1d;font-weight:700}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.task-report-card.is-failed[_ngcontent-%COMP%] .action-status-badge.is-failed[_ngcontent-%COMP%]{background:#fee2e2;color:#b91c1c;border:1px solid #fca5a5}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.task-report-card[_ngcontent-%COMP%] .report-markdown-body[_ngcontent-%COMP%]{font-size:12px;line-height:1.5;color:#1f2937;font-weight:400;-webkit-user-select:text;user-select:text;word-break:break-word}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.task-report-card[_ngcontent-%COMP%] .report-markdown-body[_ngcontent-%COMP%] p[_ngcontent-%COMP%]{margin:3px 0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.task-report-card[_ngcontent-%COMP%] .report-markdown-body[_ngcontent-%COMP%] strong[_ngcontent-%COMP%]{font-weight:600;color:#0f172a}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.task-report-card[_ngcontent-%COMP%] .report-markdown-body[_ngcontent-%COMP%] ul[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.task-report-card[_ngcontent-%COMP%] .report-markdown-body[_ngcontent-%COMP%] ol[_ngcontent-%COMP%]{margin:3px 0;padding-left:18px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.task-report-card[_ngcontent-%COMP%] .report-markdown-body[_ngcontent-%COMP%] code[_ngcontent-%COMP%]{font-family:Fira Code,monospace;background:#e2e8f0;color:#1f2937;padding:1px 4px;border-radius:4px;font-size:11px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.has-no-details[_ngcontent-%COMP%]{padding:8px 12px;gap:0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card.has-no-details[_ngcontent-%COMP%] .action-card-header[_ngcontent-%COMP%]{border-bottom:none;padding-bottom:0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-header[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px;border-bottom:1px solid #f1f5f9;padding-bottom:6px;width:100%}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-header[_ngcontent-%COMP%] .action-icon[_ngcontent-%COMP%]{font-size:18px;color:#64748b}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-header[_ngcontent-%COMP%] .action-icon.is-failed[_ngcontent-%COMP%]{color:#ef4444}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-header[_ngcontent-%COMP%] .action-title[_ngcontent-%COMP%]{font-family:Google Sans,Google Sans Text,Inter,Arial,Liberation Sans,sans-serif;font-weight:700;font-size:12px;color:#0f172a;flex-grow:1}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-header[_ngcontent-%COMP%] .action-agent-badge[_ngcontent-%COMP%]{display:inline-flex;align-items:center;font-size:10px;font-weight:600;padding:1px 6px;border-radius:4px;background:#f0fdf4;color:#166534;border:1px solid #bbf7d0;letter-spacing:.2px;margin-right:6px;white-space:nowrap}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-header[_ngcontent-%COMP%] .action-agent-badge.is-outputter[_ngcontent-%COMP%]{background:linear-gradient(135deg,#f5f3fff2,#eef2fff2);color:#6d28d9;border-color:#c4b5fd}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-header[_ngcontent-%COMP%] .action-status-badge[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:3px;font-size:11px;font-weight:600;padding:1px 6px;border-radius:4px;margin-left:auto;white-space:nowrap}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-header[_ngcontent-%COMP%] .action-status-badge[_ngcontent-%COMP%] .status-icon[_ngcontent-%COMP%]{font-size:13px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-header[_ngcontent-%COMP%] .action-status-badge.is-failed[_ngcontent-%COMP%]{background:#fef2f2;color:#dc2626;border:1px solid #fee2e2}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-header[_ngcontent-%COMP%] .action-status-badge.is-failed[_ngcontent-%COMP%] .status-icon[_ngcontent-%COMP%]{color:#ef4444}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-header[_ngcontent-%COMP%] .action-status-badge.is-paused[_ngcontent-%COMP%]{background:#fff7ed;color:#c2410c;border:1px solid #fed7aa}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-header[_ngcontent-%COMP%] .action-status-badge.is-paused[_ngcontent-%COMP%] .status-icon[_ngcontent-%COMP%]{color:#f97316}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-header[_ngcontent-%COMP%] .action-expand-icon[_ngcontent-%COMP%]{font-size:18px;color:#94a3b8;margin-left:4px;padding:2px;border-radius:50%;transition:all .2s ease;display:flex;align-items:center;justify-content:center}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-body[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:6px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-body[_ngcontent-%COMP%] .action-detail-row[_ngcontent-%COMP%]{display:flex;align-items:baseline;font-size:12px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-body[_ngcontent-%COMP%] .action-detail-row[_ngcontent-%COMP%] .detail-label[_ngcontent-%COMP%]{color:#64748b;font-weight:500;width:85px;flex-shrink:0}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-body[_ngcontent-%COMP%] .action-detail-row[_ngcontent-%COMP%] .detail-value[_ngcontent-%COMP%]{color:#334155;font-weight:600;word-break:break-word}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-body[_ngcontent-%COMP%] .action-detail-row[_ngcontent-%COMP%] .detail-value.coordinate-badge[_ngcontent-%COMP%]{background:#e2e8f0;color:#475569;padding:2px 6px;border-radius:4px;font-family:Fira Code,monospace;font-size:11px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-body[_ngcontent-%COMP%] .action-detail-row[_ngcontent-%COMP%] .detail-value.command-value[_ngcontent-%COMP%]{font-family:Fira Code,Consolas,Monaco,monospace;background:#f1f5f9;border:1px solid #e2e8f0;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:500;color:#0f172a;white-space:pre-wrap;word-break:break-all}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-body[_ngcontent-%COMP%] .action-detail-row[_ngcontent-%COMP%] .detail-value.command-value[_ngcontent-%COMP%] code[_ngcontent-%COMP%]{font-family:inherit;font-size:inherit;color:inherit;background:none;padding:0;border:none}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-body[_ngcontent-%COMP%] .failure-retry-details[_ngcontent-%COMP%]{margin-top:4px;padding-top:9px;border-top:1px solid #fecaca;display:flex;flex-direction:column;gap:7px;animation:_ngcontent-%COMP%_fadeInDetails .2s ease-out}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-body[_ngcontent-%COMP%] .failure-retry-details[_ngcontent-%COMP%] .failure-retry-title[_ngcontent-%COMP%]{color:#991b1b;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.03em}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-body[_ngcontent-%COMP%] .failure-retry-details[_ngcontent-%COMP%] .failure-retry-row[_ngcontent-%COMP%]{display:flex;justify-content:space-between;gap:12px;color:#7f1d1d;font-size:10.5px;line-height:1.4}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-body[_ngcontent-%COMP%] .failure-retry-details[_ngcontent-%COMP%] .failure-retry-row[_ngcontent-%COMP%] span[_ngcontent-%COMP%]:last-child{color:#64748b;text-align:right}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-expanded-details[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:10px;padding-top:4px;animation:_ngcontent-%COMP%_fadeInDetails .2s ease-out}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-expanded-details[_ngcontent-%COMP%] .expanded-grid[_ngcontent-%COMP%]{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;background:#f8fafc;padding:10px;border-radius:8px;border:1px solid #f1f5f9}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-expanded-details[_ngcontent-%COMP%] .expanded-grid[_ngcontent-%COMP%] .grid-item[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:2px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-expanded-details[_ngcontent-%COMP%] .expanded-grid[_ngcontent-%COMP%] .grid-item.full-width[_ngcontent-%COMP%]{grid-column:1/-1}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-expanded-details[_ngcontent-%COMP%] .expanded-grid[_ngcontent-%COMP%] .grid-item[_ngcontent-%COMP%] .grid-label[_ngcontent-%COMP%]{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#64748b}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-expanded-details[_ngcontent-%COMP%] .expanded-grid[_ngcontent-%COMP%] .grid-item[_ngcontent-%COMP%] .grid-value[_ngcontent-%COMP%]{font-size:12px;font-weight:600;color:#1e293b;word-break:break-all}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-expanded-details[_ngcontent-%COMP%] .expanded-grid[_ngcontent-%COMP%] .grid-item[_ngcontent-%COMP%] .grid-value.code-pill[_ngcontent-%COMP%]{font-family:Roboto Mono,Fira Code,Consolas,monospace;font-size:11px;background:#e2e8f0;color:#334155;padding:2px 6px;border-radius:4px;display:inline-block;width:fit-content}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-expanded-details[_ngcontent-%COMP%] .expanded-grid[_ngcontent-%COMP%] .grid-item[_ngcontent-%COMP%] .grid-value.command-value[_ngcontent-%COMP%] code[_ngcontent-%COMP%]{font-family:Roboto Mono,Fira Code,Consolas,monospace;font-size:11px;background:#0f172a;color:#38bdf8;padding:4px 8px;border-radius:4px;display:block;word-break:break-all;white-space:pre-wrap}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-expanded-details[_ngcontent-%COMP%] .expanded-screenshots-container[_ngcontent-%COMP%]{display:flex;flex-direction:row;flex-wrap:nowrap;gap:12px;margin-top:4px;justify-content:flex-start;width:100%}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-expanded-details[_ngcontent-%COMP%] .expanded-screenshots-container[_ngcontent-%COMP%] .screenshot-column[_ngcontent-%COMP%]{flex:1 1 0;min-width:0;max-width:320px;display:flex;flex-direction:column;gap:6px;align-items:center}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-expanded-details[_ngcontent-%COMP%] .expanded-screenshots-container[_ngcontent-%COMP%] .screenshot-column[_ngcontent-%COMP%] .screenshot-label[_ngcontent-%COMP%]{font-family:Google Sans,Google Sans Text,Inter,Arial,Liberation Sans,sans-serif;font-size:11.5px;font-weight:500;color:#64748b;letter-spacing:normal;text-transform:none}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-expanded-details[_ngcontent-%COMP%] .expanded-screenshots-container[_ngcontent-%COMP%] .screenshot-column[_ngcontent-%COMP%] .image-wrapper[_ngcontent-%COMP%]{display:inline-flex;justify-content:center;align-items:center;max-width:100%;border:1px solid #e2e8f0;border-radius:8px;background-color:#f8fafc;cursor:zoom-in;overflow:hidden;box-shadow:0 1px 3px #0000000d;transition:transform .15s ease,box-shadow .15s ease,border-color .15s ease}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-expanded-details[_ngcontent-%COMP%] .expanded-screenshots-container[_ngcontent-%COMP%] .screenshot-column[_ngcontent-%COMP%] .image-wrapper[_ngcontent-%COMP%]:hover{transform:translateY(-1px);box-shadow:0 4px 12px #00000014;border-color:#cbd5e1}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-expanded-details[_ngcontent-%COMP%] .expanded-screenshots-container[_ngcontent-%COMP%] .screenshot-column[_ngcontent-%COMP%] .image-wrapper[_ngcontent-%COMP%] .img-relative-box[_ngcontent-%COMP%]{position:relative;display:inline-block;max-width:100%}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-expanded-details[_ngcontent-%COMP%] .expanded-screenshots-container[_ngcontent-%COMP%] .screenshot-column[_ngcontent-%COMP%] .image-wrapper[_ngcontent-%COMP%] .img-relative-box[_ngcontent-%COMP%] img[_ngcontent-%COMP%]{display:block;max-width:100%;max-height:280px;width:auto;height:auto;margin:0 auto;border-radius:6px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-expanded-details[_ngcontent-%COMP%] .expanded-screenshots-container[_ngcontent-%COMP%] .screenshot-column[_ngcontent-%COMP%] .image-wrapper[_ngcontent-%COMP%] .img-relative-box[_ngcontent-%COMP%] .overlay-layer[_ngcontent-%COMP%]{position:absolute;inset:0;pointer-events:none;overflow:visible}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-expanded-details[_ngcontent-%COMP%] .expanded-error-banner[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px;background:#fff1f2;border:1px solid #fecdd3;color:#be123c;padding:8px 12px;border-radius:6px;font-size:12px;font-weight:500}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .step-android-action-card[_ngcontent-%COMP%] .action-card-expanded-details[_ngcontent-%COMP%] .expanded-error-banner[_ngcontent-%COMP%] .icon[_ngcontent-%COMP%]{font-size:16px;color:#e11d48}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .final-summary-card[_ngcontent-%COMP%]{padding:20px 24px;display:flex;flex-direction:column;gap:12px;background:#fff;border:1px solid rgba(0,0,0,.06);border-radius:12px;box-shadow:0 4px 6px -1px #00000003,0 2px 4px -1px #00000002}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .final-summary-card[_ngcontent-%COMP%] .final-summary-header[_ngcontent-%COMP%]{font-weight:500;font-size:12px;color:#71717a;display:flex;align-items:center;gap:6px;-webkit-user-select:none;user-select:none}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .final-summary-card[_ngcontent-%COMP%] .final-summary-body[_ngcontent-%COMP%]{font-size:12px;line-height:1.6;color:#27272a}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .final-summary-card[_ngcontent-%COMP%] .final-summary-body[_ngcontent-%COMP%] strong[_ngcontent-%COMP%]{font-weight:600;color:#09090b}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .final-summary-card[_ngcontent-%COMP%] .final-summary-body[_ngcontent-%COMP%] ul[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .final-summary-card[_ngcontent-%COMP%] .final-summary-body[_ngcontent-%COMP%] ol[_ngcontent-%COMP%]{margin:6px 0;padding-left:20px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .final-summary-card[_ngcontent-%COMP%] .final-summary-body[_ngcontent-%COMP%] ul[_ngcontent-%COMP%] li[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .final-summary-card[_ngcontent-%COMP%] .final-summary-body[_ngcontent-%COMP%] ol[_ngcontent-%COMP%] li[_ngcontent-%COMP%]{margin-bottom:6px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .final-summary-card[_ngcontent-%COMP%] .final-summary-body[_ngcontent-%COMP%] div[_ngcontent-%COMP%]{margin-bottom:4px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item[_ngcontent-%COMP%] .step-body[_ngcontent-%COMP%] .step-actions-container[_ngcontent-%COMP%] .final-summary-card[_ngcontent-%COMP%] .final-summary-body[_ngcontent-%COMP%] .md-para-gap[_ngcontent-%COMP%]{height:8px}@keyframes _ngcontent-%COMP%_spin-loader{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@keyframes _ngcontent-%COMP%_pulse-green{0%{box-shadow:0 0 #10b98166}70%{box-shadow:0 0 0 6px #10b98100}to{box-shadow:0 0 #10b98100}}@keyframes _ngcontent-%COMP%_pulse-stream-blue{0%{box-shadow:0 0 #0ea5e966}70%{box-shadow:0 0 0 6px #0ea5e900}to{box-shadow:0 0 #0ea5e900}}@keyframes _ngcontent-%COMP%_pulse-stream-green{0%{box-shadow:0 0 #22c55e66}70%{box-shadow:0 0 0 6px #22c55e00}to{box-shadow:0 0 #22c55e00}}@keyframes _ngcontent-%COMP%_pulse-stream-purple{0%{box-shadow:0 0 #a855f766}70%{box-shadow:0 0 0 6px #a855f700}to{box-shadow:0 0 #a855f700}}@keyframes _ngcontent-%COMP%_pulse-stream-grey{0%{box-shadow:0 0 #71717a66}70%{box-shadow:0 0 0 6px #71717a00}to{box-shadow:0 0 #71717a00}}@keyframes _ngcontent-%COMP%_pulse-black-dot{0%{box-shadow:0 0 #0006}70%{box-shadow:0 0 0 5px #0000}to{box-shadow:0 0 #0000}}@keyframes _ngcontent-%COMP%_pulse-black-dot-small{0%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.8)}to{opacity:1;transform:scale(1)}}@keyframes _ngcontent-%COMP%_fadeInDetails{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}@keyframes _ngcontent-%COMP%_dropdownFadeIn{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}@keyframes _ngcontent-%COMP%_spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.image-modal-backdrop[_ngcontent-%COMP%]{position:absolute;inset:0;width:100%;height:100%;background-color:#0f172abf;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);z-index:1000;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;animation:_ngcontent-%COMP%_fadeInModal .2s ease-out}.image-modal-backdrop[_ngcontent-%COMP%] .image-modal-content[_ngcontent-%COMP%]{position:relative;max-width:calc(100% - 48px);max-height:calc(100% - 48px);background:#0f172a;border-radius:12px;padding:12px;box-shadow:0 20px 25px -5px #00000080,0 8px 10px -6px #00000080;display:flex;align-items:center;justify-content:center;box-sizing:border-box}.image-modal-backdrop[_ngcontent-%COMP%] .image-modal-content[_ngcontent-%COMP%] .image-modal-close[_ngcontent-%COMP%]{position:absolute;top:-12px;right:-12px;width:32px;height:32px;border-radius:50%;background:#ef4444;color:#fff;border:2px solid white;display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 4px 6px -1px #0000004d;transition:transform .15s ease,background-color .15s ease;z-index:20}.image-modal-backdrop[_ngcontent-%COMP%] .image-modal-content[_ngcontent-%COMP%] .image-modal-close[_ngcontent-%COMP%]:hover{transform:scale(1.1);background:#dc2626}.image-modal-backdrop[_ngcontent-%COMP%] .image-modal-content[_ngcontent-%COMP%] .image-modal-close[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:18px}.image-modal-backdrop[_ngcontent-%COMP%] .image-modal-content[_ngcontent-%COMP%] .img-relative-box[_ngcontent-%COMP%]{position:relative;display:inline-flex;align-items:center;justify-content:center;max-width:100%;max-height:100%}.image-modal-backdrop[_ngcontent-%COMP%] .image-modal-content[_ngcontent-%COMP%] .img-relative-box[_ngcontent-%COMP%] img[_ngcontent-%COMP%]{display:block;max-width:100%;max-height:calc(100vh - 140px);width:auto;height:auto;object-fit:contain;border-radius:6px}.image-modal-backdrop[_ngcontent-%COMP%] .image-modal-content[_ngcontent-%COMP%] .img-relative-box[_ngcontent-%COMP%] .overlay-layer[_ngcontent-%COMP%]{position:absolute;inset:0;pointer-events:none;overflow:visible;border-radius:6px}@keyframes _ngcontent-%COMP%_pulse-animation{0%{transform:scale(.5);opacity:1}to{transform:scale(1.8);opacity:0}}@keyframes _ngcontent-%COMP%_fadeInModal{0%{opacity:0}to{opacity:1}}@keyframes _ngcontent-%COMP%_subtle-color-flow{0%{background-position:0% 50%}to{background-position:200% 50%}}@keyframes _ngcontent-%COMP%_pulse-stream-dot{0%,to{transform:scale(1);box-shadow:0 0 #3b82f673}50%{transform:scale(1.15);box-shadow:0 0 0 4px #3b82f600}}@keyframes _ngcontent-%COMP%_subtle-card-glow{0%{border-color:#3b82f640;box-shadow:0 2px 8px #3b82f60d}to{border-color:#6366f173;box-shadow:0 4px 14px #6366f11f}}.outputter-synthesis-stream-card[_ngcontent-%COMP%]{margin:16px 0 12px;border-radius:12px;border:1px solid rgba(196,181,253,.85);background:linear-gradient(135deg,#f5f3fff2,#faf5fffa);box-shadow:0 4px 16px #7c3aed14,0 1px 3px #00000008;overflow:hidden;animation:_ngcontent-%COMP%_fadeInModal .25s ease-out}.outputter-synthesis-stream-card[_ngcontent-%COMP%] .synthesis-card-header[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;background:#ede9fe99;border-bottom:1px solid rgba(196,181,253,.7);gap:12px;flex-wrap:wrap}.outputter-synthesis-stream-card[_ngcontent-%COMP%] .synthesis-card-header[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%]{display:flex;align-items:center;gap:10px}.outputter-synthesis-stream-card[_ngcontent-%COMP%] .synthesis-card-header[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%] .icon-sparkle[_ngcontent-%COMP%]{font-size:22px;color:#7c3aed}.outputter-synthesis-stream-card[_ngcontent-%COMP%] .synthesis-card-header[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%] .main-title[_ngcontent-%COMP%]{font-size:14px;font-weight:700;color:#4338ca}.outputter-synthesis-stream-card[_ngcontent-%COMP%] .synthesis-card-header[_ngcontent-%COMP%] .header-right[_ngcontent-%COMP%] .btn-open-note[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:5px;padding:5px 12px;background:linear-gradient(135deg,#6366f1,#7c3aed);color:#fff;border:none;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer;transition:all .15s ease;box-shadow:0 2px 6px #6366f140}.outputter-synthesis-stream-card[_ngcontent-%COMP%] .synthesis-card-header[_ngcontent-%COMP%] .header-right[_ngcontent-%COMP%] .btn-open-note[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:15px}.outputter-synthesis-stream-card[_ngcontent-%COMP%] .synthesis-card-header[_ngcontent-%COMP%] .header-right[_ngcontent-%COMP%] .btn-open-note[_ngcontent-%COMP%]:hover{background:linear-gradient(135deg,#4f46e5,#6d28d9);box-shadow:0 4px 10px #6366f159;transform:translateY(-1px)}.outputter-synthesis-stream-card[_ngcontent-%COMP%] .synthesis-card-preview[_ngcontent-%COMP%]{padding:14px 16px}.outputter-synthesis-stream-card[_ngcontent-%COMP%] .synthesis-card-preview[_ngcontent-%COMP%] .parsed-note-container[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:10px}.outputter-synthesis-stream-card[_ngcontent-%COMP%] .synthesis-card-preview[_ngcontent-%COMP%] .parsed-note-container[_ngcontent-%COMP%] .parsed-note-title[_ngcontent-%COMP%]{font-size:14px;font-weight:700;color:#1e1b4b;margin:0}.outputter-synthesis-stream-card[_ngcontent-%COMP%] .synthesis-card-preview[_ngcontent-%COMP%] .parsed-note-container[_ngcontent-%COMP%] .parsed-milestones[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:6px}.outputter-synthesis-stream-card[_ngcontent-%COMP%] .synthesis-card-preview[_ngcontent-%COMP%] .parsed-note-container[_ngcontent-%COMP%] .parsed-milestones[_ngcontent-%COMP%] .milestone-item[_ngcontent-%COMP%]{display:flex;align-items:flex-start;gap:8px;font-size:12.5px;line-height:1.45;color:#374151}.outputter-synthesis-stream-card[_ngcontent-%COMP%] .synthesis-card-preview[_ngcontent-%COMP%] .parsed-note-container[_ngcontent-%COMP%] .parsed-milestones[_ngcontent-%COMP%] .milestone-item[_ngcontent-%COMP%] .status-icon[_ngcontent-%COMP%]{font-size:16px;color:#7c3aed;margin-top:1px;flex-shrink:0}.outputter-synthesis-stream-card[_ngcontent-%COMP%] .synthesis-card-preview[_ngcontent-%COMP%] .parsed-note-container[_ngcontent-%COMP%] .parsed-milestones[_ngcontent-%COMP%] .milestone-item.status-checked[_ngcontent-%COMP%] .status-icon[_ngcontent-%COMP%]{color:#10b981}.outputter-synthesis-stream-card[_ngcontent-%COMP%] .synthesis-card-preview[_ngcontent-%COMP%] .parsed-note-container[_ngcontent-%COMP%] .parsed-milestones[_ngcontent-%COMP%] .milestone-item.status-progress[_ngcontent-%COMP%] .status-icon[_ngcontent-%COMP%]{color:#f59e0b}.outputter-synthesis-stream-card[_ngcontent-%COMP%] .synthesis-card-preview[_ngcontent-%COMP%] .parsed-note-container[_ngcontent-%COMP%] .parsed-other-lines[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:4px}.outputter-synthesis-stream-card[_ngcontent-%COMP%] .synthesis-card-preview[_ngcontent-%COMP%] .parsed-note-container[_ngcontent-%COMP%] .parsed-other-lines[_ngcontent-%COMP%] .other-line[_ngcontent-%COMP%]{margin:0;font-size:12.5px;line-height:1.5;color:#4b5563}.outputter-synthesis-stream-card[_ngcontent-%COMP%] .synthesis-card-preview[_ngcontent-%COMP%] .parsed-note-container[_ngcontent-%COMP%] .parsed-other-lines[_ngcontent-%COMP%] .other-line[_ngcontent-%COMP%] strong[_ngcontent-%COMP%]{color:#1e1b4b}.outputter-synthesis-stream-card[_ngcontent-%COMP%] .synthesis-card-preview[_ngcontent-%COMP%] .parsed-note-container[_ngcontent-%COMP%] .parsed-other-lines[_ngcontent-%COMP%] .other-line[_ngcontent-%COMP%] code[_ngcontent-%COMP%]{font-family:JetBrains Mono,monospace;background:#ffffffe6;border:1px solid #e5e7eb;padding:1px 4px;border-radius:4px;color:#6d28d9;font-size:11.5px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%]{border-left:2px solid #ddd6fe;padding-left:12px;margin-left:-14px;font-family:Google Sans,Google Sans Text,Inter,Arial,sans-serif}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-index[_ngcontent-%COMP%]{flex:0 0 14px;text-align:right;color:#a1a1aa;font-variant-numeric:tabular-nums;-webkit-user-select:none;user-select:none}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-lane[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:8px;margin-bottom:8px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-lane[_ngcontent-%COMP%] .check-header[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px;min-height:20px;font-size:12px;line-height:1.5;letter-spacing:.2px;color:#71717a;-webkit-user-select:none;user-select:none}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-lane[_ngcontent-%COMP%] .check-header[_ngcontent-%COMP%] .stream-status-dot[_ngcontent-%COMP%]{flex:0 0 auto;width:8px;height:8px;border-radius:50%;display:inline-block;background:#d4d4d8}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-lane[_ngcontent-%COMP%] .check-header[_ngcontent-%COMP%] .stream-status-dot.streaming[_ngcontent-%COMP%]{background:#7c3aed;box-shadow:0 0 #7c3aed73;animation:_ngcontent-%COMP%_pulse-stream-dot 1.8s cubic-bezier(.4,0,.6,1) infinite}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-lane[_ngcontent-%COMP%] .check-header[_ngcontent-%COMP%] .check-title[_ngcontent-%COMP%]{flex:0 0 auto;font-weight:500;color:#52525b}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-lane[_ngcontent-%COMP%] .check-header[_ngcontent-%COMP%] .check-subject[_ngcontent-%COMP%]{flex:1 1 auto;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-lane[_ngcontent-%COMP%] .check-header[_ngcontent-%COMP%] .check-status[_ngcontent-%COMP%]{flex:0 0 auto;margin-left:auto;font-weight:500;color:#52525b;white-space:nowrap}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-lane.tone-passed[_ngcontent-%COMP%] .check-status[_ngcontent-%COMP%]{color:#15803d}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-lane.tone-failed[_ngcontent-%COMP%] .check-status[_ngcontent-%COMP%]{color:#b91c1c}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-lane.tone-inconclusive[_ngcontent-%COMP%] .check-status[_ngcontent-%COMP%]{color:#b45309}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-lane.tone-running[_ngcontent-%COMP%] .check-status[_ngcontent-%COMP%]{color:#6d28d9}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-lane.tone-muted[_ngcontent-%COMP%]{opacity:.6}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-lane[_ngcontent-%COMP%] .check-items[_ngcontent-%COMP%]{list-style:none;margin:0;padding:0 0 0 16px;display:flex;flex-direction:column;gap:2px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-lane[_ngcontent-%COMP%] .check-items[_ngcontent-%COMP%] .check-item[_ngcontent-%COMP%]{display:flex;align-items:baseline;gap:8px;font-size:12px;line-height:1.5;color:#3f3f46}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-lane[_ngcontent-%COMP%] .check-items[_ngcontent-%COMP%] .check-item[_ngcontent-%COMP%] .check-text[_ngcontent-%COMP%]{flex:1 1 auto;min-width:0;word-break:break-word}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-lane[_ngcontent-%COMP%] .check-items[_ngcontent-%COMP%] .check-item[_ngcontent-%COMP%] .check-method[_ngcontent-%COMP%]{flex:0 0 auto;color:#a1a1aa;white-space:nowrap}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-result[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:4px;margin-top:8px;padding-left:16px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-result[_ngcontent-%COMP%] .check-verdict[_ngcontent-%COMP%]{display:flex;align-items:flex-start;gap:8px;font-size:12px;line-height:1.5;color:#52525b}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-result[_ngcontent-%COMP%] .check-verdict[_ngcontent-%COMP%] .check-index[_ngcontent-%COMP%]{line-height:18px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-result[_ngcontent-%COMP%] .check-verdict[_ngcontent-%COMP%] .verdict-icon[_ngcontent-%COMP%]{flex:0 0 auto;font-size:15px;line-height:18px;color:#a1a1aa}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-result[_ngcontent-%COMP%] .check-verdict.passed[_ngcontent-%COMP%] .verdict-icon[_ngcontent-%COMP%]{color:#16a34a}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-result[_ngcontent-%COMP%] .check-verdict.failed[_ngcontent-%COMP%] .verdict-icon[_ngcontent-%COMP%]{color:#dc2626}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-result[_ngcontent-%COMP%] .check-verdict.inconclusive[_ngcontent-%COMP%] .verdict-icon[_ngcontent-%COMP%]{color:#d97706}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-result[_ngcontent-%COMP%] .check-verdict.superseded[_ngcontent-%COMP%], .agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-result[_ngcontent-%COMP%] .check-verdict.unchecked[_ngcontent-%COMP%]{color:#a1a1aa}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-result[_ngcontent-%COMP%] .check-verdict[_ngcontent-%COMP%] .verdict-body[_ngcontent-%COMP%]{flex:1 1 auto;min-width:0;word-break:break-word}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-result[_ngcontent-%COMP%] .check-verdict[_ngcontent-%COMP%] .verdict-suggestion[_ngcontent-%COMP%]{display:block;color:#1d4ed8}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .phase-body[_ngcontent-%COMP%] .block-item.type-checker[_ngcontent-%COMP%] .check-result[_ngcontent-%COMP%] .check-note[_ngcontent-%COMP%]{font-size:12px;line-height:1.5;color:#71717a;padding-left:22px;word-break:break-word}']})};function f3(n,t){if(n&1){let e=L();l(0,"button",10),k("click",function(){v(e);let i=m();return y(i.clearHistory())}),l(1,"span",11),u(2,"delete"),d()()}if(n&2){let e=m();R("disabled",e.isSubmitting())}}function m3(n,t){n&1&&(l(0,"div",14),u(1," No active or queued tasks. "),d())}function _3(n,t){n&1&&$(0,"span",22)}function h3(n,t){if(n&1&&(l(0,"span",24)(1,"span",33),u(2,"phone_android"),d(),l(3,"span",34),u(4),d()()),n&2){let e=m().$implicit,o=m(2);R("title","Device: "+o.getDeviceSerial(e)),g(4),O(o.getDeviceSerial(e))}}function C3(n,t){n&1&&(l(0,"span",25),u(1,"\xB7"),d())}function M3(n,t){if(n&1){let e=L();l(0,"button",35),k("click",function(i){v(e);let r=m().$implicit,a=m(2);return y(a.stopTask(r.session_id,i))}),l(1,"span",11),u(2,"stop_circle"),d()()}}function b3(n,t){if(n&1){let e=L();l(0,"button",36),k("click",function(i){v(e);let r=m().$implicit,a=m(2);return y(a.deleteTask(r.session_id,i))}),l(1,"span",11),u(2,"delete"),d()()}if(n&2){let e=m(3);R("disabled",e.isSubmitting())}}function P3(n,t){if(n&1){let e=L();l(0,"div",19),k("click",function(){let i=v(e).$implicit,r=m(2);return y(r.selectTask(i.session_id))}),l(1,"div",20)(2,"span",21),M(3,_3,1,0,"span",22),u(4),it(5,"uppercase"),d(),l(6,"div",23),M(7,h3,5,2,"span",24),M(8,C3,2,0,"span",25),l(9,"span",26),u(10),it(11,"date"),d(),M(12,M3,3,0,"button",27),M(13,b3,3,1,"button",28),d()(),l(14,"div",29),u(15),d(),l(16,"div",30)(17,"span",31),u(18),d(),l(19,"span",32),u(20),it(21,"date"),d()()()}if(n&2){let e=t.$implicit,o=m(2);w("selected",o.agentService.currentSessionId()===e.session_id)("running",o.getTaskStatus(e)==="running"),g(2),R("ngClass",o.getTaskStatus(e)),g(),b(o.getTaskStatus(e)==="running"?3:-1),g(),V(" ",wi(5,18,o.getTaskStatus(e))," "),g(3),b(o.getDeviceSerial(e)?7:-1),g(),b(o.getDeviceSerial(e)?8:-1),g(2),O(Et(11,20,e.start_time*1e3,"shortTime")),g(2),b(o.getTaskStatus(e)==="running"||o.getTaskStatus(e)==="paused"?12:-1),g(),b(o.getTaskStatus(e)!=="running"&&o.getTaskStatus(e)!=="paused"?13:-1),g(),R("title",xi(e.initial_goal)),g(),V(" ",e.initial_goal||"Unnamed Task"," "),g(2),R("title",e.session_id),g(),V("ID: ",e.session_id),g(2),O(Et(21,23,e.start_time*1e3,"MM/dd/yy"))}}function O3(n,t){n&1&&(l(0,"div",14),u(1," No past tasks. "),d())}function v3(n,t){n&1&&$(0,"span",22)}function y3(n,t){if(n&1&&(l(0,"span",24)(1,"span",33),u(2,"phone_android"),d(),l(3,"span",34),u(4),d()()),n&2){let e=m().$implicit,o=m(2);R("title","Device: "+o.getDeviceSerial(e)),g(4),O(o.getDeviceSerial(e))}}function x3(n,t){n&1&&(l(0,"span",25),u(1,"\xB7"),d())}function w3(n,t){if(n&1){let e=L();l(0,"button",36),k("click",function(i){v(e);let r=m().$implicit,a=m(2);return y(a.deleteTask(r.session_id,i))}),l(1,"span",11),u(2,"delete"),d()()}if(n&2){let e=m(3);R("disabled",e.isSubmitting())}}function S3(n,t){if(n&1){let e=L();l(0,"div",19),k("click",function(){let i=v(e).$implicit,r=m(2);return y(r.selectTask(i.session_id))}),l(1,"div",20)(2,"span",21),M(3,v3,1,0,"span",22),u(4),it(5,"uppercase"),d(),l(6,"div",23),M(7,y3,5,2,"span",24),M(8,x3,2,0,"span",25),l(9,"span",26),u(10),it(11,"date"),d(),M(12,w3,3,1,"button",28),d()(),l(13,"div",29),u(14),d(),l(15,"div",30)(16,"span",31),u(17),d(),l(18,"span",32),u(19),it(20,"date"),d()()()}if(n&2){let e=t.$implicit,o=m(2);w("selected",o.agentService.currentSessionId()===e.session_id)("running",o.getTaskStatus(e)==="running"),g(2),R("ngClass",o.getTaskStatus(e)),g(),b(o.getTaskStatus(e)==="running"?3:-1),g(),V(" ",wi(5,17,o.getTaskStatus(e))," "),g(3),b(o.getDeviceSerial(e)?7:-1),g(),b(o.getDeviceSerial(e)?8:-1),g(2),O(Et(11,19,e.start_time*1e3,"shortTime")),g(2),b(o.getTaskStatus(e)!=="running"&&o.getTaskStatus(e)!=="paused"?12:-1),g(),R("title",xi(e.initial_goal)),g(),V(" ",e.initial_goal||"Unnamed Task"," "),g(2),R("title",e.session_id),g(),V("ID: ",e.session_id),g(2),O(Et(20,22,e.start_time*1e3,"MM/dd/yy"))}}function k3(n,t){if(n&1&&(l(0,"div",12)(1,"span",13),u(2,"bolt"),d(),u(3),d(),M(4,m3,2,0,"div",14),G(5,P3,22,26,"div",15,De().trackSession,!0),l(7,"details",16)(8,"summary",17)(9,"span",13),u(10,"history"),d(),u(11),d(),l(12,"div",18),M(13,O3,2,0,"div",14),G(14,S3,21,25,"div",15,De().trackSession,!0),d()()),n&2){let e=m();g(3),V(" Active Queue (",e.activeQueue().length,") "),g(),b(e.activeQueue().length===0?4:-1),g(),q(e.activeQueue()),g(6),V(" Past Runs & History (",e.historyTasks().length,") "),g(2),b(e.historyTasks().length===0?13:-1),g(),q(e.historyTasks())}}function E3(n,t){if(n&1){let e=L();l(0,"button",42),k("click",function(){let i=v(e).$implicit,r=m(3);return y(r.selectNote(i))}),l(1,"span",43),u(2,"draft"),d(),u(3),d()}if(n&2){let e=t.$implicit,o=m(3);w("active",o.agentService.selectedNoteKey()===e),g(3),V(" ",e," ")}}function D3(n,t){if(n&1&&(l(0,"div",37)(1,"div",40),G(2,E3,4,3,"button",41,De().trackNoteKey,!0),d()()),n&2){let e=m(2);g(2),q(e.noteKeys())}}function I3(n,t){n&1&&(l(0,"div",38)(1,"div",44)(2,"span",11),u(3,"description"),d()(),l(4,"h4",45),u(5,"No Notes & Plans"),d(),l(6,"p",46),u(7,"No notes or structured plans were recorded for this session."),d()())}function T3(n,t){if(n&1&&(l(0,"h2",47),u(1),d()),n&2){let e=m(3);g(),O(e.parsedNote().title)}}function A3(n,t){n&1&&(l(0,"span",52),u(1,"check_box"),d())}function N3(n,t){n&1&&(l(0,"span",53),u(1,"indeterminate_check_box"),d())}function R3(n,t){n&1&&(l(0,"span",54),u(1,"check_box_outline_blank"),d())}function F3(n,t){if(n&1&&(l(0,"span"),u(1),d()),n&2){let e=t.$implicit;w("bold-segment",e.bold)("code-segment",e.code),g(),O(e.text)}}function L3(n,t){n&1&&(l(0,"span",61),u(1,"check_box"),d())}function V3(n,t){n&1&&(l(0,"span",62),u(1,"indeterminate_check_box"),d())}function j3(n,t){n&1&&(l(0,"span",63),u(1,"check_box_outline_blank"),d())}function $3(n,t){n&1&&(l(0,"span",64),u(1,"\u2022"),d())}function z3(n,t){if(n&1&&(l(0,"span"),u(1),d()),n&2){let e=t.$implicit;w("bold-segment",e.bold)("code-segment",e.code),g(),O(e.text)}}function B3(n,t){if(n&1&&(l(0,"span",65),G(1,z3,2,5,"span",56,Ie),d()),n&2){let e=m().$implicit;g(),q(e.segments)}}function H3(n,t){if(n&1&&(l(0,"div",60),M(1,L3,2,0,"span",61)(2,V3,2,0,"span",62)(3,j3,2,0,"span",63)(4,$3,2,0,"span",64),M(5,B3,3,0,"span",65),d()),n&2){let e,o=t.$implicit;Se("padding-left",o.indent>2?(o.indent-2)*12:0,"px"),R("ngClass",o.type),g(),b((e=o.type)==="checked"?1:e==="progress"?2:e==="unchecked"?3:e==="list-item"?4:-1),g(4),b(o.type!=="empty"?5:-1)}}function U3(n,t){if(n&1&&(l(0,"div",57),G(1,H3,6,5,"div",59,De().trackMarkdownLine,!0),d()),n&2){let e=m().$implicit;g(),q(e.subSteps)}}function G3(n,t){if(n&1&&(l(0,"span"),u(1),d()),n&2){let e=t.$implicit;w("bold-segment",e.bold)("code-segment",e.code),g(),O(e.text)}}function q3(n,t){if(n&1&&(l(0,"div",67)(1,"div",68)(2,"span",69),u(3),d()(),l(4,"div",70),G(5,G3,2,5,"span",56,Ie),d()()),n&2){let e=t.$implicit;Se("margin-left",e.indent>2?(e.indent-2)*12:0,"px"),R("ngClass",e.type),g(2),w("assert",e.checkKind==="assert"),g(),We("",e.checkKind||"verify","",e.atEnd?" @end":""),g(2),q(e.segments)}}function W3(n,t){if(n&1&&(l(0,"div",58),G(1,q3,7,7,"div",66,De().trackMarkdownLine,!0),d()),n&2){let e=m().$implicit;g(),q(e.checks)}}function K3(n,t){if(n&1&&(l(0,"div",50)(1,"div",51),M(2,A3,2,0,"span",52)(3,N3,2,0,"span",53)(4,R3,2,0,"span",54),l(5,"div",55),G(6,F3,2,5,"span",56,Ie),d()(),M(8,U3,3,0,"div",57),M(9,W3,3,0,"div",58),d()),n&2){let e,o=t.$implicit;R("ngClass",o.type),g(2),b((e=o.type)==="checked"?2:e==="progress"?3:e==="unchecked"?4:-1),g(4),q(o.segments),g(2),b(o.subSteps.length>0?8:-1),g(),b(o.checks.length>0?9:-1)}}function Y3(n,t){if(n&1&&(l(0,"div",48),G(1,K3,10,4,"div",50,De().trackMilestone,!0),d()),n&2){let e=m(3);g(),q(e.parsedNote().milestones)}}function Z3(n,t){if(n&1&&(l(0,"span"),u(1),d()),n&2){let e=t.$implicit;w("bold-segment",e.bold)("code-segment",e.code),g(),O(e.text)}}function Q3(n,t){if(n&1&&(l(0,"div",73)(1,"div",68)(2,"span",69),u(3),d()(),l(4,"div",70),G(5,Z3,2,5,"span",56,Ie),d()()),n&2){let e=m().$implicit;Se("margin-left",e.indent*12,"px"),R("ngClass",e.type),g(2),w("assert",e.checkKind==="assert"),g(),We("",e.checkKind||"verify","",e.atEnd?" @end":""),g(2),q(e.segments)}}function J3(n,t){n&1&&(l(0,"span",61),u(1,"check_box"),d())}function X3(n,t){n&1&&(l(0,"span",62),u(1,"indeterminate_check_box"),d())}function e4(n,t){n&1&&(l(0,"span",63),u(1,"check_box_outline_blank"),d())}function t4(n,t){n&1&&(l(0,"span",64),u(1,"\u2022"),d())}function n4(n,t){if(n&1&&(l(0,"span"),u(1),d()),n&2){let e=t.$implicit;w("bold-segment",e.bold)("code-segment",e.code),g(),O(e.text)}}function o4(n,t){if(n&1&&(l(0,"span",65),G(1,n4,2,5,"span",56,Ie),d()),n&2){let e=m(2).$implicit;g(),q(e.segments)}}function i4(n,t){if(n&1&&(l(0,"div",74),M(1,J3,2,0,"span",61)(2,X3,2,0,"span",62)(3,e4,2,0,"span",63)(4,t4,2,0,"span",64),M(5,o4,3,0,"span",65),d()),n&2){let e,o=m().$implicit;Se("margin-left",o.indent*12,"px"),R("ngClass",o.type),g(),b((e=o.type)==="checked"?1:e==="progress"?2:e==="unchecked"?3:e==="list-item"?4:-1),g(4),b(o.type!=="empty"?5:-1)}}function r4(n,t){if(n&1&&M(0,Q3,7,7,"div",71)(1,i4,6,5,"div",72),n&2){let e=t.$implicit;b(e.type==="verify"||e.type==="finding"?0:1)}}function a4(n,t){if(n&1&&(l(0,"div",49),G(1,r4,2,1,null,null,De().trackMarkdownLine,!0),d()),n&2){let e=m(3);g(),q(e.parsedNote().otherLines)}}function s4(n,t){if(n&1&&(l(0,"div",39),M(1,T3,2,1,"h2",47),M(2,Y3,3,0,"div",48),M(3,a4,3,0,"div",49),d()),n&2){let e=m(2);g(),b(e.parsedNote().title?1:-1),g(),b(e.parsedNote().milestones.length>0?2:-1),g(),b(e.parsedNote().otherLines.length>0?3:-1)}}function c4(n,t){if(n&1&&(l(0,"div",9),M(1,D3,4,0,"div",37),M(2,I3,8,0,"div",38),M(3,s4,4,3,"div",39),d()),n&2){let e=m();g(),b(e.noteKeys().length>1?1:-1),g(),b(e.noteKeys().length===0?2:-1),g(),b(e.noteKeys().length>0?3:-1)}}var qr=class n{agentService=D(It);taskInput="";isSubmitting=x(!1);errorMessage=x(null);deviceSerialCache=new WeakMap;activeQueue=I(()=>this.agentService.sessions().filter(e=>{let o=this.getTaskStatus(e);return o==="running"||o==="paused"||o==="pending"}).sort((e,o)=>{let i=this.getTaskStatus(e),r=this.getTaskStatus(o),a=i==="running"||i==="paused",s=r==="running"||r==="paused";return a&&!s?-1:!a&&s?1:(e.start_time||0)-(o.start_time||0)}));historyTasks=I(()=>this.agentService.sessions().filter(t=>{let e=this.getTaskStatus(t);return e==="completed"||e==="failed"||e==="cancelled"}));submitTask(){let t=this.taskInput.trim();t&&(this.isSubmitting.set(!0),this.errorMessage.set(null),this.agentService.runTask(t).subscribe({next:e=>{this.taskInput="",this.isSubmitting.set(!1),this.agentService.fetchStatus()},error:e=>{console.error("Failed to submit task:",e),this.isSubmitting.set(!1),this.errorMessage.set(e.error?.detail||"The runner is busy. Please wait for the current task to finish."),setTimeout(()=>{this.errorMessage.set(null)},5e3)}}))}stopTask(t,e){e.stopPropagation(),this.isSubmitting.set(!0),this.errorMessage.set(null),this.agentService.stopTask(t,!1),setTimeout(()=>{this.isSubmitting.set(!1)},400)}stopTasks(t=!1){this.isSubmitting.set(!0),this.errorMessage.set(null),this.agentService.stopTask(t),setTimeout(()=>{this.isSubmitting.set(!1)},400)}clearHistory(){confirm("Are you sure you want to clear all tasks and history? This cannot be undone.")&&(this.isSubmitting.set(!0),this.errorMessage.set(null),this.agentService.clearAllHistory().subscribe({next:()=>{this.isSubmitting.set(!1)},error:t=>{console.error("Failed to clear history:",t),this.isSubmitting.set(!1),this.errorMessage.set(t.error?.detail||"Failed to clear history.")}}))}deleteTask(t,e){e.stopPropagation(),confirm("Are you sure you want to delete this task? This cannot be undone.")&&(this.isSubmitting.set(!0),this.errorMessage.set(null),this.agentService.deleteSession(t).subscribe({next:()=>{this.isSubmitting.set(!1)},error:o=>{console.error(`Failed to delete task ${t}:`,o),this.isSubmitting.set(!1),this.errorMessage.set(o.error?.detail||"Failed to delete task.")}}))}getTaskStatus(t){if(t.status){let e=t.status.toLowerCase();if(e==="completed"||e==="success"||e==="failed"||e==="cancelled")return e==="success"?"completed":e;if(e==="running"||e==="paused"||e==="pending")return e}return t.session_id===this.agentService.runningSessionId()&&(this.agentService.agentStatus()==="running"||this.agentService.agentStatus()==="paused")?this.agentService.agentStatus():"completed"}getDeviceSerial(t){if(this.deviceSerialCache.has(t))return this.deviceSerialCache.get(t)??null;let e=null,o=t.device_serial||t.device_id;if(o&&o!=="pending"&&o!=="null"&&o!=="undefined")e=o;else if(t.device_info)try{let i=typeof t.device_info=="string"?JSON.parse(t.device_info):t.device_info,r=i?.device_id||i?.device_serial;r&&r!=="pending"&&r!=="null"&&r!=="undefined"&&(e=r)}catch(i){}return this.deviceSerialCache.set(t,e),e}selectTask(t){this.agentService.selectSession(t,!0)}currentNoteContent=I(()=>{let t=this.agentService.currentNotes(),e=this.agentService.selectedNoteKey();return t[e]||""});noteKeys=I(()=>Object.keys(this.agentService.currentNotes()).filter(t=>t.toLowerCase().endsWith(".md")));parsedNote=I(()=>this.getParsedNote(this.currentNoteContent()));getParsedNote(t){return ks(t)}getParsedNoteLines(t){return Ss(t)}selectNote(t){this.agentService.selectedNoteKey.set(t)}trackSession(t,e){return e.session_id}trackNoteKey(t,e){return e}trackMilestone(t,e){return e.index}trackMarkdownLine(t,e){return t}static \u0275fac=function(e){return new(e||n)};static \u0275cmp=Ge({type:n,selectors:[["app-chat-interface"]],decls:18,vars:7,consts:[[1,"chat-interface-container"],[1,"chat-header"],[1,"tab-selector-bar"],[1,"tab-selector-btn",3,"click"],[1,"material-symbols-outlined","tab-icon"],[1,"tab-text"],[1,"header-right"],["title","Clear all tasks and history","aria-label","Clear all tasks and history",1,"btn-clear-history",3,"disabled"],[1,"chat-messages"],[1,"notes-tab-content"],["title","Clear all tasks and history","aria-label","Clear all tasks and history",1,"btn-clear-history",3,"click","disabled"],[1,"material-symbols-outlined"],[1,"queue-section-header"],[1,"material-symbols-outlined","section-icon"],[1,"empty-section-placeholder"],[1,"task-card",3,"selected","running"],["open","",1,"history-section"],[1,"history-summary"],[1,"history-list-wrapper"],[1,"task-card",3,"click"],[1,"task-card-header"],[1,"task-badge",3,"ngClass"],[1,"status-dot"],[1,"task-card-header-actions"],[1,"task-device",3,"title"],[1,"task-time-divider"],[1,"task-time"],["type","button","title","Stop this task","aria-label","Stop this task",1,"btn-stop-card"],["type","button","title","Delete this task","aria-label","Delete this task",1,"btn-delete-card",3,"disabled"],[1,"task-goal",3,"title"],[1,"task-card-footer"],[1,"task-id",3,"title"],[1,"task-date"],[1,"material-symbols-outlined","device-icon"],[1,"device-name"],["type","button","title","Stop this task","aria-label","Stop this task",1,"btn-stop-card",3,"click"],["type","button","title","Delete this task","aria-label","Delete this task",1,"btn-delete-card",3,"click","disabled"],[1,"notes-selector-container"],[1,"empty-notes-container"],[1,"note-viewer-cards"],[1,"notes-selector-list"],[1,"note-selector-pill",3,"active"],[1,"note-selector-pill",3,"click"],[1,"material-symbols-outlined","pill-icon"],[1,"empty-notes-icon"],[1,"empty-notes-title"],[1,"empty-notes-subtitle"],[1,"note-title"],[1,"milestones-container"],[1,"other-lines-container"],[1,"milestone-block",3,"ngClass"],[1,"milestone-title-row"],[1,"material-symbols-outlined","milestone-status-icon","checked"],[1,"material-symbols-outlined","milestone-status-icon","progress"],[1,"material-symbols-outlined","milestone-status-icon","unchecked"],[1,"milestone-text"],[3,"bold-segment","code-segment"],[1,"milestone-substeps"],[1,"milestone-checks-container"],[1,"substep-row",3,"ngClass","padding-left"],[1,"substep-row",3,"ngClass"],[1,"material-symbols-outlined","check-icon","checked"],[1,"material-symbols-outlined","check-icon","progress"],[1,"material-symbols-outlined","check-icon","unchecked"],[1,"bullet"],[1,"line-text"],[1,"verification-card",3,"ngClass","margin-left"],[1,"verification-card",3,"ngClass"],[1,"card-header"],[1,"card-tag"],[1,"card-body"],[1,"verification-card","standalone",3,"ngClass","margin-left"],[1,"md-line",3,"ngClass","margin-left"],[1,"verification-card","standalone",3,"ngClass"],[1,"md-line",3,"ngClass"]],template:function(e,o){e&1&&(l(0,"div",0)(1,"header",1)(2,"div",2)(3,"button",3),k("click",function(){return o.agentService.activeTab.set("tasks")}),l(4,"span",4),u(5,"list_alt"),d(),l(6,"span",5),u(7,"Task Queue"),d()(),l(8,"button",3),k("click",function(){return o.agentService.activeTab.set("notes")}),l(9,"span",4),u(10,"description"),d(),l(11,"span",5),u(12,"Notes & Plans"),d()()(),l(13,"div",6),M(14,f3,3,1,"button",7),d()(),l(15,"main",8),M(16,k3,16,4),M(17,c4,4,3,"div",9),d()()),e&2&&(g(3),w("active",o.agentService.activeTab()==="tasks"),g(5),w("active",o.agentService.activeTab()==="notes"),g(6),b(o.agentService.sessions().length>0&&o.agentService.activeTab()==="tasks"?14:-1),g(2),b(o.agentService.activeTab()==="tasks"?16:-1),g(),b(o.agentService.activeTab()==="notes"?17:-1))},dependencies:[No,ki,Qt,Ya,Ei],styles:['[_nghost-%COMP%]{display:flex;flex-direction:column;height:100%;width:100%;overflow:hidden}.chat-interface-container[_ngcontent-%COMP%]{container-type:inline-size;display:flex;flex-direction:column;height:100%;background:#fff;box-sizing:border-box}.chat-interface-container[_ngcontent-%COMP%] .chat-header[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;gap:16px;height:60px;padding:0 20px;border-bottom:1px solid rgba(0,0,0,.08);background:#fff;box-sizing:border-box}.chat-interface-container[_ngcontent-%COMP%] .chat-header[_ngcontent-%COMP%] .tab-selector-bar[_ngcontent-%COMP%]{display:flex;height:36px;box-sizing:border-box;background:#f1f5f9b8;backdrop-filter:blur(16px) saturate(180%);-webkit-backdrop-filter:blur(16px) saturate(180%);border:1px solid rgba(226,232,240,.85);border-radius:18px;padding:3px;gap:3px;align-items:center;flex-shrink:0;box-shadow:0 2px 8px -1px #0f172a0a,inset 0 1px 1px #fffc}.chat-interface-container[_ngcontent-%COMP%] .chat-header[_ngcontent-%COMP%] .tab-selector-bar[_ngcontent-%COMP%] .tab-selector-btn[_ngcontent-%COMP%]{display:inline-flex;align-items:center;justify-content:center;gap:6px;height:30px;padding:0 14px;box-sizing:border-box;border:none;background:transparent;border-radius:15px;font-size:13px;font-weight:600;color:#64748b;cursor:pointer;line-height:1;transition:all .2s cubic-bezier(.4,0,.2,1);white-space:nowrap}.chat-interface-container[_ngcontent-%COMP%] .chat-header[_ngcontent-%COMP%] .tab-selector-bar[_ngcontent-%COMP%] .tab-selector-btn[_ngcontent-%COMP%] .tab-icon[_ngcontent-%COMP%]{font-size:16px;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center}.chat-interface-container[_ngcontent-%COMP%] .chat-header[_ngcontent-%COMP%] .tab-selector-bar[_ngcontent-%COMP%] .tab-selector-btn[_ngcontent-%COMP%]:hover:not(.active){color:#0f172a;background:#ffffffb3}.chat-interface-container[_ngcontent-%COMP%] .chat-header[_ngcontent-%COMP%] .tab-selector-bar[_ngcontent-%COMP%] .tab-selector-btn.active[_ngcontent-%COMP%]{background:#fff;color:#2563eb;font-weight:700;box-shadow:0 2px 6px #0000000f,0 1px 2px #00000005}.chat-interface-container[_ngcontent-%COMP%] .chat-header[_ngcontent-%COMP%] .header-right[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:flex-end;flex:1 1 0;min-width:max-content;flex-shrink:0}.chat-interface-container[_ngcontent-%COMP%] .chat-header[_ngcontent-%COMP%] .header-right[_ngcontent-%COMP%] .btn-resume-task[_ngcontent-%COMP%]{padding:6px 14px;background:linear-gradient(135deg,#2563ebeb,#1a73e8d9);backdrop-filter:blur(14px) saturate(180%);-webkit-backdrop-filter:blur(14px) saturate(180%);border:1px solid rgba(255,255,255,.35);border-radius:16px;font-size:13.5px;font-weight:600;color:#fff;cursor:pointer;display:flex;align-items:center;gap:5px;transition:all .2s cubic-bezier(.4,0,.2,1);margin-right:8px;box-shadow:0 4px 14px #2563eb4d,inset 0 1px 1px #ffffff73}.chat-interface-container[_ngcontent-%COMP%] .chat-header[_ngcontent-%COMP%] .header-right[_ngcontent-%COMP%] .btn-resume-task[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:16px;color:inherit}.chat-interface-container[_ngcontent-%COMP%] .chat-header[_ngcontent-%COMP%] .header-right[_ngcontent-%COMP%] .btn-resume-task[_ngcontent-%COMP%]:hover{background:linear-gradient(135deg,#2563eb,#1a73e8f2);border-color:#fff9;box-shadow:0 6px 18px #2563eb6b,inset 0 1px 1px #fff9;transform:translateY(-.5px)}.chat-interface-container[_ngcontent-%COMP%] .chat-header[_ngcontent-%COMP%] .header-right[_ngcontent-%COMP%] .btn-resume-task[_ngcontent-%COMP%]:active{transform:scale(.97)}.chat-interface-container[_ngcontent-%COMP%] .chat-header[_ngcontent-%COMP%] .header-right[_ngcontent-%COMP%] .btn-clear-history[_ngcontent-%COMP%]{width:32px;height:32px;padding:0;box-sizing:border-box;background:#ffffffb8;backdrop-filter:blur(16px) saturate(180%);-webkit-backdrop-filter:blur(16px) saturate(180%);border:1px solid rgba(203,213,225,.85);border-radius:50%;color:#64748b;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 6px -1px #0f172a0a,inset 0 1px 1px #ffffffe6;transition:all .2s cubic-bezier(.4,0,.2,1)}.chat-interface-container[_ngcontent-%COMP%] .chat-header[_ngcontent-%COMP%] .header-right[_ngcontent-%COMP%] .btn-clear-history[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:16px;color:inherit}.chat-interface-container[_ngcontent-%COMP%] .chat-header[_ngcontent-%COMP%] .header-right[_ngcontent-%COMP%] .btn-clear-history[_ngcontent-%COMP%]:hover:not(:disabled){background:#fef2f2;color:#ef4444;border-color:#fca5a5;box-shadow:0 3px 10px #ef444426;transform:scale(1.05)}.chat-interface-container[_ngcontent-%COMP%] .chat-header[_ngcontent-%COMP%] .header-right[_ngcontent-%COMP%] .btn-clear-history[_ngcontent-%COMP%]:disabled{opacity:.5;cursor:not-allowed}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%]{flex:1;overflow-y:auto;padding:12px 12px 100px;display:flex;flex-direction:column;gap:8px;background:#fff}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%]::-webkit-scrollbar{width:5px}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%]::-webkit-scrollbar-thumb{background:#94a3b84d;border-radius:4px}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .queue-section-header[_ngcontent-%COMP%]{font-size:11.5px;font-weight:700;color:#475569;margin:6px 0 2px;display:flex;align-items:center;gap:6px}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .queue-section-header[_ngcontent-%COMP%] .section-icon[_ngcontent-%COMP%]{font-size:14px;color:inherit}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .empty-section-placeholder[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:center;background:#f1f5f9;border:1px dashed #cbd5e1;border-radius:8px;padding:12px;color:#64748b;font-size:12px;text-align:center}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .history-section[_ngcontent-%COMP%]{margin-top:12px;border-top:1px solid rgba(226,232,240,.8);padding-top:12px}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .history-section[open][_ngcontent-%COMP%] .history-summary[_ngcontent-%COMP%]{margin-bottom:8px}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .history-section[_ngcontent-%COMP%] .history-summary[_ngcontent-%COMP%]{font-size:11.5px;font-weight:700;color:#475569;cursor:pointer;outline:none;-webkit-user-select:none;user-select:none;list-style:none;display:flex;align-items:center;gap:6px;padding:4px 6px;margin-left:-6px;border-radius:6px;transition:background .2s}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .history-section[_ngcontent-%COMP%] .history-summary[_ngcontent-%COMP%] .section-icon[_ngcontent-%COMP%]{font-size:14px;color:inherit}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .history-section[_ngcontent-%COMP%] .history-summary[_ngcontent-%COMP%]::-webkit-details-marker{display:none}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .history-section[_ngcontent-%COMP%] .history-summary[_ngcontent-%COMP%]:hover{background:#f1f5f9;color:#1e293b}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .history-section[_ngcontent-%COMP%] .history-list-wrapper[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:8px}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%]{background:#fff;border:1px solid rgba(226,232,240,.8);border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:6px;cursor:pointer;position:relative;transition:all .2s cubic-bezier(.4,0,.2,1);box-shadow:0 1px 3px #00000003}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%]:hover{transform:translateY(-1px);border-color:#cbd5e1;box-shadow:0 3px 8px #94a3b814}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%]:hover .task-card-header[_ngcontent-%COMP%] .task-card-header-actions[_ngcontent-%COMP%]:has(.btn-delete-card) .task-time[_ngcontent-%COMP%], .chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%]:hover .task-card-header[_ngcontent-%COMP%] .task-card-header-actions[_ngcontent-%COMP%]:has(.btn-delete-card) .task-time-divider[_ngcontent-%COMP%], .chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%]:hover .task-card-header[_ngcontent-%COMP%] .task-card-header-actions[_ngcontent-%COMP%]:has(.btn-stop-card) .task-time[_ngcontent-%COMP%], .chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%]:hover .task-card-header[_ngcontent-%COMP%] .task-card-header-actions[_ngcontent-%COMP%]:has(.btn-stop-card) .task-time-divider[_ngcontent-%COMP%]{opacity:0}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%]:hover .task-card-header[_ngcontent-%COMP%] .task-card-header-actions[_ngcontent-%COMP%] .btn-delete-card[_ngcontent-%COMP%], .chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%]:hover .task-card-header[_ngcontent-%COMP%] .task-card-header-actions[_ngcontent-%COMP%] .btn-stop-card[_ngcontent-%COMP%]{opacity:1;pointer-events:auto}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card.selected[_ngcontent-%COMP%]{border-color:#3b82f6;box-shadow:0 0 0 1px #3b82f6,0 4px 12px #3b82f614;background:#fff}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card.running[_ngcontent-%COMP%]{border-color:#10b981;box-shadow:0 0 0 1px #10b981,0 4px 12px #10b98114}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card.running.selected[_ngcontent-%COMP%]{border-color:#3b82f6;box-shadow:0 0 0 1px #3b82f6,0 4px 12px #3b82f614}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%]{display:flex;justify-content:space-between;align-items:center;gap:8px;min-width:0}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-badge[_ngcontent-%COMP%]{flex-shrink:0;display:inline-flex;align-items:center;font-size:10.5px;font-weight:700;padding:2px 5px;border-radius:4px;letter-spacing:.05em;border:1px solid #e2e8f0;background:#fafafa;color:#64748b}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-badge[_ngcontent-%COMP%]:before{content:"";width:4px;height:4px;border-radius:50%;display:inline-block;margin-right:4px}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-badge.running[_ngcontent-%COMP%]{background:#fafafa;color:#1f1f1f;border-color:#1f1f1f}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-badge.running[_ngcontent-%COMP%]:before{background-color:#1f1f1f;animation:_ngcontent-%COMP%_pulse-black-dot-small 1.5s infinite ease-in-out}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-badge.paused[_ngcontent-%COMP%]{background:#fff7ed;color:#c2410c;border-color:#fdba74}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-badge.paused[_ngcontent-%COMP%]:before{background-color:#f97316}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-badge.completed[_ngcontent-%COMP%]{background:#fafafa;color:#1f1f1f;border-color:#e2e8f0}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-badge.completed[_ngcontent-%COMP%]:before{background-color:#1f1f1f}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-badge.pending[_ngcontent-%COMP%]{background:#fafafa;color:#64748b;border-color:#e2e8f0}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-badge.pending[_ngcontent-%COMP%]:before{background-color:#94a3b8}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-badge.failed[_ngcontent-%COMP%]{background:#fafafa;color:#ef4444;border-color:#fee2e2}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-badge.failed[_ngcontent-%COMP%]:before{background-color:#ef4444}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-badge.cancelled[_ngcontent-%COMP%]{background:#fafafa;color:#d97706;border-color:#fef3c7}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-badge.cancelled[_ngcontent-%COMP%]:before{background-color:#f59e0b}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-badge[_ngcontent-%COMP%] .status-dot[_ngcontent-%COMP%]{display:none}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-card-header-actions[_ngcontent-%COMP%]{display:flex;align-items:center;gap:4px;position:relative;min-width:0;flex-shrink:1;justify-content:flex-end}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-card-header-actions[_ngcontent-%COMP%] .task-device[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:2.5px;min-width:0;flex-shrink:1;font-size:11px;color:#94a3b8;line-height:1;cursor:default;transition:color .15s ease,opacity .15s ease}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-card-header-actions[_ngcontent-%COMP%] .task-device[_ngcontent-%COMP%] .device-icon[_ngcontent-%COMP%]{font-size:12.5px;width:12.5px;height:12.5px;line-height:12.5px;color:#94a3b8;opacity:.75;flex-shrink:0}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-card-header-actions[_ngcontent-%COMP%] .task-device[_ngcontent-%COMP%] .device-name[_ngcontent-%COMP%]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;font-family:inherit;letter-spacing:-.15px}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-card-header-actions[_ngcontent-%COMP%] .task-device[_ngcontent-%COMP%]:hover{color:#64748b}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-card-header-actions[_ngcontent-%COMP%] .task-device[_ngcontent-%COMP%]:hover .device-icon[_ngcontent-%COMP%]{color:#64748b;opacity:1}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-card-header-actions[_ngcontent-%COMP%] .task-time-divider[_ngcontent-%COMP%]{font-size:10px;color:#cbd5e1;line-height:1;-webkit-user-select:none;user-select:none;flex-shrink:0;transition:opacity .15s ease}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-card-header-actions[_ngcontent-%COMP%] .task-time[_ngcontent-%COMP%]{font-size:11.5px;color:#94a3b8;white-space:nowrap;flex-shrink:0;line-height:1;transition:opacity .15s ease}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-card-header-actions[_ngcontent-%COMP%] .btn-delete-card[_ngcontent-%COMP%]{position:absolute;right:0;top:50%;transform:translateY(-50%);width:20px;height:20px;padding:0;border:none;background:transparent;border-radius:4px;color:#94a3b8;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:all .15s ease}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-card-header-actions[_ngcontent-%COMP%] .btn-delete-card[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:14px;line-height:1}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-card-header-actions[_ngcontent-%COMP%] .btn-delete-card[_ngcontent-%COMP%]:hover:not(:disabled){color:#ef4444;background:#fee2e2}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-card-header-actions[_ngcontent-%COMP%] .btn-delete-card[_ngcontent-%COMP%]:disabled{opacity:.3;cursor:not-allowed}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-card-header-actions[_ngcontent-%COMP%] .btn-stop-card[_ngcontent-%COMP%]{position:absolute;right:0;top:50%;transform:translateY(-50%);width:20px;height:20px;padding:0;border:none;background:transparent;border-radius:4px;color:#ef4444;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:all .15s ease}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-card-header-actions[_ngcontent-%COMP%] .btn-stop-card[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:15px;line-height:1}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-card-header-actions[_ngcontent-%COMP%] .btn-stop-card[_ngcontent-%COMP%]:hover:not(:disabled){color:#dc2626;background:#fee2e2;transform:translateY(-50%) scale(1.1)}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-header[_ngcontent-%COMP%] .task-card-header-actions[_ngcontent-%COMP%] .btn-stop-card[_ngcontent-%COMP%]:disabled{opacity:.3;cursor:not-allowed}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-goal[_ngcontent-%COMP%]{font-size:12.5px;font-weight:500;color:#1e293b;line-height:1.4;word-break:break-word;display:-webkit-box;-webkit-line-clamp:2;line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-footer[_ngcontent-%COMP%]{display:flex;justify-content:space-between;align-items:center;gap:12px;border-top:1px solid rgba(241,245,249,.8);padding-top:6px;font-size:11.5px;color:#94a3b8}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-footer[_ngcontent-%COMP%] .task-id[_ngcontent-%COMP%]{font-family:Fira Code,Roboto Mono,Consolas,monospace;font-size:11px;color:#94a3b8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;flex:1;transition:color .15s ease}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-footer[_ngcontent-%COMP%] .task-id[_ngcontent-%COMP%]:hover{color:#475569}.chat-interface-container[_ngcontent-%COMP%] .chat-messages[_ngcontent-%COMP%] .task-card[_ngcontent-%COMP%] .task-card-footer[_ngcontent-%COMP%] .task-date[_ngcontent-%COMP%]{flex-shrink:0;font-size:11px;color:#94a3b8}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:16px;height:100%}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .notes-selector-container[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:8px}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .notes-selector-container[_ngcontent-%COMP%] .selector-title[_ngcontent-%COMP%]{font-size:11px;font-weight:700;color:#94a3b8;letter-spacing:.05em}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .notes-selector-container[_ngcontent-%COMP%] .notes-selector-list[_ngcontent-%COMP%]{display:flex;flex-wrap:wrap;gap:8px}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .notes-selector-container[_ngcontent-%COMP%] .notes-selector-list[_ngcontent-%COMP%] .note-selector-pill[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border:1px solid #e2e8f0;background:#fff;border-radius:20px;font-size:13px;font-weight:500;color:#475569;cursor:pointer;transition:all .15s ease}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .notes-selector-container[_ngcontent-%COMP%] .notes-selector-list[_ngcontent-%COMP%] .note-selector-pill[_ngcontent-%COMP%] .pill-icon[_ngcontent-%COMP%]{font-size:15px}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .notes-selector-container[_ngcontent-%COMP%] .notes-selector-list[_ngcontent-%COMP%] .note-selector-pill[_ngcontent-%COMP%]:hover{background:#f8fafc;border-color:#cbd5e1;color:#1e293b}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .notes-selector-container[_ngcontent-%COMP%] .notes-selector-list[_ngcontent-%COMP%] .note-selector-pill.active[_ngcontent-%COMP%]{background:#eff6ff;border-color:#bfdbfe;color:#1d4ed8;font-weight:600}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .empty-notes-container[_ngcontent-%COMP%]{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:48px 24px;margin:auto 0;text-align:center;-webkit-user-select:none;user-select:none;animation:_ngcontent-%COMP%_fadeIn .25s cubic-bezier(.16,1,.3,1)}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .empty-notes-container[_ngcontent-%COMP%] .empty-notes-icon[_ngcontent-%COMP%]{width:52px;height:52px;border-radius:50%;background:#f1f5f9;border:1px solid #e2e8f0;display:flex;align-items:center;justify-content:center;margin-bottom:14px;box-shadow:0 1px 3px #00000005}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .empty-notes-container[_ngcontent-%COMP%] .empty-notes-icon[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:26px;color:#94a3b8}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .empty-notes-container[_ngcontent-%COMP%] .empty-notes-title[_ngcontent-%COMP%]{font-family:Google Sans,Google Sans Text,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-size:14px;font-weight:600;color:#1e293b;margin:0 0 6px}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .empty-notes-container[_ngcontent-%COMP%] .empty-notes-subtitle[_ngcontent-%COMP%]{font-family:Google Sans,Google Sans Text,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-size:12.5px;color:#64748b;line-height:1.5;max-width:240px;margin:0}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%]{display:flex;flex-direction:column;flex:1;padding:16px 8px;overflow-y:auto}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .note-title[_ngcontent-%COMP%]{font-size:14px;font-weight:700;color:#1f1f1f;margin-top:0;margin-bottom:20px;font-family:Google Sans,Google Sans Text,Inter,sans-serif}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .code-segment[_ngcontent-%COMP%]{font-family:Fira Code,Roboto Mono,Consolas,monospace;background-color:#f1f3f4;color:#1967d2;padding:2px 6px;border-radius:4px;font-size:14px;margin:0 2px;word-break:break-all;font-weight:400}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .checked[_ngcontent-%COMP%] .code-segment[_ngcontent-%COMP%]{color:#a1a1aa;background-color:#f8f9fa}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestones-container[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:6px;margin-bottom:24px}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%]{display:flex;flex-direction:column}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block.checked[_ngcontent-%COMP%] .milestone-title-row[_ngcontent-%COMP%] .milestone-status-icon.checked[_ngcontent-%COMP%]{opacity:.7}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block.checked[_ngcontent-%COMP%] .milestone-title-row[_ngcontent-%COMP%] .milestone-text[_ngcontent-%COMP%]{color:#a1a1aa}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-title-row[_ngcontent-%COMP%]{display:flex;align-items:flex-start;gap:10px}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-title-row[_ngcontent-%COMP%] .milestone-status-icon[_ngcontent-%COMP%]{font-size:20px;-webkit-user-select:none;user-select:none;margin-top:3px;font-variation-settings:"FILL" 1,"wght" 400,"GRAD" 0,"opsz" 24}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-title-row[_ngcontent-%COMP%] .milestone-status-icon.checked[_ngcontent-%COMP%], .chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-title-row[_ngcontent-%COMP%] .milestone-status-icon.progress[_ngcontent-%COMP%]{color:#1a73e8}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-title-row[_ngcontent-%COMP%] .milestone-status-icon.unchecked[_ngcontent-%COMP%]{color:#cbd5e1}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-title-row[_ngcontent-%COMP%] .milestone-text[_ngcontent-%COMP%]{font-family:Google Sans,Google Sans Text,Inter,sans-serif;color:#1f1f1f;font-size:14px;line-height:1.8;font-weight:400;word-break:break-word;flex:1}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-title-row[_ngcontent-%COMP%] .milestone-text[_ngcontent-%COMP%] span[_ngcontent-%COMP%]{color:inherit}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-title-row[_ngcontent-%COMP%] .milestone-text[_ngcontent-%COMP%] .bold-segment[_ngcontent-%COMP%]{font-weight:inherit;color:inherit}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%]{margin-top:8px;display:flex;flex-direction:column;gap:10px;padding-left:28px}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row[_ngcontent-%COMP%]{display:flex;align-items:flex-start;gap:10px;font-family:Google Sans,Google Sans Text,Inter,sans-serif;color:#1f1f1f;font-size:14px;line-height:1.8}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row[_ngcontent-%COMP%] span[_ngcontent-%COMP%]{color:inherit}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row.checked[_ngcontent-%COMP%]{color:#a1a1aa}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row.checked[_ngcontent-%COMP%] .check-icon.checked[_ngcontent-%COMP%]{opacity:.7}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row[_ngcontent-%COMP%] .check-icon[_ngcontent-%COMP%]{font-size:20px;-webkit-user-select:none;user-select:none;margin-top:3px;font-variation-settings:"FILL" 1,"wght" 400,"GRAD" 0,"opsz" 24}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row[_ngcontent-%COMP%] .check-icon.checked[_ngcontent-%COMP%], .chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row[_ngcontent-%COMP%] .check-icon.progress[_ngcontent-%COMP%]{color:#1a73e8}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row[_ngcontent-%COMP%] .check-icon.unchecked[_ngcontent-%COMP%]{color:#cbd5e1}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row[_ngcontent-%COMP%] .bullet[_ngcontent-%COMP%]{color:#9ca3af;font-weight:700;-webkit-user-select:none;user-select:none;padding-left:2px}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row[_ngcontent-%COMP%] .line-text[_ngcontent-%COMP%]{word-break:break-word;color:inherit}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row[_ngcontent-%COMP%] .line-text[_ngcontent-%COMP%] span[_ngcontent-%COMP%]{color:inherit}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-substeps[_ngcontent-%COMP%] .substep-row[_ngcontent-%COMP%] .line-text[_ngcontent-%COMP%] .bold-segment[_ngcontent-%COMP%]{font-weight:inherit;color:inherit}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .milestone-block[_ngcontent-%COMP%] .milestone-checks-container[_ngcontent-%COMP%]{margin-top:8px;padding-left:28px;display:flex;flex-direction:column;gap:8px}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .verification-card[_ngcontent-%COMP%]{background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;padding:8px 12px;display:flex;flex-direction:column;gap:4px}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .verification-card.standalone[_ngcontent-%COMP%]{margin-top:8px;margin-bottom:8px}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .verification-card[_ngcontent-%COMP%] .card-header[_ngcontent-%COMP%]{display:flex;align-items:center}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .verification-card[_ngcontent-%COMP%] .card-header[_ngcontent-%COMP%] .card-tag[_ngcontent-%COMP%]{font-family:Google Sans Text,Inter,-apple-system,sans-serif;font-size:10.5px;font-weight:600;line-height:1;letter-spacing:.6px;text-transform:uppercase;color:#64748b}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .verification-card[_ngcontent-%COMP%] .card-header[_ngcontent-%COMP%] .card-tag.assert[_ngcontent-%COMP%]{color:#0284c7}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .verification-card[_ngcontent-%COMP%] .card-body[_ngcontent-%COMP%]{font-family:inherit;font-size:13.5px;line-height:1.6;color:#334155;word-break:break-word}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .verification-card[_ngcontent-%COMP%] .card-body[_ngcontent-%COMP%] .bold-segment[_ngcontent-%COMP%]{font-weight:600;color:#1e293b}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .verification-card[_ngcontent-%COMP%] .card-body[_ngcontent-%COMP%] .code-segment[_ngcontent-%COMP%]{font-family:Fira Code,Roboto Mono,Consolas,monospace;font-size:12px;background:#f1f5f9;color:#0f172a;padding:1.5px 6px;border-radius:4px;border:1px solid #e2e8f0}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .verification-card.finding[_ngcontent-%COMP%]{background:#fffbeb;border-color:#fef3c7}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .verification-card.finding[_ngcontent-%COMP%] .card-header[_ngcontent-%COMP%] .card-tag[_ngcontent-%COMP%]{color:#b45309}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .verification-card.finding[_ngcontent-%COMP%] .card-body[_ngcontent-%COMP%]{color:#92400e}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .other-lines-container[_ngcontent-%COMP%]{display:flex;flex-direction:column;padding:8px 4px}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .other-lines-container[_ngcontent-%COMP%] .md-line[_ngcontent-%COMP%]{display:flex;align-items:flex-start;gap:10px;margin-bottom:14px;min-height:22px;font-family:Google Sans,Google Sans Text,Inter,sans-serif;color:#1f1f1f;font-size:14px;line-height:1.6}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .other-lines-container[_ngcontent-%COMP%] .md-line.empty[_ngcontent-%COMP%]{height:12px}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .other-lines-container[_ngcontent-%COMP%] .md-line.h1[_ngcontent-%COMP%]{font-size:14px;font-weight:700;margin-top:24px;margin-bottom:12px;border-bottom:1px solid #e5e7eb;padding-bottom:6px}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .other-lines-container[_ngcontent-%COMP%] .md-line.h2[_ngcontent-%COMP%]{font-size:14px;font-weight:700;margin-top:20px;margin-bottom:10px}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .other-lines-container[_ngcontent-%COMP%] .md-line.h3[_ngcontent-%COMP%]{font-size:14px;font-weight:700;margin-top:14px;margin-bottom:8px}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .other-lines-container[_ngcontent-%COMP%] .md-line.checked[_ngcontent-%COMP%]{color:#a1a1aa}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .other-lines-container[_ngcontent-%COMP%] .md-line.checked[_ngcontent-%COMP%] .check-icon[_ngcontent-%COMP%]{color:#1a73e8;opacity:.7}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .other-lines-container[_ngcontent-%COMP%] .md-line.progress[_ngcontent-%COMP%] .check-icon[_ngcontent-%COMP%]{color:#1a73e8}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .other-lines-container[_ngcontent-%COMP%] .md-line.unchecked[_ngcontent-%COMP%] .check-icon[_ngcontent-%COMP%]{color:#cbd5e1}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .other-lines-container[_ngcontent-%COMP%] .md-line.list-item[_ngcontent-%COMP%] .bullet[_ngcontent-%COMP%]{color:#9ca3af;font-weight:700;-webkit-user-select:none;user-select:none}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .other-lines-container[_ngcontent-%COMP%] .md-line[_ngcontent-%COMP%] span[_ngcontent-%COMP%]{color:inherit}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .other-lines-container[_ngcontent-%COMP%] .md-line[_ngcontent-%COMP%] .check-icon[_ngcontent-%COMP%]{font-size:20px;-webkit-user-select:none;user-select:none;margin-top:1px;font-variation-settings:"FILL" 1,"wght" 400,"GRAD" 0,"opsz" 24}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .other-lines-container[_ngcontent-%COMP%] .md-line[_ngcontent-%COMP%] .line-text[_ngcontent-%COMP%]{word-break:break-word;color:inherit}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .other-lines-container[_ngcontent-%COMP%] .md-line[_ngcontent-%COMP%] .line-text[_ngcontent-%COMP%] span[_ngcontent-%COMP%]{color:inherit}.chat-interface-container[_ngcontent-%COMP%] .notes-tab-content[_ngcontent-%COMP%] .note-viewer-cards[_ngcontent-%COMP%] .other-lines-container[_ngcontent-%COMP%] .md-line[_ngcontent-%COMP%] .line-text[_ngcontent-%COMP%] .bold-segment[_ngcontent-%COMP%]{font-weight:inherit;color:inherit}@keyframes _ngcontent-%COMP%_pulse{0%{transform:scale(.85);opacity:.6}50%{transform:scale(1.15);opacity:1}to{transform:scale(.85);opacity:.6}}@keyframes _ngcontent-%COMP%_bannerSlideIn{0%{transform:translateY(5px);opacity:0}to{transform:translateY(0);opacity:1}}@keyframes _ngcontent-%COMP%_fadeIn{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}@keyframes _ngcontent-%COMP%_pulse-black-dot-small{0%{box-shadow:0 0 #0006}70%{box-shadow:0 0 0 4px #0000}to{box-shadow:0 0 #0000}}@keyframes _ngcontent-%COMP%_pulse-blue-dot{0%{box-shadow:0 0 #2563eb66}70%{box-shadow:0 0 0 5px #2563eb00}to{box-shadow:0 0 #2563eb00}}@container (max-width: 380px){.chat-header[_ngcontent-%COMP%]{padding:0 8px;gap:6px;height:36px}.chat-header[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%]{display:none}.chat-header[_ngcontent-%COMP%] .tab-selector-bar[_ngcontent-%COMP%]{padding:2px;gap:1px}.chat-header[_ngcontent-%COMP%] .tab-selector-bar[_ngcontent-%COMP%] .tab-selector-btn[_ngcontent-%COMP%]{padding:5px 8px;font-size:12px;gap:4px}.chat-header[_ngcontent-%COMP%] .tab-selector-bar[_ngcontent-%COMP%] .tab-selector-btn[_ngcontent-%COMP%] .tab-icon[_ngcontent-%COMP%]{font-size:14px}.chat-header[_ngcontent-%COMP%] .header-right[_ngcontent-%COMP%] .btn-clear-history[_ngcontent-%COMP%]{height:24px;width:30px;padding:0;font-size:12px;gap:0;border-radius:16px;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center}.chat-header[_ngcontent-%COMP%] .header-right[_ngcontent-%COMP%] .btn-clear-history[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:14px;line-height:1}.chat-header[_ngcontent-%COMP%] .header-right[_ngcontent-%COMP%] .btn-clear-history[_ngcontent-%COMP%] .btn-text[_ngcontent-%COMP%]{display:none}}@container (max-width: 340px){.chat-header[_ngcontent-%COMP%] .tab-selector-bar[_ngcontent-%COMP%] .tab-selector-btn[_ngcontent-%COMP%]{padding:5px 6px}.chat-header[_ngcontent-%COMP%] .tab-selector-bar[_ngcontent-%COMP%] .tab-selector-btn[_ngcontent-%COMP%] .tab-text[_ngcontent-%COMP%]{display:none}.chat-header[_ngcontent-%COMP%] .header-right[_ngcontent-%COMP%] .btn-clear-history[_ngcontent-%COMP%]{width:26px}}']})};var l4=["videoRef"],d4=["stepImgRef"],u4=["stepOverlayRef"];function g4(n,t){if(n&1){let e=L();j(0,"div",5),ue("click",function(){v(e);let i=m();return y(i.isTheaterMode.set(!1))}),H()}}function p4(n,t){if(n&1){let e=L();j(0,"div",12)(1,"button",18),ue("click",function(){v(e);let i=m(2);return y(i.agentService.setPlayerMode("video"))}),j(2,"span",19),u(3,"videocam"),H(),j(4,"span"),u(5,"Video"),H()(),j(6,"button",20),ue("click",function(){v(e);let i=m(2);return y(i.agentService.setPlayerMode("steps"))}),j(7,"span",19),u(8,"slideshow"),H(),j(9,"span"),u(10,"Steps"),H()()()}if(n&2){let e=m(2);g(),w("active",e.agentService.playerMode()==="video"),g(5),w("active",e.agentService.playerMode()==="steps")}}function f4(n,t){if(n&1){let e=L();j(0,"button",21),ue("click",function(){v(e);let i=m(2);return y(i.toggleTheater())}),j(1,"span",14),u(2),H()()}if(n&2){let e=m(2);je("title",e.isTheaterMode()?"Exit Theater":"Theater Mode"),g(2),O(e.isTheaterMode()?"close_fullscreen":"open_in_full")}}function m4(n,t){if(n&1){let e=L();j(0,"div",22)(1,"img",24),ue("error",function(){v(e);let i=m(4);return y(i.onLiveStreamError())}),H(),j(2,"div",25)(3,"div",26),yi(4,"span",27),j(5,"span"),u(6,"LIVE"),H()(),j(7,"button",28),ue("click",function(){v(e);let i=m(4);return y(i.retryLiveStream())}),j(8,"span",14),u(9,"refresh"),H()()()()}if(n&2){let e=m(4);g(),je("src",e.liveStreamUrl(),kt)}}function _4(n,t){if(n&1){let e=L();j(0,"div",23)(1,"div",29)(2,"span",30),u(3,"phonelink_off"),H()(),j(4,"h4",31),u(5,"Device Stream Connecting"),H(),j(6,"p",32),u(7,"Waiting for ADB device screen stream..."),H(),j(8,"button",33),ue("click",function(){v(e);let i=m(4);return y(i.retryLiveStream())}),j(9,"span",14),u(10,"refresh"),H(),u(11," Reconnect "),H()()}}function h4(n,t){if(n&1&&(M(0,m4,10,1,"div",22),M(1,_4,12,0,"div",23)),n&2){let e=m(3);b(e.liveStreamError()?-1:0),g(),b(e.liveStreamError()?1:-1)}}function C4(n,t){if(n&1&&(j(0,"div",34)(1,"span",36),u(2,"progress_activity"),H(),j(3,"span"),u(4),H()()),n&2){let e=m(4);g(4),O(e.agentService.recordingPlaybackMessage()||"Preparing screen recording...")}}function M4(n,t){n&1&&(j(0,"div",37)(1,"span",60),u(2,"info"),H(),j(3,"span"),u(4,"Screen recording unavailable. Replaying step-by-step screenshots."),H()())}function b4(n,t){n&1&&(j(0,"div",39)(1,"span",36),u(2,"progress_activity"),H(),j(3,"span"),u(4,"Loading screenshot..."),H()())}function P4(n,t){n&1&&(j(0,"div",40)(1,"span",61),u(2,"broken_image"),H(),j(3,"span"),u(4,"Screenshot preview unavailable for this step"),H()())}function O4(n,t){if(n&1){let e=L();j(0,"div",41)(1,"img",62,0),ue("load",function(){v(e);let i=m(5);return y(i.onStepImageLoad())})("error",function(){v(e);let i=m(5);return y(i.onStepImageError())})("click",function(){v(e);let i=m(5);return y(i.openImageInNewTab())}),H(),yi(3,"div",63,1),H()}if(n&2){let e,o=m(5);g(),je("src",o.currentStepImageUrl(),kt)("alt",(e=o.currentStepFrame())==null?null:e.title),g(2),Se("display",o.viewMode()==="post"?"none":"block")}}function v4(n,t){if(n&1&&(j(0,"span",68),u(1),H()),n&2){let e=m(),o=m(5);g(),V(" ",o.getStepActionIcon(e.action)," ")}}function y4(n,t){if(n&1){let e=L();j(0,"div",70)(1,"button",72),ue("click",function(i){v(e);let r=m(6);return y(r.setStepViewMode("pre",i))}),u(2," Pre "),H(),j(3,"button",73),ue("click",function(i){v(e);let r=m(6);return y(r.setStepViewMode("post",i))}),u(4," Post "),H()()}if(n&2){let e=m(6);g(),w("active",e.viewMode()==="pre"),g(2),w("active",e.viewMode()==="post")}}function x4(n,t){if(n&1){let e=L();j(0,"div",64),ue("click",function(i){return i.stopPropagation()}),j(1,"div",65)(2,"span",66),u(3),H(),j(4,"span",67),M(5,v4,2,1,"span",68),u(6),H()(),j(7,"div",69),M(8,y4,5,4,"div",70),j(9,"button",71),ue("click",function(i){v(e);let r=m(5);return y(r.toggleCardPin(i))}),j(10,"span",14),u(11),H()()()()}if(n&2){let e=t,o=m(5);w("visible",o.isCardVisible()),g(3),V("Step ",e.stepNumber),g(),je("title",e.actionText||e.title),g(),b(e.action?5:-1),g(),V(" ",e.actionText||e.title," "),g(2),b(e.postImageUrl?8:-1),g(),w("pinned",o.isCardPinned()),je("title",o.isCardPinned()?"Unpin details card (auto-hide on mouse leave)":"Pin details card (keep always visible)"),g(2),O(o.isCardPinned()?"push_pin":"keep")}}function w4(n,t){if(n&1){let e=L();j(0,"span",75),ue("click",function(i){let r=v(e).$index;return m(6).seekStepFrame(r),y(i.stopPropagation())}),H()}if(n&2){let e=t.$implicit,o=t.$index,i=m(6);Se("left",o/(i.totalStepFrames()-1)*100,"%"),w("active",o===i.activeStepIndex())("passed",o1?11:-1),g(3),je("title",o.isStepPlaying()?"Pause":"Play Slideshow"),g(2),O(o.isStepPlaying()?"pause":"play_arrow"),g(),je("disabled",o.activeStepIndex()<=0),g(3),je("disabled",o.activeStepIndex()>=o.totalStepFrames()-1),g(7),We(" Step ",o.activeStepIndex()+1," / ",o.totalStepFrames()," "),g(2),je("title","Speed: "+o.playbackRate()+"x (Click to cycle)"),g(2),V("",o.playbackRate(),"x"),g(),w("active",o.isLooping()),je("title",o.isLooping()?"Loop enabled":"Enable loop")}}function E4(n,t){if(n&1){let e=L();j(0,"div",76)(1,"video",77,2),ue("loadedmetadata",function(){v(e);let i=m(5);return y(i.onLoadedMetadata())})("timeupdate",function(){v(e);let i=m(5);return y(i.onTimeUpdate())})("play",function(){v(e);let i=m(5);return y(i.onPlay())})("pause",function(){v(e);let i=m(5);return y(i.onPause())})("ended",function(){v(e);let i=m(5);return y(i.onEnded())})("error",function(){v(e);let i=m(5);return y(i.onError())})("click",function(){v(e);let i=m(5);return y(i.togglePlay())}),H()()}if(n&2){let e=m(5);g(),je("src",e.currentVideoUrl(),kt)}}function D4(n,t){if(n&1){let e=L();j(0,"div",43)(1,"div",44)(2,"input",45),ue("input",function(i){v(e);let r=m(5);return y(r.onScrub(i))}),H(),yi(3,"div",46),H(),j(4,"div",48)(5,"div",49)(6,"button",50),ue("click",function(){v(e);let i=m(5);return y(i.togglePlay())}),j(7,"span",14),u(8),H()(),j(9,"button",53),ue("click",function(){v(e);let i=m(5);return y(i.restart())}),j(10,"span",14),u(11,"replay"),H()(),j(12,"span",54),u(13),H()(),j(14,"div",55)(15,"button",56),ue("click",function(){v(e);let i=m(5);return y(i.cycleSpeed())}),j(16,"span",57),u(17),H()(),j(18,"button",58),ue("click",function(){v(e);let i=m(5);return y(i.toggleLoop())}),j(19,"span",14),u(20,"repeat"),H()(),j(21,"button",58),ue("click",function(){v(e);let i=m(5);return y(i.toggleMute())}),j(22,"span",14),u(23),H()(),j(24,"button",78),ue("click",function(){v(e);let i=m(5);return y(i.openInNewTab())}),j(25,"span",14),u(26,"open_in_new"),H()()()()()}if(n&2){let e=m(5);g(2),je("min",0)("max",e.duration()||100)("step",.1)("value",e.currentTime()),g(),Se("width",e.progressPercent(),"%"),g(3),je("title",e.isPlaying()?"Pause":"Play"),g(2),O(e.isPlaying()?"pause":"play_arrow"),g(5),We(" ",e.formatTime(e.currentTime())," / ",e.formatTime(e.duration())," "),g(2),je("title","Speed: "+e.playbackRate()+"x (Click to cycle)"),g(2),V("",e.playbackRate(),"x"),g(),w("active",e.isLooping()),je("title",e.isLooping()?"Loop enabled":"Enable loop"),g(3),je("title",e.isMuted()?"Unmute":"Mute"),g(2),O(e.isMuted()?"volume_off":"volume_up")}}function I4(n,t){if(n&1&&(M(0,E4,3,1,"div",76),M(1,D4,27,17,"div",43)),n&2){let e=m(4);b(e.videoLoadError()?-1:0),g(),b(e.videoLoadError()?-1:1)}}function T4(n,t){if(n&1){let e=L();j(0,"button",33),ue("click",function(){v(e);let i=m(5);return i.videoLoadError.set(!1),y(i.agentService.retryVideoRecording())}),j(1,"span",14),u(2,"refresh"),H(),u(3," Retry "),H()}}function A4(n,t){if(n&1&&(j(0,"div",23)(1,"div",29)(2,"span",30),u(3,"videocam_off"),H()(),j(4,"h4",31),u(5),H(),j(6,"p",32),u(7),H(),M(8,T4,4,0,"button",79),H()),n&2){let e=m(4);g(5),V(" ",e.agentService.recordingPlaybackStatus()==="failed"||e.videoLoadError()?"Recording Playback Failed":"No Video Recording Available"," "),g(2),V(" ",e.videoLoadError()?"The finalized recording could not be played.":e.agentService.recordingPlaybackMessage()||"No screen recording was found for this task."," "),g(),b(e.agentService.recordingPlaybackStatus()==="failed"||e.videoLoadError()?8:-1)}}function N4(n,t){if(n&1&&(M(0,C4,5,1,"div",34),M(1,k4,38,23,"div",35),M(2,I4,2,2),M(3,A4,9,3,"div",23)),n&2){let e,o=m(3);b(o.agentService.playerMode()==="video"&&(o.agentService.recordingPlaybackStatus()==="processing"||o.agentService.isVideoLoading())?0:-1),g(),b(o.agentService.playerMode()==="steps"&&o.totalStepFrames()>0?1:-1),g(),b((e=o.agentService.playerMode()==="video"&&o.agentService.recordingPlaybackStatus()==="ready"&&!o.agentService.isVideoLoading()&&o.agentService.activeVideoUrl())?2:-1,e),g(),b(!o.agentService.isVideoLoading()&&(o.agentService.playerMode()==="video"||o.totalStepFrames()===0)&&(o.agentService.recordingPlaybackStatus()==="failed"||o.agentService.recordingPlaybackStatus()==="unavailable"||o.videoLoadError())&&o.totalStepFrames()===0?3:-1)}}function R4(n,t){if(n&1&&(j(0,"div",17),M(1,h4,2,2),M(2,N4,4,4),H()),n&2){let e=m(2);g(),b(e.agentService.recordingPlaybackStatus()==="live"?1:-1),g(),b(e.agentService.recordingPlaybackStatus()!=="live"?2:-1)}}function F4(n,t){if(n&1){let e=L();j(0,"div",6)(1,"header",7),ue("mousedown",function(i){v(e);let r=m();return y(r.startDrag(i))}),j(2,"div",8)(3,"span",9),u(4),H(),j(5,"span",10),u(6),H()(),j(7,"div",11),ue("mousedown",function(i){return i.stopPropagation()}),M(8,p4,11,4,"div",12),j(9,"button",13),ue("click",function(){v(e);let i=m();return y(i.toggleMinimize())}),j(10,"span",14),u(11),H()(),M(12,f4,3,2,"button",15),j(13,"button",16),ue("click",function(){v(e);let i=m();return y(i.agentService.closeVideoPlayer())}),j(14,"span",14),u(15,"close"),H()()()(),M(16,R4,3,2,"div",17),H()}if(n&2){let e=m();Se("left",e.isTheaterMode()?null:e.posX(),"px")("top",e.isTheaterMode()?null:e.posY(),"px"),w("minimized",e.agentService.isVideoMinimized())("theater",e.isTheaterMode()),g(4),V(" ",e.agentService.recordingPlaybackStatus()==="live"?"sensors":e.agentService.playerMode()==="steps"?"slideshow":e.agentService.recordingPlaybackStatus()==="processing"?"progress_activity":"smart_display"," "),g(),je("title",e.agentService.activeVideoTitle()),g(),V(" ",e.agentService.recordingPlaybackStatus()==="live"?"Live Device Screen":e.agentService.playerMode()==="steps"?"Step Replay ("+(e.totalStepFrames()>0?e.activeStepIndex()+1+"/"+e.totalStepFrames():"0")+")":e.agentService.recordingPlaybackStatus()==="processing"?"Preparing Recording":e.agentService.activeVideoTitle()||"Screen Recording"," "),g(2),b(e.agentService.recordingPlaybackStatus()==="ready"&&e.agentService.hasCurrentSessionStepFrames()&&!e.agentService.isVideoMinimized()?8:-1),g(),je("title",e.agentService.isVideoMinimized()?"Restore":"Minimize"),g(2),O(e.agentService.isVideoMinimized()?"expand_less":"remove"),g(),b(e.agentService.isVideoMinimized()?-1:12),g(4),b(e.agentService.isVideoMinimized()?-1:16)}}var Kd=class n{agentService=D(It);zone=D(ge);videoRef;stepImgRef;stepOverlayRef;isPlaying=x(!1);playbackRate=x(1);isLooping=x(!1);currentTime=x(0);duration=x(0);isMuted=x(!1);isTheaterMode=x(!1);videoLoadError=x(!1);liveStreamUrl=x("/api/stream/device-live");liveStreamError=x(!1);activeSegmentIndex=x(0);currentVideoUrl=I(()=>this.agentService.activeVideoSegments()[this.activeSegmentIndex()]?.url||this.agentService.activeVideoUrl());pendingLocalTime=null;pendingAutoplay=!1;pendingAbsoluteSeek=null;activeStepIndex=x(0);isStepPlaying=x(!1);viewMode=x("pre");isCardHovered=x(!1);isCardPinned=x(!1);isCardVisible=I(()=>this.isCardHovered()||this.isCardPinned());isStepImageLoading=x(!1);stepImageError=x(!1);stepTimer=null;stepFrames=I(()=>this.agentService.currentSessionStepFrames());totalStepFrames=I(()=>this.stepFrames().length);currentStepFrame=I(()=>{let t=this.stepFrames(),e=this.activeStepIndex();return t[e]||null});currentStepImageUrl=I(()=>{let t=this.currentStepFrame();return t?this.viewMode()==="post"&&t.postImageUrl?t.postImageUrl:t.preImageUrl||t.imageUrl||"":""});stepProgressPercent=I(()=>{let t=this.totalStepFrames();return t<=1?0:this.activeStepIndex()/(t-1)*100});speedOptions=[.5,1,1.5,2,4];posX=x(typeof window<"u"?Math.max(20,window.innerWidth-420):100);posY=x(typeof window<"u"?Math.max(60,window.innerHeight-700):100);isDragging=!1;dragStartX=0;dragStartY=0;initialPosX=0;initialPosY=0;constructor(){Ye(()=>{let e=this.agentService.activeVideoUrl(),o=this.agentService.activeVideoSegments();this.videoLoadError.set(!1),this.activeSegmentIndex.set(0),this.currentTime.set(0),this.duration.set(o.reduce((i,r)=>i+r.duration,0)),this.isPlaying.set(!1)},{allowSignalWrites:!0});let t=null;Ye(()=>{let e=this.agentService.currentSessionId(),o=this.totalStepFrames();e!==t?(t=e,this.activeStepIndex.set(0),this.viewMode.set("pre"),this.pauseStepPlay()):o>0&&this.activeStepIndex()>=o&&this.activeStepIndex.set(o-1)},{allowSignalWrites:!0}),Ye(()=>{this.currentStepImageUrl()?(this.isStepImageLoading.set(!0),this.stepImageError.set(!1)):this.isStepImageLoading.set(!1)},{allowSignalWrites:!0}),Ye(()=>{this.activeStepIndex(),this.viewMode(),setTimeout(()=>this.drawStepOverlay(),40)}),Ye(()=>{let e=this.agentService.videoSeekRequest();if(!e||(this.pendingAbsoluteSeek=e.seconds,this.agentService.recordingPlaybackStatus()!=="ready"))return;let o=this.videoRef?.nativeElement;if(o&&o.readyState>=1){let i=this.pendingAbsoluteSeek;this.pendingAbsoluteSeek=null,this.seek(i,!0)}}),Ye(()=>{let e=this.agentService.stepSeekRequest();e&&this.seekStepFrame(e.index)})}onLiveStreamError(){this.liveStreamError.set(!0)}retryLiveStream(){this.liveStreamError.set(!1),this.liveStreamUrl.set(`/api/stream/device-live?t=${Date.now()}`)}formatTime(t){if(isNaN(t)||t<0)return"00:00";let e=Math.floor(t/60),o=Math.floor(t%60),i=e<10?"0"+e:String(e),r=o<10?"0"+o:String(o);return`${i}:${r}`}progressPercent=I(()=>{let t=this.duration();return t<=0?0:Math.min(100,Math.max(0,this.currentTime()/t*100))});startDrag(t){this.isTheaterMode()||(this.isDragging=!0,this.dragStartX=t.clientX,this.dragStartY=t.clientY,this.initialPosX=this.posX(),this.initialPosY=this.posY(),t.preventDefault(),this.zone.runOutsideAngular(()=>{document.addEventListener("mousemove",this.onDrag),document.addEventListener("mouseup",this.stopDrag)}))}onDrag=t=>{if(!this.isDragging)return;let e=t.clientX-this.dragStartX,o=t.clientY-this.dragStartY,i=10,r=10,a=Math.max(10,window.innerWidth-240),s=Math.max(10,window.innerHeight-80),c=Math.max(i,Math.min(a,this.initialPosX+e)),p=Math.max(r,Math.min(s,this.initialPosY+o));this.posX.set(c),this.posY.set(p)};stopDrag=()=>{this.isDragging=!1,document.removeEventListener("mousemove",this.onDrag),document.removeEventListener("mouseup",this.stopDrag)};onResize(){let t=Math.max(10,window.innerWidth-240),e=Math.max(10,window.innerHeight-80);this.posX()>t&&this.posX.set(t),this.posY()>e&&this.posY.set(e)}onKeyDown(t){this.agentService.isVideoWindowOpen()&&t.key==="Escape"&&(this.isTheaterMode()?this.isTheaterMode.set(!1):this.agentService.closeVideoPlayer())}onLoadedMetadata(){let t=this.videoRef?.nativeElement;if(t){let e=this.agentService.activeVideoSegments();if(e.length||this.duration.set(t.duration||0),this.videoLoadError.set(!1),t.playbackRate=this.playbackRate(),t.loop=this.isLooping()&&!e.length,t.muted=this.isMuted(),this.pendingAbsoluteSeek!==null){let o=this.pendingAbsoluteSeek;if(this.pendingAbsoluteSeek=null,this.seek(o,!0),this.pendingLocalTime!==null)return}this.pendingLocalTime!==null&&(t.currentTime=Math.max(0,Math.min(t.duration||this.pendingLocalTime,this.pendingLocalTime)),this.pendingLocalTime=null),this.pendingAutoplay&&(this.pendingAutoplay=!1,t.play().catch(()=>{})),this.agentService.consumeVideoAutoplay()&&(this.isMuted.set(!0),t.muted=!0,t.play().catch(()=>{this.isPlaying.set(!1)}))}}onTimeUpdate(){let t=this.videoRef?.nativeElement;if(t){let e=this.agentService.activeVideoSegments(),o=e[this.activeSegmentIndex()];this.currentTime.set((o?.start||0)+t.currentTime),!e.length&&t.duration&&t.duration!==this.duration()&&this.duration.set(t.duration)}}onPlay(){this.isPlaying.set(!0)}onPause(){this.isPlaying.set(!1)}onEnded(){let t=this.agentService.activeVideoSegments();if(t.length&&this.activeSegmentIndex()e+1);return}if(t.length&&this.isLooping()){this.seek(0,!0);return}this.isPlaying.set(!1)}onError(){this.videoLoadError.set(!0),this.isPlaying.set(!1)}togglePlay(){let t=this.videoRef?.nativeElement;t&&(t.paused?t.play().catch(()=>{}):t.pause())}restart(){this.seek(0,!0)}seek(t,e=!1){let o=this.videoRef?.nativeElement;if(!o)return;let i=Math.max(0,Math.min(this.duration(),t)),r=this.agentService.activeVideoSegments();if(!r.length){o.currentTime=i,e&&o.play().catch(()=>{});return}let a=r.findIndex(c=>i{})):(this.pendingLocalTime=s,this.pendingAutoplay=e||!o.paused,this.activeSegmentIndex.set(a)),this.currentTime.set(i)}onScrub(t){let e=t.target,o=parseFloat(e.value);this.seek(o)}setSpeed(t){this.playbackRate.set(t);let e=this.videoRef?.nativeElement;e&&(e.playbackRate=t),this.isStepPlaying()&&this.scheduleNextStepFrame()}toggleLoop(){let t=!this.isLooping();this.isLooping.set(t);let e=this.videoRef?.nativeElement;e&&(e.loop=t&&!this.agentService.activeVideoSegments().length)}toggleMute(){let t=!this.isMuted();this.isMuted.set(t);let e=this.videoRef?.nativeElement;e&&(e.muted=t)}toggleTheater(){this.isTheaterMode.set(!this.isTheaterMode())}toggleMinimize(){this.agentService.isVideoMinimized.set(!this.agentService.isVideoMinimized())}openInNewTab(){let t=this.agentService.activeVideoUrl();t&&window.open(t,"_blank")}toggleStepPlay(){this.isStepPlaying()?this.pauseStepPlay():this.startStepPlay()}startStepPlay(){this.totalStepFrames()<=0||(this.activeStepIndex()>=this.totalStepFrames()-1&&this.activeStepIndex.set(0),this.isStepPlaying.set(!0),this.scheduleNextStepFrame())}pauseStepPlay(){this.isStepPlaying.set(!1),this.stepTimer&&(clearTimeout(this.stepTimer),this.stepTimer=null)}scheduleNextStepFrame(){this.stepTimer&&clearTimeout(this.stepTimer);let t=Math.max(300,1500/this.playbackRate());this.stepTimer=setTimeout(()=>{this.isStepPlaying()&&(this.activeStepIndex()e+1),this.scheduleNextStepFrame()):this.isLooping()?(this.activeStepIndex.set(0),this.scheduleNextStepFrame()):this.pauseStepPlay())},t)}prevStepFrame(){this.pauseStepPlay(),this.viewMode.set("pre"),this.activeStepIndex.update(t=>Math.max(0,t-1))}nextStepFrame(){this.pauseStepPlay(),this.viewMode.set("pre"),this.activeStepIndex.update(t=>Math.min(this.totalStepFrames()-1,t+1))}seekStepFrame(t){this.viewMode.set("pre"),this.activeStepIndex.set(Math.max(0,Math.min(this.totalStepFrames()-1,t)))}onStepScrub(t){let e=t.target;this.seekStepFrame(parseInt(e.value,10))}restartStep(){this.seekStepFrame(0),this.startStepPlay()}setStepViewMode(t,e){e&&e.stopPropagation(),this.viewMode.set(t),setTimeout(()=>this.drawStepOverlay(),40)}toggleStepViewMode(t){t&&t.stopPropagation(),this.setStepViewMode(this.viewMode()==="pre"?"post":"pre")}onViewportMouseEnter(){this.isCardHovered.set(!0)}onViewportMouseLeave(){this.isCardHovered.set(!1)}toggleCardPin(t){t&&t.stopPropagation(),this.isCardPinned.update(e=>!e)}cycleSpeed(){let t=this.playbackRate(),o=(this.speedOptions.indexOf(t)+1)%this.speedOptions.length;this.setSpeed(this.speedOptions[o])}onStepImageLoad(){this.isStepImageLoading.set(!1),this.stepImageError.set(!1),this.drawStepOverlay()}onStepImageError(){this.isStepImageLoading.set(!1),this.stepImageError.set(!0)}drawStepOverlay(){let t=this.stepImgRef?.nativeElement,e=this.stepOverlayRef?.nativeElement;if(!t||!e||(e.innerHTML="",this.viewMode()==="post"))return;let o=this.currentStepFrame();if(!(!o||!o.action))try{Rd(t,e,o.action)}catch(i){console.warn("Failed to draw step coordinates overlay:",i)}}getStepActionIcon(t){return zd(t)}openImageInNewTab(){let t=this.currentStepImageUrl()||this.currentStepFrame()?.imageUrl;t&&window.open(t,"_blank")}trackStepFrame(t,e){return e.stepId||String(t)}ngOnDestroy(){this.stepTimer&&(clearTimeout(this.stepTimer),this.stepTimer=null),document.removeEventListener("mousemove",this.onDrag),document.removeEventListener("mouseup",this.stopDrag)}static \u0275fac=function(e){return new(e||n)};static \u0275cmp=Ge({type:n,selectors:[["app-floating-video-player"]],viewQuery:function(e,o){if(e&1&&fr(l4,5)(d4,5)(u4,5),e&2){let i;eo(i=to())&&(o.videoRef=i.first),eo(i=to())&&(o.stepImgRef=i.first),eo(i=to())&&(o.stepOverlayRef=i.first)}},hostBindings:function(e,o){e&1&&k("resize",function(){return o.onResize()},_l)("keydown",function(r){return o.onKeyDown(r)},_l)},decls:2,vars:2,consts:[["stepImgRef",""],["stepOverlayRef",""],["videoRef",""],[1,"theater-backdrop"],[1,"floating-video-wrapper",3,"minimized","theater","left","top"],[1,"theater-backdrop",3,"click"],[1,"floating-video-wrapper"],[1,"window-header",3,"mousedown"],[1,"header-left"],[1,"material-symbols-outlined","header-icon"],[1,"header-title",3,"title"],[1,"header-actions",3,"mousedown"],[1,"mode-toggle-group"],["aria-label","Minimize",1,"header-btn",3,"click","title"],[1,"material-symbols-outlined"],["aria-label","Theater",1,"header-btn",3,"title"],["title","Close","aria-label","Close",1,"header-btn","close-btn",3,"click"],[1,"video-body"],["title","Video Recording",1,"mode-btn",3,"click"],[1,"material-symbols-outlined","mode-icon"],["title","Step Screenshot Replay",1,"mode-btn",3,"click"],["aria-label","Theater",1,"header-btn",3,"click","title"],[1,"live-stream-container"],[1,"empty-video-fallback"],["alt","Live Device Screen",1,"live-screen-img",3,"error","src"],[1,"live-status-overlay"],[1,"live-badge"],[1,"pulse-dot"],["title","Refresh Live Stream",1,"live-refresh-btn",3,"click"],[1,"fallback-icon-wrap"],[1,"material-symbols-outlined","fallback-icon"],[1,"fallback-title"],[1,"fallback-desc"],[1,"retry-btn",3,"click"],[1,"video-loading-state"],[1,"step-replay-container"],[1,"material-symbols-outlined","spinning-icon"],[1,"step-replay-notice"],[1,"step-image-viewport",3,"mouseenter","mouseleave"],[1,"step-image-loading-state"],[1,"step-image-error-state"],[1,"step-image-frame-wrap"],[1,"step-frame-badge",3,"visible"],[1,"video-controls-footer"],[1,"scrubber-bar-container"],["type","range",1,"scrubber-range",3,"input","min","max","step","value"],[1,"scrubber-progress-fill"],[1,"step-ticks-wrapper"],[1,"controls-row"],[1,"controls-left"],[1,"ctrl-btn","play-btn",3,"click","title"],["title","Previous Step",1,"ctrl-btn",3,"click","disabled"],["title","Next Step",1,"ctrl-btn",3,"click","disabled"],["title","Replay from start",1,"ctrl-btn",3,"click"],[1,"time-display"],[1,"controls-right"],[1,"ctrl-btn","speed-pill-btn",3,"click","title"],[1,"speed-label"],[1,"ctrl-btn",3,"click","title"],["title","Open image in new tab",1,"ctrl-btn",3,"click"],[1,"material-symbols-outlined","notice-icon"],[1,"material-symbols-outlined","broken-icon"],["title","Click to view image in new tab",1,"step-frame-img",3,"load","error","click","src","alt"],[1,"overlay-layer"],[1,"step-frame-badge",3,"click"],[1,"badge-content-left"],[1,"badge-step-no"],[1,"badge-action-desc",3,"title"],[1,"material-symbols-outlined","action-type-icon"],[1,"badge-actions-right"],[1,"pre-post-toggle"],[1,"pin-card-btn",3,"click","title"],["title","View screen before action (with touch coordinates)",1,"toggle-btn",3,"click"],["title","View resulting screen after action executed",1,"toggle-btn",3,"click"],[1,"step-tick",3,"active","passed","left","title"],[1,"step-tick",3,"click","title"],[1,"video-container"],["playsinline","",3,"loadedmetadata","timeupdate","play","pause","ended","error","click","src"],["title","Open video in new tab",1,"ctrl-btn",3,"click"],[1,"retry-btn"]],template:function(e,o){e&1&&(M(0,g4,1,0,"div",3),M(1,F4,17,16,"div",4)),e&2&&(b(o.agentService.isVideoWindowOpen()&&o.isTheaterMode()?0:-1),g(),b(o.agentService.isVideoWindowOpen()?1:-1))},dependencies:[Qt],styles:['.theater-backdrop[_ngcontent-%COMP%]{position:fixed;top:0;left:0;width:100vw;height:100vh;background:#0f172a99;backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);z-index:1040;animation:_ngcontent-%COMP%_fadeIn .15s ease-out}.floating-video-wrapper[_ngcontent-%COMP%]{position:fixed;width:400px;max-width:calc(100vw - 32px);background:#ffffffd6;backdrop-filter:blur(24px) saturate(180%);-webkit-backdrop-filter:blur(24px) saturate(180%);border:1px solid rgba(255,255,255,.85);border-radius:16px;box-shadow:0 20px 50px -10px #0f172a38,0 0 0 1px #e2e8f099,inset 0 1px 1px #ffffffe6;z-index:1050;overflow:hidden;display:flex;flex-direction:column;box-sizing:border-box;animation:_ngcontent-%COMP%_popIn .2s cubic-bezier(.16,1,.3,1);-webkit-user-select:none;user-select:none;font-family:Google Sans,Google Sans Text,Inter,system-ui,-apple-system,sans-serif}.floating-video-wrapper.minimized[_ngcontent-%COMP%]{width:280px;box-shadow:0 10px 25px -5px #0f172a2e}.floating-video-wrapper.minimized[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%]{border-bottom:none}.floating-video-wrapper.theater[_ngcontent-%COMP%]{left:50%!important;top:50%!important;transform:translate(-50%,-50%);width:85vw;max-width:960px;height:auto;max-height:88vh;box-shadow:0 24px 60px -12px #0f172a59}.floating-video-wrapper.theater[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%]{max-height:calc(88vh - 46px)}.floating-video-wrapper.theater[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-container[_ngcontent-%COMP%] video[_ngcontent-%COMP%]{max-height:calc(88vh - 120px)}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%]{height:44px;background:#ffffffbf;-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);border-bottom:1px solid rgba(226,232,240,.75);display:flex;align-items:center;justify-content:space-between;padding:0 8px 0 12px;cursor:grab;gap:8px;flex-shrink:0}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%]:active{cursor:grabbing}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px;overflow:hidden;flex:1}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%] .header-icon[_ngcontent-%COMP%]{font-size:18px;color:#2563eb;flex-shrink:0}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%] .header-title[_ngcontent-%COMP%]{font-size:13px;font-weight:600;color:#0f172a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;letter-spacing:-.2px}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%] .mode-switcher-group[_ngcontent-%COMP%]{display:flex;align-items:center;background:#f1f5f9a6;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border:1px solid rgba(226,232,240,.8);border-radius:8px;padding:2px;margin-left:4px;flex-shrink:0}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%] .mode-switcher-group[_ngcontent-%COMP%] .mode-btn[_ngcontent-%COMP%]{border:none;background:transparent;color:#475569;font-size:10px;font-weight:700;padding:2px 7px;border-radius:6px;cursor:pointer;transition:all .15s ease;display:flex;align-items:center;gap:4px}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%] .mode-switcher-group[_ngcontent-%COMP%] .mode-btn[_ngcontent-%COMP%] .pulse-dot-mini[_ngcontent-%COMP%]{width:5px;height:5px;border-radius:50%;background:#ef4444;animation:_ngcontent-%COMP%_pulseRed 1.2s infinite}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%] .mode-switcher-group[_ngcontent-%COMP%] .mode-btn[_ngcontent-%COMP%]:hover:not(:disabled){color:#0f172a;background:#fff9}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%] .mode-switcher-group[_ngcontent-%COMP%] .mode-btn.active[_ngcontent-%COMP%]{background:#0284c7;color:#fff;font-weight:700;box-shadow:0 1px 3px #0284c74d}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-left[_ngcontent-%COMP%] .mode-switcher-group[_ngcontent-%COMP%] .mode-btn[_ngcontent-%COMP%]:disabled{opacity:.4;cursor:not-allowed}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-actions[_ngcontent-%COMP%]{display:flex;align-items:center;gap:4px;flex-shrink:0}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-actions[_ngcontent-%COMP%] .mode-toggle-group[_ngcontent-%COMP%]{display:flex;align-items:center;background:#f1f5f9b3;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border:1px solid rgba(226,232,240,.85);border-radius:8px;padding:2px;margin-right:4px;gap:2px}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-actions[_ngcontent-%COMP%] .mode-toggle-group[_ngcontent-%COMP%] .mode-btn[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:3px;border:none;background:transparent;color:#475569;font-size:11px;font-weight:600;padding:2px 7px;border-radius:6px;cursor:pointer;transition:all .15s ease}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-actions[_ngcontent-%COMP%] .mode-toggle-group[_ngcontent-%COMP%] .mode-btn[_ngcontent-%COMP%] .mode-icon[_ngcontent-%COMP%]{font-size:13px}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-actions[_ngcontent-%COMP%] .mode-toggle-group[_ngcontent-%COMP%] .mode-btn[_ngcontent-%COMP%]:hover:not(.active){color:#0f172a;background:#fffc}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-actions[_ngcontent-%COMP%] .mode-toggle-group[_ngcontent-%COMP%] .mode-btn.active[_ngcontent-%COMP%]{background:#0284c7;color:#fff;box-shadow:0 1px 3px #0284c74d}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-actions[_ngcontent-%COMP%] .speed-pill-group[_ngcontent-%COMP%]{display:flex;align-items:center;background:#f1f5f9a6;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border:1px solid rgba(226,232,240,.8);border-radius:8px;padding:2px;margin-right:4px}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-actions[_ngcontent-%COMP%] .speed-pill-group[_ngcontent-%COMP%] .speed-btn[_ngcontent-%COMP%]{border:none;background:transparent;color:#334155;font-size:11px;font-weight:700;padding:2px 6px;border-radius:4px;cursor:pointer;transition:all .15s ease}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-actions[_ngcontent-%COMP%] .speed-pill-group[_ngcontent-%COMP%] .speed-btn[_ngcontent-%COMP%]:hover{color:#0f172a;background:#cbd5e1}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-actions[_ngcontent-%COMP%] .speed-pill-group[_ngcontent-%COMP%] .speed-btn.active[_ngcontent-%COMP%]{background:#0284c7;color:#fff;font-weight:700;box-shadow:0 1px 3px #0284c74d}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-actions[_ngcontent-%COMP%] .header-btn[_ngcontent-%COMP%]{background:#f1f5f9bf;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid rgba(226,232,240,.85);color:#1e293b;cursor:pointer;display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:7px;box-shadow:0 1px 3px #0f172a0a,inset 0 1px 1px #fffc;transition:all .2s cubic-bezier(.4,0,.2,1)}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-actions[_ngcontent-%COMP%] .header-btn[_ngcontent-%COMP%]:hover{background:#fffffff2;color:#0f172a;border-color:#cbd5e1f2;transform:translateY(-.5px);box-shadow:0 2px 6px #0f172a14}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-actions[_ngcontent-%COMP%] .header-btn.close-btn[_ngcontent-%COMP%]:hover{background:#fee2e2f2;color:#dc2626;border-color:#fca5a5e6;box-shadow:0 2px 6px #dc262626}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-actions[_ngcontent-%COMP%] .header-btn[_ngcontent-%COMP%]:active{transform:scale(.96)}.floating-video-wrapper[_ngcontent-%COMP%] .window-header[_ngcontent-%COMP%] .header-actions[_ngcontent-%COMP%] .header-btn[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:17px;font-weight:600}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%]{position:relative;background:#fff;display:flex;flex-direction:column;align-items:stretch;justify-content:flex-start;overflow:hidden}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-loading-state[_ngcontent-%COMP%]{padding:36px 20px;display:flex;flex-direction:column;align-items:center;gap:10px;background:#fff;width:100%;box-sizing:border-box;color:#475569;font-size:13px;font-weight:500}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-loading-state[_ngcontent-%COMP%] .spinning-icon[_ngcontent-%COMP%]{font-size:28px;color:#2563eb;animation:_ngcontent-%COMP%_spin 1s linear infinite}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .live-stream-container[_ngcontent-%COMP%]{position:relative;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#fff;min-height:260px;overflow:hidden}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .live-stream-container[_ngcontent-%COMP%] .live-screen-img[_ngcontent-%COMP%]{width:100%;max-height:520px;display:block;object-fit:contain;-webkit-user-select:none;user-select:none;background:#fff}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .live-stream-container[_ngcontent-%COMP%] .live-status-overlay[_ngcontent-%COMP%]{position:absolute;top:10px;left:12px;right:12px;display:flex;align-items:center;justify-content:space-between;pointer-events:none}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .live-stream-container[_ngcontent-%COMP%] .live-status-overlay[_ngcontent-%COMP%] .live-badge[_ngcontent-%COMP%]{display:flex;align-items:center;gap:6px;background:#fef2f2;border:1px solid #fca5a5;color:#dc2626;font-size:10.5px;font-weight:700;padding:3px 9px;border-radius:12px;letter-spacing:.5px;box-shadow:0 1px 4px #dc262626}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .live-stream-container[_ngcontent-%COMP%] .live-status-overlay[_ngcontent-%COMP%] .live-badge[_ngcontent-%COMP%] .pulse-dot[_ngcontent-%COMP%]{width:6px;height:6px;border-radius:50%;background:#ef4444;animation:_ngcontent-%COMP%_pulseRed 1.2s infinite}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .live-stream-container[_ngcontent-%COMP%] .live-status-overlay[_ngcontent-%COMP%] .live-refresh-btn[_ngcontent-%COMP%]{pointer-events:auto;background:#fffffff2;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);border:1px solid #cbd5e1;color:#1e293b;cursor:pointer;display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;box-shadow:0 1px 3px #00000014;transition:all .15s ease}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .live-stream-container[_ngcontent-%COMP%] .live-status-overlay[_ngcontent-%COMP%] .live-refresh-btn[_ngcontent-%COMP%]:hover{background:#f1f5f9;color:#0f172a;border-color:#94a3b8;transform:rotate(90deg)}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .live-stream-container[_ngcontent-%COMP%] .live-status-overlay[_ngcontent-%COMP%] .live-refresh-btn[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:16px}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%]{display:flex;flex-direction:column;background:#fff;position:relative;width:100%;overflow:hidden}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-replay-notice[_ngcontent-%COMP%]{display:flex;align-items:center;gap:6px;background:#fffbeb;color:#b45309;padding:6px 12px;font-size:11.5px;font-weight:500;border-bottom:1px solid #fde68a}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-replay-notice[_ngcontent-%COMP%] .notice-icon[_ngcontent-%COMP%]{font-size:14px;color:#d97706;flex-shrink:0}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:center;position:relative;background:#f8fafc;min-height:280px;max-height:520px;overflow:hidden;padding:10px 0}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-image-loading-state[_ngcontent-%COMP%], .floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-image-error-state[_ngcontent-%COMP%]{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;background:#f8fafc;color:#64748b;font-size:12px;font-weight:500;z-index:5}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-image-loading-state[_ngcontent-%COMP%] .spinning-icon[_ngcontent-%COMP%], .floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-image-error-state[_ngcontent-%COMP%] .spinning-icon[_ngcontent-%COMP%]{font-size:24px;color:#0284c7;animation:_ngcontent-%COMP%_spin 1s linear infinite}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-image-loading-state[_ngcontent-%COMP%] .broken-icon[_ngcontent-%COMP%], .floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-image-error-state[_ngcontent-%COMP%] .broken-icon[_ngcontent-%COMP%]{font-size:28px;color:#94a3b8}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-image-frame-wrap[_ngcontent-%COMP%]{position:relative;display:inline-flex;align-items:center;justify-content:center;max-width:92%;max-height:500px}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-image-frame-wrap[_ngcontent-%COMP%] .step-frame-img[_ngcontent-%COMP%]{max-width:100%;max-height:500px;width:auto;height:auto;object-fit:contain;display:block;-webkit-user-select:none;user-select:none;cursor:pointer;border-radius:8px;box-shadow:0 4px 20px -2px #0f172a24,0 0 0 1px #e2e8f0e6;background:#fff;transition:opacity .15s ease}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-image-frame-wrap[_ngcontent-%COMP%] .step-frame-img[_ngcontent-%COMP%]:hover{opacity:.98}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-image-frame-wrap[_ngcontent-%COMP%] .overlay-layer[_ngcontent-%COMP%]{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:8}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%]:hover .step-frame-badge[_ngcontent-%COMP%]{opacity:1;transform:translateY(0);pointer-events:auto}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-frame-badge[_ngcontent-%COMP%]{position:absolute;bottom:12px;left:10px;right:10px;display:flex;align-items:center;justify-content:space-between;gap:8px;background:#0f172ae0;backdrop-filter:blur(14px) saturate(180%);-webkit-backdrop-filter:blur(14px) saturate(180%);border:1px solid rgba(255,255,255,.2);border-radius:10px;padding:6px 10px;color:#fff;box-shadow:0 8px 24px #00000080;opacity:0;transform:translateY(8px);pointer-events:none;transition:opacity .22s cubic-bezier(.16,1,.3,1),transform .22s cubic-bezier(.16,1,.3,1);z-index:15}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-frame-badge.visible[_ngcontent-%COMP%]{opacity:1;transform:translateY(0);pointer-events:auto}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-frame-badge[_ngcontent-%COMP%] .badge-content-left[_ngcontent-%COMP%]{display:flex;align-items:center;gap:7px;overflow:hidden;flex:1}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-frame-badge[_ngcontent-%COMP%] .badge-content-left[_ngcontent-%COMP%] .badge-step-no[_ngcontent-%COMP%]{font-size:10.5px;font-weight:700;background:#0284c7;padding:2px 7px;border-radius:5px;letter-spacing:.3px;flex-shrink:0;box-shadow:0 1px 3px #0284c766}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-frame-badge[_ngcontent-%COMP%] .badge-content-left[_ngcontent-%COMP%] .badge-action-desc[_ngcontent-%COMP%]{font-size:11.5px;font-weight:500;color:#f8fafc;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;gap:4px}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-frame-badge[_ngcontent-%COMP%] .badge-content-left[_ngcontent-%COMP%] .badge-action-desc[_ngcontent-%COMP%] .action-type-icon[_ngcontent-%COMP%]{font-size:15px;color:#38bdf8;flex-shrink:0}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-frame-badge[_ngcontent-%COMP%] .badge-actions-right[_ngcontent-%COMP%]{display:flex;align-items:center;gap:6px;flex-shrink:0}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-frame-badge[_ngcontent-%COMP%] .badge-actions-right[_ngcontent-%COMP%] .pre-post-toggle[_ngcontent-%COMP%]{display:inline-flex;align-items:center;background:#ffffff1f;border:1px solid rgba(255,255,255,.15);border-radius:6px;padding:2px;gap:2px}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-frame-badge[_ngcontent-%COMP%] .badge-actions-right[_ngcontent-%COMP%] .pre-post-toggle[_ngcontent-%COMP%] .toggle-btn[_ngcontent-%COMP%]{border:none;background:transparent;color:#ffffffb3;font-size:10.5px;font-weight:700;padding:2px 7px;border-radius:4px;cursor:pointer;transition:all .15s ease}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-frame-badge[_ngcontent-%COMP%] .badge-actions-right[_ngcontent-%COMP%] .pre-post-toggle[_ngcontent-%COMP%] .toggle-btn[_ngcontent-%COMP%]:hover{color:#fff;background:#fff3}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-frame-badge[_ngcontent-%COMP%] .badge-actions-right[_ngcontent-%COMP%] .pre-post-toggle[_ngcontent-%COMP%] .toggle-btn.active[_ngcontent-%COMP%]{background:#0284c7;color:#fff;box-shadow:0 1px 3px #0000004d}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-frame-badge[_ngcontent-%COMP%] .badge-actions-right[_ngcontent-%COMP%] .pin-card-btn[_ngcontent-%COMP%]{background:transparent;border:none;color:#fff9;cursor:pointer;padding:3px;display:flex;align-items:center;justify-content:center;border-radius:4px;transition:all .15s ease}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-frame-badge[_ngcontent-%COMP%] .badge-actions-right[_ngcontent-%COMP%] .pin-card-btn[_ngcontent-%COMP%]:hover{color:#fff;background:#ffffff26}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-frame-badge[_ngcontent-%COMP%] .badge-actions-right[_ngcontent-%COMP%] .pin-card-btn.pinned[_ngcontent-%COMP%]{color:#38bdf8;background:#38bdf833}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .step-replay-container[_ngcontent-%COMP%] .step-image-viewport[_ngcontent-%COMP%] .step-frame-badge[_ngcontent-%COMP%] .badge-actions-right[_ngcontent-%COMP%] .pin-card-btn[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:14px}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-container[_ngcontent-%COMP%]{position:relative;width:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#fff}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-container[_ngcontent-%COMP%] video[_ngcontent-%COMP%]{width:100%;height:auto;max-height:520px;display:block;object-fit:contain;cursor:pointer;background:#fff}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%]{background:#ffffffd6;backdrop-filter:blur(20px) saturate(180%);-webkit-backdrop-filter:blur(20px) saturate(180%);border-top:1px solid rgba(226,232,240,.75);padding:12px 14px;display:flex;flex-direction:column;gap:10px;flex-shrink:0;box-shadow:0 -4px 16px #0f172a0a,inset 0 1px 1px #ffffffe6}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .scrubber-bar-container[_ngcontent-%COMP%]{position:relative;width:100%;height:8px;display:flex;align-items:center;cursor:pointer}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .scrubber-bar-container[_ngcontent-%COMP%] .scrubber-range[_ngcontent-%COMP%]{position:absolute;width:100%;height:100%;opacity:0;cursor:pointer;z-index:5;margin:0}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .scrubber-bar-container[_ngcontent-%COMP%]:before{content:"";position:absolute;left:0;right:0;height:5px;background:#e2e8f0d9;border-radius:3px}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .scrubber-bar-container[_ngcontent-%COMP%] .scrubber-progress-fill[_ngcontent-%COMP%]{position:absolute;left:0;height:5px;background:linear-gradient(90deg,#0284c7,#2563eb);border-radius:3px;pointer-events:none;box-shadow:0 0 8px #0284c766}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .scrubber-bar-container[_ngcontent-%COMP%] .step-ticks-wrapper[_ngcontent-%COMP%]{position:absolute;left:0;right:0;height:100%;pointer-events:none;display:flex;align-items:center;z-index:6}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .scrubber-bar-container[_ngcontent-%COMP%] .step-ticks-wrapper[_ngcontent-%COMP%] .step-tick[_ngcontent-%COMP%]{position:absolute;width:7px;height:7px;border-radius:50%;background:#ffffffe6;border:1.5px solid #94a3b8;transform:translate(-50%);pointer-events:auto;cursor:pointer;transition:all .15s ease}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .scrubber-bar-container[_ngcontent-%COMP%] .step-ticks-wrapper[_ngcontent-%COMP%] .step-tick[_ngcontent-%COMP%]:hover{transform:translate(-50%) scale(1.4);border-color:#0284c7;background:#fff}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .scrubber-bar-container[_ngcontent-%COMP%] .step-ticks-wrapper[_ngcontent-%COMP%] .step-tick.passed[_ngcontent-%COMP%]{background:#0284c7;border-color:#fff}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .scrubber-bar-container[_ngcontent-%COMP%] .step-ticks-wrapper[_ngcontent-%COMP%] .step-tick.active[_ngcontent-%COMP%]{background:#38bdf8;border-color:#fff;transform:translate(-50%) scale(1.3);box-shadow:0 0 6px #38bdf8cc}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .controls-row[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .controls-row[_ngcontent-%COMP%] .controls-left[_ngcontent-%COMP%], .floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .controls-row[_ngcontent-%COMP%] .controls-right[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .controls-row[_ngcontent-%COMP%] .ctrl-btn[_ngcontent-%COMP%]{background:#f1f5f9bf;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid rgba(226,232,240,.85);box-shadow:0 1px 3px #0f172a0a,inset 0 1px 1px #ffffffd9;color:#0f172a;width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all .2s cubic-bezier(.4,0,.2,1)}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .controls-row[_ngcontent-%COMP%] .ctrl-btn[_ngcontent-%COMP%]:hover{background:#fffffff2;border-color:#bfdbfee6;color:#0284c7;transform:translateY(-.5px);box-shadow:0 3px 8px #0f172a14}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .controls-row[_ngcontent-%COMP%] .ctrl-btn[_ngcontent-%COMP%]:active{transform:scale(.96)}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .controls-row[_ngcontent-%COMP%] .ctrl-btn.active[_ngcontent-%COMP%]{color:#0284c7;background:#e0f2fed9;border-color:#38bdf8cc;box-shadow:0 2px 6px #0284c726}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .controls-row[_ngcontent-%COMP%] .ctrl-btn.play-btn[_ngcontent-%COMP%]{background:linear-gradient(135deg,#0284c7eb,#2563ebe0);backdrop-filter:blur(14px) saturate(180%);-webkit-backdrop-filter:blur(14px) saturate(180%);border:1px solid rgba(255,255,255,.35);color:#fff;width:36px;height:36px;border-radius:50%;box-shadow:0 4px 14px #0284c759,inset 0 1px 1px #ffffff80}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .controls-row[_ngcontent-%COMP%] .ctrl-btn.play-btn[_ngcontent-%COMP%]:hover{background:linear-gradient(135deg,#0284c7,#2563ebf2);box-shadow:0 6px 18px #0284c773,inset 0 1px 1px #fff9;transform:translateY(-1px) scale(1.04)}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .controls-row[_ngcontent-%COMP%] .ctrl-btn.play-btn[_ngcontent-%COMP%]:active{transform:scale(.96)}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .controls-row[_ngcontent-%COMP%] .ctrl-btn.play-btn[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:22px;color:#fff}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .controls-row[_ngcontent-%COMP%] .ctrl-btn.speed-pill-btn[_ngcontent-%COMP%]{width:auto;min-width:34px;padding:0 7px;font-size:11px;font-weight:700;font-family:JetBrains Mono,Roboto Mono,monospace;color:#334155;letter-spacing:-.2px}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .controls-row[_ngcontent-%COMP%] .ctrl-btn.speed-pill-btn[_ngcontent-%COMP%]:hover{color:#0284c7;border-color:#bfdbfee6}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .controls-row[_ngcontent-%COMP%] .ctrl-btn[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:18px;color:#1e293b}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .video-controls-footer[_ngcontent-%COMP%] .controls-row[_ngcontent-%COMP%] .time-display[_ngcontent-%COMP%]{font-size:12px;font-weight:700;font-family:JetBrains Mono,Roboto Mono,monospace;color:#1e293b;margin-left:4px;letter-spacing:.5px}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .empty-video-fallback[_ngcontent-%COMP%]{background:#fff;padding:36px 20px;display:flex;flex-direction:column;align-items:center;text-align:center;box-sizing:border-box;width:100%}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .empty-video-fallback[_ngcontent-%COMP%] .fallback-icon-wrap[_ngcontent-%COMP%]{width:48px;height:48px;border-radius:50%;background:#fef2f2;border:1px solid #fee2e2;display:flex;align-items:center;justify-content:center;margin-bottom:12px}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .empty-video-fallback[_ngcontent-%COMP%] .fallback-icon-wrap[_ngcontent-%COMP%] .fallback-icon[_ngcontent-%COMP%]{font-size:24px;color:#ef4444}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .empty-video-fallback[_ngcontent-%COMP%] .fallback-title[_ngcontent-%COMP%]{margin:0 0 6px;font-size:13.5px;font-weight:600;color:#0f172a}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .empty-video-fallback[_ngcontent-%COMP%] .fallback-desc[_ngcontent-%COMP%]{margin:0 0 12px;font-size:12px;color:#64748b;line-height:1.4;max-width:240px}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .empty-video-fallback[_ngcontent-%COMP%] .retry-btn[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:6px;background:#0284c7;color:#fff;border:none;border-radius:6px;padding:5px 12px;font-size:11.5px;font-weight:600;cursor:pointer;transition:all .15s ease}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .empty-video-fallback[_ngcontent-%COMP%] .retry-btn[_ngcontent-%COMP%]:hover{background:#0369a1;transform:translateY(-1px)}.floating-video-wrapper[_ngcontent-%COMP%] .video-body[_ngcontent-%COMP%] .empty-video-fallback[_ngcontent-%COMP%] .retry-btn[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:15px}@keyframes _ngcontent-%COMP%_popIn{0%{opacity:0;transform:scale(.96) translateY(6px)}to{opacity:1;transform:scale(1) translateY(0)}}@keyframes _ngcontent-%COMP%_fadeIn{0%{opacity:0}to{opacity:1}}@keyframes _ngcontent-%COMP%_spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@keyframes _ngcontent-%COMP%_pulseRed{0%,to{opacity:1}50%{opacity:.5}} .action-point-marker{z-index:10;pointer-events:none}@keyframes _ngcontent-%COMP%_pulse-animation{0%{transform:scale(.6);opacity:1}to{transform:scale(2.2);opacity:0}}']})};var L4=["dockInput"];function V4(n,t){if(n&1){let e=L();l(0,"div",4)(1,"span",55),u(2,"error"),d(),l(3,"span",56),u(4),d(),l(5,"button",57),k("click",function(){v(e);let i=m();return y(i.errorMessage.set(null))}),l(6,"span",42),u(7,"close"),d()()()}if(n&2){let e=m();g(4),O(e.errorMessage())}}function j4(n,t){if(n&1){let e=L();l(0,"button",58),k("click",function(i){v(e);let r=m();return y(r.clearInput(i))}),l(1,"span",42),u(2,"close"),d()()}}function $4(n,t){if(n&1){let e=L();l(0,"button",59),k("click",function(i){v(e);let r=m();return y(r.stopTask(i))}),l(1,"span",42),u(2,"stop"),d()()}if(n&2){let e=m();R("disabled",e.isSubmitting())("title",e.stopButtonTitle()),Wt("aria-label",e.stopButtonTitle())}}function z4(n,t){n&1&&(l(0,"span",41),u(1,"sync"),d())}function B4(n,t){n&1&&(l(0,"span",42),u(1,"arrow_upward"),d())}function H4(n,t){if(n&1){let e=L();l(0,"button",60),k("click",function(i){v(e);let r=m();return y(r.stopTask(i))}),l(1,"span",42),u(2,"stop"),d()()}if(n&2){let e=m();R("disabled",e.isSubmitting())("title",e.stopButtonTitle()),Wt("aria-label",e.stopButtonTitle())}}var Yd=class n{agentService=D(It);zone=D(ge);destroyRef=D(Ae);rightPanelWidth=x(typeof window<"u"?Math.round(window.innerWidth/3):450);isDragging=x(!1);dragWidthRafId=null;pendingDragWidth=0;taskInputSignal=x("");get taskInput(){return this.taskInputSignal()}set taskInput(t){this.taskInputSignal.set(t)}isSubmitting=x(!1);errorMessage=x(null);selectedProfile=x("flash");isHoveringCard=x(!1);isInputFocused=x(!1);dockInputRef;ngOnInit(){if(typeof localStorage<"u"){let t=localStorage.getItem("artemis_selected_profile");(t==="flash"||t==="pro")&&this.selectedProfile.set(t)}this.zone.runOutsideAngular(()=>{window.addEventListener("keydown",this.onGlobalKeyDown)}),this.destroyRef.onDestroy(()=>{window.removeEventListener("keydown",this.onGlobalKeyDown),this.detachDragListeners()})}setProfile(t,e){e&&e.stopPropagation(),this.selectedProfile.set(t),typeof localStorage<"u"&&localStorage.setItem("artemis_selected_profile",t)}isTaskRunning=I(()=>this.agentService.isCurrentSessionRunning());stopButtonTitle=I(()=>{let t=this.agentService.currentSession();if(t?.initial_goal)return`Stop current task: "${t.initial_goal.length>45?t.initial_goal.substring(0,42)+"...":t.initial_goal}"`;let e=this.agentService.currentSessionId();return e?`Stop current task (${e})`:"Stop current running task"});isBarExpanded=I(()=>this.isHoveringCard()||this.isInputFocused()||this.taskInput.trim().length>0);onInputFocus(){this.isInputFocused.set(!0)}onInputBlur(){this.isInputFocused.set(!1)}onCardClick(t){let e=t.target;e.closest("button")||e.tagName.toLowerCase()==="textarea"||this.focusInput()}focusInput(){setTimeout(()=>{this.dockInputRef?.nativeElement?.focus()},30)}clearInput(t){t&&t.stopPropagation(),this.taskInput="",this.dockInputRef?.nativeElement&&(this.dockInputRef.nativeElement.style.height="auto",this.dockInputRef.nativeElement.focus())}onTextareaInput(t){let e=t.target;if(!e)return;e.style.height="auto";let o=Math.min(Math.max(e.scrollHeight,24),120);e.style.height=`${o}px`}onGlobalKeyDown=t=>{(t.metaKey||t.ctrlKey)&&t.key.toLowerCase()==="k"&&(t.preventDefault(),this.focusInput())};onKeyDown(t){t.key==="Enter"&&!t.shiftKey&&(t.preventDefault(),this.submitTask())}submitTask(){let t=this.taskInput.trim();!t||this.isSubmitting()||(this.isSubmitting.set(!0),this.errorMessage.set(null),this.dockInputRef?.nativeElement&&this.dockInputRef.nativeElement.blur(),this.isInputFocused.set(!1),this.agentService.runTask(t,this.selectedProfile()).subscribe({next:e=>{this.taskInput="",this.dockInputRef?.nativeElement&&(this.dockInputRef.nativeElement.style.height="auto"),this.isSubmitting.set(!1),this.agentService.fetchStatus()},error:e=>{console.error("Failed to submit task:",e),this.isSubmitting.set(!1),this.errorMessage.set(e.error?.detail||"The runner is busy. Please wait for current task to finish."),setTimeout(()=>{this.errorMessage.set(null)},5e3)}}))}stopTask(t){if(t&&t.stopPropagation(),!this.isTaskRunning())return;let e=this.agentService.currentSessionId();this.isSubmitting.set(!0),this.errorMessage.set(null),this.agentService.stopTask(e,!1),setTimeout(()=>{this.isSubmitting.set(!1)},400)}onDragStart(t){this.isDragging.set(!0),t.preventDefault(),this.zone.runOutsideAngular(()=>{document.addEventListener("mousemove",this.onMouseMove),document.addEventListener("mouseup",this.onMouseUp)})}onMouseMove=t=>{if(!this.isDragging())return;let e=window.innerWidth-t.clientX,o=250,i=window.innerWidth-300;e>=o&&e<=i&&(this.pendingDragWidth=e,this.dragWidthRafId===null&&(this.dragWidthRafId=requestAnimationFrame(()=>{this.dragWidthRafId=null,this.rightPanelWidth.set(this.pendingDragWidth)})))};onMouseUp=()=>{this.isDragging.set(!1),this.detachDragListeners()};detachDragListeners(){document.removeEventListener("mousemove",this.onMouseMove),document.removeEventListener("mouseup",this.onMouseUp),this.dragWidthRafId!==null&&(cancelAnimationFrame(this.dragWidthRafId),this.dragWidthRafId=null)}static \u0275fac=function(e){return new(e||n)};static \u0275cmp=Ge({type:n,selectors:[["app-workspace"]],viewQuery:function(e,o){if(e&1&&fr(L4,5),e&2){let i;eo(i=to())&&(o.dockInputRef=i.first)}},decls:67,vars:33,consts:[["dockInput",""],[1,"workspace-container"],[1,"left-panel","glass-panel"],[1,"workspace-floating-bar-wrapper"],[1,"floating-dock-toast","error"],[1,"floating-dock-row"],[1,"floating-dock-card",3,"click","mouseenter","mouseleave"],[1,"dock-wave-container"],[1,"wave-glow-ambient"],[1,"wave-tracks-wrapper"],["viewBox","0 0 2400 80","preserveAspectRatio","none",1,"liquid-wave","wave-back"],["id","waveGradBack","x1","0%","y1","0%","x2","100%","y2","0%"],["offset","0%",1,"wave-stop-1"],["offset","25%",1,"wave-stop-2"],["offset","50%",1,"wave-stop-3"],["offset","75%",1,"wave-stop-4"],["offset","100%",1,"wave-stop-1"],["d","M 0,25 Q 300,55 600,20 T 1200,25 Q 1500,55 1800,20 T 2400,25 L 2400,80 L 0,80 Z","fill","url(#waveGradBack)"],["viewBox","0 0 2400 80","preserveAspectRatio","none",1,"liquid-wave","wave-mid"],["id","waveGradMid","x1","0%","y1","0%","x2","100%","y2","0%"],["offset","0%",1,"wave-stop-3"],["offset","35%",1,"wave-stop-1"],["offset","70%",1,"wave-stop-4"],["offset","100%",1,"wave-stop-3"],["d","M 0,35 Q 300,5 600,45 T 1200,35 Q 1500,5 1800,45 T 2400,35 L 2400,80 L 0,80 Z","fill","url(#waveGradMid)"],["viewBox","0 0 2400 80","preserveAspectRatio","none",1,"liquid-wave","wave-front"],["id","waveGradFront","x1","0%","y1","0%","x2","100%","y2","0%"],["offset","0%",1,"wave-stop-2"],["offset","30%",1,"wave-stop-4"],["offset","65%",1,"wave-stop-1"],["offset","100%",1,"wave-stop-2"],["d","M 0,18 Q 300,50 600,15 T 1200,18 Q 1500,50 1800,15 T 2400,18 L 2400,80 L 0,80 Z","fill","url(#waveGradFront)"],[1,"capsule-peek-view"],[1,"capsule-placeholder"],[1,"expanded-card-content"],[1,"card-main-input-row"],[1,"input-content-area"],["rows","1","spellcheck","false",1,"dock-textarea",3,"ngModelChange","input","keydown","focus","blur","ngModel","disabled","placeholder"],["type","button","title","Clear input text","aria-label","Clear input text",1,"dock-circle-btn","btn-clear"],["type","button",1,"dock-circle-btn","btn-stop",3,"disabled","title"],["type","button","aria-label","Start task",1,"dock-circle-btn","btn-send",3,"click","disabled","title"],[1,"material-symbols-outlined","spinning-icon"],[1,"material-symbols-outlined"],[1,"card-footer-row"],[1,"footer-left-group"],["role","group","aria-label","Select Agent Architecture",1,"model-profile-toggle"],["type","button","title","Flash: fast single-look loop for everyday app tasks (3\u20135s/step)",1,"profile-toggle-pill",3,"mousedown","click"],[1,"material-symbols-outlined","pill-icon","icon-flash"],[1,"pill-label"],["type","button","title","Pro: plans first, checks results and recovers from blocked steps. For long or complex tasks (15\u201340s/step)",1,"profile-toggle-pill",3,"mousedown","click"],[1,"material-symbols-outlined","pill-icon","icon-pro"],["type","button",1,"standalone-stop-btn",3,"disabled","title"],[1,"resizer",3,"mousedown"],[1,"resizer-handle"],[1,"right-panel"],[1,"material-symbols-outlined","toast-icon"],[1,"toast-text"],["type","button","aria-label","Close error message",1,"btn-toast-close",3,"click"],["type","button","title","Clear input text","aria-label","Clear input text",1,"dock-circle-btn","btn-clear",3,"click"],["type","button",1,"dock-circle-btn","btn-stop",3,"click","disabled","title"],["type","button",1,"standalone-stop-btn",3,"click","disabled","title"]],template:function(e,o){if(e&1){let i=L();l(0,"div",1)(1,"main",2),$(2,"app-agent-stream"),l(3,"div",3),M(4,V4,8,1,"div",4),l(5,"div",5)(6,"div",6),k("click",function(a){return o.onCardClick(a)})("mouseenter",function(){return o.isHoveringCard.set(!0)})("mouseleave",function(){return o.isHoveringCard.set(!1)}),l(7,"div",7),$(8,"div",8),l(9,"div",9),yo(),l(10,"svg",10)(11,"defs")(12,"linearGradient",11),$(13,"stop",12)(14,"stop",13)(15,"stop",14)(16,"stop",15)(17,"stop",16),d()(),$(18,"path",17),d(),l(19,"svg",18)(20,"defs")(21,"linearGradient",19),$(22,"stop",20)(23,"stop",21)(24,"stop",22)(25,"stop",23),d()(),$(26,"path",24),d(),l(27,"svg",25)(28,"defs")(29,"linearGradient",26),$(30,"stop",27)(31,"stop",28)(32,"stop",29)(33,"stop",30),d()(),$(34,"path",31),d()()(),rr(),l(35,"div",32)(36,"span",33),u(37,"Start a new task..."),d()(),l(38,"div",34)(39,"div",35)(40,"div",36)(41,"textarea",37,0),hr("ngModelChange",function(a){return v(i),$a(o.taskInput,a)||(o.taskInput=a),y(a)}),k("input",function(a){return o.onTextareaInput(a)})("keydown",function(a){return o.onKeyDown(a)})("focus",function(){return o.onInputFocus()})("blur",function(){return o.onInputBlur()}),d(),Rt(),d(),M(43,j4,3,0,"button",38),M(44,$4,3,3,"button",39),l(45,"button",40),k("click",function(){return o.submitTask()}),M(46,z4,2,0,"span",41),M(47,B4,2,0,"span",42),d()(),l(48,"div",43)(49,"div",44)(50,"div",45)(51,"button",46),k("mousedown",function(a){return a.preventDefault()})("click",function(a){return o.setProfile("flash",a)}),l(52,"span",47),u(53,"bolt"),d(),l(54,"span",48),u(55,"Flash"),d()(),l(56,"button",49),k("mousedown",function(a){return a.preventDefault()})("click",function(a){return o.setProfile("pro",a)}),l(57,"span",50),u(58,"psychology"),d(),l(59,"span",48),u(60,"Pro"),d()()()()()()(),M(61,H4,3,3,"button",51),d()()(),l(62,"div",52),k("mousedown",function(a){return o.onDragStart(a)}),$(63,"div",53),d(),l(64,"aside",54),$(65,"app-chat-interface"),d(),$(66,"app-floating-video-player"),d()}e&2&&(w("is-resizing",o.isDragging()),g(3),w("is-expanded",o.isBarExpanded())("is-dormant",!o.isBarExpanded())("is-focused",o.isInputFocused())("has-text",!!o.taskInput.trim())("is-running",o.isTaskRunning()),g(),b(o.errorMessage()?4:-1),g(3),w("is-pro",o.selectedProfile()==="pro"),g(34),_r("ngModel",o.taskInput),R("disabled",o.isSubmitting())("placeholder",o.selectedProfile()==="pro"?"Pro: plans first, checks results and recovers from blocked steps. For long or complex tasks":"Flash: fast single-look loop for everyday app tasks"),Ft(),g(2),b(o.taskInput.trim()&&!o.isSubmitting()?43:-1),g(),b(o.isTaskRunning()?44:-1),g(),w("has-content",!!o.taskInput.trim()),R("disabled",!o.taskInput.trim()||o.isSubmitting())("title",o.isSubmitting()?"Starting task...":"Start task (Enter)"),g(),b(o.isSubmitting()?46:-1),g(),b(o.isSubmitting()?-1:47),g(4),w("active",o.selectedProfile()==="flash"),g(5),w("active",o.selectedProfile()==="pro"),g(5),b(o.isTaskRunning()?61:-1),g(3),Se("width",o.rightPanelWidth(),"px"))},dependencies:[Qt,zr,Nd,ws,Wd,qr,Kd],styles:["[_nghost-%COMP%]{display:block;height:100vh;width:100vw;overflow:hidden;font-family:Google Sans Text,Google Sans,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif}.workspace-container[_ngcontent-%COMP%]{position:relative;display:flex;height:100vh;width:100vw;background-color:#e2e5ea;overflow:hidden}.workspace-container.is-resizing[_ngcontent-%COMP%]{cursor:col-resize;-webkit-user-select:none;user-select:none}.workspace-container[_ngcontent-%COMP%] .glass-panel[_ngcontent-%COMP%]{position:relative;z-index:1;background:#f4fafb;border:none;box-shadow:none}.workspace-container[_ngcontent-%COMP%] .left-panel[_ngcontent-%COMP%]{flex:1 0 500px;display:flex;flex-direction:column;overflow:hidden;background:#f4fafb;border-right:1px solid #e2e8f0;border-left:none;border-top:none;border-bottom:none;min-width:450px;height:100%}.workspace-container[_ngcontent-%COMP%] .right-panel[_ngcontent-%COMP%]{flex:0 1 auto;min-width:250px;max-width:100%;padding:0;border:none;background-color:#fff;box-shadow:-6px 0 24px #00000008;height:100%;display:flex;flex-direction:column;box-sizing:border-box;z-index:2}.workspace-container[_ngcontent-%COMP%] .resizer[_ngcontent-%COMP%]{width:12px;cursor:col-resize;display:flex;align-items:center;justify-content:center;z-index:10;transition:background .2s ease;margin:0 -6px}.workspace-container[_ngcontent-%COMP%] .resizer[_ngcontent-%COMP%]:hover, .workspace-container.is-resizing[_ngcontent-%COMP%] .workspace-container[_ngcontent-%COMP%] .resizer[_ngcontent-%COMP%]{background:#ffffff26}.workspace-container[_ngcontent-%COMP%] .resizer[_ngcontent-%COMP%]:hover .resizer-handle[_ngcontent-%COMP%], .workspace-container.is-resizing[_ngcontent-%COMP%] .workspace-container[_ngcontent-%COMP%] .resizer[_ngcontent-%COMP%] .resizer-handle[_ngcontent-%COMP%]{background:#5e72e4cc;height:60px}.workspace-container[_ngcontent-%COMP%] .resizer[_ngcontent-%COMP%] .resizer-handle[_ngcontent-%COMP%]{width:4px;height:40px;background:#00000026;border-radius:4px;transition:background .2s ease,height .2s ease}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%]{position:absolute;bottom:18px;left:50%;transform:translate(-50%);width:100%;max-width:min(680px,100% - 36px);z-index:50;display:flex;flex-direction:column;align-items:center;pointer-events:none;transition:all .25s cubic-bezier(.16,1,.3,1)}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .floating-dock-toast[_ngcontent-%COMP%]{pointer-events:auto;margin-bottom:8px;display:flex;align-items:center;gap:8px;padding:7px 14px;border-radius:12px;font-size:12.5px;font-weight:500;box-shadow:0 8px 24px -4px #ef444426;animation:_ngcontent-%COMP%_slideUpFade .2s ease-out;max-width:90%}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .floating-dock-toast.error[_ngcontent-%COMP%]{background:#fef2f2e0;backdrop-filter:blur(20px) saturate(180%);-webkit-backdrop-filter:blur(20px) saturate(180%);border:1px solid rgba(254,202,202,.9);color:#991b1b}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .floating-dock-toast.error[_ngcontent-%COMP%] .toast-icon[_ngcontent-%COMP%]{font-size:16px;color:#ef4444;flex-shrink:0}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .floating-dock-toast.error[_ngcontent-%COMP%] .toast-text[_ngcontent-%COMP%]{flex:1;line-height:1.4}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .floating-dock-toast.error[_ngcontent-%COMP%] .btn-toast-close[_ngcontent-%COMP%]{background:transparent;border:none;padding:2px;cursor:pointer;color:#991b1b;display:flex;align-items:center;justify-content:center;border-radius:4px;opacity:.7;transition:opacity .15s ease}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .floating-dock-toast.error[_ngcontent-%COMP%] .btn-toast-close[_ngcontent-%COMP%]:hover{opacity:1;background:#0000000d}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .floating-dock-toast.error[_ngcontent-%COMP%] .btn-toast-close[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:15px}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .floating-dock-row[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:center;gap:10px;width:100%;pointer-events:none;transition:gap .32s cubic-bezier(.16,1,.3,1)}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .standalone-stop-btn[_ngcontent-%COMP%]{pointer-events:auto;display:flex;align-items:center;justify-content:center;width:34px;height:34px;min-width:34px;border-radius:50%;border:1px solid rgba(255,255,255,.85);background:#ef4444e6;backdrop-filter:blur(24px) saturate(190%) brightness(105%);-webkit-backdrop-filter:blur(24px) saturate(190%) brightness(105%);color:#fff;box-shadow:0 6px 18px -2px #ef444466,inset 0 1px 1.5px #ffffffb3;cursor:pointer;outline:none;flex-shrink:0;transition:opacity .28s cubic-bezier(.16,1,.3,1),transform .28s cubic-bezier(.16,1,.3,1),width .28s cubic-bezier(.16,1,.3,1),min-width .28s cubic-bezier(.16,1,.3,1),margin .28s cubic-bezier(.16,1,.3,1),background .2s ease,box-shadow .2s ease;animation:_ngcontent-%COMP%_popIn .25s cubic-bezier(.16,1,.3,1)}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .standalone-stop-btn[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:17px;line-height:1}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .standalone-stop-btn[_ngcontent-%COMP%]:hover:not(:disabled){background:#dc2626;transform:scale(1.1);box-shadow:0 8px 22px #ef44448c,inset 0 1px 1.5px #ffffffe6}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .standalone-stop-btn[_ngcontent-%COMP%]:active:not(:disabled){transform:scale(.93)}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .standalone-stop-btn[_ngcontent-%COMP%]:disabled{opacity:.6;cursor:not-allowed}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .floating-dock-card[_ngcontent-%COMP%]{pointer-events:auto;position:relative;width:100%;border-radius:18px;box-sizing:border-box;overflow:hidden;transition:max-width .32s cubic-bezier(.16,1,.3,1),width .32s cubic-bezier(.16,1,.3,1),border-radius .28s cubic-bezier(.16,1,.3,1),box-shadow .28s ease,background .28s ease,transform .28s cubic-bezier(.16,1,.3,1),border-color .28s ease}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .dock-wave-container[_ngcontent-%COMP%]{position:absolute;bottom:0;left:0;right:0;height:100%;pointer-events:none;border-radius:inherit;overflow:hidden;z-index:0;opacity:.78;transition:opacity .35s ease,filter .35s ease}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .dock-wave-container[_ngcontent-%COMP%] .wave-stop-1[_ngcontent-%COMP%]{stop-color:#3b82f6;stop-opacity:.65}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .dock-wave-container[_ngcontent-%COMP%] .wave-stop-2[_ngcontent-%COMP%]{stop-color:#8b5cf6;stop-opacity:.7}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .dock-wave-container[_ngcontent-%COMP%] .wave-stop-3[_ngcontent-%COMP%]{stop-color:#f59e0b;stop-opacity:.65}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .dock-wave-container[_ngcontent-%COMP%] .wave-stop-4[_ngcontent-%COMP%]{stop-color:#06b6d4;stop-opacity:.6}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .dock-wave-container.is-pro[_ngcontent-%COMP%] .wave-stop-1[_ngcontent-%COMP%]{stop-color:#7c3aed;stop-opacity:.7}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .dock-wave-container.is-pro[_ngcontent-%COMP%] .wave-stop-2[_ngcontent-%COMP%]{stop-color:#ec4899;stop-opacity:.75}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .dock-wave-container.is-pro[_ngcontent-%COMP%] .wave-stop-3[_ngcontent-%COMP%]{stop-color:#6366f1;stop-opacity:.65}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .dock-wave-container.is-pro[_ngcontent-%COMP%] .wave-stop-4[_ngcontent-%COMP%]{stop-color:#a855f7;stop-opacity:.7}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .dock-wave-container[_ngcontent-%COMP%] .wave-glow-ambient[_ngcontent-%COMP%]{position:absolute;bottom:-8px;left:5%;right:5%;height:30px;border-radius:50%;background:radial-gradient(ellipse at center,rgba(99,102,241,.4) 0%,rgba(245,158,11,.2) 50%,transparent 80%);filter:blur(16px);opacity:.7;animation:_ngcontent-%COMP%_glowBreathe 5s ease-in-out infinite alternate}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .dock-wave-container.is-pro[_ngcontent-%COMP%] .wave-glow-ambient[_ngcontent-%COMP%]{background:radial-gradient(ellipse at center,rgba(168,85,247,.45) 0%,rgba(236,72,153,.25) 50%,transparent 80%)}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .dock-wave-container[_ngcontent-%COMP%] .wave-tracks-wrapper[_ngcontent-%COMP%]{position:absolute;bottom:0;left:0;width:100%;height:22px;overflow:hidden;mask-image:linear-gradient(to top,rgba(0,0,0,.9) 0%,rgba(0,0,0,.5) 60%,transparent 100%);-webkit-mask-image:linear-gradient(to top,rgba(0,0,0,.9) 0%,rgba(0,0,0,.5) 60%,transparent 100%)}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .dock-wave-container[_ngcontent-%COMP%] .wave-tracks-wrapper[_ngcontent-%COMP%] .liquid-wave[_ngcontent-%COMP%]{position:absolute;bottom:0;left:0;width:200%;height:100%;will-change:transform;filter:blur(8px)}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .dock-wave-container[_ngcontent-%COMP%] .wave-tracks-wrapper[_ngcontent-%COMP%] .liquid-wave.wave-back[_ngcontent-%COMP%]{opacity:.45;animation:_ngcontent-%COMP%_waveSlide 12s ease-in-out infinite alternate}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .dock-wave-container[_ngcontent-%COMP%] .wave-tracks-wrapper[_ngcontent-%COMP%] .liquid-wave.wave-mid[_ngcontent-%COMP%]{opacity:.6;animation:_ngcontent-%COMP%_waveSlideReverse 9s ease-in-out infinite alternate}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .dock-wave-container[_ngcontent-%COMP%] .wave-tracks-wrapper[_ngcontent-%COMP%] .liquid-wave.wave-front[_ngcontent-%COMP%]{opacity:.75;animation:_ngcontent-%COMP%_waveSlide 6.5s ease-in-out infinite alternate}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper.is-dormant[_ngcontent-%COMP%] .floating-dock-card[_ngcontent-%COMP%]{max-width:160px;width:160px;margin:0;background:#ffffffa6;backdrop-filter:blur(26px) saturate(190%) brightness(104%);-webkit-backdrop-filter:blur(26px) saturate(190%) brightness(104%);border:1px solid rgba(255,255,255,.85);border-radius:17px;box-shadow:0 6px 18px -2px #0f172a12,inset 0 1px 1.5px #fffffff2,inset 0 -1px 1px #ffffff4d;cursor:pointer;opacity:.92;transform:translateY(2px)}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper.is-dormant[_ngcontent-%COMP%] .floating-dock-card[_ngcontent-%COMP%] .dock-wave-container[_ngcontent-%COMP%]{opacity:.78}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper.is-dormant[_ngcontent-%COMP%] .floating-dock-card[_ngcontent-%COMP%] .dock-wave-container[_ngcontent-%COMP%] .wave-tracks-wrapper[_ngcontent-%COMP%]{height:12px}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper.is-dormant[_ngcontent-%COMP%] .floating-dock-card[_ngcontent-%COMP%]:hover{opacity:1;transform:translateY(0);background:#ffffffd1;border-color:#fff;box-shadow:0 8px 24px -2px #0f172a1f,inset 0 1px 1.5px #fff}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper.is-dormant[_ngcontent-%COMP%] .floating-dock-card[_ngcontent-%COMP%]:hover .dock-wave-container[_ngcontent-%COMP%]{opacity:.95;filter:drop-shadow(0 0 6px rgba(99,102,241,.35))}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper.is-dormant[_ngcontent-%COMP%] .floating-dock-card[_ngcontent-%COMP%] .capsule-peek-view[_ngcontent-%COMP%]{display:flex;opacity:1}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper.is-dormant[_ngcontent-%COMP%] .floating-dock-card[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%]{display:none;opacity:0}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .capsule-peek-view[_ngcontent-%COMP%]{position:relative;z-index:1;display:flex;align-items:center;justify-content:center;padding:6px 14px;min-height:34px;box-sizing:border-box;-webkit-user-select:none;user-select:none}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .capsule-peek-view[_ngcontent-%COMP%] .capsule-placeholder[_ngcontent-%COMP%]{color:#475569;font-size:12px;font-weight:500;letter-spacing:.1px;white-space:nowrap;text-align:center}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper.is-expanded[_ngcontent-%COMP%] .floating-dock-row[_ngcontent-%COMP%]{gap:0}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper.is-expanded[_ngcontent-%COMP%] .standalone-stop-btn[_ngcontent-%COMP%]{opacity:0;width:0;min-width:0;margin:0;padding:0;border-width:0;transform:scale(.5);pointer-events:none}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper.is-expanded[_ngcontent-%COMP%] .floating-dock-card[_ngcontent-%COMP%]{max-width:100%;width:100%;background:#ffffffad;backdrop-filter:blur(32px) saturate(210%) brightness(105%);-webkit-backdrop-filter:blur(32px) saturate(210%) brightness(105%);border:1px solid rgba(255,255,255,.82);box-shadow:0 16px 44px -8px #0f172a1f,0 2px 8px #00000005,inset 0 1.5px 2px #fffffff2,inset 0 -1px 2px #ffffff4d;opacity:1;transform:translateY(0)}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper.is-expanded[_ngcontent-%COMP%] .floating-dock-card[_ngcontent-%COMP%] .dock-wave-container[_ngcontent-%COMP%]{opacity:.88}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper.is-expanded[_ngcontent-%COMP%] .floating-dock-card[_ngcontent-%COMP%] .dock-wave-container[_ngcontent-%COMP%] .wave-tracks-wrapper[_ngcontent-%COMP%]{height:22px}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper.is-expanded[_ngcontent-%COMP%] .floating-dock-card[_ngcontent-%COMP%] .capsule-peek-view[_ngcontent-%COMP%]{display:none;opacity:0}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper.is-expanded[_ngcontent-%COMP%] .floating-dock-card[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%]{display:block;opacity:1;animation:_ngcontent-%COMP%_cardFadeIn .25s cubic-bezier(.16,1,.3,1)}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper.is-focused[_ngcontent-%COMP%] .floating-dock-card[_ngcontent-%COMP%]{background:#ffffffc7;backdrop-filter:blur(36px) saturate(220%) brightness(106%);-webkit-backdrop-filter:blur(36px) saturate(220%) brightness(106%);border-color:#3b82f68c;box-shadow:0 20px 48px -6px #2563eb29,0 0 0 2.5px #3b82f62e,inset 0 1.5px 2px #fff}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper.is-focused[_ngcontent-%COMP%] .floating-dock-card[_ngcontent-%COMP%] .dock-wave-container[_ngcontent-%COMP%]{opacity:1;filter:drop-shadow(0 0 8px rgba(59,130,246,.35))}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%]{position:relative;z-index:1;display:flex;flex-direction:column;width:100%}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-main-input-row[_ngcontent-%COMP%]{display:flex;align-items:center;gap:10px;padding:8px 10px 3px 14px;min-height:48px;box-sizing:border-box}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-main-input-row[_ngcontent-%COMP%] .leading-status-icon[_ngcontent-%COMP%]{width:14px;height:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-main-input-row[_ngcontent-%COMP%] .leading-status-icon[_ngcontent-%COMP%] .pulse-indicator-dot[_ngcontent-%COMP%]{width:8px;height:8px;border-radius:50%;background:#ef4444;box-shadow:0 0 #ef444499;animation:_ngcontent-%COMP%_pulseDot 1.4s infinite}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-main-input-row[_ngcontent-%COMP%] .input-content-area[_ngcontent-%COMP%]{flex:1;min-width:0;display:flex;align-items:center}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-main-input-row[_ngcontent-%COMP%] .input-content-area[_ngcontent-%COMP%] .dock-textarea[_ngcontent-%COMP%]{width:100%;border:none;outline:none;background:transparent;resize:none;font-family:inherit;font-size:13.5px;font-weight:450;line-height:1.5;color:#0f172a;padding:3px 0;max-height:120px;box-sizing:border-box;display:block}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-main-input-row[_ngcontent-%COMP%] .input-content-area[_ngcontent-%COMP%] .dock-textarea[_ngcontent-%COMP%]::placeholder{color:#64748b;font-size:12.5px;font-weight:400;transition:color .15s ease}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-main-input-row[_ngcontent-%COMP%] .input-content-area[_ngcontent-%COMP%] .dock-textarea[_ngcontent-%COMP%]:disabled{opacity:.6;cursor:not-allowed}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-main-input-row[_ngcontent-%COMP%] .dock-circle-btn[_ngcontent-%COMP%]{border-radius:50%;display:flex;align-items:center;justify-content:center;border:none;cursor:pointer;outline:none;flex-shrink:0;transition:background .18s cubic-bezier(.16,1,.3,1),color .18s cubic-bezier(.16,1,.3,1),transform .18s cubic-bezier(.16,1,.3,1),box-shadow .18s cubic-bezier(.16,1,.3,1),border-color .18s cubic-bezier(.16,1,.3,1)}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-main-input-row[_ngcontent-%COMP%] .dock-circle-btn[_ngcontent-%COMP%] .material-symbols-outlined[_ngcontent-%COMP%]{font-size:18px;line-height:1}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-main-input-row[_ngcontent-%COMP%] .dock-circle-btn[_ngcontent-%COMP%] .spinning-icon[_ngcontent-%COMP%]{font-size:17px;animation:_ngcontent-%COMP%_spin 1s linear infinite}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-main-input-row[_ngcontent-%COMP%] .dock-circle-btn.btn-clear[_ngcontent-%COMP%]{width:26px;height:26px;background:#ffffff73;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);border:1px solid rgba(255,255,255,.6);color:#64748b}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-main-input-row[_ngcontent-%COMP%] .dock-circle-btn.btn-clear[_ngcontent-%COMP%]:hover{background:#ffffffbf;color:#0f172a}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-main-input-row[_ngcontent-%COMP%] .dock-circle-btn.btn-stop[_ngcontent-%COMP%]{width:36px;height:36px;background:#ef4444e6;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);color:#fff;border:1px solid rgba(255,255,255,.35);box-shadow:0 4px 12px #ef44444d;animation:_ngcontent-%COMP%_popIn .2s cubic-bezier(.16,1,.3,1)}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-main-input-row[_ngcontent-%COMP%] .dock-circle-btn.btn-stop[_ngcontent-%COMP%]:hover:not(:disabled){background:#dc2626;transform:scale(1.05);box-shadow:0 6px 16px #ef444466}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-main-input-row[_ngcontent-%COMP%] .dock-circle-btn.btn-stop[_ngcontent-%COMP%]:active:not(:disabled){transform:scale(.95)}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-main-input-row[_ngcontent-%COMP%] .dock-circle-btn.btn-stop[_ngcontent-%COMP%]:disabled{opacity:.6;cursor:not-allowed}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-main-input-row[_ngcontent-%COMP%] .dock-circle-btn.btn-send[_ngcontent-%COMP%]{width:36px;height:36px;background:#0f172ae0;-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,.25);color:#fff;box-shadow:0 4px 14px #0f172a33,inset 0 1px 1px #ffffff40}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-main-input-row[_ngcontent-%COMP%] .dock-circle-btn.btn-send[_ngcontent-%COMP%]:disabled{background:#e2e8f099;color:#94a3b8;cursor:not-allowed;box-shadow:none;border-color:#ffffff80;transform:none}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-main-input-row[_ngcontent-%COMP%] .dock-circle-btn.btn-send.has-content[_ngcontent-%COMP%]:not(:disabled){background:linear-gradient(135deg,#1e293bf2,#0f172afa);box-shadow:0 4px 14px #0f172a40,inset 0 1px 1px #ffffff4d}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-main-input-row[_ngcontent-%COMP%] .dock-circle-btn.btn-send.has-content[_ngcontent-%COMP%]:not(:disabled):hover{background:linear-gradient(135deg,#2563eb,#1d4ed8);transform:scale(1.05);box-shadow:0 6px 18px #2563eb59}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-main-input-row[_ngcontent-%COMP%] .dock-circle-btn.btn-send[_ngcontent-%COMP%]:active:not(:disabled){transform:scale(.95)}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-footer-row[_ngcontent-%COMP%]{display:flex;align-items:center;justify-content:space-between;padding:3px 14px 8px;box-sizing:border-box}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-footer-row[_ngcontent-%COMP%] .footer-left-group[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-footer-row[_ngcontent-%COMP%] .footer-left-group[_ngcontent-%COMP%] .model-profile-toggle[_ngcontent-%COMP%]{display:inline-flex;align-items:center;background:#ffffff73;backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border:1px solid rgba(255,255,255,.65);box-shadow:inset 0 1px 1px #fffc,0 1px 3px #00000008;border-radius:8px;padding:2px;gap:2px}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-footer-row[_ngcontent-%COMP%] .footer-left-group[_ngcontent-%COMP%] .model-profile-toggle[_ngcontent-%COMP%] .profile-toggle-pill[_ngcontent-%COMP%]{display:inline-flex;align-items:center;justify-content:center;gap:3.5px;width:62px;min-width:62px;height:22px;border-radius:6px;border:none;background:transparent;color:#64748b;font-family:inherit;font-size:11px;font-weight:550;cursor:pointer;transition:color .16s cubic-bezier(.16,1,.3,1),background .16s cubic-bezier(.16,1,.3,1),box-shadow .16s cubic-bezier(.16,1,.3,1);outline:none;-webkit-user-select:none;user-select:none;box-sizing:border-box}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-footer-row[_ngcontent-%COMP%] .footer-left-group[_ngcontent-%COMP%] .model-profile-toggle[_ngcontent-%COMP%] .profile-toggle-pill[_ngcontent-%COMP%] .pill-icon[_ngcontent-%COMP%]{font-size:13px;line-height:1;flex-shrink:0}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-footer-row[_ngcontent-%COMP%] .footer-left-group[_ngcontent-%COMP%] .model-profile-toggle[_ngcontent-%COMP%] .profile-toggle-pill[_ngcontent-%COMP%] .pill-label[_ngcontent-%COMP%]{line-height:1}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-footer-row[_ngcontent-%COMP%] .footer-left-group[_ngcontent-%COMP%] .model-profile-toggle[_ngcontent-%COMP%] .profile-toggle-pill[_ngcontent-%COMP%]:hover:not(.active){color:#1e293b;background:#ffffffa6}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-footer-row[_ngcontent-%COMP%] .footer-left-group[_ngcontent-%COMP%] .model-profile-toggle[_ngcontent-%COMP%] .profile-toggle-pill.active[_ngcontent-%COMP%]{background:#ffffffeb;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);color:#0f172a;font-weight:700;box-shadow:0 1.5px 4px #00000014,inset 0 1px 1.5px #fff}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-footer-row[_ngcontent-%COMP%] .footer-left-group[_ngcontent-%COMP%] .model-profile-toggle[_ngcontent-%COMP%] .profile-toggle-pill.active[_ngcontent-%COMP%] .icon-flash[_ngcontent-%COMP%]{color:#d97706}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-footer-row[_ngcontent-%COMP%] .footer-left-group[_ngcontent-%COMP%] .model-profile-toggle[_ngcontent-%COMP%] .profile-toggle-pill.active[_ngcontent-%COMP%] .icon-pro[_ngcontent-%COMP%]{color:#7c3aed}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-footer-row[_ngcontent-%COMP%] .footer-right-status[_ngcontent-%COMP%] .running-status-pill[_ngcontent-%COMP%]{display:inline-flex;align-items:center;gap:4.5px;font-size:10.5px;font-weight:600;color:#dc2626;background:#fee2e2b3;padding:2px 7px;border-radius:10px}.workspace-container[_ngcontent-%COMP%] .workspace-floating-bar-wrapper[_ngcontent-%COMP%] .expanded-card-content[_ngcontent-%COMP%] .card-footer-row[_ngcontent-%COMP%] .footer-right-status[_ngcontent-%COMP%] .running-status-pill[_ngcontent-%COMP%] .running-pulse-dot[_ngcontent-%COMP%]{width:5.5px;height:5.5px;border-radius:50%;background:#ef4444;animation:_ngcontent-%COMP%_pulseDot 1.2s infinite}@media(max-width:1150px){.workspace-container[_ngcontent-%COMP%] .right-panel[_ngcontent-%COMP%], .workspace-container[_ngcontent-%COMP%] .resizer[_ngcontent-%COMP%]{display:none!important}.workspace-container[_ngcontent-%COMP%] .left-panel[_ngcontent-%COMP%]{border-right:none!important;width:100%!important;flex:1 1 100%!important;min-width:0!important}}@keyframes _ngcontent-%COMP%_popIn{0%{opacity:0;transform:scale(.8)}to{opacity:1;transform:scale(1)}}@keyframes _ngcontent-%COMP%_spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@keyframes _ngcontent-%COMP%_slideUpFade{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}@keyframes _ngcontent-%COMP%_cardFadeIn{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}@keyframes _ngcontent-%COMP%_fadeIn{0%{opacity:0}to{opacity:1}}@keyframes _ngcontent-%COMP%_pulseDot{0%{transform:scale(.95);box-shadow:0 0 #ef4444b3}70%{transform:scale(1);box-shadow:0 0 0 6px #ef444400}to{transform:scale(.95);box-shadow:0 0 #ef444400}}@keyframes _ngcontent-%COMP%_waveSlide{0%{transform:translateZ(0)}to{transform:translate3d(-50%,0,0)}}@keyframes _ngcontent-%COMP%_waveSlideReverse{0%{transform:translate3d(-50%,0,0)}to{transform:translateZ(0)}}@keyframes _ngcontent-%COMP%_glowBreathe{0%{opacity:.55;transform:scaleX(.92)}to{opacity:.95;transform:scaleX(1.08)}}@media(prefers-reduced-motion:reduce){.liquid-wave[_ngcontent-%COMP%], .wave-glow-ambient[_ngcontent-%COMP%], .pulse-indicator-dot[_ngcontent-%COMP%], .running-pulse-dot[_ngcontent-%COMP%]{animation:none!important}}"]})};var U4=(n,t)=>[n,t];function G4(n,t){if(n&1&&(l(0,"option",8),u(1),it(2,"date"),d()),n&2){let e=t.$implicit;R("value",e.session_id),g(),mr(" ",e.initial_goal||"Unnamed Goal"," (",e.device_serial?e.device_serial+" \xB7 ":"","",Et(2,4,e.start_time*1e3,"short"),") ")}}function q4(n,t){n&1&&(l(0,"div",11)(1,"div",13),u(2,"\u{1F4E1}"),d(),l(3,"h3"),u(4,"No Stream Activity Selected"),d(),l(5,"p"),u(6,"Please select a running session from the dropdown above to view real-time log payloads."),d()())}function W4(n,t){if(n&1&&(l(0,"div",22)(1,"div",27),u(2,"\u{1F4AC} Message to User / Summary:"),d(),l(3,"div",28),u(4),d()()),n&2){let e=m(2).$implicit;g(4),O(e.data.summary)}}function K4(n,t){if(n&1&&(l(0,"details",23)(1,"summary",29),u(2,"\u{1F9E0} Model Thinking Process (Click to expand)"),d(),l(3,"div",30),u(4),d()()),n&2){let e=m(2).$implicit;g(4),O(e.data.operator_native_thinking)}}function Y4(n,t){if(n&1&&(l(0,"details",24)(1,"summary",29),u(2,"\u{1F4DD} Raw Plan / Thoughts (Click to expand)"),d(),l(3,"div",31),u(4),d()()),n&2){let e=m(2).$implicit;g(4),O(e.data.operator_raw_thinking)}}function Z4(n,t){if(n&1&&(l(0,"details",25)(1,"summary",29),u(2,"\u{1F6E0}\uFE0F Tool Call Details (Click to expand)"),d(),l(3,"div",32)(4,"pre",33)(5,"code"),u(6),d()()()()),n&2){let e=m(2).$implicit,o=m(2);R("open",o.isShortContent(e.data.action_taken)),g(6),O(o.formatJson(e.data.action_taken))}}function Q4(n,t){if(n&1&&(l(0,"details",26)(1,"summary",29),u(2,"\u2713 Execution Result (Click to expand)"),d(),l(3,"div",32)(4,"pre",33)(5,"code"),u(6),d()()()()),n&2){let e=m(2).$implicit,o=m(2);g(6),O(o.formatJson(e.data.last_execution_result))}}function J4(n,t){if(n&1&&(l(0,"div",20),M(1,W4,5,1,"div",22),M(2,K4,5,1,"details",23),M(3,Y4,5,1,"details",24),M(4,Z4,7,2,"details",25),M(5,Q4,7,1,"details",26),d()),n&2){let e=m().$implicit;g(),b(e.data.summary?1:-1),g(),b(e.data.operator_native_thinking?2:-1),g(),b(e.data.operator_raw_thinking?3:-1),g(),b(e.data.action_taken?4:-1),g(),b(e.data.last_execution_result?5:-1)}}function X4(n,t){if(n&1&&(l(0,"div",21)(1,"details",34)(2,"summary",29),u(3,"\u{1F4C4} Raw Payload Data (Click to expand)"),d(),l(4,"div",32)(5,"pre",33)(6,"code"),u(7),d()()()()()),n&2){let e=m().$implicit,o=m(2);g(),R("open",o.isShortContent(e.data)),g(6),O(o.formatJson(e.data))}}function e5(n,t){if(n&1&&(l(0,"div",14)(1,"div",15)(2,"div",16)(3,"span",17),u(4),d(),l(5,"span",18),u(6),d()(),l(7,"span",19),u(8),it(9,"date"),d()(),M(10,J4,6,5,"div",20),M(11,X4,8,2,"div",21),d()),n&2){let e=t.$implicit,o=t.$index,i=m(2);R("ngClass",Gp(10,U4,"type-"+e.type,i.isStepEvent(e.type)?"step-event-card":"raw-event-card")),g(4),V("#",o+1),g(),R("ngClass",e.type),g(),O(e.type),g(2),O(Et(9,7,e.timestamp,"HH:mm:ss.SSS")),g(2),b(i.isStepEvent(e.type)?10:-1),g(),b(i.isStepEvent(e.type)?-1:11)}}function t5(n,t){if(n&1&&(l(0,"div",12),G(1,e5,12,13,"div",14,Ie),d()),n&2){let e=m();g(),q(e.agentService.sessionLogs())}}var Zd=class n{agentService=D(It);onSessionChange(t){let o=t.target.value;o&&this.agentService.selectSession(o,!0)}isStepEvent(t){return t==="step_recorded"||t==="step_updated"}isShortContent(t){return t?(typeof t=="string"?t:JSON.stringify(t)).length<200:!0}formatJson(t){if(typeof t=="string")try{return JSON.stringify(JSON.parse(t),null,2)}catch(e){return t}return JSON.stringify(t,null,2)}static \u0275fac=function(e){return new(e||n)};static \u0275cmp=Ge({type:n,selectors:[["app-legacy-agent-stream"]],decls:20,vars:3,consts:[[1,"agent-stream-container"],[1,"stream-header"],[1,"header-title"],[1,"subtitle"],[1,"session-selector-wrapper"],["for","session-select"],["id","session-select",1,"session-dropdown",3,"change","value"],["value","","disabled","","selected",""],[3,"value"],[1,"btn-refresh",3,"click"],[1,"stream-logs-content"],[1,"empty-state"],[1,"logs-list"],[1,"empty-icon"],[1,"log-card",3,"ngClass"],[1,"log-card-header"],[1,"badge-group"],[1,"index-badge"],[1,"type-badge",3,"ngClass"],[1,"timestamp"],[1,"log-card-body","step-body"],[1,"log-card-body","raw-body"],[1,"step-summary-section"],[1,"thinking-details"],[1,"planning-details"],[1,"tool-details",3,"open"],[1,"result-details"],[1,"section-title"],[1,"summary-bubble"],[1,"details-summary"],[1,"details-content","thinking-text"],[1,"details-content","planning-text"],[1,"details-content"],[1,"json-code"],[1,"raw-details",3,"open"]],template:function(e,o){e&1&&(l(0,"div",0)(1,"header",1)(2,"div",2)(3,"h2"),u(4,"\u{1F463} Real-time Agent Stream (Legacy Card View)"),d(),l(5,"p",3),u(6,"Select a session to monitor raw agent activity"),d()(),l(7,"div",4)(8,"label",5),u(9,"Active Session:"),d(),l(10,"select",6),k("change",function(r){return o.onSessionChange(r)}),l(11,"option",7),u(12,"-- Select a Session --"),d(),G(13,G4,3,7,"option",8,Ie),d(),l(15,"button",9),k("click",function(){return o.agentService.fetchSessions()}),u(16,"\u{1F504} Refresh"),d()()(),l(17,"main",10),M(18,q4,7,0,"div",11),M(19,t5,3,0,"div",12),d()()),e&2&&(g(10),R("value",o.agentService.currentSessionId()||""),g(3),q(o.agentService.sessions()),g(5),b(o.agentService.sessionLogs().length===0?18:-1),g(),b(o.agentService.sessionLogs().length>0?19:-1))},dependencies:[No,ki,Qt,cP,dP,Ei],styles:["[_nghost-%COMP%]{display:flex;flex-direction:column;height:100%;width:100%;overflow:hidden}.agent-stream-container[_ngcontent-%COMP%]{display:flex;flex-direction:column;height:100%;background:#fff6;-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);box-sizing:border-box}.agent-stream-container[_ngcontent-%COMP%] .stream-header[_ngcontent-%COMP%]{display:flex;justify-content:space-between;align-items:center;padding:20px 24px;border-bottom:1px solid rgba(0,0,0,.08);background:#fff9;flex-wrap:wrap;gap:16px}.agent-stream-container[_ngcontent-%COMP%] .stream-header[_ngcontent-%COMP%] .header-title[_ngcontent-%COMP%] h2[_ngcontent-%COMP%]{margin:0 0 4px;font-size:20px;font-weight:700;color:#1f2937;letter-spacing:-.5px}.agent-stream-container[_ngcontent-%COMP%] .stream-header[_ngcontent-%COMP%] .header-title[_ngcontent-%COMP%] .subtitle[_ngcontent-%COMP%]{margin:0;font-size:13px;color:#4b5563}.agent-stream-container[_ngcontent-%COMP%] .stream-header[_ngcontent-%COMP%] .session-selector-wrapper[_ngcontent-%COMP%]{display:flex;align-items:center;gap:12px}.agent-stream-container[_ngcontent-%COMP%] .stream-header[_ngcontent-%COMP%] .session-selector-wrapper[_ngcontent-%COMP%] label[_ngcontent-%COMP%]{font-size:13.5px;font-weight:600;color:#1f2937}.agent-stream-container[_ngcontent-%COMP%] .stream-header[_ngcontent-%COMP%] .session-selector-wrapper[_ngcontent-%COMP%] .session-dropdown[_ngcontent-%COMP%]{padding:8px 12px;border:1px solid rgba(0,0,0,.12);border-radius:8px;font-size:13.5px;background-color:#fff;outline:none;color:#1f2937;min-width:250px;max-width:400px;cursor:pointer;transition:border-color .2s}.agent-stream-container[_ngcontent-%COMP%] .stream-header[_ngcontent-%COMP%] .session-selector-wrapper[_ngcontent-%COMP%] .session-dropdown[_ngcontent-%COMP%]:focus{border-color:#3b82f6}.agent-stream-container[_ngcontent-%COMP%] .stream-header[_ngcontent-%COMP%] .session-selector-wrapper[_ngcontent-%COMP%] .btn-refresh[_ngcontent-%COMP%]{padding:8px 14px;background:#fff;border:1px solid rgba(0,0,0,.12);border-radius:8px;font-size:13px;font-weight:600;color:#1f2937;cursor:pointer;display:flex;align-items:center;gap:6px;transition:background .2s,transform .1s}.agent-stream-container[_ngcontent-%COMP%] .stream-header[_ngcontent-%COMP%] .session-selector-wrapper[_ngcontent-%COMP%] .btn-refresh[_ngcontent-%COMP%]:hover{background:#f3f4f6}.agent-stream-container[_ngcontent-%COMP%] .stream-header[_ngcontent-%COMP%] .session-selector-wrapper[_ngcontent-%COMP%] .btn-refresh[_ngcontent-%COMP%]:active{transform:scale(.98)}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%]{flex:1;overflow-y:auto;padding:24px;background:#f9fafb4d}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%]::-webkit-scrollbar{width:6px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%]::-webkit-scrollbar-track{background:transparent}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%]::-webkit-scrollbar-thumb{background:#00000026;border-radius:10px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%]::-webkit-scrollbar-thumb:hover{background:#00000040}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .empty-state[_ngcontent-%COMP%]{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:40px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .empty-state[_ngcontent-%COMP%] .empty-icon[_ngcontent-%COMP%]{font-size:48px;margin-bottom:16px;opacity:.8}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .empty-state[_ngcontent-%COMP%] h3[_ngcontent-%COMP%]{margin:0 0 8px;font-size:18px;font-weight:600;color:#1f2937}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .empty-state[_ngcontent-%COMP%] p[_ngcontent-%COMP%]{margin:0;font-size:14px;color:#4b5563;max-width:320px;line-height:1.5}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:16px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%]{background:#fff;border:1px solid rgba(0,0,0,.06);border-radius:12px;padding:16px 20px;box-shadow:0 4px 6px -1px #00000003,0 2px 4px -1px #00000002;display:flex;flex-direction:column;gap:12px;transition:transform .2s,box-shadow .2s}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%]:hover{transform:translateY(-1px);box-shadow:0 10px 15px -3px #00000005,0 4px 6px -2px #00000003}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-header[_ngcontent-%COMP%]{display:flex;justify-content:space-between;align-items:center}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-header[_ngcontent-%COMP%] .badge-group[_ngcontent-%COMP%]{display:flex;align-items:center;gap:8px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-header[_ngcontent-%COMP%] .badge-group[_ngcontent-%COMP%] .index-badge[_ngcontent-%COMP%]{font-size:11px;font-weight:700;color:#9ca3af;font-family:monospace}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-header[_ngcontent-%COMP%] .badge-group[_ngcontent-%COMP%] .type-badge[_ngcontent-%COMP%]{font-size:10px;font-weight:800;text-transform:uppercase;padding:2.5px 7px;border-radius:9999px;letter-spacing:.5px;font-family:monospace}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-header[_ngcontent-%COMP%] .badge-group[_ngcontent-%COMP%] .type-badge.llm_stream[_ngcontent-%COMP%]{background:#e0f2fe;color:#0369a1}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-header[_ngcontent-%COMP%] .badge-group[_ngcontent-%COMP%] .type-badge.trace_recorded[_ngcontent-%COMP%]{background:#f3e8ff;color:#6b21a8}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-header[_ngcontent-%COMP%] .badge-group[_ngcontent-%COMP%] .type-badge.step_recorded[_ngcontent-%COMP%]{background:#ecfdf5;color:#047857}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-header[_ngcontent-%COMP%] .badge-group[_ngcontent-%COMP%] .type-badge.step_updated[_ngcontent-%COMP%]{background:#fef3c7;color:#b45309}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-header[_ngcontent-%COMP%] .badge-group[_ngcontent-%COMP%] .type-badge.background_tasks_updated[_ngcontent-%COMP%]{background:#fff1f2;color:#be123c}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-header[_ngcontent-%COMP%] .timestamp[_ngcontent-%COMP%]{font-size:11.5px;color:#9ca3af;font-family:monospace}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body.step-body[_ngcontent-%COMP%]{display:flex;flex-direction:column;gap:16px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body.step-body[_ngcontent-%COMP%] .step-summary-section[_ngcontent-%COMP%] .section-title[_ngcontent-%COMP%]{font-size:11px;font-weight:700;color:#4b5563;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body.step-body[_ngcontent-%COMP%] .step-summary-section[_ngcontent-%COMP%] .summary-bubble[_ngcontent-%COMP%]{background:#f1f5f9;border:1px solid rgba(0,0,0,.05);border-left:4px solid #475569;border-radius:8px;padding:12px 16px;font-size:13.5px;line-height:1.5;color:#1e293b}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body.step-body[_ngcontent-%COMP%] details[_ngcontent-%COMP%]{border:1px solid rgba(0,0,0,.06);border-radius:8px;background:#fafafa;overflow:hidden;transition:border-color .2s}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body.step-body[_ngcontent-%COMP%] details[open][_ngcontent-%COMP%]{border-color:#0000001f}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body.step-body[_ngcontent-%COMP%] details[open][_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]{border-bottom:1px solid rgba(0,0,0,.06);background:#00000005}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body.step-body[_ngcontent-%COMP%] details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]{padding:10px 14px;font-size:12.5px;font-weight:700;color:#4b5563;cursor:pointer;-webkit-user-select:none;user-select:none;outline:none;background:#fff;transition:background .2s}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body.step-body[_ngcontent-%COMP%] details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]:hover{background:#00000003}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body.step-body[_ngcontent-%COMP%] details[_ngcontent-%COMP%] .details-content[_ngcontent-%COMP%]{padding:14px;font-size:13px;line-height:1.6;color:#334155}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body.step-body[_ngcontent-%COMP%] details[_ngcontent-%COMP%] .details-content.thinking-text[_ngcontent-%COMP%]{background:#f0fdf4;color:#14532d;white-space:pre-wrap;font-family:inherit}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body.step-body[_ngcontent-%COMP%] details[_ngcontent-%COMP%] .details-content.planning-text[_ngcontent-%COMP%]{background:#f8fafc;color:#334155;white-space:pre-wrap;font-family:inherit}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body.step-body[_ngcontent-%COMP%] details.thinking-details[_ngcontent-%COMP%]{border-left:4px solid #16a34a}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body.step-body[_ngcontent-%COMP%] details.thinking-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]{color:#16a34a}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body.step-body[_ngcontent-%COMP%] details.planning-details[_ngcontent-%COMP%]{border-left:4px solid #4f46e5}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body.step-body[_ngcontent-%COMP%] details.planning-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]{color:#4f46e5}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body.step-body[_ngcontent-%COMP%] details.tool-details[_ngcontent-%COMP%]{border-left:4px solid #2563eb}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body.step-body[_ngcontent-%COMP%] details.tool-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]{color:#2563eb}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body.step-body[_ngcontent-%COMP%] details.result-details[_ngcontent-%COMP%]{border-left:4px solid #0891b2}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body.step-body[_ngcontent-%COMP%] details.result-details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]{color:#0891b2}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body.raw-body[_ngcontent-%COMP%] details[_ngcontent-%COMP%]{border:1px solid rgba(0,0,0,.06);border-radius:8px;overflow:hidden}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body.raw-body[_ngcontent-%COMP%] details[_ngcontent-%COMP%] .details-summary[_ngcontent-%COMP%]{padding:10px 14px;font-size:12px;font-weight:600;color:#4b5563;cursor:pointer;outline:none;background:#fff}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body.raw-body[_ngcontent-%COMP%] details[_ngcontent-%COMP%] .details-content[_ngcontent-%COMP%]{padding:12px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body[_ngcontent-%COMP%] .json-code[_ngcontent-%COMP%]{margin:0;background:#1e293b;color:#f8fafc;padding:14px 18px;border-radius:8px;font-family:Fira Code,Roboto Mono,monospace;font-size:12.5px;line-height:1.5;overflow-x:auto;white-space:pre-wrap;word-break:break-all}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body[_ngcontent-%COMP%] .json-code[_ngcontent-%COMP%]::-webkit-scrollbar{height:4px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body[_ngcontent-%COMP%] .json-code[_ngcontent-%COMP%]::-webkit-scrollbar-thumb{background:#fff3;border-radius:4px}.agent-stream-container[_ngcontent-%COMP%] .stream-logs-content[_ngcontent-%COMP%] .logs-list[_ngcontent-%COMP%] .log-card[_ngcontent-%COMP%] .log-card-body[_ngcontent-%COMP%] .json-code[_ngcontent-%COMP%]::-webkit-scrollbar-thumb:hover{background:#ffffff4d}"],changeDetection:1})};var Qd=class n{rightPanelWidth=typeof window<"u"?Math.round(window.innerWidth/3):450;isDragging=!1;onDragStart(t){this.isDragging=!0,t.preventDefault()}onMouseMove(t){if(!this.isDragging)return;let e=window.innerWidth-t.clientX,o=300,i=window.innerWidth-300;e>=o&&e<=i&&(this.rightPanelWidth=e)}onMouseUp(){this.isDragging=!1}static \u0275fac=function(e){return new(e||n)};static \u0275cmp=Ge({type:n,selectors:[["app-legacy-workspace"]],hostBindings:function(e,o){e&1&&k("mousemove",function(r){return o.onMouseMove(r)},hl)("mouseup",function(){return o.onMouseUp()},hl)},decls:11,vars:4,consts:[[1,"workspace-container"],[1,"aurora-background"],[1,"glow","glow-1"],[1,"glow","glow-2"],[1,"glow","glow-3"],[1,"left-panel","glass-panel"],[1,"resizer",3,"mousedown"],[1,"resizer-handle"],[1,"right-panel"]],template:function(e,o){e&1&&(l(0,"div",0)(1,"div",1),$(2,"div",2)(3,"div",3)(4,"div",4),d(),l(5,"main",5),$(6,"app-legacy-agent-stream"),d(),l(7,"div",6),k("mousedown",function(r){return o.onDragStart(r)}),$(8,"div",7),d(),l(9,"aside",8),$(10,"app-chat-interface"),d()()),e&2&&(w("is-resizing",o.isDragging),g(9),Se("width",o.rightPanelWidth,"px"))},dependencies:[Zd,qr],styles:[".workspace-container[_ngcontent-%COMP%]{position:relative;display:flex;height:100vh;width:100vw;background-color:#e2e5ea;overflow:hidden}.workspace-container.is-resizing[_ngcontent-%COMP%]{cursor:col-resize;-webkit-user-select:none;user-select:none}.workspace-container[_ngcontent-%COMP%] .aurora-background[_ngcontent-%COMP%]{position:absolute;top:0;left:0;width:100%;height:100%;z-index:0;overflow:hidden;background:linear-gradient(135deg,#e2e5ea,#cfd6df)}.workspace-container[_ngcontent-%COMP%] .aurora-background[_ngcontent-%COMP%] .glow[_ngcontent-%COMP%]{position:absolute;border-radius:50%;filter:blur(90px);opacity:.5;animation:_ngcontent-%COMP%_aurora-drift 22s infinite alternate ease-in-out}.workspace-container[_ngcontent-%COMP%] .aurora-background[_ngcontent-%COMP%] .glow-1[_ngcontent-%COMP%]{width:55vw;height:55vh;background:#5e72e440;top:-15%;left:-5%;animation-delay:0s}.workspace-container[_ngcontent-%COMP%] .aurora-background[_ngcontent-%COMP%] .glow-2[_ngcontent-%COMP%]{width:45vw;height:45vh;background:#2dce8933;bottom:-10%;left:30%;animation-delay:-5s}.workspace-container[_ngcontent-%COMP%] .aurora-background[_ngcontent-%COMP%] .glow-3[_ngcontent-%COMP%]{width:50vw;height:60vh;background:#ce68ac26;top:20%;right:-10%;animation-delay:-10s}.workspace-container[_ngcontent-%COMP%] .glass-panel[_ngcontent-%COMP%]{position:relative;z-index:1;background:#ffffff73;backdrop-filter:blur(24px) saturate(120%);-webkit-backdrop-filter:blur(24px) saturate(120%);border:1px solid rgba(255,255,255,.6);box-shadow:0 8px 32px #1f26870d}.workspace-container[_ngcontent-%COMP%] .left-panel[_ngcontent-%COMP%]{flex:1;display:flex;flex-direction:column;overflow:hidden;background:#ffffff59;border-right:1px solid rgba(255,255,255,.8);border-left:none;border-top:none;border-bottom:none}.workspace-container[_ngcontent-%COMP%] .right-panel[_ngcontent-%COMP%]{min-width:300px;flex-shrink:0;padding:0;border:none;background-color:#fff;box-shadow:-6px 0 24px #00000008;height:100%;display:flex;flex-direction:column;box-sizing:border-box;z-index:2}.workspace-container[_ngcontent-%COMP%] .resizer[_ngcontent-%COMP%]{width:12px;cursor:col-resize;display:flex;align-items:center;justify-content:center;z-index:10;transition:background .2s ease;margin:0 -6px}.workspace-container[_ngcontent-%COMP%] .resizer[_ngcontent-%COMP%]:hover, .workspace-container.is-resizing[_ngcontent-%COMP%] .workspace-container[_ngcontent-%COMP%] .resizer[_ngcontent-%COMP%]{background:#ffffff26}.workspace-container[_ngcontent-%COMP%] .resizer[_ngcontent-%COMP%]:hover .resizer-handle[_ngcontent-%COMP%], .workspace-container.is-resizing[_ngcontent-%COMP%] .workspace-container[_ngcontent-%COMP%] .resizer[_ngcontent-%COMP%] .resizer-handle[_ngcontent-%COMP%]{background:#5e72e4cc;height:60px}.workspace-container[_ngcontent-%COMP%] .resizer[_ngcontent-%COMP%] .resizer-handle[_ngcontent-%COMP%]{width:4px;height:40px;background:#00000026;border-radius:4px;transition:all .2s ease}@keyframes _ngcontent-%COMP%_aurora-drift{0%{transform:translate(0) scale(1)}50%{transform:translate(5%,3%) scale(1.05)}to{transform:translate(-2%,-5%) scale(.95)}}"],changeDetection:1})};var iO=[{path:"",component:qd},{path:"workspace",component:Yd},{path:"check",component:Qd},{path:"**",redirectTo:""}];var rO={providers:[qM({eventCoalescing:!0}),Wf(iO),_f(hf())]};var n5=()=>({exact:!0}),Jd=class n{static \u0275fac=function(e){return new(e||n)};static \u0275cmp=Ge({type:n,selectors:[["app-nav-switcher"]],decls:11,vars:2,consts:[["aria-label","Main Navigation",1,"floating-nav-switcher"],["routerLink","/","routerLinkActive","active","title","Return to Home Launcher to start a new task",1,"nav-tab-btn",3,"routerLinkActiveOptions"],[1,"material-symbols-outlined","tab-icon"],[1,"tab-label"],["routerLink","/workspace","routerLinkActive","active","title","Open Workspace",1,"nav-tab-btn"]],template:function(e,o){e&1&&(l(0,"nav",0)(1,"a",1)(2,"span",2),u(3,"add_task"),d(),l(4,"span",3),u(5,"New / Home"),d()(),l(6,"a",4)(7,"span",2),u(8,"space_dashboard"),d(),l(9,"span",3),u(10,"Workspace"),d()()()),e&2&&(g(),R("routerLinkActiveOptions",za(1,n5)))},dependencies:[Lr,qf],styles:[".floating-nav-switcher[_ngcontent-%COMP%]{position:fixed;top:12px;left:24px;z-index:1100;height:36px;box-sizing:border-box;display:inline-flex;align-items:center;background:#ffffffb8;backdrop-filter:blur(16px) saturate(180%);-webkit-backdrop-filter:blur(16px) saturate(180%);border:1px solid rgba(226,232,240,.85);border-radius:18px;padding:3px;gap:3px;box-shadow:0 2px 8px -1px #0f172a0f,0 1px 2px #00000005,inset 0 1px 1px #ffffffe6;-webkit-user-select:none;user-select:none;font-family:Google Sans,Google Sans Text,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;transition:background .25s cubic-bezier(.4,0,.2,1),box-shadow .25s cubic-bezier(.4,0,.2,1),border-color .25s cubic-bezier(.4,0,.2,1)}.floating-nav-switcher[_ngcontent-%COMP%]:hover{background:#ffffffd9;box-shadow:0 4px 14px -2px #0f172a1a,0 2px 4px #00000008,inset 0 1px 1px #fff;border-color:#cbd5e1e6}.floating-nav-switcher[_ngcontent-%COMP%] .nav-tab-btn[_ngcontent-%COMP%]{display:inline-flex;align-items:center;justify-content:center;gap:6px;height:30px;padding:0 14px;box-sizing:border-box;border-radius:15px;font-size:13px;font-weight:600;color:#475569;text-decoration:none;cursor:pointer;line-height:1;transition:background .2s cubic-bezier(.4,0,.2,1),color .2s cubic-bezier(.4,0,.2,1),box-shadow .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1);outline:none;white-space:nowrap}.floating-nav-switcher[_ngcontent-%COMP%] .nav-tab-btn[_ngcontent-%COMP%] .tab-icon[_ngcontent-%COMP%]{font-size:16px;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;color:#64748b;transition:color .15s ease,transform .15s ease}.floating-nav-switcher[_ngcontent-%COMP%] .nav-tab-btn[_ngcontent-%COMP%] .tab-label[_ngcontent-%COMP%]{color:#475569;transition:color .15s ease}.floating-nav-switcher[_ngcontent-%COMP%] .nav-tab-btn[_ngcontent-%COMP%]:hover:not(.active){background:#ffffffb3;color:#0f172a}.floating-nav-switcher[_ngcontent-%COMP%] .nav-tab-btn[_ngcontent-%COMP%]:hover:not(.active) .tab-icon[_ngcontent-%COMP%]{color:#1a73e8}.floating-nav-switcher[_ngcontent-%COMP%] .nav-tab-btn[_ngcontent-%COMP%]:hover:not(.active) .tab-label[_ngcontent-%COMP%]{color:#0f172a}.floating-nav-switcher[_ngcontent-%COMP%] .nav-tab-btn.active[_ngcontent-%COMP%]{background:#1a73e8f2!important;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);color:#fff!important;font-weight:700;box-shadow:0 2px 8px #1a73e859,inset 0 1px 1px #ffffff59}.floating-nav-switcher[_ngcontent-%COMP%] .nav-tab-btn.active[_ngcontent-%COMP%] .tab-icon[_ngcontent-%COMP%], .floating-nav-switcher[_ngcontent-%COMP%] .nav-tab-btn.active[_ngcontent-%COMP%] .tab-label[_ngcontent-%COMP%], .floating-nav-switcher[_ngcontent-%COMP%] .nav-tab-btn.active[_ngcontent-%COMP%] span[_ngcontent-%COMP%]{color:#fff!important}.floating-nav-switcher[_ngcontent-%COMP%] .nav-tab-btn[_ngcontent-%COMP%]:active{transform:scale(.97)}"],changeDetection:1})};var Xd=class n{title="frontend";static \u0275fac=function(e){return new(e||n)};static \u0275cmp=Ge({type:n,selectors:[["app-root"]],decls:2,vars:0,template:function(e,o){e&1&&$(0,"app-nav-switcher")(1,"router-outlet")},dependencies:[hs,Jd],encapsulation:2,changeDetection:1})};df(Xd,rO).catch(n=>console.error(n)); diff --git a/artemis/resources/showcase_ui/polyfills-5CFQRCPP.js b/artemis/resources/showcase_ui/polyfills-5CFQRCPP.js deleted file mode 100644 index b237b5ebf..000000000 --- a/artemis/resources/showcase_ui/polyfills-5CFQRCPP.js +++ /dev/null @@ -1,2 +0,0 @@ -var ce=globalThis;function te(t){return(ce.__Zone_symbol_prefix||"__zone_symbol__")+t}function ht(){let t=ce.performance;function n(I){t&&t.mark&&t.mark(I)}function a(I,s){t&&t.measure&&t.measure(I,s)}n("Zone");class e{static __symbol__=te;static assertZonePatched(){if(ce.Promise!==S.ZoneAwarePromise)throw new Error("Zone.js has detected that ZoneAwarePromise `(window|global).Promise` has been overwritten.\nMost likely cause is that a Promise polyfill has been loaded after Zone.js (Polyfilling Promise api is not necessary when zone.js is loaded. If you must load one, do so before loading zone.js.)")}static get root(){let s=e.current;for(;s.parent;)s=s.parent;return s}static get current(){return b.zone}static get currentTask(){return D}static __load_patch(s,i,r=!1){if(S.hasOwnProperty(s)){let E=ce[te("forceDuplicateZoneCheck")]===!0;if(!r&&E)throw Error("Already loaded patch: "+s)}else if(!ce["__Zone_disable_"+s]){let E="Zone:"+s;n(E),S[s]=i(ce,e,R),a(E,E)}}get parent(){return this._parent}get name(){return this._name}_parent;_name;_properties;_zoneDelegate;constructor(s,i){this._parent=s,this._name=i?i.name||"unnamed":"",this._properties=i&&i.properties||{},this._zoneDelegate=new f(this,this._parent&&this._parent._zoneDelegate,i)}get(s){let i=this.getZoneWith(s);if(i)return i._properties[s]}getZoneWith(s){let i=this;for(;i;){if(i._properties.hasOwnProperty(s))return i;i=i._parent}return null}fork(s){if(!s)throw new Error("ZoneSpec required!");return this._zoneDelegate.fork(this,s)}wrap(s,i){if(typeof s!="function")throw new Error("Expecting function got: "+s);let r=this._zoneDelegate.intercept(this,s,i),E=this;return function(){return E.runGuarded(r,this,arguments,i)}}run(s,i,r,E){b={parent:b,zone:this};try{return this._zoneDelegate.invoke(this,s,i,r,E)}finally{b=b.parent}}runGuarded(s,i=null,r,E){b={parent:b,zone:this};try{try{return this._zoneDelegate.invoke(this,s,i,r,E)}catch(x){if(this._zoneDelegate.handleError(this,x))throw x}}finally{b=b.parent}}runTask(s,i,r){if(s.zone!=this)throw new Error("A task can only be run in the zone of creation! (Creation: "+(s.zone||J).name+"; Execution: "+this.name+")");let E=s,{type:x,data:{isPeriodic:ee=!1,isRefreshable:M=!1}={}}=s;if(s.state===q&&(x===U||x===k))return;let he=s.state!=A;he&&E._transitionTo(A,d);let _e=D;D=E,b={parent:b,zone:this};try{x==k&&s.data&&!ee&&!M&&(s.cancelFn=void 0);try{return this._zoneDelegate.invokeTask(this,E,i,r)}catch(Q){if(this._zoneDelegate.handleError(this,Q))throw Q}}finally{let Q=s.state;if(Q!==q&&Q!==X)if(x==U||ee||M&&Q===p)he&&E._transitionTo(d,A,p);else{let Te=E._zoneDelegates;this._updateTaskCount(E,-1),he&&E._transitionTo(q,A,q),M&&(E._zoneDelegates=Te)}b=b.parent,D=_e}}scheduleTask(s){if(s.zone&&s.zone!==this){let r=this;for(;r;){if(r===s.zone)throw Error(`can not reschedule task to ${this.name} which is descendants of the original zone ${s.zone.name}`);r=r.parent}}s._transitionTo(p,q);let i=[];s._zoneDelegates=i,s._zone=this;try{s=this._zoneDelegate.scheduleTask(this,s)}catch(r){throw s._transitionTo(X,p,q),this._zoneDelegate.handleError(this,r),r}return s._zoneDelegates===i&&this._updateTaskCount(s,1),s.state==p&&s._transitionTo(d,p),s}scheduleMicroTask(s,i,r,E){return this.scheduleTask(new g(F,s,i,r,E,void 0))}scheduleMacroTask(s,i,r,E,x){return this.scheduleTask(new g(k,s,i,r,E,x))}scheduleEventTask(s,i,r,E,x){return this.scheduleTask(new g(U,s,i,r,E,x))}cancelTask(s){if(s.zone!=this)throw new Error("A task can only be cancelled in the zone of creation! (Creation: "+(s.zone||J).name+"; Execution: "+this.name+")");if(!(s.state!==d&&s.state!==A)){s._transitionTo(V,d,A);try{this._zoneDelegate.cancelTask(this,s)}catch(i){throw s._transitionTo(X,V),this._zoneDelegate.handleError(this,i),i}return this._updateTaskCount(s,-1),s._transitionTo(q,V),s.runCount=-1,s}}_updateTaskCount(s,i){let r=s._zoneDelegates;i==-1&&(s._zoneDelegates=null);for(let E=0;EI.hasTask(i,r),onScheduleTask:(I,s,i,r)=>I.scheduleTask(i,r),onInvokeTask:(I,s,i,r,E,x)=>I.invokeTask(i,r,E,x),onCancelTask:(I,s,i,r)=>I.cancelTask(i,r)};class f{get zone(){return this._zone}_zone;_taskCounts={microTask:0,macroTask:0,eventTask:0};_parentDelegate;_forkDlgt;_forkZS;_forkCurrZone;_interceptDlgt;_interceptZS;_interceptCurrZone;_invokeDlgt;_invokeZS;_invokeCurrZone;_handleErrorDlgt;_handleErrorZS;_handleErrorCurrZone;_scheduleTaskDlgt;_scheduleTaskZS;_scheduleTaskCurrZone;_invokeTaskDlgt;_invokeTaskZS;_invokeTaskCurrZone;_cancelTaskDlgt;_cancelTaskZS;_cancelTaskCurrZone;_hasTaskDlgt;_hasTaskDlgtOwner;_hasTaskZS;_hasTaskCurrZone;constructor(s,i,r){this._zone=s,this._parentDelegate=i,this._forkZS=r&&(r&&r.onFork?r:i._forkZS),this._forkDlgt=r&&(r.onFork?i:i._forkDlgt),this._forkCurrZone=r&&(r.onFork?this._zone:i._forkCurrZone),this._interceptZS=r&&(r.onIntercept?r:i._interceptZS),this._interceptDlgt=r&&(r.onIntercept?i:i._interceptDlgt),this._interceptCurrZone=r&&(r.onIntercept?this._zone:i._interceptCurrZone),this._invokeZS=r&&(r.onInvoke?r:i._invokeZS),this._invokeDlgt=r&&(r.onInvoke?i:i._invokeDlgt),this._invokeCurrZone=r&&(r.onInvoke?this._zone:i._invokeCurrZone),this._handleErrorZS=r&&(r.onHandleError?r:i._handleErrorZS),this._handleErrorDlgt=r&&(r.onHandleError?i:i._handleErrorDlgt),this._handleErrorCurrZone=r&&(r.onHandleError?this._zone:i._handleErrorCurrZone),this._scheduleTaskZS=r&&(r.onScheduleTask?r:i._scheduleTaskZS),this._scheduleTaskDlgt=r&&(r.onScheduleTask?i:i._scheduleTaskDlgt),this._scheduleTaskCurrZone=r&&(r.onScheduleTask?this._zone:i._scheduleTaskCurrZone),this._invokeTaskZS=r&&(r.onInvokeTask?r:i._invokeTaskZS),this._invokeTaskDlgt=r&&(r.onInvokeTask?i:i._invokeTaskDlgt),this._invokeTaskCurrZone=r&&(r.onInvokeTask?this._zone:i._invokeTaskCurrZone),this._cancelTaskZS=r&&(r.onCancelTask?r:i._cancelTaskZS),this._cancelTaskDlgt=r&&(r.onCancelTask?i:i._cancelTaskDlgt),this._cancelTaskCurrZone=r&&(r.onCancelTask?this._zone:i._cancelTaskCurrZone),this._hasTaskZS=null,this._hasTaskDlgt=null,this._hasTaskDlgtOwner=null,this._hasTaskCurrZone=null;let E=r&&r.onHasTask,x=i&&i._hasTaskZS;(E||x)&&(this._hasTaskZS=E?r:c,this._hasTaskDlgt=i,this._hasTaskDlgtOwner=this,this._hasTaskCurrZone=this._zone,r.onScheduleTask||(this._scheduleTaskZS=c,this._scheduleTaskDlgt=i,this._scheduleTaskCurrZone=this._zone),r.onInvokeTask||(this._invokeTaskZS=c,this._invokeTaskDlgt=i,this._invokeTaskCurrZone=this._zone),r.onCancelTask||(this._cancelTaskZS=c,this._cancelTaskDlgt=i,this._cancelTaskCurrZone=this._zone))}fork(s,i){return this._forkZS?this._forkZS.onFork(this._forkDlgt,this.zone,s,i):new e(s,i)}intercept(s,i,r){return this._interceptZS?this._interceptZS.onIntercept(this._interceptDlgt,this._interceptCurrZone,s,i,r):i}invoke(s,i,r,E,x){return this._invokeZS?this._invokeZS.onInvoke(this._invokeDlgt,this._invokeCurrZone,s,i,r,E,x):i.apply(r,E)}handleError(s,i){return this._handleErrorZS?this._handleErrorZS.onHandleError(this._handleErrorDlgt,this._handleErrorCurrZone,s,i):!0}scheduleTask(s,i){let r=i;if(this._scheduleTaskZS)this._hasTaskZS&&r._zoneDelegates.push(this._hasTaskDlgtOwner),r=this._scheduleTaskZS.onScheduleTask(this._scheduleTaskDlgt,this._scheduleTaskCurrZone,s,i),r||(r=i);else if(i.scheduleFn)i.scheduleFn(i);else if(i.type==F)z(i);else throw new Error("Task is missing scheduleFn.");return r}invokeTask(s,i,r,E){return this._invokeTaskZS?this._invokeTaskZS.onInvokeTask(this._invokeTaskDlgt,this._invokeTaskCurrZone,s,i,r,E):i.callback.apply(r,E)}cancelTask(s,i){let r;if(this._cancelTaskZS)r=this._cancelTaskZS.onCancelTask(this._cancelTaskDlgt,this._cancelTaskCurrZone,s,i);else{if(!i.cancelFn)throw Error("Task is not cancelable");r=i.cancelFn(i)}return r}hasTask(s,i){try{this._hasTaskZS&&this._hasTaskZS.onHasTask(this._hasTaskDlgt,this._hasTaskCurrZone,s,i)}catch(r){this.handleError(s,r)}}_updateTaskCount(s,i){let r=this._taskCounts,E=r[s],x=r[s]=E+i;if(x<0)throw new Error("More tasks executed then were scheduled.");if(E==0||x==0){let ee={microTask:r.microTask>0,macroTask:r.macroTask>0,eventTask:r.eventTask>0,change:s};this.hasTask(this._zone,ee)}}}class g{type;source;invoke;callback;data;scheduleFn;cancelFn;_zone=null;runCount=0;_zoneDelegates=null;_state="notScheduled";constructor(s,i,r,E,x,ee){if(this.type=s,this.source=i,this.data=E,this.scheduleFn=x,this.cancelFn=ee,!r)throw new Error("callback is not defined");this.callback=r;let M=this;s===U&&E&&E.useG?this.invoke=g.invokeTask:this.invoke=function(){return g.invokeTask.call(ce,M,this,arguments)}}static invokeTask(s,i,r){s||(s=this),K++;try{return s.runCount++,s.zone.runTask(s,i,r)}finally{K==1&&$(),K--}}get zone(){return this._zone}get state(){return this._state}cancelScheduleRequest(){this._transitionTo(q,p)}_transitionTo(s,i,r){if(this._state===i||this._state===r)this._state=s,s==q&&(this._zoneDelegates=null);else throw new Error(`${this.type} '${this.source}': can not transition to '${s}', expecting state '${i}'${r?" or '"+r+"'":""}, was '${this._state}'.`)}toString(){return this.data&&typeof this.data.handleId<"u"?this.data.handleId.toString():Object.prototype.toString.call(this)}toJSON(){return{type:this.type,state:this.state,source:this.source,zone:this.zone.name,runCount:this.runCount}}}let T=te("setTimeout"),y=te("Promise"),w=te("then"),_=[],P=!1,L;function H(I){if(L||ce[y]&&(L=ce[y].resolve(0)),L){let s=L[w];s||(s=L.then),s.call(L,I)}else ce[T](I,0)}function z(I){K===0&&_.length===0&&H($),I&&_.push(I)}function $(){if(!P){for(P=!0;_.length;){let I=_;_=[];for(let s=0;sb,onUnhandledError:W,microtaskDrainDone:W,scheduleMicroTask:z,showUncaughtError:()=>!e[te("ignoreConsoleErrorUncaughtError")],patchEventTarget:()=>[],patchOnProperties:W,patchMethod:()=>W,bindArguments:()=>[],patchThen:()=>W,patchMacroTask:()=>W,patchEventPrototype:()=>W,isIEOrEdge:()=>!1,getGlobalObjects:()=>{},ObjectDefineProperty:()=>W,ObjectGetOwnPropertyDescriptor:()=>{},ObjectCreate:()=>{},ArraySlice:()=>[],patchClass:()=>W,wrapWithCurrentZone:()=>W,filterProperties:()=>[],attachOriginToPatched:()=>W,_redefineProperty:()=>W,patchCallbacks:()=>W,nativeScheduleMicroTask:H},b={parent:null,zone:new e(null,null)},D=null,K=0;function W(){}return a("Zone","Zone"),e}function dt(){let t=globalThis,n=t[te("forceDuplicateZoneCheck")]===!0;if(t.Zone&&(n||typeof t.Zone.__symbol__!="function"))throw new Error("Zone already loaded.");return t.Zone??=ht(),t.Zone}var pe=Object.getOwnPropertyDescriptor,Me=Object.defineProperty,Ae=Object.getPrototypeOf,_t=Object.create,Tt=Array.prototype.slice,je="addEventListener",He="removeEventListener",Ne=te(je),Ze=te(He),ae="true",le="false",ve=te("");function Ve(t,n){return Zone.current.wrap(t,n)}function xe(t,n,a,e,c){return Zone.current.scheduleMacroTask(t,n,a,e,c)}var j=te,we=typeof window<"u",be=we?window:void 0,Y=we&&be||globalThis,Et="removeAttribute";function Fe(t,n){for(let a=t.length-1;a>=0;a--)typeof t[a]=="function"&&(t[a]=Ve(t[a],n+"_"+a));return t}function gt(t,n){let a=t.constructor.name;for(let e=0;e{let y=function(){return T.apply(this,Fe(arguments,a+"."+c))};return fe(y,T),y})(f)}}}function et(t){return t?t.writable===!1?!1:!(typeof t.get=="function"&&typeof t.set>"u"):!0}var tt=typeof WorkerGlobalScope<"u"&&self instanceof WorkerGlobalScope,De=!("nw"in Y)&&typeof Y.process<"u"&&Y.process.toString()==="[object process]",Ge=!De&&!tt&&!!(we&&be.HTMLElement),nt=typeof Y.process<"u"&&Y.process.toString()==="[object process]"&&!tt&&!!(we&&be.HTMLElement),Ce={},kt=j("enable_beforeunload"),Xe=function(t){if(t=t||Y.event,!t)return;let n=Ce[t.type];n||(n=Ce[t.type]=j("ON_PROPERTY"+t.type));let a=this||t.target||Y,e=a[n],c;if(Ge&&a===be&&t.type==="error"){let f=t;c=e&&e.call(this,f.message,f.filename,f.lineno,f.colno,f.error),c===!0&&t.preventDefault()}else c=e&&e.apply(this,arguments),t.type==="beforeunload"&&Y[kt]&&typeof c=="string"?t.returnValue=c:c!=null&&!c&&t.preventDefault();return c};function Ye(t,n,a){let e=pe(t,n);if(!e&&a&&pe(a,n)&&(e={enumerable:!0,configurable:!0}),!e||!e.configurable)return;let c=j("on"+n+"patched");if(t.hasOwnProperty(c)&&t[c])return;delete e.writable,delete e.value;let f=e.get,g=e.set,T=n.slice(2),y=Ce[T];y||(y=Ce[T]=j("ON_PROPERTY"+T)),e.set=function(w){let _=this;if(!_&&t===Y&&(_=Y),!_)return;typeof _[y]=="function"&&_.removeEventListener(T,Xe),g?.call(_,null),_[y]=w,typeof w=="function"&&_.addEventListener(T,Xe,!1)},e.get=function(){let w=this;if(!w&&t===Y&&(w=Y),!w)return null;let _=w[y];if(_)return _;if(f){let P=f.call(this);if(P)return e.set.call(this,P),typeof w[Et]=="function"&&w.removeAttribute(n),P}return null},Me(t,n,e),t[c]=!0}function rt(t,n,a){if(n)for(let e=0;efunction(g,T){let y=a(g,T);return y.cbIdx>=0&&typeof T[y.cbIdx]=="function"?xe(y.name,T[y.cbIdx],y,c):f.apply(g,T)})}function fe(t,n){t[j("OriginalDelegate")]=n}var $e=!1,Le=!1;function yt(){if($e)return Le;$e=!0;try{let t=be.navigator.userAgent;(t.indexOf("MSIE ")!==-1||t.indexOf("Trident/")!==-1||t.indexOf("Edge/")!==-1)&&(Le=!0)}catch{}return Le}function Je(t){return typeof t=="function"}function Ke(t){return typeof t=="number"}var pt={useG:!0},ne={},ot={},st=new RegExp("^"+ve+"(\\w+)(true|false)$"),it=j("propagationStopped");function ct(t,n){let a=(n?n(t):t)+le,e=(n?n(t):t)+ae,c=ve+a,f=ve+e;ne[t]={},ne[t][le]=c,ne[t][ae]=f}function vt(t,n,a,e){let c=e&&e.add||je,f=e&&e.rm||He,g=e&&e.listeners||"eventListeners",T=e&&e.rmAll||"removeAllListeners",y=j(c),w="."+c+":",_="prependListener",P="."+_+":",L=function(p,d,A){if(p.isRemoved)return;let V=p.callback;typeof V=="object"&&V.handleEvent&&(p.callback=k=>V.handleEvent(k),p.originalDelegate=V);let X;try{p.invoke(p,d,[A])}catch(k){X=k}let F=p.options;if(F&&typeof F=="object"&&F.once){let k=p.originalDelegate?p.originalDelegate:p.callback;d[f].call(d,A.type,k,F)}return X};function H(p,d,A){if(d=d||t.event,!d)return;let V=p||d.target||t,X=V[ne[d.type][A?ae:le]];if(X){let F=[];if(X.length===1){let k=L(X[0],V,d);k&&F.push(k)}else{let k=X.slice();for(let U=0;U{throw U})}}}let z=function(p){return H(this,p,!1)},$=function(p){return H(this,p,!0)};function J(p,d){if(!p)return!1;let A=!0;d&&d.useG!==void 0&&(A=d.useG);let V=d&&d.vh,X=!0;d&&d.chkDup!==void 0&&(X=d.chkDup);let F=!1;d&&d.rt!==void 0&&(F=d.rt);let k=p;for(;k&&!k.hasOwnProperty(c);)k=Ae(k);if(!k&&p[c]&&(k=p),!k||k[y])return!1;let U=d&&d.eventNameToString,S={},R=k[y]=k[c],b=k[j(f)]=k[f],D=k[j(g)]=k[g],K=k[j(T)]=k[T],W;d&&d.prepend&&(W=k[j(d.prepend)]=k[d.prepend]);function I(o,u){return u?typeof o=="boolean"?{capture:o,passive:!0}:o?typeof o=="object"&&o.passive!==!1?{...o,passive:!0}:o:{passive:!0}:o}let s=function(o){if(!S.isExisting)return R.call(S.target,S.eventName,S.capture?$:z,S.options)},i=function(o){if(!o.isRemoved){let u=ne[o.eventName],v;u&&(v=u[o.capture?ae:le]);let C=v&&o.target[v];if(C){for(let m=0;mre.zone.cancelTask(re);o.call(Ee,"abort",ie,{once:!0}),re.removeAbortListener=()=>Ee.removeEventListener("abort",ie)}if(S.target=null,me&&(me.taskData=null),Be&&(S.options.once=!0),typeof re.options!="boolean"&&(re.options=se),re.target=N,re.capture=Se,re.eventName=Z,B&&(re.originalDelegate=G),O?ge.unshift(re):ge.push(re),m)return N}};return k[c]=l(R,w,ee,M,F),W&&(k[_]=l(W,P,E,M,F,!0)),k[f]=function(){let o=this||t,u=arguments[0];d&&d.transferEventName&&(u=d.transferEventName(u));let v=arguments[2],C=v?typeof v=="boolean"?!0:v.capture:!1,m=arguments[1];if(!m)return b.apply(this,arguments);if(V&&!V(b,m,o,arguments))return;let O=ne[u],N;O&&(N=O[C?ae:le]);let Z=N&&o[N];if(Z)for(let G=0;Gfunction(c,f){c[it]=!0,e&&e.apply(c,f)})}function Pt(t,n){n.patchMethod(t,"queueMicrotask",a=>function(e,c){Zone.current.scheduleMicroTask("queueMicrotask",c[0])})}var Re=j("zoneTask");function ke(t,n,a,e){let c=null,f=null;n+=e,a+=e;let g={};function T(w){let _=w.data;_.args[0]=function(){return w.invoke.apply(this,arguments)};let P=c.apply(t,_.args);return Ke(P)?_.handleId=P:(_.handle=P,_.isRefreshable=Je(P.refresh)),w}function y(w){let{handle:_,handleId:P}=w.data;return f.call(t,_??P)}c=ue(t,n,w=>function(_,P){if(Je(P[0])){let L={isRefreshable:!1,isPeriodic:e==="Interval",delay:e==="Timeout"||e==="Interval"?P[1]||0:void 0,args:P},H=P[0];P[0]=function(){try{return H.apply(this,arguments)}finally{let{handle:A,handleId:V,isPeriodic:X,isRefreshable:F}=L;!X&&!F&&(V?delete g[V]:A&&(A[Re]=null))}};let z=xe(n,P[0],L,T,y);if(!z)return z;let{handleId:$,handle:J,isRefreshable:q,isPeriodic:p}=z.data;if($)g[$]=z;else if(J&&(J[Re]=z,q&&!p)){let d=J.refresh;J.refresh=function(){let{zone:A,state:V}=z;return V==="notScheduled"?(z._state="scheduled",A._updateTaskCount(z,1)):V==="running"&&(z._state="scheduling"),d.call(this)}}return J??$??z}else return w.apply(t,P)}),f=ue(t,a,w=>function(_,P){let L=P[0],H;Ke(L)?(H=g[L],delete g[L]):(H=L?.[Re],H?L[Re]=null:H=L),H?.type?H.cancelFn&&H.zone.cancelTask(H):w.apply(t,P)})}function Rt(t,n){let{isBrowser:a,isMix:e}=n.getGlobalObjects();if(!a&&!e||!t.customElements||!("customElements"in t))return;let c=["connectedCallback","disconnectedCallback","adoptedCallback","attributeChangedCallback","formAssociatedCallback","formDisabledCallback","formResetCallback","formStateRestoreCallback"];n.patchCallbacks(n,t.customElements,"customElements","define",c)}function Ct(t,n){if(Zone[n.symbol("patchEventTarget")])return;let{eventNames:a,zoneSymbolEventNames:e,TRUE_STR:c,FALSE_STR:f,ZONE_SYMBOL_PREFIX:g}=n.getGlobalObjects();for(let y=0;yf.target===t);if(e.length===0)return n;let c=e[0].ignoreProperties;return n.filter(f=>c.indexOf(f)===-1)}function Qe(t,n,a,e){if(!t)return;let c=lt(t,n,a);rt(t,c,e)}function Ie(t){return Object.getOwnPropertyNames(t).filter(n=>n.startsWith("on")&&n.length>2).map(n=>n.substring(2))}function Dt(t,n){if(De&&!nt||Zone[t.symbol("patchEvents")])return;let a=n.__Zone_ignore_on_properties,e=[];if(Ge){let c=window;e=e.concat(["Document","SVGElement","Element","HTMLElement","HTMLBodyElement","HTMLMediaElement","HTMLFrameSetElement","HTMLFrameElement","HTMLIFrameElement","HTMLMarqueeElement","Worker"]);let f=[];Qe(c,Ie(c),a&&a.concat(f),Ae(c))}e=e.concat(["XMLHttpRequest","XMLHttpRequestEventTarget","IDBIndex","IDBRequest","IDBOpenDBRequest","IDBDatabase","IDBTransaction","IDBCursor","WebSocket"]);for(let c=0;c{let a=n[t.__symbol__("legacyPatch")];a&&a()}),t.__load_patch("timers",n=>{let e="clear";ke(n,"set",e,"Timeout"),ke(n,"set",e,"Interval"),ke(n,"set",e,"Immediate")}),t.__load_patch("requestAnimationFrame",n=>{ke(n,"request","cancel","AnimationFrame"),ke(n,"mozRequest","mozCancel","AnimationFrame"),ke(n,"webkitRequest","webkitCancel","AnimationFrame")}),t.__load_patch("blocking",(n,a)=>{let e=["alert","prompt","confirm"];for(let c=0;cfunction(w,_){return a.current.run(g,n,_,y)})}}),t.__load_patch("EventTarget",(n,a,e)=>{wt(n,e),Ct(n,e);let c=n.XMLHttpRequestEventTarget;c&&c.prototype&&e.patchEventTarget(n,e,[c.prototype])}),t.__load_patch("MutationObserver",(n,a,e)=>{ye("MutationObserver"),ye("WebKitMutationObserver")}),t.__load_patch("IntersectionObserver",(n,a,e)=>{ye("IntersectionObserver")}),t.__load_patch("FileReader",(n,a,e)=>{ye("FileReader")}),t.__load_patch("on_property",(n,a,e)=>{Dt(e,n)}),t.__load_patch("customElements",(n,a,e)=>{Rt(n,e)}),t.__load_patch("XHR",(n,a)=>{w(n);let e=j("xhrTask"),c=j("xhrSync"),f=j("xhrListener"),g=j("xhrScheduled"),T=j("xhrURL"),y=j("xhrErrorBeforeScheduled");function w(_){let P=_.XMLHttpRequest;if(!P)return;let L=P.prototype;function H(R){return R[e]}let z=L[Ne],$=L[Ze];if(!z){let R=_.XMLHttpRequestEventTarget;if(R){let b=R.prototype;z=b[Ne],$=b[Ze]}}let J="readystatechange",q="scheduled";function p(R){let b=R.data,D=b.target;D[g]=!1,D[y]=!1;let K=D[f];z||(z=D[Ne],$=D[Ze]),K&&$.call(D,J,K);let W=D[f]=()=>{if(D.readyState===D.DONE)if(!b.aborted&&D[g]&&R.state===q){let s=D[a.__symbol__("loadfalse")];if(D.status!==0&&s&&s.length>0){let i=R.invoke;R.invoke=function(){let r=D[a.__symbol__("loadfalse")];for(let E=0;Efunction(R,b){return R[c]=b[2]==!1,R[T]=b[1],V.apply(R,b)}),X="XMLHttpRequest.send",F=j("fetchTaskAborting"),k=j("fetchTaskScheduling"),U=ue(L,"send",()=>function(R,b){if(a.current[k]===!0||R[c])return U.apply(R,b);{let D={target:R,url:R[T],isPeriodic:!1,args:b,aborted:!1},K=xe(X,d,D,p,A);R&&R[y]===!0&&!D.aborted&&K.state===q&&K.invoke()}}),S=ue(L,"abort",()=>function(R,b){let D=H(R);if(D&&typeof D.type=="string"){if(D.cancelFn==null||D.data&&D.data.aborted)return;D.zone.cancelTask(D)}else if(a.current[F]===!0)return S.apply(R,b)})}}),t.__load_patch("geolocation",n=>{n.navigator&&n.navigator.geolocation&>(n.navigator.geolocation,["getCurrentPosition","watchPosition"])}),t.__load_patch("PromiseRejectionEvent",(n,a)=>{function e(c){return function(f){at(n,c).forEach(T=>{let y=n.PromiseRejectionEvent;if(y){let w=new y(c,{promise:f.promise,reason:f.rejection});T.invoke(w)}})}}n.PromiseRejectionEvent&&(a[j("unhandledPromiseRejectionHandler")]=e("unhandledrejection"),a[j("rejectionHandledHandler")]=e("rejectionhandled"))}),t.__load_patch("queueMicrotask",(n,a,e)=>{Pt(n,e)})}function Ot(t){t.__load_patch("ZoneAwarePromise",(n,a,e)=>{let c=Object.getOwnPropertyDescriptor,f=Object.defineProperty;function g(h){if(h&&h.toString===Object.prototype.toString){let l=h.constructor&&h.constructor.name;return(l||"")+": "+JSON.stringify(h)}return h?h.toString():Object.prototype.toString.call(h)}let T=e.symbol,y=[],w=n[T("DISABLE_WRAPPING_UNCAUGHT_PROMISE_REJECTION")]!==!1,_=T("Promise"),P=T("then"),L="__creationTrace__";e.onUnhandledError=h=>{if(e.showUncaughtError()){let l=h&&h.rejection;l?console.error("Unhandled Promise rejection:",l instanceof Error?l.message:l,"; Zone:",h.zone.name,"; Task:",h.task&&h.task.source,"; Value:",l,l instanceof Error?l.stack:void 0):console.error(h)}},e.microtaskDrainDone=()=>{for(;y.length;){let h=y.shift();try{h.zone.runGuarded(()=>{throw h.throwOriginal?h.rejection:h})}catch(l){z(l)}}};let H=T("unhandledPromiseRejectionHandler");function z(h){e.onUnhandledError(h);try{let l=a[H];typeof l=="function"&&l.call(this,h)}catch{}}function $(h){return h&&typeof h.then=="function"}function J(h){return h}function q(h){return M.reject(h)}let p=T("state"),d=T("value"),A=T("finally"),V=T("parentPromiseValue"),X=T("parentPromiseState"),F="Promise.then",k=null,U=!0,S=!1,R=0;function b(h,l){return o=>{try{I(h,l,o)}catch(u){I(h,!1,u)}}}let D=function(){let h=!1;return function(o){return function(){h||(h=!0,o.apply(null,arguments))}}},K="Promise resolved with itself",W=T("currentTaskTrace");function I(h,l,o){let u=D();if(h===o)throw new TypeError(K);if(h[p]===k){let v=null;try{(typeof o=="object"||typeof o=="function")&&(v=o&&o.then)}catch(C){return u(()=>{I(h,!1,C)})(),h}if(l!==S&&o instanceof M&&o.hasOwnProperty(p)&&o.hasOwnProperty(d)&&o[p]!==k)i(o),I(h,o[p],o[d]);else if(l!==S&&typeof v=="function")try{v.call(o,u(b(h,l)),u(b(h,!1)))}catch(C){u(()=>{I(h,!1,C)})()}else{h[p]=l;let C=h[d];if(h[d]=o,h[A]===A&&l===U&&(h[p]=h[X],h[d]=h[V]),l===S&&o instanceof Error){let m=a.currentTask&&a.currentTask.data&&a.currentTask.data[L];m&&f(o,W,{configurable:!0,enumerable:!1,writable:!0,value:m})}for(let m=0;m{try{let O=h[d],N=!!o&&A===o[A];N&&(o[V]=O,o[X]=C);let Z=l.run(m,void 0,N&&m!==q&&m!==J?[]:[O]);I(o,!0,Z)}catch(O){I(o,!1,O)}},o)}let E="function ZoneAwarePromise() { [native code] }",x=function(){},ee=n.AggregateError;class M{static toString(){return E}static resolve(l){return l instanceof M?l:I(new this(null),U,l)}static reject(l){return I(new this(null),S,l)}static withResolvers(){let l={};return l.promise=new M((o,u)=>{l.resolve=o,l.reject=u}),l}static any(l){if(!l||typeof l[Symbol.iterator]!="function")return Promise.reject(new ee([],"All promises were rejected"));let o=[],u=0;try{for(let m of l)u++,o.push(M.resolve(m))}catch{return Promise.reject(new ee([],"All promises were rejected"))}if(u===0)return Promise.reject(new ee([],"All promises were rejected"));let v=!1,C=[];return new M((m,O)=>{for(let N=0;N{v||(v=!0,m(Z))},Z=>{C.push(Z),u--,u===0&&(v=!0,O(new ee(C,"All promises were rejected")))})})}static race(l){let o,u,v=new this((O,N)=>{o=O,u=N});function C(O){o(O)}function m(O){u(O)}for(let O of l)$(O)||(O=this.resolve(O)),O.then(C,m);return v}static all(l){return M.allWithCallback(l)}static allSettled(l){return(this&&this.prototype instanceof M?this:M).allWithCallback(l,{thenCallback:u=>({status:"fulfilled",value:u}),errorCallback:u=>({status:"rejected",reason:u})})}static allWithCallback(l,o){let u,v,C=new this((Z,G)=>{u=Z,v=G}),m=2,O=0,N=[];for(let Z of l){$(Z)||(Z=this.resolve(Z));let G=O;try{Z.then(B=>{N[G]=o?o.thenCallback(B):B,m--,m===0&&u(N)},B=>{o?(N[G]=o.errorCallback(B),m--,m===0&&u(N)):v(B)})}catch(B){v(B)}m++,O++}return m-=2,m===0&&u(N),C}constructor(l){let o=this;if(!(o instanceof M))throw new Error("Must be an instanceof Promise.");o[p]=k,o[d]=[];try{let u=D();l&&l(u(b(o,U)),u(b(o,S)))}catch(u){I(o,!1,u)}}get[Symbol.toStringTag](){return"Promise"}get[Symbol.species](){return M}then(l,o){let u=this.constructor?.[Symbol.species];(!u||typeof u!="function")&&(u=this.constructor||M);let v=new u(x),C=a.current;return this[p]==k?this[d].push(C,v,l,o):r(this,C,v,l,o),v}catch(l){return this.then(null,l)}finally(l){let o=this.constructor?.[Symbol.species];(!o||typeof o!="function")&&(o=M);let u=new o(x);u[A]=A;let v=a.current;return this[p]==k?this[d].push(v,u,l,l):r(this,v,u,l,l),u}}M.resolve=M.resolve,M.reject=M.reject,M.race=M.race,M.all=M.all;let he=n[_]=n.Promise;n.Promise=M;let _e=T("thenPatched");function Q(h){let l=h.prototype,o=c(l,"then");if(o&&(o.writable===!1||!o.configurable))return;let u=l.then;l[P]=u,h.prototype.then=function(v,C){return new M((O,N)=>{u.call(this,O,N)}).then(v,C)},h[_e]=!0}e.patchThen=Q;function Te(h){return function(l,o){let u=h.apply(l,o);if(u instanceof M)return u;let v=u.constructor;return v[_e]||Q(v),u}}return he&&(Q(he),ue(n,"fetch",h=>Te(h))),Promise[a.__symbol__("uncaughtPromiseErrors")]=y,M})}function Nt(t){t.__load_patch("toString",n=>{let a=Function.prototype.toString,e=j("OriginalDelegate"),c=j("Promise"),f=j("Error"),g=function(){if(typeof this=="function"){let _=this[e];if(_)return typeof _=="function"?a.call(_):Object.prototype.toString.call(_);if(this===Promise){let P=n[c];if(P)return a.call(P)}if(this===Error){let P=n[f];if(P)return a.call(P)}}return a.call(this)};g[e]=a,Function.prototype.toString=g;let T=Object.prototype.toString,y="[object Promise]";Object.prototype.toString=function(){return typeof Promise=="function"&&this instanceof Promise?y:T.call(this)}})}function Zt(t,n,a,e,c){let f=Zone.__symbol__(e);if(n[f])return;let g=n[f]=n[e];n[e]=function(T,y,w){return y&&y.prototype&&c.forEach(function(_){let P=`${a}.${e}::`+_,L=y.prototype;try{if(L.hasOwnProperty(_)){let H=t.ObjectGetOwnPropertyDescriptor(L,_);H&&H.value?(H.value=t.wrapWithCurrentZone(H.value,P),t._redefineProperty(y.prototype,_,H)):L[_]&&(L[_]=t.wrapWithCurrentZone(L[_],P))}else L[_]&&(L[_]=t.wrapWithCurrentZone(L[_],P))}catch{}}),g.call(n,T,y,w)},t.attachOriginToPatched(n[e],g)}function Lt(t){t.__load_patch("util",(n,a,e)=>{let c=Ie(n);e.patchOnProperties=rt,e.patchMethod=ue,e.bindArguments=Fe,e.patchMacroTask=mt;let f=a.__symbol__("BLACK_LISTED_EVENTS"),g=a.__symbol__("UNPATCHED_EVENTS");n[g]&&(n[f]=n[g]),n[f]&&(a[f]=a[g]=n[f]),e.patchEventPrototype=bt,e.patchEventTarget=vt,e.isIEOrEdge=yt,e.ObjectDefineProperty=Me,e.ObjectGetOwnPropertyDescriptor=pe,e.ObjectCreate=_t,e.ArraySlice=Tt,e.patchClass=ye,e.wrapWithCurrentZone=Ve,e.filterProperties=lt,e.attachOriginToPatched=fe,e._redefineProperty=Object.defineProperty,e.patchCallbacks=Zt,e.getGlobalObjects=()=>({globalSources:ot,zoneSymbolEventNames:ne,eventNames:c,isBrowser:Ge,isMix:nt,isNode:De,TRUE_STR:ae,FALSE_STR:le,ZONE_SYMBOL_PREFIX:ve,ADD_EVENT_LISTENER_STR:je,REMOVE_EVENT_LISTENER_STR:He})})}function It(t){Ot(t),Nt(t),Lt(t)}var ut=dt();It(ut);St(ut); diff --git a/artemis/resources/showcase_ui/styles-CDJM3XQS.css b/artemis/resources/showcase_ui/styles-CDJM3XQS.css deleted file mode 100644 index a0a5f1514..000000000 --- a/artemis/resources/showcase_ui/styles-CDJM3XQS.css +++ /dev/null @@ -1 +0,0 @@ -@font-face{font-family:Inter;font-style:normal;font-weight:300;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format("woff2");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter;font-style:normal;font-weight:300;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format("woff2");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter;font-style:normal;font-weight:300;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format("woff2");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter;font-style:normal;font-weight:300;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format("woff2");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter;font-style:normal;font-weight:300;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format("woff2");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter;font-style:normal;font-weight:300;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter;font-style:normal;font-weight:300;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Inter;font-style:normal;font-weight:400;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format("woff2");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter;font-style:normal;font-weight:400;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format("woff2");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter;font-style:normal;font-weight:400;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format("woff2");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter;font-style:normal;font-weight:400;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format("woff2");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter;font-style:normal;font-weight:400;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format("woff2");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter;font-style:normal;font-weight:400;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter;font-style:normal;font-weight:400;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Inter;font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format("woff2");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter;font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format("woff2");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter;font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format("woff2");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter;font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format("woff2");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter;font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format("woff2");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter;font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter;font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Inter;font-style:normal;font-weight:600;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format("woff2");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter;font-style:normal;font-weight:600;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format("woff2");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter;font-style:normal;font-weight:600;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format("woff2");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter;font-style:normal;font-weight:600;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format("woff2");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter;font-style:normal;font-weight:600;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format("woff2");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter;font-style:normal;font-weight:600;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter;font-style:normal;font-weight:600;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Inter;font-style:normal;font-weight:700;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format("woff2");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter;font-style:normal;font-weight:700;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format("woff2");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter;font-style:normal;font-weight:700;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format("woff2");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter;font-style:normal;font-weight:700;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format("woff2");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter;font-style:normal;font-weight:700;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format("woff2");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter;font-style:normal;font-weight:700;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter;font-style:normal;font-weight:700;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Inter;font-style:normal;font-weight:800;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format("woff2");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter;font-style:normal;font-weight:800;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format("woff2");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter;font-style:normal;font-weight:800;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format("woff2");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter;font-style:normal;font-weight:800;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format("woff2");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter;font-style:normal;font-weight:800;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format("woff2");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter;font-style:normal;font-weight:800;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter;font-style:normal;font-weight:800;font-display:swap;src:url(https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Outfit;font-style:normal;font-weight:300;font-display:swap;src:url(https://fonts.gstatic.com/s/outfit/v15/QGYvz_MVcBeNP4NJuktqQ4E.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Outfit;font-style:normal;font-weight:300;font-display:swap;src:url(https://fonts.gstatic.com/s/outfit/v15/QGYvz_MVcBeNP4NJtEtq.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Outfit;font-style:normal;font-weight:400;font-display:swap;src:url(https://fonts.gstatic.com/s/outfit/v15/QGYvz_MVcBeNP4NJuktqQ4E.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Outfit;font-style:normal;font-weight:400;font-display:swap;src:url(https://fonts.gstatic.com/s/outfit/v15/QGYvz_MVcBeNP4NJtEtq.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Outfit;font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/outfit/v15/QGYvz_MVcBeNP4NJuktqQ4E.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Outfit;font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/outfit/v15/QGYvz_MVcBeNP4NJtEtq.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Outfit;font-style:normal;font-weight:600;font-display:swap;src:url(https://fonts.gstatic.com/s/outfit/v15/QGYvz_MVcBeNP4NJuktqQ4E.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Outfit;font-style:normal;font-weight:600;font-display:swap;src:url(https://fonts.gstatic.com/s/outfit/v15/QGYvz_MVcBeNP4NJtEtq.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Outfit;font-style:normal;font-weight:700;font-display:swap;src:url(https://fonts.gstatic.com/s/outfit/v15/QGYvz_MVcBeNP4NJuktqQ4E.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Outfit;font-style:normal;font-weight:700;font-display:swap;src:url(https://fonts.gstatic.com/s/outfit/v15/QGYvz_MVcBeNP4NJtEtq.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Roboto;font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2) format("woff2");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Roboto;font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2) format("woff2");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Roboto;font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2) format("woff2");unicode-range:U+1F00-1FFF}@font-face{font-family:Roboto;font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2) format("woff2");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Roboto;font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2) format("woff2");unicode-range:U+0302-0303,U+0305,U+0307-0308,U+0310,U+0312,U+0315,U+031A,U+0326-0327,U+032C,U+032F-0330,U+0332-0333,U+0338,U+033A,U+0346,U+034D,U+0391-03A1,U+03A3-03A9,U+03B1-03C9,U+03D1,U+03D5-03D6,U+03F0-03F1,U+03F4-03F5,U+2016-2017,U+2034-2038,U+203C,U+2040,U+2043,U+2047,U+2050,U+2057,U+205F,U+2070-2071,U+2074-208E,U+2090-209C,U+20D0-20DC,U+20E1,U+20E5-20EF,U+2100-2112,U+2114-2115,U+2117-2121,U+2123-214F,U+2190,U+2192,U+2194-21AE,U+21B0-21E5,U+21F1-21F2,U+21F4-2211,U+2213-2214,U+2216-22FF,U+2308-230B,U+2310,U+2319,U+231C-2321,U+2336-237A,U+237C,U+2395,U+239B-23B7,U+23D0,U+23DC-23E1,U+2474-2475,U+25AF,U+25B3,U+25B7,U+25BD,U+25C1,U+25CA,U+25CC,U+25FB,U+266D-266F,U+27C0-27FF,U+2900-2AFF,U+2B0E-2B11,U+2B30-2B4C,U+2BFE,U+3030,U+FF5B,U+FF5D,U+1D400-1D7FF,U+1EE00-1EEFF}@font-face{font-family:Roboto;font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2) format("woff2");unicode-range:U+0001-000C,U+000E-001F,U+007F-009F,U+20DD-20E0,U+20E2-20E4,U+2150-218F,U+2190,U+2192,U+2194-2199,U+21AF,U+21E6-21F0,U+21F3,U+2218-2219,U+2299,U+22C4-22C6,U+2300-243F,U+2440-244A,U+2460-24FF,U+25A0-27BF,U+2800-28FF,U+2921-2922,U+2981,U+29BF,U+29EB,U+2B00-2BFF,U+4DC0-4DFF,U+FFF9-FFFB,U+10140-1018E,U+10190-1019C,U+101A0,U+101D0-101FD,U+102E0-102FB,U+10E60-10E7E,U+1D2C0-1D2D3,U+1D2E0-1D37F,U+1F000-1F0FF,U+1F100-1F1AD,U+1F1E6-1F1FF,U+1F30D-1F30F,U+1F315,U+1F31C,U+1F31E,U+1F320-1F32C,U+1F336,U+1F378,U+1F37D,U+1F382,U+1F393-1F39F,U+1F3A7-1F3A8,U+1F3AC-1F3AF,U+1F3C2,U+1F3C4-1F3C6,U+1F3CA-1F3CE,U+1F3D4-1F3E0,U+1F3ED,U+1F3F1-1F3F3,U+1F3F5-1F3F7,U+1F408,U+1F415,U+1F41F,U+1F426,U+1F43F,U+1F441-1F442,U+1F444,U+1F446-1F449,U+1F44C-1F44E,U+1F453,U+1F46A,U+1F47D,U+1F4A3,U+1F4B0,U+1F4B3,U+1F4B9,U+1F4BB,U+1F4BF,U+1F4C8-1F4CB,U+1F4D6,U+1F4DA,U+1F4DF,U+1F4E3-1F4E6,U+1F4EA-1F4ED,U+1F4F7,U+1F4F9-1F4FB,U+1F4FD-1F4FE,U+1F503,U+1F507-1F50B,U+1F50D,U+1F512-1F513,U+1F53E-1F54A,U+1F54F-1F5FA,U+1F610,U+1F650-1F67F,U+1F687,U+1F68D,U+1F691,U+1F694,U+1F698,U+1F6AD,U+1F6B2,U+1F6B9-1F6BA,U+1F6BC,U+1F6C6-1F6CF,U+1F6D3-1F6D7,U+1F6E0-1F6EA,U+1F6F0-1F6F3,U+1F6F7-1F6FC,U+1F700-1F7FF,U+1F800-1F80B,U+1F810-1F847,U+1F850-1F859,U+1F860-1F887,U+1F890-1F8AD,U+1F8B0-1F8BB,U+1F8C0-1F8C1,U+1F900-1F90B,U+1F93B,U+1F946,U+1F984,U+1F996,U+1F9E9,U+1FA00-1FA6F,U+1FA70-1FA7C,U+1FA80-1FA89,U+1FA8F-1FAC6,U+1FACE-1FADC,U+1FADF-1FAE9,U+1FAF0-1FAF8,U+1FB00-1FBFF}@font-face{font-family:Roboto;font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2) format("woff2");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Roboto;font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Roboto;font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Roboto;font-style:normal;font-weight:500;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2) format("woff2");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Roboto;font-style:normal;font-weight:500;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2) format("woff2");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Roboto;font-style:normal;font-weight:500;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2) format("woff2");unicode-range:U+1F00-1FFF}@font-face{font-family:Roboto;font-style:normal;font-weight:500;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2) format("woff2");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Roboto;font-style:normal;font-weight:500;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2) format("woff2");unicode-range:U+0302-0303,U+0305,U+0307-0308,U+0310,U+0312,U+0315,U+031A,U+0326-0327,U+032C,U+032F-0330,U+0332-0333,U+0338,U+033A,U+0346,U+034D,U+0391-03A1,U+03A3-03A9,U+03B1-03C9,U+03D1,U+03D5-03D6,U+03F0-03F1,U+03F4-03F5,U+2016-2017,U+2034-2038,U+203C,U+2040,U+2043,U+2047,U+2050,U+2057,U+205F,U+2070-2071,U+2074-208E,U+2090-209C,U+20D0-20DC,U+20E1,U+20E5-20EF,U+2100-2112,U+2114-2115,U+2117-2121,U+2123-214F,U+2190,U+2192,U+2194-21AE,U+21B0-21E5,U+21F1-21F2,U+21F4-2211,U+2213-2214,U+2216-22FF,U+2308-230B,U+2310,U+2319,U+231C-2321,U+2336-237A,U+237C,U+2395,U+239B-23B7,U+23D0,U+23DC-23E1,U+2474-2475,U+25AF,U+25B3,U+25B7,U+25BD,U+25C1,U+25CA,U+25CC,U+25FB,U+266D-266F,U+27C0-27FF,U+2900-2AFF,U+2B0E-2B11,U+2B30-2B4C,U+2BFE,U+3030,U+FF5B,U+FF5D,U+1D400-1D7FF,U+1EE00-1EEFF}@font-face{font-family:Roboto;font-style:normal;font-weight:500;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2) format("woff2");unicode-range:U+0001-000C,U+000E-001F,U+007F-009F,U+20DD-20E0,U+20E2-20E4,U+2150-218F,U+2190,U+2192,U+2194-2199,U+21AF,U+21E6-21F0,U+21F3,U+2218-2219,U+2299,U+22C4-22C6,U+2300-243F,U+2440-244A,U+2460-24FF,U+25A0-27BF,U+2800-28FF,U+2921-2922,U+2981,U+29BF,U+29EB,U+2B00-2BFF,U+4DC0-4DFF,U+FFF9-FFFB,U+10140-1018E,U+10190-1019C,U+101A0,U+101D0-101FD,U+102E0-102FB,U+10E60-10E7E,U+1D2C0-1D2D3,U+1D2E0-1D37F,U+1F000-1F0FF,U+1F100-1F1AD,U+1F1E6-1F1FF,U+1F30D-1F30F,U+1F315,U+1F31C,U+1F31E,U+1F320-1F32C,U+1F336,U+1F378,U+1F37D,U+1F382,U+1F393-1F39F,U+1F3A7-1F3A8,U+1F3AC-1F3AF,U+1F3C2,U+1F3C4-1F3C6,U+1F3CA-1F3CE,U+1F3D4-1F3E0,U+1F3ED,U+1F3F1-1F3F3,U+1F3F5-1F3F7,U+1F408,U+1F415,U+1F41F,U+1F426,U+1F43F,U+1F441-1F442,U+1F444,U+1F446-1F449,U+1F44C-1F44E,U+1F453,U+1F46A,U+1F47D,U+1F4A3,U+1F4B0,U+1F4B3,U+1F4B9,U+1F4BB,U+1F4BF,U+1F4C8-1F4CB,U+1F4D6,U+1F4DA,U+1F4DF,U+1F4E3-1F4E6,U+1F4EA-1F4ED,U+1F4F7,U+1F4F9-1F4FB,U+1F4FD-1F4FE,U+1F503,U+1F507-1F50B,U+1F50D,U+1F512-1F513,U+1F53E-1F54A,U+1F54F-1F5FA,U+1F610,U+1F650-1F67F,U+1F687,U+1F68D,U+1F691,U+1F694,U+1F698,U+1F6AD,U+1F6B2,U+1F6B9-1F6BA,U+1F6BC,U+1F6C6-1F6CF,U+1F6D3-1F6D7,U+1F6E0-1F6EA,U+1F6F0-1F6F3,U+1F6F7-1F6FC,U+1F700-1F7FF,U+1F800-1F80B,U+1F810-1F847,U+1F850-1F859,U+1F860-1F887,U+1F890-1F8AD,U+1F8B0-1F8BB,U+1F8C0-1F8C1,U+1F900-1F90B,U+1F93B,U+1F946,U+1F984,U+1F996,U+1F9E9,U+1FA00-1FA6F,U+1FA70-1FA7C,U+1FA80-1FA89,U+1FA8F-1FAC6,U+1FACE-1FADC,U+1FADF-1FAE9,U+1FAF0-1FAF8,U+1FB00-1FBFF}@font-face{font-family:Roboto;font-style:normal;font-weight:500;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2) format("woff2");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Roboto;font-style:normal;font-weight:500;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Roboto;font-style:normal;font-weight:500;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Roboto;font-style:normal;font-weight:700;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2) format("woff2");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Roboto;font-style:normal;font-weight:700;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2) format("woff2");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Roboto;font-style:normal;font-weight:700;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2) format("woff2");unicode-range:U+1F00-1FFF}@font-face{font-family:Roboto;font-style:normal;font-weight:700;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2) format("woff2");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Roboto;font-style:normal;font-weight:700;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2) format("woff2");unicode-range:U+0302-0303,U+0305,U+0307-0308,U+0310,U+0312,U+0315,U+031A,U+0326-0327,U+032C,U+032F-0330,U+0332-0333,U+0338,U+033A,U+0346,U+034D,U+0391-03A1,U+03A3-03A9,U+03B1-03C9,U+03D1,U+03D5-03D6,U+03F0-03F1,U+03F4-03F5,U+2016-2017,U+2034-2038,U+203C,U+2040,U+2043,U+2047,U+2050,U+2057,U+205F,U+2070-2071,U+2074-208E,U+2090-209C,U+20D0-20DC,U+20E1,U+20E5-20EF,U+2100-2112,U+2114-2115,U+2117-2121,U+2123-214F,U+2190,U+2192,U+2194-21AE,U+21B0-21E5,U+21F1-21F2,U+21F4-2211,U+2213-2214,U+2216-22FF,U+2308-230B,U+2310,U+2319,U+231C-2321,U+2336-237A,U+237C,U+2395,U+239B-23B7,U+23D0,U+23DC-23E1,U+2474-2475,U+25AF,U+25B3,U+25B7,U+25BD,U+25C1,U+25CA,U+25CC,U+25FB,U+266D-266F,U+27C0-27FF,U+2900-2AFF,U+2B0E-2B11,U+2B30-2B4C,U+2BFE,U+3030,U+FF5B,U+FF5D,U+1D400-1D7FF,U+1EE00-1EEFF}@font-face{font-family:Roboto;font-style:normal;font-weight:700;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2) format("woff2");unicode-range:U+0001-000C,U+000E-001F,U+007F-009F,U+20DD-20E0,U+20E2-20E4,U+2150-218F,U+2190,U+2192,U+2194-2199,U+21AF,U+21E6-21F0,U+21F3,U+2218-2219,U+2299,U+22C4-22C6,U+2300-243F,U+2440-244A,U+2460-24FF,U+25A0-27BF,U+2800-28FF,U+2921-2922,U+2981,U+29BF,U+29EB,U+2B00-2BFF,U+4DC0-4DFF,U+FFF9-FFFB,U+10140-1018E,U+10190-1019C,U+101A0,U+101D0-101FD,U+102E0-102FB,U+10E60-10E7E,U+1D2C0-1D2D3,U+1D2E0-1D37F,U+1F000-1F0FF,U+1F100-1F1AD,U+1F1E6-1F1FF,U+1F30D-1F30F,U+1F315,U+1F31C,U+1F31E,U+1F320-1F32C,U+1F336,U+1F378,U+1F37D,U+1F382,U+1F393-1F39F,U+1F3A7-1F3A8,U+1F3AC-1F3AF,U+1F3C2,U+1F3C4-1F3C6,U+1F3CA-1F3CE,U+1F3D4-1F3E0,U+1F3ED,U+1F3F1-1F3F3,U+1F3F5-1F3F7,U+1F408,U+1F415,U+1F41F,U+1F426,U+1F43F,U+1F441-1F442,U+1F444,U+1F446-1F449,U+1F44C-1F44E,U+1F453,U+1F46A,U+1F47D,U+1F4A3,U+1F4B0,U+1F4B3,U+1F4B9,U+1F4BB,U+1F4BF,U+1F4C8-1F4CB,U+1F4D6,U+1F4DA,U+1F4DF,U+1F4E3-1F4E6,U+1F4EA-1F4ED,U+1F4F7,U+1F4F9-1F4FB,U+1F4FD-1F4FE,U+1F503,U+1F507-1F50B,U+1F50D,U+1F512-1F513,U+1F53E-1F54A,U+1F54F-1F5FA,U+1F610,U+1F650-1F67F,U+1F687,U+1F68D,U+1F691,U+1F694,U+1F698,U+1F6AD,U+1F6B2,U+1F6B9-1F6BA,U+1F6BC,U+1F6C6-1F6CF,U+1F6D3-1F6D7,U+1F6E0-1F6EA,U+1F6F0-1F6F3,U+1F6F7-1F6FC,U+1F700-1F7FF,U+1F800-1F80B,U+1F810-1F847,U+1F850-1F859,U+1F860-1F887,U+1F890-1F8AD,U+1F8B0-1F8BB,U+1F8C0-1F8C1,U+1F900-1F90B,U+1F93B,U+1F946,U+1F984,U+1F996,U+1F9E9,U+1FA00-1FA6F,U+1FA70-1FA7C,U+1FA80-1FA89,U+1FA8F-1FAC6,U+1FACE-1FADC,U+1FADF-1FAE9,U+1FAF0-1FAF8,U+1FB00-1FBFF}@font-face{font-family:Roboto;font-style:normal;font-weight:700;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2) format("woff2");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Roboto;font-style:normal;font-weight:700;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2) format("woff2");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Roboto;font-style:normal;font-weight:700;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/roboto/v51/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2) format("woff2");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Material Symbols Outlined;font-style:normal;font-weight:100 700;src:url(https://fonts.gstatic.com/s/materialsymbolsoutlined/v368/kJEhBvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oFsI.woff2) format("woff2")}.material-symbols-outlined{font-family:Material Symbols Outlined;font-weight:400;font-style:normal;font-size:24px;line-height:1;letter-spacing:normal;text-transform:none;display:inline-block;white-space:nowrap;word-wrap:normal;direction:ltr;-webkit-font-feature-settings:"liga";-webkit-font-smoothing:antialiased}*,*:before,*:after{box-sizing:border-box;font-family:Google Sans,Google Sans Text,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif}.material-symbols-outlined{font-family:Material Symbols Outlined!important;font-weight:400;font-style:normal;font-size:20px;line-height:1;letter-spacing:normal;text-transform:none;display:inline-block;white-space:nowrap;word-wrap:normal;direction:ltr;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-rendering:optimizeLegibility;font-feature-settings:"liga";font-variation-settings:"FILL" 0,"wght" 300,"GRAD" 0,"opsz" 20}body,input,button,select,textarea{font-family:Google Sans,Google Sans Text,Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif}body{margin:0;padding:0;color:#1e293b;background-color:#f8f9fa;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale} diff --git a/artemis/runtime/__init__.py b/artemis/runtime/__init__.py index 9821beece..c29a11041 100644 --- a/artemis/runtime/__init__.py +++ b/artemis/runtime/__init__.py @@ -12,7 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Artemis cross-platform runtime and process supervisor subsystem.""" +"""Device runtime: device locks, adb endpoints, device pool, awake service. + +The task daemon, web-server lifecycle, process supervisor and trace store +shipped with the smart stack and web console; they now live in the DSH host +and have been removed. +""" from artemis.runtime.device_lock import ( ConcurrencyMode, @@ -29,7 +34,19 @@ ) from artemis.runtime.device_pool import DevicePool, DeviceStatus, device_pool from artemis.runtime.process_probe import pid_is_alive -from artemis.runtime.daemon_client import ( +from artemis.runtime.awake_service import ( + ensure_device_awake, + sanitize_device_state, + screen_awake_service, + shutdown_awake_service, + start_awake_service, +) + +# Task-daemon surface restored for the mcp_server provider (G72 route 4: the +# provider subtree came back from main onto this branch). The DSH host replaced +# the daemon in the plugin world, but the provider's task face imports these +# names unconditionally at module level, so they must resolve. +from artemis.runtime.daemon_client import ( # noqa: E402 ensure_daemon_running, get_daemon_session, get_daemon_status, @@ -40,29 +57,7 @@ submit_task_to_daemon, wait_for_daemon_task, ) -from artemis.runtime.awake_service import ( - ensure_device_awake, - sanitize_device_state, - screen_awake_service, - shutdown_awake_service, - start_awake_service, -) -from artemis.runtime.server_lifecycle import ( - clear_server_info, - find_server_pids, - get_server_status, - is_port_in_use, - read_server_info, - stop_server, - write_server_info, -) -from artemis.runtime.supervisor import ProcessSupervisor, process_supervisor -from artemis.runtime.cancel_requests import ( - clear_cancel_request, - is_cancel_requested, - request_cancel, - watch_for_cancel_request, -) +from artemis.runtime.supervisor import ProcessSupervisor, process_supervisor # noqa: E402 __all__ = [ "ADB_ENDPOINT_ID_ENV", @@ -75,34 +70,23 @@ "DevicePool", "DeviceStatus", "InvalidAdbEndpoint", - "clear_cancel_request", - "clear_server_info", + "ProcessSupervisor", "current_adb_endpoint", "device_pool", "ensure_daemon_running", "ensure_device_awake", - "find_server_pids", "get_daemon_session", "get_daemon_status", - "get_server_status", "is_artemis_daemon", "is_daemon_running", - "is_cancel_requested", - "is_port_in_use", "pid_is_alive", - "ProcessSupervisor", "process_supervisor", - "read_server_info", - "request_cancel", "sanitize_device_state", "screen_awake_service", "shutdown_awake_service", "start_awake_service", - "stop_server", "stop_task_on_daemon", "submit_batch_to_daemon", "submit_task_to_daemon", "wait_for_daemon_task", - "watch_for_cancel_request", - "write_server_info", ] diff --git a/artemis/runtime/device_lock.py b/artemis/runtime/device_lock.py index 9579d2347..f4025d125 100644 --- a/artemis/runtime/device_lock.py +++ b/artemis/runtime/device_lock.py @@ -22,6 +22,7 @@ import os from pathlib import Path import re +import threading import time from typing import Any import uuid @@ -85,6 +86,14 @@ class DeviceExecutionLock: QUEUE_TICKET_ENV = "ARTEMIS_DEVICE_QUEUE_TICKET" LOCK_SCOPE_ENV = "ARTEMIS_ADB_ENDPOINT_ID" + #: Queue tickets sort by filename prefix, so the time_ns prefix must be + #: unique and strictly increasing in reservation order. Coarse clocks + #: (Windows ticks at ~1ms) hand consecutive reservations the same prefix, + #: which would fall back to random uuid ordering and break FIFO; the lock + #: serializes reservations within a process and the prefix-bump loop below + #: keeps each prefix distinct. + _reserve_lock = threading.Lock() + @staticmethod def _normalize_device_id(device_id: str | None) -> str: """Normalize device serial/identifier to a filesystem-safe string.""" @@ -397,18 +406,24 @@ def reserve( ingress=ingress, lock_scope=lock_scope or os.getenv(cls.LOCK_SCOPE_ENV) or None, ) - for _ in range(8): - path = queue_dir / f"{time.time_ns():020d}-{token}.wait" - try: - fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) - except FileExistsError: - continue - else: + with cls._reserve_lock: + prefix = time.time_ns() + for _ in range(64): + path = queue_dir / f"{prefix:020d}-{token}.wait" + if any(queue_dir.glob(f"{prefix:020d}-*.wait")): + prefix += 1 + continue try: - os.write(fd, json.dumps(payload, ensure_ascii=False).encode("utf-8")) - finally: - os.close(fd) - return token + fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except FileExistsError: + prefix += 1 + continue + else: + try: + os.write(fd, json.dumps(payload, ensure_ascii=False).encode("utf-8")) + finally: + os.close(fd) + return token raise DeviceBusyError("Could not reserve a position in the Artemis device queue.") @classmethod diff --git a/artemis/runtime/server_lifecycle.py b/artemis/runtime/server_lifecycle.py index 820404a7e..b3b914a8b 100644 --- a/artemis/runtime/server_lifecycle.py +++ b/artemis/runtime/server_lifecycle.py @@ -245,14 +245,18 @@ def find_server_pids(port: int = 8000) -> list[int]: # 2c. netstat (Windows fallback) if not discovered and sys.platform == "win32": try: + # netstat emits OEM/ANSI codepage text (e.g. GBK on zh-CN Windows) that + # breaks strict UTF-8 decoding; only ASCII tokens are matched below, so + # undecodable bytes can be safely replaced instead of failing the read. res = subprocess.run( ["netstat", "-ano"], stdin=subprocess.DEVNULL, capture_output=True, text=True, + errors="replace", timeout=3.0, ) - if res.returncode == 0: + if res.returncode == 0 and res.stdout: for line in res.stdout.splitlines(): if f":{port}" in line and "LISTENING" in line.upper(): parts = line.strip().split() diff --git a/artemis/sdk/agent.py b/artemis/sdk/agent.py index c0c0da08b..1beb02aff 100644 --- a/artemis/sdk/agent.py +++ b/artemis/sdk/agent.py @@ -22,7 +22,6 @@ from dotenv import load_dotenv from google import genai -from langchain_google_genai import ChatGoogleGenerativeAI from artemis.clients.screen_client_factory import ( describe_backend, @@ -34,6 +33,7 @@ run_tuning_for_profile, settings, ) +from artemis.services.llm import get_default_deployment, get_default_deployment_llm from artemis.context import ( ArtemisContext, DevicePlatform, @@ -137,34 +137,38 @@ async def _prewarm_llm_connections(self, api_key: str | None = None): publish_startup_progress( "model_warmup", "Warming the model connection", session_id=self._session_id ) - logger.info("Starting background pre-warming of Gemini API connection pools...") + deployment = get_default_deployment() + logger.info( + f"Starting background pre-warming of {deployment.provider} API connection pools..." + ) try: - key = api_key - if not key and settings.GOOGLE_API_KEY: - key = settings.GOOGLE_API_KEY.get_secret_value() - - if not key: - logger.warning("Skipping LLM pre-warming: No API key available.") - publish_startup_progress( - "model_ready", - "Model connection will initialize on first use", - session_id=self._session_id, + pings = [] + if deployment.provider == "google": + key = api_key or deployment.api_key + if not key and settings.GOOGLE_API_KEY: + key = settings.GOOGLE_API_KEY.get_secret_value() + + if not key: + logger.warning("Skipping LLM pre-warming: No API key available.") + publish_startup_progress( + "model_ready", + "Model connection will initialize on first use", + session_id=self._session_id, + ) + return + + # 1. Pre-warm Native GenAI SDK client (Google-only path) + client = genai.Client(api_key=key) + pings.append( + client.aio.models.count_tokens(model=deployment.model, contents="ping") ) - return - - # 1. Pre-warm Native SDK client - client = genai.Client(api_key=key) - # 2. Pre-warm LangChain client - chat = ChatGoogleGenerativeAI(model="gemini-3.8-flash", google_api_key=key) + # 2. Pre-warm the LangChain client on the configured default endpoint + pings.append(get_default_deployment_llm().ainvoke("ping")) - # Fire both calls concurrently in the background - await asyncio.gather( - client.aio.models.count_tokens(model="gemini-3.8-flash", contents="ping"), - chat.ainvoke("ping"), - return_exceptions=True, - ) - logger.success("Gemini API connection pools successfully pre-warmed.") + # Fire all calls concurrently in the background + await asyncio.gather(*pings, return_exceptions=True) + logger.success(f"{deployment.provider} API connection pools successfully pre-warmed.") publish_startup_progress( "model_ready", "Model connection is ready", session_id=self._session_id ) @@ -351,7 +355,23 @@ async def _finalize_tracing_safely(self, task: Task, context: ArtemisContext): ) def _get_graph_state(self, task: Task): - return State.initial(task.request.goal) + # Conversation continuation: when this worker was queued for a chat + # thread (ARTEMIS_CONVERSATION_ID), inherit the thread's latest notes + # so the planner continues where the previous task left off. Runs + # before start_session, so the query naturally targets prior tasks. + prior_context = None + conversation_id = os.environ.get("ARTEMIS_CONVERSATION_ID", "").strip() + if conversation_id: + try: + from artemis.config import DB_PATH, TRACES_PATH + from artemis.data_engine.storage import StorageManager + + prior_context = StorageManager(DB_PATH, TRACES_PATH).get_conversation_prior_context( + conversation_id + ) + except Exception as exc: + logger.debug(f"Could not load prior conversation context: {exc}") + return State.initial(task.request.goal, prior_conversation_context=prior_context) async def _connect_screen_client(self, context: ArtemisContext, session_id: str) -> None: """Connect the screen client with visible progress for slow first-time steps.""" diff --git a/artemis/services/llm.py b/artemis/services/llm.py index ec91e6fa0..38c8290e1 100644 --- a/artemis/services/llm.py +++ b/artemis/services/llm.py @@ -22,8 +22,10 @@ from collections.abc import Awaitable, Callable, Iterator from contextlib import contextmanager from contextvars import ContextVar, Token +from dataclasses import dataclass import functools import logging +import os from pathlib import Path import re import sys @@ -870,18 +872,31 @@ async def acomplete_structured( ).__call__ +def _resolve_configured_api_key(api_key: str | None, api_key_env: str | None) -> str | None: + """Config api_key wins; otherwise the env var named by api_key_env.""" + if api_key: + return api_key + if api_key_env: + return os.environ.get(api_key_env) + return None + + # Backward compatible factory functions delegating to ModelFactory -def get_google_llm( - model_name: str = "gemini-3.8-flash", +def get_provider_llm( + model_name: str, + provider: str = "google", temperature: float | None = None, timeout: float | None = None, - thinking_budget: int | None = None, + thinking_budget: float | None = None, thinking_level: str | None = "medium", include_thoughts: bool | None = None, enable_grounding: bool = False, + api_base: str | None = None, + api_key: str | None = None, ) -> BaseChatModel: + """Build a chat model for an explicit model name on an explicit endpoint.""" ep = ModelEndpoint( - provider=ModelProvider.GOOGLE, + provider=ModelProvider.from_string(provider), model_name=model_name, temperature=temperature or 0.0, timeout_seconds=timeout or 60.0, @@ -889,10 +904,79 @@ def get_google_llm( thinking_level=thinking_level, include_thoughts=include_thoughts, enable_grounding=enable_grounding, + api_base=api_base, + api_key=api_key, ) return ModelFactory.create_model(ep) +@dataclass(frozen=True) +class DefaultDeployment: + """The endpoint every node that configures only a model name inherits. + + Empty ``provider``/``model`` mean no deployment default: there is no + built-in endpoint of any kind, so nothing works until the user configures + one. + """ + + provider: str = "" + model: str = "" + api_base: str | None = None + api_key: str | None = None + api_key_env: str | None = None + + +def get_default_deployment() -> DefaultDeployment: + """Endpoint settings of the raw "default" entry in artemis.jsonc. + + Reads the unmerged entry (not a specific node), so per-node overrides can + never leak into the inheritance base. Returns an empty deployment when the + config provides no default — there is no built-in endpoint to fall back to. + """ + try: + from artemis.config.llm import load_default_model_cfg + + cfg = load_default_model_cfg() + return DefaultDeployment( + provider=str(cfg.get("provider") or ""), + model=str(cfg.get("model") or ""), + api_base=cfg.get("api_base") or None, + api_key=cfg.get("api_key") or None, + api_key_env=cfg.get("api_key_env") or None, + ) + except Exception: # pylint: disable=broad-exception-caught + return DefaultDeployment() + + +def get_default_deployment_llm( + model_name: str | None = None, + temperature: float | None = None, + timeout: float | None = None, +) -> BaseChatModel: + """Build a model on the deployment's default endpoint, overriding only the model name. + + Single entry point for nodes that carry a bare model name in config (step + summarizer, history chunker) and for degraded-mode fallbacks. Never + hardcodes a provider: provider and endpoint come from artemis.jsonc. + """ + deployment = get_default_deployment() + if not deployment.provider or not deployment.model: + raise RuntimeError( + "No default model endpoint is configured. Set provider/model " + "(optionally api_base) under 'default' in config/artemis.jsonc — " + "there is no built-in endpoint, so nothing runs until it is set." + ) + return get_provider_llm( + model_name=model_name or deployment.model, + provider=deployment.provider, + temperature=temperature, + timeout=timeout, + thinking_level=None, + api_base=deployment.api_base, + api_key=_resolve_configured_api_key(deployment.api_key, deployment.api_key_env), + ) + + def _resolve_endpoint( ctx: ArtemisContext, name: str, @@ -915,7 +999,15 @@ def _resolve_endpoint( if use_fallback: if isinstance(cfg, LLMWithFallback) or (hasattr(cfg, "fallback") and cfg.fallback): - cfg = cfg.fallback + parent, cfg = cfg, cfg.fallback + # Endpoint overrides inherit into the fallback unless it declares its own. + inherited = { + field: getattr(parent, field) + for field in ("api_base", "api_key", "api_key_env") + if getattr(cfg, field, None) is None and getattr(parent, field, None) is not None + } + if inherited: + cfg = cfg.model_copy(update=inherited) else: raise ValueError(f"LLM configuration for '{name}' has no fallback!") @@ -923,8 +1015,15 @@ def _get_val(obj, attr, expected_type): val = getattr(obj, attr, None) return val if isinstance(val, expected_type) else None - provider_val = getattr(cfg, "provider", "google") - model_val = getattr(cfg, "model", "gemini-2.5-flash") + provider_val = getattr(cfg, "provider", None) + model_val = getattr(cfg, "model", None) + if not provider_val or not model_val: + raise RuntimeError( + f"No model endpoint configured for '{name}'. Set provider/model " + "(optionally api_base) for this node or 'default' in " + "config/artemis.jsonc — there is no built-in endpoint, so " + "nothing runs until it is set." + ) return ModelEndpoint( provider=ModelProvider.from_string(provider_val), @@ -936,6 +1035,10 @@ def _get_val(obj, attr, expected_type): reasoning_effort=_get_val(cfg, "reasoning_effort", str), include_thoughts=_get_val(cfg, "include_thoughts", bool), enable_grounding=_get_val(cfg, "enable_grounding", bool) or False, + api_base=_get_val(cfg, "api_base", str), + api_key=_resolve_configured_api_key( + _get_val(cfg, "api_key", str), _get_val(cfg, "api_key_env", str) + ), ) diff --git a/artemis/telemetry/__init__.py b/artemis/telemetry/__init__.py deleted file mode 100644 index 6431741a7..000000000 --- a/artemis/telemetry/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Telemetry and distributed tracing layer for ARTEMIS.""" - -from artemis.telemetry.models import ( - SpanType, - TelemetrySpan, - TokenUsage, -) -from artemis.telemetry.tracer import ( - CURRENT_PARENT_SPAN_ID, - CURRENT_TRACE_ID, - TelemetryTracer, -) - -__all__ = [ - "TelemetryTracer", - "TelemetrySpan", - "SpanType", - "TokenUsage", - "CURRENT_TRACE_ID", - "CURRENT_PARENT_SPAN_ID", -] diff --git a/artemis/telemetry/models.py b/artemis/telemetry/models.py deleted file mode 100644 index 919eab7c3..000000000 --- a/artemis/telemetry/models.py +++ /dev/null @@ -1,64 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Telemetry Data Models and Schema Definitions.""" - -from enum import Enum -import time -from typing import Any -from uuid import uuid4 -from pydantic import BaseModel, Field - - -class SpanType(str, Enum): - AGENT = "agent" - LLM_CALL = "llm_call" - TOOL_EXECUTION = "tool_execution" - DEVICE_ACTION = "device_action" - SCREEN_CAPTURE = "screen_capture" - CUSTOM = "custom" - - -class TokenUsage(BaseModel): - """LLM token consumption metrics.""" - - prompt_tokens: int = Field(default=0, description="Input prompt tokens") - completion_tokens: int = Field(default=0, description="Output generated tokens") - total_tokens: int = Field(default=0, description="Total tokens consumed") - - -class TelemetrySpan(BaseModel): - """Structured telemetry span representing a single unit of agentic execution.""" - - span_id: str = Field(default_factory=lambda: str(uuid4())) - trace_id: str = Field(..., description="Root trace/session identifier") - parent_id: str | None = Field(default=None, description="Parent span identifier") - name: str = Field(..., description="Human-readable span operation name") - type: SpanType = Field(default=SpanType.CUSTOM, description="Span categorization") - start_time: float = Field(default_factory=time.time, description="Start timestamp") - end_time: float | None = Field(default=None, description="Completion timestamp") - duration_ms: float | None = Field(default=None, description="Elapsed execution time in ms") - status: str = Field(default="running", description="Status ('running', 'success', 'failed')") - payload: dict[str, Any] = Field( - default_factory=dict, description="Custom parameters and payloads" - ) - error: str | None = Field(default=None, description="Exception message if failed") - token_usage: TokenUsage | None = Field(default=None, description="Token consumption metrics") - - def finish(self, status: str = "success", error: str | None = None) -> None: - """Closes the span and calculates duration.""" - self.end_time = time.time() - self.duration_ms = (self.end_time - self.start_time) * 1000.0 - self.status = status - self.error = error diff --git a/artemis/telemetry/replay.py b/artemis/telemetry/replay.py deleted file mode 100644 index bc4581d9e..000000000 --- a/artemis/telemetry/replay.py +++ /dev/null @@ -1,34 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Trace Replay and Inspection utilities.""" - -from pathlib import Path -from typing import Any -from artemis.telemetry.storage.json_storage import JsonStorage - - -class TraceReplayer: - """Loads and formats historic task traces for replay and visual inspection.""" - - def __init__(self, traces_dir: Path | str = "traces"): - self.storage = JsonStorage(storage_dir=traces_dir) - - def get_trace_summary(self, trace_id: str) -> dict[str, Any]: - spans = self.storage.read_spans(trace_id) - return { - "trace_id": trace_id, - "total_spans": len(spans), - "spans": spans, - } diff --git a/artemis/telemetry/storage/json_storage.py b/artemis/telemetry/storage/json_storage.py deleted file mode 100644 index a928e54f2..000000000 --- a/artemis/telemetry/storage/json_storage.py +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""JSON Lines Telemetry Storage Backend.""" - -import json -from pathlib import Path -from typing import Any -from artemis.telemetry.models import TelemetrySpan - - -class JsonStorage: - """Persists spans into newline-delimited JSON files.""" - - def __init__(self, storage_dir: Path | str = "traces"): - self.storage_dir = Path(storage_dir) - - def write_span(self, trace_id: str, span: TelemetrySpan) -> None: - target_dir = self.storage_dir / trace_id - target_dir.mkdir(parents=True, exist_ok=True) - log_file = target_dir / "spans.jsonl" - - with open(log_file, "a", encoding="utf-8") as f: - f.write(json.dumps(span.model_dump(), ensure_ascii=False) + "\n") - - def read_spans(self, trace_id: str) -> list[dict[str, Any]]: - log_file = self.storage_dir / trace_id / "spans.jsonl" - if not log_file.exists(): - return [] - - spans = [] - with open(log_file, encoding="utf-8") as f: - for line in f: - if line.strip(): - spans.append(json.loads(line)) - return spans diff --git a/artemis/telemetry/tracer.py b/artemis/telemetry/tracer.py deleted file mode 100644 index 5364a15df..000000000 --- a/artemis/telemetry/tracer.py +++ /dev/null @@ -1,85 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Telemetry Tracer and Span Life Cycle Manager.""" - -from contextlib import asynccontextmanager -from contextvars import ContextVar -import json -from pathlib import Path -from typing import Any -from collections.abc import AsyncIterator -from uuid import uuid4 - -from artemis.telemetry.models import SpanType, TelemetrySpan -from third_party.mobile_use.utils.logger import get_logger - -logger = get_logger(__name__) - -CURRENT_TRACE_ID: ContextVar[str] = ContextVar("CURRENT_TRACE_ID", default="global-session") -CURRENT_PARENT_SPAN_ID: ContextVar[str | None] = ContextVar("CURRENT_PARENT_SPAN_ID", default=None) - - -class TelemetryTracer: - """Central tracer for recording and persisting telemetry spans.""" - - def __init__(self, trace_id: str | None = None, traces_dir: Path | None = None): - self.trace_id = trace_id or str(uuid4()) - self.traces_dir = traces_dir or Path("traces") / self.trace_id - self.spans: list[TelemetrySpan] = [] - self._log_file: Path | None = None - - def _ensure_log_file(self) -> Path: - if self._log_file is None: - self.traces_dir.mkdir(parents=True, exist_ok=True) - self._log_file = self.traces_dir / "telemetry_spans.jsonl" - return self._log_file - - def record_span(self, span: TelemetrySpan) -> None: - """Appends and persists completed span.""" - self.spans.append(span) - try: - log_file = self._ensure_log_file() - with open(log_file, "a", encoding="utf-8") as f: - f.write(json.dumps(span.model_dump(), ensure_ascii=False) + "\n") - except Exception as e: - logger.warning(f"Failed to persist telemetry span {span.span_id}: {e}") - - @asynccontextmanager - async def start_span( - self, - name: str, - span_type: SpanType = SpanType.CUSTOM, - payload: dict[str, Any] | None = None, - ) -> AsyncIterator[TelemetrySpan]: - """Asynchronous context manager creating and recording a span.""" - parent_id = CURRENT_PARENT_SPAN_ID.get() - span = TelemetrySpan( - trace_id=self.trace_id, - parent_id=parent_id, - name=name, - type=span_type, - payload=payload or {}, - ) - - token = CURRENT_PARENT_SPAN_ID.set(span.span_id) - try: - yield span - span.finish(status="success") - except Exception as e: - span.finish(status="failed", error=str(e)) - raise - finally: - CURRENT_PARENT_SPAN_ID.reset(token) - self.record_span(span) diff --git a/artemis/utils/credentials_validator.py b/artemis/utils/credentials_validator.py index 29b224e60..a46831202 100644 --- a/artemis/utils/credentials_validator.py +++ b/artemis/utils/credentials_validator.py @@ -123,7 +123,7 @@ async def validate_api_key( err_msg = _extract_error_message(resp) return False, f"Vision OCR API verification failed ({resp.status_code}): {err_msg}" - elif clean_provider == "openai": + elif clean_provider in ("openai", "openai_responses"): endpoint = ( f"{base_url.rstrip('/')}/models" if base_url diff --git a/artemis/utils/python_executor.py b/artemis/utils/python_executor.py index b2a548ebb..a3cbaf076 100644 --- a/artemis/utils/python_executor.py +++ b/artemis/utils/python_executor.py @@ -17,6 +17,7 @@ from pathlib import Path import queue import subprocess +import sys import time from typing import IO @@ -73,7 +74,15 @@ def _start(self) -> None: kernel_output = self._kernel_log if self._kernel_log is not None else subprocess.DEVNULL self.km = jupyter_client.KernelManager(kernel_name="python3") - self.km.start_kernel(stdout=kernel_output, stderr=kernel_output) + extra_kwargs: dict = {} + if sys.platform == "win32": + # jupyter_client only sets CREATE_NO_WINDOW when the parent is + # pythonw.exe; spawned from a console-less daemon/worker the + # kernel would otherwise get a fresh, blank console window. + extra_kwargs["creationflags"] = ( + subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW + ) + self.km.start_kernel(stdout=kernel_output, stderr=kernel_output, **extra_kwargs) self.kc = self.km.client() self.kc.start_channels() self.kc.wait_for_ready(timeout=KERNEL_STARTUP_TIMEOUT_S) diff --git a/config/artemis.jsonc b/config/artemis.jsonc index 517d73840..4ffadab20 100644 --- a/config/artemis.jsonc +++ b/config/artemis.jsonc @@ -7,49 +7,15 @@ // 1. Global Default Model (Applied to all sub-agents unless overridden below) // ------------------------------------------------------------------------------ "default": { - "provider": "google", - "model": "gemini-3.8-flash", - "thinking_level": "medium", - "fallback": { - "provider": "google", - "model": "gemini-3.7-flash", - "thinking_level": "medium" - } - }, - - // ------------------------------------------------------------------------------ - // 2. Ready-to-Use Presets (Switch easily in code or CLI) - // ------------------------------------------------------------------------------ - "presets": { - "gemini-flagship": { - "provider": "google", - "model": "gemini-3.8-flash", - "fallback": { "provider": "google", "model": "gemini-3.7-flash" } - }, - "gemini-flash": { - "provider": "google", - "model": "gemini-2.5-flash", - "fallback": { "provider": "google", "model": "gemini-2.0-flash" } - }, - "openai-gpt4o": { - "provider": "openai", - "model": "gpt-4o", - "fallback": { "provider": "openai", "model": "gpt-4o-mini" } - }, - "cost-saving": { - "provider": "google", - "model": "gemini-3.5-flash-lite", - "fallback": { "provider": "google", "model": "gemini-3.1-flash-lite" } - }, - "local-ollama": { - "provider": "openai", - "model": "llama3.2-vision", - "fallback": { "provider": "openai", "model": "llama3.2-vision" } - } + "provider_label": "local-lmstudio", + "provider": "openai", + "model": "qwen3.6-35b-a3b-mtp", + "api_base": "http://127.0.0.1:1234/v1", + "thinking_level": "medium" }, // ------------------------------------------------------------------------------ - // 3. Agent & Sub-Agent Node Customizations (Optional - Inherits from 'default') + // 2. Agent & Sub-Agent Node Customizations (Optional - Inherits from 'default') // ------------------------------------------------------------------------------ "nodes": { // 🧠 Planner: High-level goal decomposition and strategic step planning @@ -75,10 +41,10 @@ // To achieve accurate [x, y] point and bounding box localization, this node MUST use specialized Gemini ER (Embodied Reasoning / Robotics) models: // e.g., "gemini-robotics-er-2-preview". Non-ER models will fail spatial coordinate detection. "object_detector": { - "model": "gemini-robotics-er-2-preview", + "model": "qwen3.6-35b-a3b-mtp", "fallback": { - "provider": "google", - "model": "gemini-robotics-er-2-preview" + "provider": "openai", + "model": "qwen3.6-35b-a3b-mtp" } }, @@ -95,10 +61,10 @@ // 🔽 Hopper: Fast lightweight package name locator "hopper": { - "model": "gemini-3.5-flash-lite", + "model": "qwen3.6-35b-a3b-mtp", "fallback": { - "provider": "google", - "model": "gemini-3.1-flash-lite" + "provider": "openai", + "model": "qwen3.6-35b-a3b-mtp" } }, @@ -129,11 +95,11 @@ }, // ------------------------------------------------------------------------------ - // 4. Runtime Mode & Agent Capabilities Configuration (Dual-Engine: Flash vs. Pro) + // 3. Runtime Mode & Agent Capabilities Configuration (Dual-Engine: Flash vs. Pro) // ------------------------------------------------------------------------------ "agent": { // ========================================================================== - // ⚡ 4.1 FLASH Profile Options (Reactive Single-Agent Lightweight Loop) + // ⚡ 3.1 FLASH Profile Options (Reactive Single-Agent Lightweight Loop) // ========================================================================== // Best for: Simple, fast, deterministic tasks; runs unbounded by default. // Execution: Observe-Think-Act single LLM reactive loop without graph overhead. @@ -145,12 +111,12 @@ // replacing images only after strictly objective, high-density summaries are ready. "step_summarizer": { "enabled": true, // Whether background step summarization is active - "model": "gemini-3.5-flash-lite", // Lightweight model for fast background inference + "model": "qwen3.6-35b-a3b-mtp", // Lightweight model for fast background inference "prune_history_xml": true // Prune outdated heavy XML trees from historical steps } }, // ========================================================================== - // 🚀 4.2 PRO Profile Options (Deep Multi-Agent Graph Closed-Loop) + // 🚀 3.2 PRO Profile Options (Deep Multi-Agent Graph Closed-Loop) // ========================================================================== // Best for: Complex, exploratory, dynamic multi-branch tasks. // Execution: LangGraph multi-node graph with a living task plan, a pre-execution safety net (single actions) / fast-action bursts (multi-action turns), @@ -190,7 +156,7 @@ } }, // ========================================================================== - // 🌐 4.3 Shared Capabilities & Global Overrides + // 🌐 3.3 Shared Capabilities & Global Overrides // ========================================================================== // Shared step memory and history compression // Transcript ledger + layered compression for the Pro operator. @@ -246,7 +212,7 @@ // call each); milestone closes are exempt, the hard line waives it. "min_steps": 3, "target_source_tokens": 2000, - "model": "gemini-3.8-flash", + "model": "qwen3.6-35b-a3b-mtp", "max_chunks": 8 // "max_eras": 8 // independent era cap; omitted = follows max_chunks }, diff --git a/docs/CHANGELOGS.md b/docs/CHANGELOGS.md new file mode 100644 index 000000000..ef734ff97 --- /dev/null +++ b/docs/CHANGELOGS.md @@ -0,0 +1,3613 @@ +# ARTEMIS 变更历史(CHANGELOGS) + +> 本文档只记**已经发生并结案**的变更:每条是一个 `### #N` 条目,含「问题描述 / 整改方案与实施 / 验收标准」三段。 +> **在办事项、差距、方案与验收清单在 `TODO.md`,不要写在这里**;两者的分工与追加规则见下方「写入规则」与 `AGENTS.md`。 +> **条目编号 `#N` 一经发布不得改写**——`AGENTS.md`、`TODO.md`、`migration/registry.md` 与守则都按 `changelogs #N` 引用它,分类只移动块的位置,不动编号。 + +## 索引 + +| 分类 | 条目 | 范围 | +|---|---|---| +| 一 · 模型端点与配置 | #1, #2, #3, #4, #8 | 出厂端点不写死、provider 感知工厂、失败可观测性、零配置路径的移除。 | +| 二 · 前端 Web 控制台 | #6 | Angular 22 → Vue 3 + Arco Design Vue 的整仓迁移与收尾。 | +| 三 · 实机与测试环境 | #5, #7 | Windows 真机全量测试暴露的问题:控制台弹窗、FIFO 预约乱序、缓存强刷、测试隔离。 | +| 四 · DSH 插件轨道(当前主线) | #9–#83 | `plugins/dsh-autogamer`(原 `autogamer-device`)与 py 动作面的接线、契约与真机联调问题;#30/#31 是文档层自身的漂移与它的机械看守;#45 是同一条规则的第二次漂移,以及量它的那把尺子本身绑在 checkout 换行符上;#46/#47 把这条规则接进 CI 并让行尾成为仓库事实;#48 更正 #47 一处勾早了的验收;#49 是判据命令本身没牙(管道吞退出码);#50 是离线 action 面少了第 14 个工具,#51 更正它发明的 `ratio`,#52 让 py 实现本身离线可跑(G62 结案),#53 更正「CI 用 spike-mcp 做比对」这条从未成立的假陈述,#54 把那条比对真的接进 CI(G63 结案);#55 是设备池闸门(G3/G28 A 片,顺带修掉一处 G13 标记在 abort 时永久滞留);#56 给 py 面加只读 `list_devices`,roster 从此有真来源(G64 结案);#57 把 py 提交门禁的第一道门移植进插件;#59 补上锁屏三态(G65 结案,准入 fail-closed 半边齐);#60 把 `{index}` 句柄从按数组位置重解改成按节点身份解析(G67 结案,更正 #40 一处安全语义);#61 补回 README 漏掉的三个模块并给它一枚双向锁;#62 补上 incident 的 CLOSED 通知并锁死 `run_outcome` 双轴(G66 第③④项);#63 收掉 #60/#62 复审轮的 5 条缺陷(G68 结案,含对 #60 一处半证声明的更正);#64 收尾复审轮的台账卫生四项(README 锁改吃共享遍历并只看 Layout 块);#65 收回工具描述里那句比代码强的「即使屏幕移动也跟随」;#66 抹掉我自己加错的提交署名尾注(只改 message,树内容以 diff 为空为证);#67 记录推送后 run 立刻产出——G54「push 从不触发」被否证、#46 的 CI 留白关闭;#68 修掉那次 run 里唯一红的自建步骤(fixture 重导吃 `dist/`,python job 需先构建插件);#69 修好那步之后 ratchet 头一回被执行——基线 754 已腐六周、且它声明的唯一本地执行点(pre-commit hook)从未安装;#70 拿到本 fork 第一次整体绿的 push run(38297953839),把 #46/#54/#68/#69 的四处「等真 run 复验」一次闭合;#71 清空旧待办(P4 阶段 A/B 归档、C/D 与 10 个未关闭差距放弃、门禁自测锚点维修);#72 新方案 Phase 1 落地(骨架矫正、预检、只读设备状态工具);#73 新方案 Phase 2 落地(完整工具集,后端真映射、适配器工厂、诊断合并预检);#74 新方案 Phase 3 落地(浏览器半,toolview 卡片、手写 ModuleLoader 形态);#75 新方案 Phase 4 落地(镜像 Host 半,scrcpy 启停、触摸降级,流与下载明确不做);#76 新方案 Phase 5 落地(rules 注入与收尾,错误路径补齐、README 完整化);#77 CI 修绿(删旧树 job/步、pyproject 手术、命名空间代换寻址器);#78 仓库卫生与主线定位(清 `origin` 三条死分支、删已对偶证明的安全网分支、默认分支迁到 `dsh-plugin-contraction`、插件本机环境装好并复验);#79 预检承认 project-venv 提供方形态(G77 结案,含三条变异的证伪记录);#80 doctor 收尾不再提示已删命令(G73 结案,新增一条吃已注册命令集的防腐烂锁);#81 门禁自测的注入变异会腐烂——悬空 id 改由登记表推导;#82 预检按配置设备问 helper、rules 注入暴露「为什么没注入」(G79 + G80 结案,真机七项首次全绿);#83 headless 死链移除、web 模型端点同步、G71 以 wire 抓包结案。 | +| 已关闭差距史 | G* | `TODO.md` 差距清单里结案条目的去处 | + +## 写入规则(如何新增一条变更历史) + +1. **判归属**:问题已修好并有验收证据 → 写这里;还没修 → 写 `TODO.md` 差距行并按 `registry.md` 编号;只是方案/验收清单 → 写 `TODO.md` 主题条目。 +2. **取号**:`### #N` 取当前最大号 +1,**永不复用、永不重排**;分类只影响块的位置。 +3. **写三段**:`#### 问题描述`(含实测证据与原文/出处)、`#### 整改方案与实施`(改了哪些文件与为什么这么改)、`#### 验收标准`(可复跑的命令/测试勾选项)。 +4. **与 TODO 的交接**:条目若关闭某个 `G*`,本文件写正文,`registry.md` 的已关闭表写一行结论,`TODO.md` 只保留一行指针或直接从差距表移除——**同一事实只在一处展开**。 +5. **防膨胀**:单条超过 ~60 行时,把调研正文移进 `docs/migration/` 对应文档,本文件留结论 + 链接;分类小节只增不改,新增分类须同步更新上方索引表。 + +--- + +## 附录 · 早期整改背景(2026-10-06 前后) + +### 测试环境 +| 项 | 说明 | +|---|---| +| 模拟器 | MuMu 模拟器(`adb connect 127.0.0.1:16384`,设备序列号已写入 `.env`) | +| 本地模型服务 | LM Studio,http://127.0.0.1:1234/v1(OpenAI 兼容);已加载 `qwen3.6-35b-a3b-mtp`(VLM + tool_use) | +| 平台 | Windows / Git Bash | + +### 整改后的预期形状 +(2026-10-06 已落地) + +- **端点即配置**:`artemis.jsonc` 的每个节点(`default` / `nodes`)都可用 `api_base` + `api_key`(或 `api_key_env`)声明完整端点;只写 `model` 的节点自动继承部署默认端点(含 fallback)。 +- **优先级链**:配置显式指定 > 环境变量(`OPENAI_BASE_URL` 等)> 提供商官方默认端点。 +- **零硬编码**:代码中不存在任何按调用点写死的 provider/端点;所有模型构建收敛到 provider 感知工厂。 +- **2026-10-07 修订**:`presets` 段从 `config/artemis.jsonc` 与打包副本 + `artemis/resources/config/artemis.jsonc` 中删除——全仓没有任何代码按名字解析过它 + (`load_default_model_cfg` 只读 `default`),注释里的"Switch easily in code or CLI"是空头承诺; + 设置界面可选端点改由端点库 `endpoint_library.json`(`.env` 同级、gitignore)承载, + 运行时读的仍是 `default` 块。 +- **2026-10-07 修订(#8)**:**出厂默认端点彻底移除**。不再把 Google 端点作为任何形式的 + fallback——一切端点信息(provider / model / api_base / api_key)都来自用户配置; + 用户没有配置时系统明确不可用(报错附配置指引),有无 Google Key 都不存在零配置可用路径。 + +### 早期验证记录(2026-10-06 端点整改) + +| 时间 | 项目 | 结果 | +|---|---|---| +| 10-06 首测 | Flash「设置查电量」 | ❌ 崩溃(`ChatGoogleGenerativeAI` 校验错误)→ 立出 #2 | +| 10-06 二测 | 同任务(#2 热修 + `OPENAI_BASE_URL` 旁路) | ✅ 3 回合,电量 80% 充电中,全程约 50s | +| 10-06 冒烟 | 端点贯通 6 项(原始 default / DefaultDeployment / 节点继承 / fallback 继承 / ChatOpenAI base_url / 实时调用) | ✅ 全过(此时 `.env` 旁路已移除,端点纯来自配置) | +| 10-06 三测 | Flash 同任务(纯配置端点) | ✅ 2 回合,电量 73%,全部请求命中 `127.0.0.1:1234` | +| 10-06 四测 | Pro「关于手机查 Android 版本」(纯配置端点) | ✅ LangGraph 全链路(planner/operator/checker)命中本地端点,status completed | +| 10-06 冒烟 | 空默认行为 4 项(无 google key 展开为空 / LLMConfig 校验 / 节点级清晰报错 / 真实配置不受影响) | ✅ 全过(#4) | +| 10-06 五测 | 故障注入:`api_base` 指向死端口 | ✅ CLI 显示 `Connection error.` + 定位提示;`sessions.error_message` 持久化(#3-1) | +| 10-06 六测 | Flash 回归(恢复配置)+ 编译产物 | ✅ 任务成功;`Materialized N steps`、`steps.json` 非空、`trace.gif` 生成;成功会话 error_message 为 NULL(#3-2) | +| 10-06 七测 | 无控制台父进程启动 Jupyter 内核 + 窗口快照 diff | ✅ 无新窗口弹出;内核执行正常(`print(2+3)`→`5`),输出落盘 `kernel.log`(#5) | +| 10-07 全量 | 后端 pytest 全量套件(Windows 实机,修复前) | ❌ 6 处失败:explorer×9 / video_analyzer×3 / readiness 缓存 / 设备发现 / device_lock 偶发(立出 #7) | +| 10-07 全量 | 后端 pytest 全量套件(修复后,含 device_lock 连跑 10 次) | ✅ 2570 passed / 0 failed,ruff 通过;前端 254 用例 + vue-tsc + 生产构建通过(#7) | +| 10-07 冒烟 | 无出厂默认行为 4 项(假 Key 注入下:缺文件→空默认 / 空配置→未配置节点与判官 / 解析报清晰指引 / 显式配置不受影响) | ✅ 全过,`test_no_google_factory_default` 钉住(#8) | + +--- + +--- + +## 一 · 模型端点与配置 + +### #1 默认端点不应写死:应支持预设提供商与自定义端点 + +- **日期**: 2026-10-06 +- **状态**: ✅ 已完成 +- **优先级**: 高(曾阻塞本地模型实测) +- **来源**: 实测准备阶段 + +#### 问题描述 + +模型端点与提供商绑定写死,无法在配置中指定自定义推理端点(本地 LM Studio / Ollama / vLLM 或企业网关)。期望:既支持**预设提供商**(google / openai / anthropic / openrouter / xai,自动使用官方默认端点,零配置可用),也支持**自定义端点**(在配置中覆盖 base_url)。 + +#### 现状分析(整改前的代码依据) + +| 位置 | 整改前现状 | +|---|---| +| `artemis/llm/router.py:93` | `ModelEndpoint.api_base` / `api_key` 字段已存在且路由层优先使用——**底层能力具备,但配置层未暴露** | +| `artemis/services/llm.py`(`_resolve_endpoint`) | 仅透传 provider / model / temperature 等字段,不读取 `api_base` / `api_key` | +| `third_party/mobile_use/config/llm.py`(`LLM` 模型) | 无 `api_base` / `api_key` 字段;pydantic 默认忽略未知字段,配置写了会被**静默丢弃** | +| `third_party/mobile_use/utils/file.py`(`load_jsonc`) | **新发现**:注释剥离正则不感知字符串字面量,URL 中的 `//` 被当作注释截断(`"http://…"` → 解析报 `Invalid control character`)——即使补上字段,配置端点也落不了地 | +| `.env.example` | 未记载 `OPENAI_BASE_URL` 旁路 | + +#### 整改方案与实施 + +1. **配置模型层**(`third_party/mobile_use/config/llm.py`):`LLM` 新增 `api_base` / `api_key` / `api_key_env` 字段;`validate_provider` 认可配置内凭证(`api_key` 或 `api_key_env` 指向的环境变量),不再强制要求 `.env` 提供商 Key。 +2. **解析链路**(`artemis/services/llm.py` `_resolve_endpoint`):将 `api_base` / `api_key`(经 `api_key_env` 解析)映射进 `ModelEndpoint`;**fallback 端点继承**——fallback 未声明端点字段时自动继承父节点的 `api_base` / `api_key` / `api_key_env`。 +3. **继承基准提取**(`artemis/config/llm.py`):提取 `_resolve_llm_config_path()` 与出厂默认常量 `FACTORY_DEFAULT_MODEL_CFG`,新增 `load_default_model_cfg()` 读取**未被合并的**原始 `default` 条目,避免节点覆盖泄漏进继承基准;`parse_llm_config` 复用同一路径解析。 +4. **JSONC 解析修复**(`third_party/mobile_use/utils/file.py`):`strip_json_comments` 改为字符串感知模式(先匹配并原样保留字符串字面量,再剥离注释,与 `artemis/config/core/engine.py` 的成熟实现同款);`engine.py` 委托该实现,消除重复。 +5. **配置面**:`config/artemis.jsonc` 的 `default` 直接声明 `api_base` + `api_key`(fallback 演示继承);`local-lmstudio` / `local-ollama` 预设补全端点字段;`.env` 移除 `OPENAI_BASE_URL` 旁路;`.env.example` 补充字段说明与优先级链。 + +#### 验收标准 + +- [x] 在 `artemis.jsonc` 中为节点指定 `api_base` 后,请求确实发往该端点——冒烟 6 项全过 + Flash/Pro 实测,守护进程日志确认全部请求命中 `http://127.0.0.1:1234/v1/chat/completions` +- [x] 只配置 `model` 的节点(含 fallback)继承部署默认端点——冒烟 #3/#4 验证 operator 及其 fallback 均继承 `api_base` / `api_key` +- [x] `.env.example` 补充端点字段与优先级说明 +- [x] ~~未指定 `api_base` 时走官方端点的线上回归~~ —— 2026-10-07 随 **#8** 作废删除:官方端点回退路径已整体移除(不再有任何内置端点),无需回归 + +--- + + +### #2 多处调用点硬编码 Google 客户端,配置中的 provider 被忽略 + +- **日期**: 2026-10-06 +- **状态**: ✅ 已完成 +- **优先级**: 高(非 Google 部署下 Flash 主链路直接崩溃) +- **来源**: 首次实测(任务启动即失败) + +#### 问题描述 + +`artemis.jsonc` 配置 `"provider": "openai"` 后,Flash 档主链路仍以 `ChatGoogleGenerativeAI` 实例化本地模型名(`API key required for Gemini Developer API`)。根因:`artemis/services/llm.py` 的 `get_google_llm()` 将 provider 硬编码为 `ModelProvider.GOOGLE`,部分节点只透传 model 名。 + +#### 影响范围与整改结果 + +| 调用点 | 整改前 | 结果 | +|---|---|---| +| `artemis/agents/flash/summarizer.py`(step_summarizer 显式 model 路径,首次实测崩溃点) | 硬编码 Google | ✅ 改走 `get_default_deployment_llm()`;顺带修复 else 分支误用 utils 访问器(`summarizer` 是 agent 节点,原 `is_utils=True` 必然 AttributeError) | +| `artemis/memory/chunking.py`(chunker 主/备模型) | 硬编码 Google | ✅ 同上 | +| `artemis/agents/flash/runner.py`(operator 构建失败的兜底模型) | 硬编码 `gemini-2.5-flash` + Google | ✅ 兜底改为部署默认端点的默认模型 | +| `artemis/sdk/agent.py`(连接预热) | 硬编码 `ChatGoogleGenerativeAI(gemini-3.8-flash)` | ✅ 预热改走默认端点;Google 专属的 Native GenAI 客户端预热仅在 provider=google 时执行 | + +#### 最终形状(工厂收敛) + +- 删除 `get_google_llm()` 与过渡期辅助 `get_default_provider()`(全仓 grep 零残留)。 +- 统一为三个 provider 感知入口(`artemis/services/llm.py`): + - `get_provider_llm(model_name, provider, ..., api_base, api_key)`:显式端点构建; + - `get_default_deployment() -> DefaultDeployment`:读取部署默认端点(provider / model / api_base / api_key / api_key_env); + - `get_default_deployment_llm(model_name=None, ...)`:**只覆盖模型名、继承默认端点**的唯一入口,供「配置里只有裸模型名」的节点(step_summarizer、chunker)与降级兜底使用。 + +#### 验收标准 + +- [x] 全仓 grep 确认无 `get_google_llm` 裸调用点与残留引用(仅剩新工厂自身) +- [x] `provider: openai` 配置下 Flash 档跑通真实任务(设置查电量,报告 80%→充电中) +- [x] Pro 档跑通真实任务(关于手机查 Android 版本,LangGraph 多智能体图 + checker 全链路走本地端点,status completed) +- [x] ruff 全部改动文件 lint 通过 + +--- + + +### #3 失败可观测性不足:CLI 报错信息为空,trace 编译丢失步骤 + +- **日期**: 2026-10-06 +- **状态**: ✅ 已完成 +- **优先级**: 中(不影响功能,但显著拉高排障成本) +- **来源**: 首次实测排障过程 + +#### 问题描述 + +1. 任务失败时 CLI 仅输出 `✖ Task failed: `(空消息),真实异常只存在于守护进程日志 `%LOCALAPPDATA%\Artemis\logs\daemon-8000.log`,用户无从得知。 +2. 会话步骤实际落盘在 SQLite(`steps` 表)与 `/images/`,而 trace 编译器只扫描空的临时目录(遗留扁平布局),产物恒为 `Found 0 images / 0 steps`。 +3. 环境项:scrcpy 未安装导致录屏/回放不可用(`winget install Genymobile.scrcpy` 可解)。 +4. 追加观察:`artemis restart` 时 Showcase UI(Angular)增量构建报 `Failed to inline external stylesheet`(预编译产物仍可服务,疑似前端构建链问题,与后端整改无关,待排查)。 + +#### 整改方案与实施 + +1. **错误传播链贯通**:`sessions` 表新增 `error_message` 列(ALTER 迁移,`artemis/data_engine/storage.py`);`SessionMetadata` 增加同名字段并纳入 `update_session`;`DataEngine.end_session(status, error_message=None)` 透传;worker 侧三个收尾点全部写入错误(`third_party/mobile_use/sdk/agent.py` 的 cancelled / except 分支与 `_finalize_tracing`);CLI(`run.py`)优先读取 `error_message`,失败时附带 `artemis trace view ` 与守护进程日志路径提示。 +2. **编译产物修复**:新增 `_materialize_session_trace_artifacts`——编译前把 DataEngine 会话步骤(截图帧 `.jpeg` + 步骤载荷 `.json`)从 SQLite/图片目录物化进编译目录;GIF 编译改为容错(缺 ffmpeg 只跳过 GIF,不影响 steps.json)。 + +#### 验收标准 + +- [x] 故障注入(`api_base` 指向死端口 `127.0.0.1:9`):CLI 输出 `✖ Task failed: Error running automation: Connection error.` + 定位提示;DB `sessions.error_message` 持久化完整异常 +- [x] 正常任务编译产物包含全部步骤与帧(`Materialized N steps → Found N images / N steps → steps.json` 非空 + `trace.gif`) +- [x] 成功会话 `error_message` 保持 NULL,无残留 +- [ ] (可选)安装 scrcpy 后录屏回放可用 + +--- + + +### #4 无 Google Key 时默认环境应为空,不指定任何端点 + +- **日期**: 2026-10-06 +- **状态**: ✅ 已完成 +- **优先级**: 中(影响首次部署体验与误导性报错) +- **来源**: 用户要求 + #1 验收遗留项 + +#### 问题描述 + +内置出厂默认(`FACTORY_DEFAULT_MODEL_CFG`)隐式指向 Google 官方端点。无 Google Key 的环境里,未显式配置模型时系统会默默构造 Gemini 客户端并以误导性的「API key required」报错失败。期望:无 Google 凭证时默认环境为**空**(不指定任何端点),解析时给出清晰、可操作的配置指引。 + +#### 整改方案与实施 + +1. **配置模型容忍空端点**(`third_party/mobile_use/config/llm.py`):`LLM.provider` / `model` 允许为 None,`LLMWithFallback.fallback` 允许为 None;`validate_provider` 对未配置节点直接跳过;`__str__` 显示 `unconfigured`。 +2. **出厂默认门控**(`artemis/config/llm.py`):新增 `_google_credentials_present()` 与 `_factory_default_cfg()`——仅当 Google 凭证存在时出厂默认才生效,否则默认环境为空;`load_default_model_cfg` / `_expand_default_into_nodes` 统一走该门控;`lightweight_judge_default`(像素安全网/计划校验判官)在无 Google 凭证时改为继承部署默认端点。 +3. **清晰报错**(`artemis/services/llm.py`):`DefaultDeployment` 空默认(provider/model 为空串);`get_default_deployment_llm` 与 `_resolve_endpoint` 在未配置时抛出带配置指引的 `RuntimeError`(指明设置 `default` 节点或提供 `GEMINI_API_KEY`),不再退回 Google 客户端。 +4. **连带修复**:`explorer/run_setup.py` 对未配置 explorer 节点的 `model=None` 兼容(回退 `DEFAULT_EXPLORER_MODEL`,避免 TypeError)。 + +#### 验收标准 + +- [x] 无 Google Key + 配置无 `default`:节点展开为未配置、`LLMConfig` 校验通过、解析时报清晰 `RuntimeError`(冒烟 1–4 通过) +- [x] 有显式 `default`(本环境 openai + api_base):行为完全不受影响(冒烟 5–7 + 实测回归通过) +- [x] ~~Google Key 存在时保留原出厂默认零配置体验~~ —— 2026-10-07 随 **#8** 政策反转:出厂默认彻底移除,有无 Google Key 都以用户配置为准 + +--- + + +### #8 移除 Google 出厂默认端点:一切端点信息均依靠用户配置 + +- **日期**: 2026-10-07 +- **状态**: ✅ 已完成 +- **优先级**: 高(产品决策:Google 端点彻底退出 fallback 体系) +- **来源**: 用户明确要求 + +#### 决策 + +不再把 Google 端点作为任何形式的 fallback / 出厂默认:一切端点信息(provider / model / +api_base / api_key)都来自用户配置(`artemis.jsonc` 的 `default`/`nodes` 与端点库); +用户没有配置时系统**明确不可用**——报错附配置指引,绝不静默假设任何厂商端点。 +#1 遗留的「未指定 `api_base` 时走官方端点的线上回归」随之作废删除(官方端点回退路径 +已不存在,无可回归)。用户**显式**配置 provider=google 的能力不受影响——移除的是 +"默认",不是"支持"。 + +#### 整改方案与实施 + +1. `artemis/config/llm.py`:删除 `FACTORY_DEFAULT_MODEL_CFG`、`_google_credentials_present()`、 + `_factory_default_cfg()`;`load_default_model_cfg` / `_expand_default_into_nodes` / + `_apply_endpoint_override` 在配置缺失/损坏/为空时统一落空 `default`(不再有任何内置端点, + 有无 Google Key 均如此);`lightweight_judge_default()` 删除 Google flash-lite 分支—— + 判官节点(像素安全网/计划校验)同样只继承用户配置的部署默认端点,未配置即为未配置。 +2. `artemis/services/llm.py`:两处未配置报错移除「或提供 GEMINI_API_KEY」指引(仅凭 Key + 已不能使用任何端点),改为明确「没有内置端点,配置前无法运行」;`DefaultDeployment` + 与 `get_default_deployment` 文档同步。 +3. `artemis/interfaces/cli/commands/init.py`:`artemis init` 生成的 `.env` 模板移除 + `ARTEMIS_DEFAULT_MODEL=gemini-2.5-flash` 误导项(该变量无消费方),改为注释指回 + `artemis.jsonc` 的 `default` 块。 +4. 测试:新增 `test_no_google_factory_default` 钉住策略(环境注入假 Google Key 也不复活 + 内置端点:缺文件→空默认 / 空配置→未配置节点与判官 / 解析报清晰错误 / 显式配置不受影响); + `test_planner_validation_node_defaults_to_lightweight_judge` 由「断言 flash-lite」改为 + 「断言继承部署默认」。 + +#### 验收标准 + +- [x] 缺文件 / 空 `{}` 配置:`load_default_model_cfg() == {}`、节点与判官展开为未配置、 + `get_default_deployment_llm()` 抛出带配置指引的 `RuntimeError`(单测覆盖,且在 + GEMINI_API_KEY/GOOGLE_API_KEY 已设的环境变量下验证) +- [x] 显式用户配置(openai + LM Studio 实测环境)行为不受影响;全量 `pytest tests/` 通过 +- [x] ruff 通过 + + +## 二 · 前端 Web 控制台 + +### #6 前端 Web 控制台重构迁移完成(Angular 22 → Vue 3 + Arco Design Vue) + +- **日期**: 2026-10-06 +- **状态**: ✅ 已完成 +- **优先级**: 高(Web 控制台前端工程整体一次性替换) +- **来源**: 前端迁移专项(M0–M6 里程碑收官) + +#### 迁移内容 + +旧 Angular 22 工程(`apps/showcase_ui`)已删除,由 Vue 3 + Arco Design Vue 新工程 +(`apps/showcase_ui_v2`)完整承接,M0–M6 全部里程碑完成: + +- **SSE 实时流**:任务时间线 / 截图 / 工具调用事件实时推送渲染,替代旧轮询与增量构建链路。 +- **回放投屏**:会话回放视图与投屏(cast)桥接层按编译期结构断言对齐真实契约。 +- **系统诊断**:连通性 / 就绪度(readiness)诊断接入,联动顶栏在线状态指示。 +- **托管切换(决策 D2,一次性替换)**:`server.py` 的静态产物探测候选指向 + `apps/showcase_ui_v2/dist/browser`(源码树托管);wheel 回退产物 + `artemis/resources/showcase_ui` 经 `npm run sync:resources` 同步为 Vite 产物 + (发布流程可 `make release-ui` 一键构建 + 同步);无 /v2 并行子路径。 + +#### 验收标准 + +- [x] 前端:vitest 198 用例全绿 + vue-tsc 零错误 + 生产构建通过 +- [x] 后端:托管链路 66 用例全绿 +- [x] 打包:`uv build --wheel` 解包确认 `showcase_ui` 资源完整 + +--- + + +## 三 · 实机与测试环境 + +### #5 每次任务弹出空白 python 控制台窗口 + +- **日期**: 2026-10-06 +- **状态**: ✅ 已完成 +- **优先级**: 中(体验问题:任务运行时弹出一个内容全空的 `.venv\Scripts\python.exe` 终端) +- **来源**: 实测观察 + +#### 问题描述 + +每次任务运行都会弹出一个标题为 `D:\DEV\autoGamer\.venv\Scripts\python.exe` 的控制台窗口,且窗口内无任何输出。 + +#### 根因 + +该窗口是 **ImageProcessor 感知工具的 Jupyter 内核**(`ipykernel`,即 `.venv\Scripts\python.exe`)。jupyter_client 的 `launch_kernel` 只在父进程为 `pythonw.exe` 时才追加 `CREATE_NO_WINDOW`(`redirect_out = sys.executable.endswith("pythonw.exe")`);而 ARTEMIS 的 worker 进程本身是无控制台启动的(`CREATE_NO_WINDOW`),无控制台的父进程再拉起控制台程序时,Windows 会为其**分配一个新的可见控制台**——内核 stdout 又被重定向到 `kernel.log`,于是窗口永久空白。 + +#### 整改方案与实施 + +`artemis/utils/python_executor.py` 的 `_start()`:Windows 下向 `KernelManager.start_kernel` 显式传入 `creationflags = CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW`,经 `launch_kernel → Popen` 透传生效;内核输出仍按设计落盘到 `session_dir/kernel.log`(满足"或能出现正常 log"的备选预期)。 + +#### 验收标准 + +- [x] 从无控制台的父进程启动内核,系统可见窗口前后快照 diff 为空(不再弹窗) +- [x] 内核功能正常:`execute("print(2+3)")` 返回 `5`;输出落盘 `kernel.log` +- [x] ruff lint 通过 + +--- + + +### #7 Windows 实机全量测试修复:FIFO 预约乱序(真 bug)+ 强刷缓存吞没 + 5 处测试隔离缺失 + +- **日期**: 2026-10-07 +- **状态**: ✅ 已完成 +- **优先级**: 高(device_lock 为真产品缺陷:队列顺序随机;其余为测试对环境/平台的隐性依赖,迁移期只跑过子集,全量套件在 Windows 实机长期未绿) +- **来源**: 从断点恢复后跑全量后端套件 + +#### 问题描述 + +迁移与端点库特性期间只验证过前端全套与后端托管链路子集;全量 `pytest tests/` 在 Windows 实机上有 6 处失败: + +| 失败 | 性质 | 根因 | +|---|---|---| +| `test_device_lock.py::test_submission_reservations_preserve_order_before_workers_start`(偶发,单独跑必过) | **真产品 bug** | `reserve()` 以 `time.time_ns()` 作为票据文件名排序前缀,Windows 时钟约 1ms 才走一格:连续两次 reserve 拿到相同前缀,`sorted()` 退化为按随机 uuid 排序——后提交的任务有 50% 概率插队到先提交的之前,FIFO 预约顺序失效 | +| `test_diagnostics.py::test_readiness_engine_reuses_cache_until_forced` | 真产品边界 bug | `run_all` 合并分支用 `>=` 比较 `_report_cache_time` 与 `request_started`;Windows `time.monotonic()` 刻度约 16ms,强刷请求与上一次刷新落在同一刻度时两值相等,强制刷新被当作「等待期间完成的刷新」吞掉,返回陈旧快照 | +| `test_explorer.py` ×9、`test_video_analyzer.py` ×3 | 测试夹具过期 | 用例只 mock 了 `genai.Client`,但引擎探测收紧后(无 `GOOGLE_API_KEY` 时走 universal 引擎)落到 `_resolve_endpoint`,MagicMock provider 撞上 `ModelProvider.from_string` 校验抛 `ValueError`;同文件后写的用例已改用「预设 `ctx._genai_client` 选 native 引擎」模式,旧用例没跟上 | +| `test_awake_service.py::test_discovery_keeps_only_pool_claimed_devices` | 测试环境泄漏 | 开发机 shell 导出了 `ADB_DEVICE_SERIAL=127.0.0.1:16384`(MuMu 序号),`_discover_connected_device_ids` 的显式 target 优先分支使断言落空返回 `[]` | + +#### 整改方案与实施 + +1. **FIFO 前缀严格递增**(`artemis/runtime/device_lock.py`):`reserve()` 增加类级 `_reserve_lock`(进程内串行化)+ 前缀占用检测——目标前缀已被任何 `*.wait` 占用时递增 ns 重试,保证前缀唯一且严格按预约完成顺序递增;跨进程残留碰撞由 O_EXCL 兜底。新增确定性回归用例 `test_reserve_prefixes_increase_even_when_the_clock_stalls`。 +2. **强刷合并改严格大于**(`artemis/core/diagnostics/engine.py`):`_report_cache_time > request_started` 才合并——刷新确实在本请求开始之后完成才允许吃掉强制刷新;同刻度歧义一律重建(对强制语义安全)。非强制请求随后的 TTL 缓存检查不受影响。 +3. **测试夹具对齐现行引擎探测**(`tests/unit/agents/test_explorer.py`、`test_video_analyzer.py`):12 个用例在创建 mock client 后补 `mock_ctx._genai_client = mock_client`,与既有 `test_explorer_final_turn_tool_stripping` 同模式,引擎选择不再依赖环境里有没有 Google Key。 +4. **测试环境钉死**(`tests/unit/runtime/test_awake_service.py`):两个 discovery 用例加 `@patch.dict` 钉住 `ARTEMIS_KEEP_DEVICE_AWAKE` / `ARTEMIS_CLOUD_MODE` / `ARTEMIS_DEVICE_ID` / `ADB_DEVICE_SERIAL`,不再受开发机 shell 环境影响。 + +#### 验收标准 + +- [x] 全量 `pytest tests/`:2570 passed / 0 failed(修复前 6 failed) +- [x] `test_device_lock.py` 连跑 10 次全绿(修复前 10 次挂 3 次;因果经 12 轮插桩试验确认:6 次前缀碰撞全部对应乱序与提前获得锁) +- [x] ruff 对全部改动文件通过 +- [x] 前端回归不受影响:vitest 254 用例 + vue-tsc + 生产构建通过 + + +--- + + +## 四 · DSH 插件轨道(当前主线) + +### #9 live 复验连带的两个插件缺陷:截图取尺寸栈溢出与 `screenSize` 真值陷阱 + +- **日期**: 2026-10-10 +- **状态**: ✅ 已完成 +- **优先级**: 中(截图取尺寸路径整体不可用;默认配置下坐标静默错误并报成功) +- **来源**: P0 live 复验顺带发现(`plugins/autogamer-device`) + +#### 问题描述 + +1. `src/coord.ts` 的 `parseImageSize` 对任何输入都栈溢出:`byteAt` 自己调自己——截图取尺寸(PNG IHDR / JPEG SOFn 解析)这条路本来就是死的,坐标换算拿不到任何设备尺寸。 +2. `src/index.ts` 的 `apply()` 里 `if (config.screenSize)` 被判据骗过——Schemastery 把未配置的可选 tuple 归一化成**真值 `[undefined, undefined]`**,于是「操作者配置了尺寸」误判成立,默认配置下的 0-1000 → 像素换算一路 `NaN` 发给 action server 还报成功。 + +#### 整改方案与实施 + +1. `src/coord.ts`:`byteAt` 修为 `bytes[index] ?? 0`(不再自递归);新增 `configuredScreenSize()` 纯函数——按数字判定(类型 / 有限性 / 正值),`[undefined, undefined]`、`[null, null]` 一律视作未配置。 +2. `src/index.ts`:`resolveScreenSize` 改调 `configuredScreenSize(config.screenSize)`;未配置时退回探针截图解析(`imageSizeFromBase64`),解析结果按设备键缓存。 + +#### 验收标准 + +- [x] `tests/coord.spec.ts`:`configuredScreenSize` 对 `[1080, 2400]` / `[undefined, undefined]` / `[null, null]` 的断言 +- [x] `tests/apply.spec.ts` wiring lock:驱动经 `apply()` 注册的工具,断言 action server 真收到的坐标;删除实验:退回旧判据 ⇒ 2 条红,实测打出 `[null,null]` 形态的 payload +- [x] vitest 全绿;mock action server 的 PNG 截图实测解析为 1080x2400(live 证据) + + +### #10 G30 双根因:cordis effect 语义反转自注销工具面 + 裸注册 schema 未编译上 wire + +- **日期**: 2026-10-10 +- **状态**: ✅ 已完成 +- **优先级**: 高(P0 唯一阻塞 G30) +- **来源**: 联网源码调研(deepseek-ai/deepseek-harness@master)+ 官方检查器分级实证(`plugins/autogamer-device`) + +#### 问题描述 + +1. **effect 语义反转**(注册生命周期):`apply()` 里 `ctx.effect?.(() => { disposers 逐个执行; client.dispose() })` 把 teardown 当成了回调体。cordis 的 `effect()` 语义是**回调立即执行、返回值才是清理函数**(官方源码 mount.ts:208、agent-preset-registry/src/index.ts:112、app-boot service.ts:110 同式)——于是两个工具在注册后数毫秒内被自己注销,而行状态、Config、注册计数一切正常。测试假 context 干脆没有 `effect` 方法,缺陷在 CI 里不可见。 +2. **裸注册 schema 未编译**(wire 契约):裸 `ctx.tools.register()` 走官方 MCP 路径,`parameters` 必须已是完整 JSON Schema(根节点 `type: "object"`);我们传的是命名 spec 映射,map→object schema 的编译只存在于 `defineTool()` DSL(`parameterSchemaSpecToJsonSchema`)。结果 LLM API 以 400 拒绝整个请求(zod: `parameters.type` 期望 "object")。 + +#### 整改方案与实施 + +1. `src/index.ts`:工具注册交还注册表自持所有权(与官方 tool-jobs 同形、不再包 effect);effect 只包 ActionClient 清理并**返回**清理函数。`src/dsh-types.ts` 的 `effect` 类型改为 `setup: () => unknown` 并注记语义出处。 +2. 注册改走官方 `defineTool()`(新增 devDependency `@deepseek-ai/dsh-tools@0.2.0-rc.2` 及其值导入所需的 4 个 peer——运行时仍经 linked-root peer 拦截解析宿主副本);`src/dsh-types.ts` 删除手镜像 spec 类型,直接使用官方包类型产物(`DefineToolOptions`)。两处定义补官方方言缺口:object 节点显式 `additionalProperties`(G34)、输出 schema 的 required 数组改 per-property `required: true`、render 返回 `ContentBlock[]`;run_device_action 的输出 schema 改显式开放对象(payload 按动作而异)。 +3. `tests/apply.spec.ts`:fakeContext 按真实 cordis 语义实现 `effect`(立即执行回调、收下返回的清理函数),新增 G30 回归锁两条——effect 必须返回清理函数而非就地执行、清理只作用于 ActionClient。 + +#### 验收标准 + +- [x] vitest 38/38(含 2 条新回归锁;删除实验:回退 effect 写法 ⇒ 两条红) +- [x] 离线自证:加载 dist 跑 `apply()`,注册产物 `parameters.type === "object"`、required 为数组、render 返回 ContentBlock +- [x] live 实证(R7 新会话):模型真实调用 `run_device_action` 三次、入参合法、按次调整重试;上一轮的 400 / 「没有这个工具」签名全部消失(mock 后端超时为另一问题,见 todo S5 跟进) + + +### #11 G36 截图媒体展示:附件服务漏声明 inject,且模型模态默认是「负能力」 + +- **日期**: 2026-10-11 +- **状态**: ✅ 已完成(模型侧打通;UI 卡片侧按官方形态归 P3,见 todo G36) +- **优先级**: 高(S10 ② 效果验证的视觉前置) +- **来源**: 真机 live 三轮 + 上游声明面(`docs/subsystems/attachment.zh.md`、`--dump-config-schema` 的 `providers.*.models[].input`、`@deepseek-ai/dsh-llm` 的 `inputModalities`) + +#### 问题描述 + +1. **截图不进模型**:`take_screenshot` 分支主动 withhold base64,只回尺寸。wire 实录:模型拿到 `screenshot=captured 1600x900 (base64 withheld…)` 后**自己退到 `get_ui_hierarchy`**,用 XML 里的 `content-desc` 拼出「屏幕内容」——看起来像看懂了图,其实一个像素都没见。 +2. **附件服务不是可选依赖**:首版按「拿不到就降级」写,直接读 `ctx.attachments` 被 cordis 在**属性 GET 处**抛 `cannot get property "attachments" without inject`;该错误没有 `environment:` 前缀,被 dispatch 归类成 **task 级失败**,模型连试三次后 `report_task_status=blocked`。 +3. **模态默认值是负数**:声明 inject 后附件确实落盘(`~/.dsh/attachments/v1/objects/…`,宿主把 PNG 重编码成 JPEG),但请求里模型看到的仍是 `[image omitted because this model accepts text only; attachment sha256:…]`。声明面口径:`inputModalities` **absent = unknown,显式省略 = 负能力**,而 provider 配置 `models[].input` 的 schema 默认就是 `[]`。 + +#### 整改方案与实施 + +1. `src/coord.ts`:新增 `decodeImageBase64`(全量解码)与 `imageMediaTypeOf`(PNG/JPEG 头部嗅探),`atLeast` 与它共用 strip/binary 助手。 +2. `src/tools/runDeviceAction.ts`:take_screenshot 改成「解码 → `saveScreenshot` → value 里只放引用」;`output.render` 按官方工具图像结果形态返回 `[text 信封, image 块]`(宿主 `read_image` 就是这个形状)。base64 绝不进 value——会话日志只记内容寻址引用。 +3. `src/dsh-types.ts`:镜像 `DshAttachments.saveImages` / `DshImageAttachment` 并标出处。不引官方包当类型源:`@deepseek-ai/dsh-attachment` 在 npm 上是 `0.0.1-rc.1`,与本机宿主 rc.2 不同版(D6 精确 pin 不允许混版)。 +4. `src/index.ts`:`inject = ["tools", "attachments"]`;`saveImages` 被拒(字节/像素/单边上限、解码失败)只 warn 并降级成文本,不失败整个动作。 +5. `spike/web-live.patch.yml`:收回交付形态(G37)+ 给本地模型补 `input: [text, image]`。 + +#### 验收标准 + +- [x] vitest 41/41,新增 3 条 G36 锁:附件收到 1080x2400 的 PNG 字节并回引用、render 出 `[text, image]`、**结果 value 内不含 base64**、store 拒绝时仍报尺寸 +- [x] R6:overlay 三个版本各跑一次 `--dump-config`,均 exit 0 且 0 unmatched +- [x] live(R7 新会话、纯交付形态 `tools=5`):一次 `take_screenshot` → 附件落盘 → 宿主把图**提升为带真实 base64 的 user 消息**(wire 请求体 12KB→296KB、`image_url`×2)→ 模型答出「太空星球主题壁纸 + 顶部时间 17:06」;17:06 是本轮真实时刻,而上一轮从 XML 读到的是 14:30,证明确实读到了像素 +- [x] **否证一条猜测**:不需要给 preset 挂 `@deepseek-ai/dsh-tool-fs` 让模型用 `read_image` 去开那个只读路径——宿主自己完成提升。带 fs 的那轮验完已收回,overlay 仍是 `inner + tool-jobs` + + +### #12 G39 路线 A:效果判定落到 py 动作面(真机 JPEG 让 TS 侧解码器失效) + +- **日期**: 2026-10-11 +- **状态**: ✅ 已完成 +- **优先级**: 高(S10 ② 效果验证的判定依据) +- **来源**: 真机 live 一轮 `effect=unknown` + 仓库内上游实现盘点(`artemis/utils/image_diff.py`) + +#### 问题描述 + +1. **TS 侧自研差分在真机上必然失效**:`artemis mcp --type adb` 的 `take_screenshot` 文档串自己写着返回 base64 **JPEG**(uiautomator2 路径),而 #11 之后补的 `src/frame.ts` 只会解 PNG。离线与 vitest 全绿,live 一轮 `back` 直接报 `result=Success; effect=unknown`。 +2. **加错了面**:`compare_ui_frames` 最初被加到 `artemis/mcp/action_server.py`(py 智能栈内部面),但插件消费的是 `artemis/mcp/adb_server.py`(`--type adb`)。靠 `tests/unit/mcp` 的 fixture 快照测试暴露(它只锁了内部面,说明**快照锁不住"哪个面被消费"这件事**)。 +3. **权威实现本来就在仓库里**:`check_ui_change`(cv2 灰度解码、遮状态栏 10%/导航栏 5%、全屏变化比例阈值 1e-4、tap 类走按屏对角缩放的 ROI、**解码失败按「有变化」兜底**)。且它在本 fork 里**没有活调用点**——只有定义与 validator 测试里的 mock,所以它的语义权威是那份测试而不是线上流量。 + +#### 整改方案与实施 + +1. `artemis/mcp/adb_server.py` 新增**只读**工具 `compare_ui_frames(before_b64, after_b64, action="tap", coordinates=None)` → `'{"changed": bool}'`;**不自己截图**(actuation 与 observation 上游刻意拆开,取帧时机归调用方),判定原样委托 `check_ui_change`,连"假定有变化"的兜底一起继承。 +2. 两份 pin 住的快照同步:`tests/fixtures/action_surfaces/adb_server_manifest.json`(13→14 工具)与插件侧 `tests/fixtures/py-action-tools.json` + `contracts.spec.ts` 的计数锁;重抓命令写回 fixture 的 `source` 字段。 +3. TS 侧删掉 `src/frame.ts` 及其解码器测试:`captureFrame` 改回吐 base64,新增 `compareFrames` 调 py 工具(传 **py 动作名**与**像素坐标**,好让 tap-ROI 规则真的生效);`effect_diff` 随契约取消——判定是布尔。 +4. 中途否证一条自己的断言:曾写测试要求"坏 base64 返回 `Error:`",实际 `b64decode("!!!")` 不抛错、空字节走 cv2 解码失败 → 兜底 `changed=true`。该契约不存在,测试删除而不是改代码去凑。 + +#### 验收标准 + +- [x] py:`tests/unit/mcp` 224 passed(新增 3 条行为锁:同帧→unchanged、半屏翻转→changed、坏字节→兜底 changed);`ruff check` 对改动文件干净 +- [x] 插件:vitest 46/46(效果锁改为「调用 py 判定」的形状,含「tap 坐标必须传给 compare_ui_frames」一条) +- [x] live(R7 新会话、真机):`back` 从 Settings 回桌面 → `effect=changed`;再按一次 `back`(已在桌面)→ `effect=unchanged`。**正反两例都有**,且 MuMu 的动态壁纸没有造成假阳性 +- [x] 字节等值路线被实测排除的结论保留在案:静态三帧哈希全不同(动态壁纸),而真换屏灰度均值差 0.69 vs 动画噪声 ≤0.015 + + +### #13 G44 终态强制打通:`turn/end` 要 `{global:true}` 才收得到,而追问必须推迟出 append 边界 + +- **日期**: 2026-10-11 +- **状态**: ✅ 机制闭环(模型服从性另计,见验收最后一条) +- **优先级**: 高(P0 唯一硬阻塞) +- **来源**: 官方声明面 `packages/core/scope/README.zh.md:71,75`、`docs/subsystems/session.zh.md:609,1301`、cordis 4.0.4 `lib/types/events.d.ts:98-105` + `lib/index.js:263`、`packages/goal/goal-round-driver/src/index.ts:322-345`;live 五轮实证 + +#### 问题描述 + +`report_task_status` 是 S2 定义的终态出口,但本地模型两次被观察到**达成目标后直接文本收尾、根本不调该工具**,于是双轴终态(`completed/blocked + tests_failed`)压根不存在,还伴随 G41 式的不收敛。要在插件侧强制,就得观察"回合结束"。三轮失败与真因: + +1. **v1 普通监听收不到 `turn/end`**:把 `session/event` 监听器注册在 preset 子作用域上,只收到 `tool/call`。当时误判为"必须另写 Host 级插件"。正解在 `packages/core/scope/README.zh.md:75`——事件按 scope 筛选,**带 `{ global: true }` 的监听器绕过筛选**,cordis 4.0.4 的 `EventOptions.global` 就是它("Receive the event regardless of context filter checks")。 +2. **v2 加了 `global` 仍无追问**:把监听到的事件类型与追问结果做成 wire 可见探针后才发现——追问**触发了但被拒**:`session append cannot reenter while another append is being published`。官方口径 `docs/subsystems/session.zh.md:609`:「在验收/发布边界打开时重入的 append 会在日志变更前被拒绝」。`agent.followup()` 本质是一次 append,**不能在 `session/event` 的 emit 里同步调用**。 +3. **对照官方实现佐证**:`goal-round-driver` 的 `case 'turn/end'` 分支**只更新状态、从不 append**,追问发生在边界之外。 + +#### 整改方案与实施 + +1. `src/turnLedger.ts`:按 agent(= session id,官方 `ctx.agents.get(session.id)` 同键)记 `acted / reported / nudged`;`consumeTurn()` 决定是否需要追问,**上限 1 次**防死循环。 +2. `src/index.ts`:监听以 `ctx.on("session/event", handler, { global: true })` 注册;`turn/end` 且 acted 未 reported 时,**`setTimeout(…, 0)` 推迟出发布边界**再调 `agent.followup()`;追问失败只 warn 不影响动作。 +3. 两个工具接入台账:`run_device_action` 记 acted,`report_task_status` 先记 reported 再 `concludeTurn()`。 +4. 临时判别探针(`agent=` / `events=` / `nudge=` 上 wire)在定性后**已全部移除**,不留调试代码。 + +#### 验收标准 + +- [x] vitest 62/62,其中 5 条锁 G44 行为(含「监听必须以 `{global:true}` 注册」「已汇报不追问」「同一 agent 至多追问一次」「非 turn/end 不动作」);追问改异步后测试等一个宏任务再断言 +- [x] **live 四段全通**:`turn/end` 被 `{global:true}` 收到 → 台账判定 acted&&!reported → 推迟后的 `followup()` **被受理并驱动出第二轮**(只发一条消息却出现 `2 轮 3 步`)→ 追问文本出现在下一请求的 user 消息里(`nudge_present=True`) +- [x] **实验②结案**:自声明 `source.kind: "autogamer"` **通过 session format V4**(消息被受理并送达模型),未用被禁的 `{kind:'plugin', plugin}` 包装 +- [x] 探针与调试代码零残留(`grep diag|nudgeOutcome|seenEvents dist/` 全 0) +- [ ] **模型服从性未达成**:本地 `qwen3.6-35b-a3b-mtp` 收到明确指令后,第二轮 1 秒返回空文本、**仍未调用 `report_task_status`**。机制已可强制,服从是模型质量问题——它作为输入进 D9 质量裁决,而不是继续算作 G44 的机制缺口 + + +### #14 G45 指令投递:交付 preset 补上 `persona` 行,flash 契约第一次真的进入模型上下文 + +- **日期**: 2026-10-11 +- **状态**: ✅ 已关闭(skill 随包路径另归 G31) +- **优先级**: 高(它使此前一批「模型不服从」观察全部失效) +- **来源**: 官方声明面 `packages/preset/persona/src/index.ts`(包源注释 + `Config`)、`packages/bundle/web-app/presets/standard.patch.yml`、`docs/subsystems/skills.zh.md`、`packages/core/system-prompt/README.zh.md`、本机 `--dump-config-schema` `$defs/config97` + +#### 问题描述 + +`autogamer` preset 的 `plugins` 只有 `autogamer-device-inner` + `@deepseek-ai/dsh-tool-jobs`:**没有任何指令投递行**。 +`skills/autogamer-flash/SKILL.md` 虽然随包出货,却从未被挂进任何会话——preset 既没挂 skill 提供方也没挂消费方; +而 `@deepseek-ai/dsh-agent-preset` 的 config 只有 `id / name / description / order / plugins`(`$defs/config97`), +**根本没有 prompt 字段**。后果是会话的 system 消息只有 3194 字符的宿主默认人设, +`run_device_action` / `report_task_status` 的操作契约一个字都没进模型。 + +于是这些观察全部作废重测:真机 spike 的「插件侧不如基线」、G41 的「模型收到指令仍不调工具」、 +以及把 D9 判成「暂不成立」的那张对照表——两侧比的不是同一件事,**基线有 flash 契约、插件侧根本没有**。 + +顺带查清两条官方口径,避免以后再猜: +1. **skill 是渐进披露,不是上下文注入**:模型侧目录「仅使用 name 和 description,从不使用正文」, + 正文要模型自己调 `skill({name})` 才拿到。把硬约束放在按需加载路径上,等于把纪律交给一次自愿的工具调用。 +2. **`persona` 是 scope-only 行**:包源注释写明「挂进 agent preset 会为那一场会话遮蔽部署 persona」, + 「挂在全局会与注册表自己的注册相撞、fail loud」,并解释该行存在的理由—— + 「agent preset 不能自己挂 prompt 注册表,所以没有这一行,preset 只能改 agent 的工具、改不了身份」。 + +#### 整改方案与实施 + +1. `cordis.patch.yml`(交付形态)与 `spike/web-live.patch.yml`(live overlay)各加一行 + `persona` / `@deepseek-ai/dsh-persona`,`config.prefix` 写死 5 条硬约束:一次一动作并附 `reason`; + 终态唯一出口是 `report_task_status`、调用后不得再动设备/再 `wait`/再汇报;`#N` 只对当次 + `get_ui_hierarchy` 有效;被拒动作不得原样重试;判断屏幕只认截图。两处文本逐字一致(G37 的教训)。 +2. **文本内不出现任何 `{{…}}`**:`system-prompt/README.zh.md` 规定未知引用会在**任何模型请求之前**使组装抛错, + 内置变量只有 `provider` / `model`,且明令自定义 persona 不得依赖 `{{cwd}}`。 +3. **本轮故意不挂 skill 行**:D9 要裁的是「插件形态能否替掉 py flash」,把 skill 的成本(多一个 `skill` 工具、 + 多一条目录消息)与 persona 的收益混进同一次真机跑就无法归因。skill 接线连同随包路径解析并到 G31。 +4. 新增 `spike/headless-real.patch.yml` 的尝试**当场作废并留下结论**:headless profile 的组合里没有 + `@deepseek-ai/dsh-agent-preset-registry`(`--dump-config` 报 `patch insert: entry "@deepseek-ai/dsh-base" not found`), + 旧 `headless-spike.patch.yml` 那套「插进 dsh-base 组」的写法从未命中过——它是 R20 之前的猜测产物。 + +#### 验收标准 + +- [x] R6:`--dump-config` 对 `cordis.patch.yml` 与 `spike/web-live.patch.yml` 均 exit 0、0 unmatched,契约文本在合成树里各出现 1/2 次 +- [x] **live 前提断言**:新会话(`agentPreset: "autogamer"`)的 wire 里 system 消息 **4052 字符**、 + `You are AutoGamer…` 起于第 **50** 字符,`run_device_action` 与 `report_task_status` 都在 `tools` 里 + ⇒ persona 行虽不在 rc.2 `--dump-config-schema` 名单内,挂载并未 fail-fast(D16 会整条拒绝,表现为工具全没) +- [x] 插件 vitest 62/62 仍绿(本轮零代码改动) +- [x] 重跑真机 spike:T1 **17s / 5 步 / 达成**(基线 77.2s / 6 步)、T2 **22s / 7 步 / 达成且自己收尾**(基线 42.1s / 3 步,上一版插件侧 47 请求失控需人工停止) +- [ ] **重判结果**:接线后模型**依旧不调用 `report_task_status`**——G44 的服从性观察这次前提成立; + G41 的 `concludeTurn()` live 证据仍缺;兜底追问从未触发,另开 **G46** + +### #15 终态闭环:`concludeTurn()` 拿到 live 证据,G44 的兜底追问实证可用(G46 当日撤回) + +- **日期**: 2026-10-11 +- **状态**: ✅ G41 / G44 关闭;G46 为当日撤回的错误归因 +- **优先级**: 高(S2 终态语义是 P0 的最后一块) +- **来源**: 会话日志 `~/.dsh/sessions/--D-DEV-llm-endpoint-probe--/session-{429a5560,ccb9a927}/session.v4.jsonl.zstd` + 临时文件探针(已回退,`grep PROBE dist/` = 0) + +#### 问题描述 + +G45 接线后重跑真机 spike,T1/T2 两轮都"没有调用 `report_task_status`、也没有追问",据此开了 **G46** +("未汇报追问在交付形态下从不触发"),并怀疑 `markActed` 用的手镜像访问器 `ctx.agents.currentInitiator()`。 + +**两条都是错的,根因是取证渠道**:`report_task_status` 带 `concludesTurn`,它若是本轮**最后一个** tool call, +之后不再有模型请求,于是**永远不会出现在任何请求体里**——拿 `proxy-dump.log` 数它就是看不见。 +叠加"插件 `ctx.logger` 不进 dsh stdout"(真机动作跑了,但 `[route]`/`[turn]` 各 0 条), +两条无否证力的渠道拼出了一个自洽的假发现。这与同日 G40(按固定 `aria-label` 找发送按钮)是同一类错误。 + +#### 整改方案与实施 + +1. **回退猜测**:临时文件探针(`appendFileSync` 打在 `initiator ->` / `event` / `consumeTurn` / `followup` 四个判定点) + 跑一轮真机任务,直接读 `dist/` 确认探针已编译进去,定性后 **`git checkout src/index.ts` + 重新 build**,零残留。 +2. **探针结论**:`currentInitiator()` 返回的就是 session id(`initiator -> session-429a5560…`), + `turn/end` 收得到,`consumeTurn` 判定正确——**G46 不成立,撤回**(登记为撤回条目而非删除,同 G40)。 +3. **改读会话日志**(唯一可信渠道)取到两条 live 证据: + - `session-429a5560`(T1 重跑):8 次 `run_device_action`(全部用 `{index:N}` 句柄,零坐标回退) + → `tool/call report_task_status` → **直接 `turn/end {kind:"completed"}`,其后无 `step/start`** + ⇒ **`concludeTurn()` 真机生效,G41 结案**。 + - `session-ccb9a927`(故意"只截图不汇报"):turn 1 以文本收尾未汇报 → 会话日志出现 + `user/message` 且 `source {kind:"autogamer", reason:"unreported-turn"}` → `turn/start {turn:2}` + → 模型在第二轮**调用 `report_task_status`** 并终结 ⇒ **G44 的兜底追问端到端可用**。 +4. **守则新增 R24**:判「没发生」只认会话日志;wire 与插件 logger 都无否证力;并记下 + `session.v4.jsonl.zstd` 是 zstd **帧流**,必须用 `stream_reader` 读(`decompress()` 只给第一帧=1 条记录, + 这正是我一开始误判"会话里没有追问"的第二个坑)。 +5. **未解释的残余观察(照实记,不当结论)**:同一份 dist 在 23:12 那个服务实例上两轮都没触发追问, + 在 23:38 / 23:47 两个实例上稳定触发。若再次复现,按新号重开并先查服务实例差异。 + +#### 验收标准 + +- [x] 探针代码零残留(`git checkout` + `npm run build`,`grep -c PROBE dist/index.js` = 0,工作树干净) +- [x] **G41 live**:`report_task_status` 的 `tool/call` 之后直接 `turn/end`,无后续 `step/start`(会话日志 `session-429a5560`) +- [x] **G44 live**:未汇报回合被追问(`source.reason:"unreported-turn"` 落进日志)→ 第二轮模型补出 `report_task_status` 并终结(`session-ccb9a927`) +- [x] **G46 撤回**并登记为撤回条目;守则补 **R24**,`registry.md` 同步 +- [x] 插件 vitest 62/62 仍绿(本轮无生产代码改动) + +### #16 G35:文档门禁脚本的默认文件集不再写死绝对路径 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(顺带修掉一个由它暴露出的自测锚点腐烂) +- **优先级**: 中(门禁不可复现 = 守则形同虚设) +- **来源**: 本机复现(仓库位于其他路径时直接 `FileNotFoundError`) + +#### 问题描述 + +`scripts/check_doc_tables.py` 的 `DEFAULTS` 是七条硬编码 `D:/DEV/autoGamer/...` 绝对路径。 +仓库换一台机器、换一个 checkout 位置,门禁脚本本身就跑不起来——而 R6/R9 这些守则的机械判据全靠它。 +同目录的 `check_doc_registry.py` 早就用 `ROOT = Path(__file__).resolve().parents[1]` 推导,两者形态不一致。 + +修它时 `--selftest` 又暴露第二处:**注入锚点写死了 `"| G30 |"`**,而 G30 早已在「已关闭差距拆进 CHANGELOGS」 +那轮从 TODO 差距表里移走,于是自测报 `anchor for injection (b) not found - the text moved`。 +(这条失败本身是对的——`inject()` 的设计就是「锚点移动就大声失败」,而不是静默跳过; +但它说明**用具体差距编号当锚点会腐烂**,因为差距结案就会从表里删。) + +#### 整改方案与实施 + +1. `DEFAULTS` 改为从 `ROOT` 拼出七条 `Path`;随之把 `unique_flag_for(path)` 从 + `path.endswith("TODO.md")` 改成 `Path(path).name == "TODO.md"`(`Path` 没有 `endswith`), + `selftest()` 里取 TODO 锚点的那处也改用同一个谓词,不再各写一遍字符串比较。 +2. 注入缺陷 (b) 的锚点改成**动态取当前表里第一条 `^| G\d+ |` 行**;取不到就报 + 「no gap row to inject into - the gap table moved」。锚点从此不绑定任何具体编号。 + +#### 验收标准 + +- [x] 从无关 cwd 调用绝对路径可跑:`cd /c && python -X utf8 "D:/DEV/autoGamer/scripts/check_doc_tables.py"` ⇒ 7 个文件全 `OK:` +- [x] `python -X utf8 scripts/check_doc_tables.py --selftest` ⇒ `SELFTEST: ruler fires on all six injected defects` +- [x] `grep -n "D:/DEV" scripts/check_doc_tables.py` 无命中;`check_doc_registry.py` 门禁仍 OK +- [x] 换锚点后自测仍能抓到「空行把表切断」这一类缺陷(六条全 CAUGHT) + +### #17 G32:bundle 补上展示元信息与 icon,插件面板不再回退成技术名 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(英文渲染与 packed file list 两项按官方口径明记为未验,见验收最后两条) +- **优先级**: 中(R19 义务项,且是 G31 真实安装前的前置) +- **来源**: 官方 `packages/preset/agent-preset/skills/cordis-plugin-development/references/host-plugin.md:28-55`("Display metadata and icon" 整节) + +#### 问题描述 + +`package.json` 只有 `name`/`description`,没有 `locale/en.json`、`locale/zh.json`、icon,也没有 `exports`/`files`。 +官方口径是:**Plugin Manager 与 Settings 不激活插件就读**导出 locale JSON 里的 `meta.title`/`meta.description` +和导出资源里的 icon;「A Host-only or configuration-only bundle still has a visible inventory entry; +**neither is an exception by itself**」。缺字段的后果是回退——实测就回退了: +面板里我们那行的描述显示成 `package.json.description` 的整段技术文案。 + +#### 整改方案与实施 + +1. 新增 `locale/en.json` 与 `locale/zh.json`,各写 `meta.title` + `meta.description` + (描述讲能力,不讲实现细节;官方禁止「与交付内容无关的模板文案」)。 +2. 新增 `icon.svg`(原创,684 字节,远低于官方 256 KiB 上限,且留在包内—— + 官方明确「绝对路径、URL、越界符号链接一律拒绝」「仅文件名合规不代表字节能渲染」)。 +3. `package.json` 加 `exports`(`.` / `./cordis.patch.yml` / `./locale/*.json` / `./icon` / `./package.json`) + 与 `files`(`dist/**/*.js`、`cordis.patch.yml`、`skills/**/SKILL.md`、`locale/*.json`、`icon.svg`), + 并按官方「package-root 可在 `package.json` 顶层声明 `icon`,它优先于 `./icon`」同时给出顶层 `icon`。 + `./package.json` 一并导出,因为 G31 要用的随包 skill 路径解析正是 `createRequire(...).resolve('/package.json')`。 +4. **加 `exports` 是有风险的改动**(会限制子路径解析),所以做了 live 复验而非只看声明面。 + +#### 验收标准 + +- [x] 静态:三份 JSON 可解析、icon 684 B 且是合法 SVG;R6 对 `cordis.patch.yml` 仍 exit 0 / 0 unmatched;插件 vitest 67/67 +- [x] **live 渲染**(新服务实例 + 官方插件面板):「已安装」区我们那行显示 **`AutoGamer 设备执行`** + 中文能力描述, + `hasFallbackDesc=false`(旧的 `package.json.description` 回退文案消失),同一行内渲染出我们的 SVG + (`img.naturalWidth=150`,`atob(src)` 解出 ` D:/DEV/autoGamer/...`), + 官方明说「`files` does not filter a linked directory」,所以 `files` 是否真起作用要等 G31 走一次真实 `install_bundle` 再核。 + +### #18 G31 引入流:`install_bundle` 端到端走通(用一次性空 bundle 验,不碰在用的) + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(packed file list 一项**证明不了**,理由见验收最后一条,非未做) +- **优先级**: 高(P0 的最后一块「怎么装」) +- **来源**: 官方 `editing-cordis-compositions/SKILL.md:18-52`(两文件 bundle 示例 + 「Install with `plugin_manager`, `action: install_bundle`, `target` set to the **absolute bundle directory**」)、`plugin_manager` 工具自己的 wire schema(action 枚举与 `target` 语义是从我们抓到的请求体里读的,不是猜的)、`packages/boot/plugin-manager/README.md:29-40` + +#### 问题描述 + +交付形态一直靠手工:`dsh plugin add file:`(pnpm 转发垫片,只证明装了不证明生效)+ 启动时 `--patch` overlay。 +官方对口径是禁止的:`SKILL.md:10`「Do not write the profile's `package.json` or `cordis.patch.yml` … +**`install_bundle` performs those steps**」、`editing-cordis-compositions:52`「**do not reproduce those steps with shell commands**」。 +G45 接线时又留了一项:随包 skill 的根路径当时只能写绝对路径,装到别的机器上就废了。 + +#### 整改方案与实施 + +1. **skill 根路径先解决**(`c24ee20`):抄 shipped `web-app/presets/cordis.patch.yml:134-138` 的官方形态, + 在补丁里用 `!!js` + `createRequire(baseUrl).resolve('autogamer-device/package.json')` 取 dirname 再拼 `skills`。 + `baseUrl` 是宿主注入的 loader 变量,**R6 把 `!!js` 原样打印、也不挂 preset,所以判不了它**——只能 live 证。 +2. **再验安装路径**:按官方示例在 `scratch/probe-bundle/`(gitignored)写**只有两个文件**的一次性 bundle, + 声明一个没人选的 inert preset(`probe-noop`),确保即使出问题也影响不到 AutoGamer 会话。 + 装之前先对它单独跑 R6(exit 0),避免把一个语法坏的补丁写进 profile。 +3. 通过 D17 那行让会话里的 agent 调 `plugin_manager`(`action: install_bundle`,`target` 给绝对目录)。 + 本机 Web 会话是用户既设的**完全权限**态,所以没弹审批;这不是我们放宽的(R19)。 +4. 验完立刻 `action: remove_bundle` 撤掉,再删 scratch 目录。全程不改我们在用的 bundle。 + +#### 验收标准 + +- [x] **安装成功**:工具返回 `{"stage":"enable","target":"@local/dsh-probe-preset","enabled":true,"changed":true,"application":"applied", "packageResult":{"exitCode":0, …}}` +- [x] **注册生效**(官方检查器,非 `dsh plugin list`):`--profile web --dump-config` 合成树里出现 `- id: preset-probe-noop`; + profile 的 `package.json` 里 `dependencies` 与 `dsh.profile.bundles` 都加上了该包 +- [x] **移除干净**:`remove_bundle` 后 `dependencies` 与 `bundles` 里都没有它,而 `autogamer-device` 原样在位 +- [x] **skill 端到端**:`` 列出 `autogamer-flash`;模型调 `skill({name:"autogamer-flash"})` + 拿回 1784 字符正文 + 解析后的 `skill_resources` 基目录 ⇒ bundle 内无绝对路径也能发现并加载随包 skill +- [x] 交付形态与 overlay 两处 preset 一致,由 `tests/deliveryShape.spec.ts` 机械守;插件 vitest 67/67 +- [ ] **packed file list 本地证明不了**(不是没做,是**做不到**):实测 pnpm 把本地目录安装解析成 + `link:D:/DEV/autoGamer/scratch/probe-bundle`,与官方「Local directory installation links the checkout instead … + `files` does not filter a linked directory」一致。我们在用的 `autogamer-device` 同样是 `link:`。 + ⇒ 要验 `files` 是否真过滤,必须走发布件或 tarball,本轮不假装验过。 + +### #19 G47:`install_bundle` 的失败回滚与 packed file list,两条都拿到实测 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(顺带钉牢一条 spec 语法与一条残留事实) +- **优先级**: 高(G31 结案时留下的两条「未验」) +- **来源**: 官方源码 `packages/boot/plugin-manager/src/install-spec.ts`(`parseInstallSpec` 全文)与 + `src/index.ts:485-594`(`installBundle` 的快照/回滚出口),加三轮 live 实测 + +#### 问题描述 + +G31 结案时明记两条没验:① 失败安装是否真把 profile 的 `package.json`/`pnpm-lock.yaml` 恢复原样; +② `files` 打包清单是否生效(本地目录安装被 pnpm 解析成 `link:`,官方说 `files` 不过滤链接目录)。 +本轮把两条都做成了实测,并且**没有发布到任何 registry、也没有 push 仓库**。 + +#### 整改方案与实施(三个实验,全部用 gitignored `scratch/` 下的一次性包) + +1. **失败回滚(官方点名的 `not-bundle` 路径)**:造一个 pnpm 装得上但没有 `dsh.bundle` 的包 + `@local/dsh-not-a-bundle`,`install_bundle` 之。返回 + `{"stage":"install","changed":false,"application":"failed","packageResult":{"exitCode":0,…}}` + ——pnpm 自己成功了(exitCode 0),是 `installBundle` 在 `manifest?.dsh?.bundle === undefined` 处拒掉的。 + **安装前后对 profile 的 `package.json` 与 `pnpm-lock.yaml` 取 SHA256,两文件完全一致**, + `dependencies` 与 `dsh.profile.bundles` 都没留下脏条目 ⇒ 源码里 `catch → restoreFiles(files)` 那条出口实测成立。 +2. **GitHub 地址形态**:`target` 给 `https://github.com/<不存在的用户>/<不存在的仓库>.git`。 + 结果 `application:"failed"`、`changed:false`,pnpm `exitCode:1`,错误输出是 + `Command failed with exit code 128: git ls-remote "git+ssh://git@github.com/…"` + ⇒ 三点结论:**(a) 普通仓库网页地址被官方接受并路由**(不是 `invalid-spec`); + **(b) 一次成功的 git 安装需要一个可达(已 push)的仓库**,push 属用户专属决定(R19),本轮不做; + **(c) ⚠️ 实测到一个坑**:`https://github.com/…` 会被 pnpm 翻成 **`git+ssh://git@github.com/…`** 去 `ls-remote`, + 本机没有 GitHub SSH 授权时,**连真实公开仓库也可能失败**;`parseInstallSpec` 另外接受显式 + `git+https://…` 形态,将来走这条路要优先用显式 https。 + 本例同样完成回滚(两文件 SHA256 不变)。 +3. **packed file list**:造一次性包 `@local/dsh-pack-probe`,`files: ["index.js","cordis.patch.yml","kept/"]`, + 另设备选项之外的 `dropped/drop.txt`。`npm pack` 产 tarball(`tar -tzf` 显示只含 4 个文件,`dropped/` 已被过滤), + 再把 **tarball 的绝对路径**交给 `install_bundle`:`application:"applied"`, + profile 依赖记为 `file:…local-dsh-pack-probe-1.0.0.tgz`(**不是 `link:`**), + 落地树里只有 `cordis.patch.yml` / `index.js` / `package.json` / `kept/keep.txt`,**`dropped/` 不存在** + ⇒ `files` 白名单端到端生效,且**验它不需要发布件**——`npm pack` 的 tarball 就是过 `files` 的那一步。 +4. **顺手钉一条 spec 语法**(`parseInstallSpec` 原文,免得以后再猜):只认四种形态 + `registry` / `path` / `tarball` / `git`;**本地路径必须绝对**(`a local path must be absolute`, + `file:`/`link:` 前缀会被剥掉);git 三种写法 = shorthand `github:|gitlab:|bitbucket:|gist:`、 + `git://`/`git+https://`/scp 式 `git@host:`、普通仓库网页地址 `https://host/user/repo[.git][#ref]`; + URL 既非 git 亦非 tarball 直接拒(`a URL must point at a git repository or a tarball`)。 + `installBundle` 对 git 形态先跑 `checkGithubConnection`,**只有 network/timeout 才拦**,鉴权与传输回退归 pnpm。 + +#### 验收标准 + +- [x] 失败回滚:`not-bundle` 与「不存在的 GitHub 仓库」两例,profile 两文件 SHA256 **前后逐字节相同**,manifest 无脏条目 +- [x] packed file list:tarball 安装成功,落地树 = `files` 白名单(`kept/` 在、`dropped/` 无),依赖记为 `file:…tgz` +- [x] 一次性产物全部撤除:`remove_bundle` 后 `@local/dsh-pack-probe` 不在 deps 与 bundles 里, + `autogamer-device` 仍是 `link:D:/DEV/autoGamer/plugins/autogamer-device` 原样在位 +- [x] 官方检查器复核:`--profile web --dump-config` 里 `preset-pack-probe` / `preset-probe-noop` 均已消失 +- [ ] **⚠️ 新记一条事实(不是缺陷,是行为)**:**失败的安装与 `remove_bundle` 都会把 + `node_modules/@local/` 那个符号链接留在原地**——实测残留过 `dsh-not-a-bundle`(失败装的)与 + `dsh-probe-preset`(上一轮 remove 后源目录已删,链接变悬空),而 `pnpm-lock.yaml` 里 0 处提及。 + 与官方 JSDoc「downloaded files can stay」一致。⇒ **G31 说的「移除干净」只在 manifest/lock 层面成立**, + 要清目录得手工删链接(本轮已手工清掉,全部是我自己产生的一次性产物)。 +- [ ] **仍缺的证据(明确挂账,不假装)**:一次**成功**的 GitHub 仓库安装。它需要一个已 push 且可达的仓库, + push 属用户专属决定(R19);且实测 `https://` 形态会被翻成 `git+ssh://`,真要走通得先解决传输与授权。 + +### #20 G48:成功的 git 安装拿到证据,`files` 与 `path:` 子目录两问一并解决 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(对 GitHub 走网络那一发另开 **G49**,它需要用户决定) +- **优先级**: 高(#19 留下的「一次成功的 git 安装」缺口) +- **来源**: pnpm 官方 `pnpm add` 文档 "Install from a subdirectory of a Git repository"、 + DSH `install-spec.ts` 的 `parseInstallSpec`、四轮 live 安装 + +#### 问题描述 + +#19 只证到「git spec 被接受并路由 + 失败完整回滚」,**从没拿到一次 `application:"applied"` 的 git 安装**。 +三件事因此悬着:git 形态到底装不装得上;`files` 白名单在 git 形态下是否还生效(#19 只证了 tarball); +我们的插件住在 monorepo 子目录 `plugins/autogamer-device`,能不能只装那个子目录。 + +#### 整改方案与实施 + +**把「需要 push」这个前提先摘掉**:在 gitignored 的 `scratch/` 里 `git init` 一个本地仓库当真仓库用, +spec 走 `git+file:///…`(`parseInstallSpec` 的 `GIT_URL` 接受 `git+://`, +`gitHost()` 对 file 协议取到空 host,`checkGithubConnection` 也不拦非 GitHub)。零网络、零第三方代码。 + +1. **根目录形态**:仓库里放一个完整 bundle。安装返回 + `{"stage":"enable","changed":true,"application":"applied", … "+ @local/dsh-git-probe git+file:///D:/DEV/autoGamer/scratch/git-probe"}`。 +2. **`files` 在 git 形态下的行为**:落地树只有 `cordis.patch.yml` / `index.js` / `package.json` / `kept/keep.txt`, + **`dropped/` 不存在、`.git` 也不存在** ⇒ pnpm 对 git 依赖同样走「按 `files` 打包」这条路。 + ⇒ **#17 里那份 `files` 清单从此是承重的**:`dist/**/*.js`、`cordis.patch.yml`、`skills/**/SKILL.md`、 + `locale/*.json`、`icon.svg` 少写一项,装出去就少一项。 +3. **子目录形态**:同一仓库里加嵌套包 `packages/inner/`,spec 用 + `git+file:///…/git-probe#HEAD&path:/packages/inner` ⇒ `application:"applied"`, + 依赖值原样记为该 spec,落地树同样只含白名单文件。 + pnpm 文档给的正是这个语法(`#path:/`,多参数用 `&` 组合), + 而 DSH 的 `HOSTED_REPOSITORY_URL` fragment 段是 `#.+$`,**装得下带斜杠的 `&path:`**。 + ⇒ **我们的 monorepo 布局可以从 GitHub 装**:`https://github.com//#&path:/plugins/autogamer-device`。 +4. **踩到一个真坑并定位**:第一次子目录安装被拒—— + `YAMLException: bad indentation of a mapping entry (7:58)`,指向补丁里的 `description` 行。 + 根因是**我自己的补丁**:`description: Inert preset used to verify the path: subdirectory spec.` + 含「冒号+空格」却没加引号,YAML 非法。加引号后即通过。 + ⇒ 教训:**补丁里任何带 `: ` 的文本(描述、persona 正文)都要引号包住或用块标量**; + 与 G45 那条「persona 文本禁 `{{}}`」同类——声明面 YAML 的语法陷阱不被 R6 之外的检查兜住。 +5. **顺带修正 #19 的一条说法**:这次验证失败后宿主自己打了 + `dsh: restored package.json, pnpm-lock.yaml, and node_modules.` + ⇒ 被拒的安装**会**连 node_modules 一起还原;#19 看到的残留链接是 `link:`/`file:` 指向我们后来删掉的目录所致, + 不是宿主不清理。 + +#### 验收标准 + +- [x] 根目录 git 安装:`application:"applied"`、`changed:true` +- [x] `files` 在 git 形态生效:落地树 = 白名单(`dropped/` 无、`.git` 无) +- [x] `path:` 子目录安装:`application:"applied"`,依赖值原样带 `#HEAD&path:/packages/inner` +- [x] 收尾干净:两个 probe bundle 撤除后不在 deps 与 `dsh.profile.bundles` 里, + `--dump-config` 里 0 条 probe preset、`preset-autogamer` 在位,`autogamer-device` 仍是原 link; + 一次性仓库与 node_modules 残留已删 +- [ ] ~~对 GitHub 走网络的一发仍未做 ⇒ 需要先解决 `https://` 被 pnpm 翻成 `git+ssh://` 的传输选型~~ + **本条的传输判断已被 #21 推翻**:ssh 只是第一次尝试,pnpm 自己回退;真实公开仓库实测走 `git+https://` 且 `exitCode:0`。 + +### #21 G49 实做:GitHub 发布与 `path:` 子目录都不是障碍,真正的墙是 Windows 符号链接权限 + +> ⚠️ **本条的根因判断已被后续文档调研推翻(同日,见下方「更正」)**。标题里的「Windows 符号链接权限」 +> 是**从一条错误串外推出来的**,不是证据。编号 #21 不变,正文保留原样以便追溯,结论以本块与 TODO 的 G49 行为准。 +> 推翻它的三条本地事实: +> 1. profile 里**本来就存在 pnpm 建的跨卷目录符号链接**——`node_modules/autogamer-device -> D:/DEV/autoGamer/plugins/autogamer-device` +> 就是早前 `dsh plugin add file:`(pnpm 转发)建下的。若 Windows 在本机不让建跨卷链接,这条链接不可能在那时成功。 +> 2. **`nodeLinker: hoisted` 早就是 profile 的现状**(`profiles/web/pnpm-workspace.yaml` 里写着, +> `pnpm config get node-linker` 从 profile 目录回 `hoisted`)。⇒ #21 列的「候选②」是**空操作**, +> 我把它当成未试方案推荐了出来。 +> 3. 用户级 `C:\Users\78557\.npmrc` 只有一行 `registry=https://registry.npmmirror.com/`, +> profile 之上也没有杂散的 `pnpm-workspace.yaml`(上游 discussion #1477 记录的那类「pnpm 向上找到 `~` 的 +> `.modules.yaml` 导致 store 版本冲突」的坑,本机**没有**命中)。 +> +> **现在能站住的描述只有**:错误原文是 +> `ERR_PNPM_EPERM [importPackage C:\…\profiles\web\node_modules\autogamer-device] symlink +> 'D:\DEV\autoGamer\plugins\autogamer-device\node_modules\.pnpm\@deepseek-ai+cordis@4.0.4\…' -> 'C:\…'` +> ——**符号链接的源指向我们仓库自己的 `node_modules/.pnpm`**,而当时 **dsh 服务正在运行并加载着这个包**, +> 且 pnpm 输出里有 `Packages: +17 -1`(要换掉同名旧依赖)。 +> ⇒ 未定的候选收敛为两类,且**都还没做判别实验**: +> (a) Windows 的「文件被占用」型 EPERM(运行中的服务锁住同名包目录,替换失败); +> (b) 同名 `link:` 依赖被 git 依赖就地替换时,pnpm 把旧链接目标的 store 路径带进了新解析。 +> 下一步按 **守则 R25** 的要求:先读机制文档定判别实验的设计,**一次只测一个变量**,不许连环试。 + + +- **日期**: 2026-10-12 +- **状态**: ✅ 缺口已定性(障碍换了地方,未修) +- **优先级**: 高(它决定「用户怎么装」这条交付路径能不能成立) +- **来源**: 三轮 live 安装(公开示例仓库 / 我们 push 后的仓库)+ `git ls-remote` 对照实验 + +#### 问题描述与**一条被推翻的错误归因** + +#20 里我写了一句:「`https://github.com/…` 会被 pnpm 翻成 `git+ssh://git@github.com/…`, +本机无 GitHub SSH 授权时**连真实公开仓库也会失败**」。**这句是错的**,来源是把两个不相干的观测 +拼成了一条因果:那个 `git+ssh` 报错出自**不存在的仓库**,我据此外推到公开仓库。 + +对照实验直接否证: +- `git ls-remote https://github.com/octocat/Hello-World.git HEAD` ⇒ 通(拿到 sha,rc 0)。 +- `git ls-remote git+ssh://git@github.com/octocat/Hello-World.git HEAD` ⇒ `port 22: Connection refused`。 +- **但走 `install_bundle`**:`https://github.com/octocat/Hello-World.git` ⇒ pnpm `exitCode:0`、 + `downloaded 1, added 1`,解析值是 **`git+https://github.com/octocat/Hello-World.git`**, + 最后只因 `error.code:"not-bundle"` 被 DSH 拒。 +⇒ **ssh 只是第一次尝试,回退由 pnpm 负责**——官方 JSDoc 那句 +「only network failures or timeouts stop installation, while **pnpm owns authentication and transport fallback**」 +说的就是这个,我先前读了这句话却没把它跟观测对上。 + +#### 实做与真正的障碍 + +1. 按用户授权把 `main` push 到公开 fork `giveUphope/autoGamer`(`759af78..a4a0f44`)。 + push 前做了泄露自查:`.env` 未被跟踪、看着敏感的 11 个文件全是应用源码(凭证校验器 / token meter / + helper 的 TokenReceiver),45 个未推提交的 diff 里 0 处 token 形态字符串、0 个转储或 cookie 文件。 +2. `install_bundle` target=`https://github.com/giveUphope/autoGamer#main&path:/plugins/autogamer-device`。 + pnpm **成功抓取仓库并解析出子目录**(`resolved 17, downloaded 4, added 15`,还会 `-1` 换掉旧的 link 安装), + 然后死在: + `[ERR_PNPM_EPERM] [importPackage C:\Users\…\node_modules\autogamer-device] EPERM: operation not permitted, + symlink 'D:\DEV\autoGamer\plugins\autogamer-device\node_modules\.pnpm\@deepseek-ai+cordis@4.0.4\…' -> 'C:\Users\…'` + ⇒ **障碍是 Windows 跨卷创建符号链接的权限**(D: → C:),与 GitHub、与 `path:` 语法、与传输都无关。 +3. **回滚第三次被观察到**:失败后 profile 的 `dependencies.autogamer-device` 仍是 + `link:D:/DEV/autoGamer/plugins/autogamer-device`、`dsh.profile.bundles` 未变、 + `node_modules/autogamer-device` 符号链接原样在位、`--dump-config` 里 `preset-autogamer` 仍合成。 + +#### 结论与下一步(**未修,只是定性**) + +- 「发布到 GitHub + 子目录」这条路**语法与传输都成立**,#20 定死的地址形状不用改。 +- 要装通得先解决 pnpm 的符号链接权限,候选(**都还没测,不许当结论**): + 开 Windows 开发者模式或以具备权限的身份运行;给 profile 配 `node-linker=hoisted` 之类不走符号链接的布局; + 或让 profile 与仓库同卷。**下一条做哪个要先定,别又写成猜测实现(R20/R22)。** + +#### 验收标准 + +- [x] 公开仓库经 `install_bundle` 抓取成功(`exitCode:0`,解析为 `git+https://…`),仅因 `not-bundle` 被拒 +- [x] 我们 push 后的仓库经 `#main&path:/plugins/autogamer-device` 被正确解析并进入导入阶段 +- [x] 障碍定位到 `ERR_PNPM_EPERM` 的跨卷符号链接,且**给出完整错误原文** +- [x] 失败后 profile 三处状态(deps / bundles / 符号链接)与 `--dump-config` 全部原样 +- [ ] 权限方案未选定、未验证 ⇒ 交付路径仍不能宣称可用 + +### #22 G49 根因确认:不是权限也不是传输,是「同名旧链接被 importPackage 顺着走」;同时暴露真正的交付阻塞 + +- **日期**: 2026-10-12 +- **状态**: ✅ G49 结案;**另开 G50**(git 依赖不含构建产物 ⇒ 装出来的 bundle 不可用) +- **优先级**: 高 +- **来源**: 三次单变量实验(bundled pnpm 11.7.0,服务停启各一次)+ 磁盘 reparse 属性检查 + +#### 问题描述与结论 + +#21 的「更正」把四条候选排除了三条(push / 传输 / Windows 权限 / 用户级配置),剩 (a) 服务占用、(b) 同名依赖就地替换。 +本轮按 R25 一次只动一个变量地跑完,结论是: + +- **(a) 否证**:**停掉 dsh 服务**后跑同一条命令,报错**逐字相同** ⇒ 与进程占用无关。 +- **(b) 成立**,但我上一轮那次「否证 (b)」**是无效的**——我当时只 `pnpm remove` 了 manifest 条目, + 而 **`node_modules/autogamer-device` 那个 junction 仍在磁盘上**(正是 #19/#21 记的「remove 不删链接」行为), + 变量根本没变。用 `os.lstat` 确认它是 reparse point(`st_file_attributes = 0x410` = DIRECTORY|REPARSE_POINT)、 + 用 `os.rmdir` **只删链接不碰目标**(删后核对仓库仍 17 项)之后,同一条 spec 立刻 + `Packages: +17 … added 17, done`、**exit 0**。 +- **机制**:pnpm 的 `importPackage` 会**顺着这个 junction 走进我们的开发工作区**,把仓库自己的 + `node_modules/.pnpm` 一起纳入,然后在目标路径重建里面的符号链接 → Windows 拒绝 → `ERR_PNPM_EPERM`。 + 错误里那个「源指向 `D:\DEV\autoGamer\plugins\autogamer-device\node_modules\.pnpm\…`」就是这么来的。 +- ⇒ **对真实用户(干净机器、没有源码 link 安装)这条路径本来就是通的**; + 我们踩的是**开发机特有**的状态:同一个包先被 `link:` 装过,再换成 git 装。 + +#### 顺带暴露的、更要紧的交付阻塞(→ G50) + +装成功之后核对落地的树:`README.md`、`cordis.patch.yml`、`icon.svg`、`locale/{en,zh}.json`、`package.json`、 +`skills/autogamer-flash/` 都在,`src`/`tests`/`spike`/`node_modules` 被 `files` 正确过滤—— +**但 `dist/` 不存在**,`dist/index.js` 缺失。 +原因:git 依赖只含**已提交**的文件,而 `dist/` 是构建产物、被 gitignore;`package.json` 里又**没有 `prepare`/`prepack` 脚本**, +没人会在检出后编译。⇒ **pnpm 层面 `exit 0` 不等于 bundle 可用**。三条候选出路(**都未验证,别当结论**): +`prepare` 脚本(会与 DSH 的构建脚本审批 `approvedBuilds` 相互作用)、提交 `dist/`(不干净)、 +或走 #19 已证可用的 **tarball 路线**(`npm pack` 从已构建的工作目录取文件,天然带 `dist/`)。 + +#### 环境还原(本轮把开发态改动了两次,全部复原) + +- 撤掉 git 安装;`dsh plugin add file:` 恢复时记录值变成 `file:` 且**落地成实体拷贝**(`readlink` 失败、无 reparse 位), + 那样改 `src/` 不生效 ⇒ 删掉拷贝、用备份把 `package.json`/`pnpm-lock.yaml` 逐字节还原成 `link:`、 + 再 `pnpm install` 重建,最终核对:`autogamer-device` 是 reparse point 且 `src/index.ts` 可透过它访问、 + 仓库 17 项完好、工作树干净、服务重启无错误、`--dump-config` 里 `preset-autogamer` 正常合成。 + +#### 验收标准 + +- [x] (a) 否证:服务停止后同一命令报错逐字相同 +- [x] (b) 成立:清掉同名 junction 后 `added 17, done`、exit 0 +- [x] junction 身份由 `st_file_attributes` 判定,删除用 `os.rmdir`,删后核对仓库完整 +- [x] 开发环境精确还原(`link:` 值 + reparse point + 仓库与 git 状态无损) +- [x] 新阻塞有实测清单(`dist/` 缺失、`src/tests/spike/node_modules` 被正确过滤) +- [ ] G50 的三条候选出路未验证 ⇒ 交付形态选哪条要先读文档再定(R25) + +### #23 G50 结案:tarball 路线端到端可用(`dist/` 天然在内),git 路线的代价是构建脚本授权 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(路线定稿;GitHub Release 那一发留成用户决定项) +- **优先级**: 高(它决定「别人怎么装到一个能跑的 bundle」) +- **来源**: 本机 `npm pack` + 一次完整「安装 → 加载 → 真机往返」;官方 `plugin-manager/README.md:50,54,64`、`references/host-plugin.md:7`、`install-spec.ts` 的 tarball 分支 + +#### 问题描述 + +#22 发现:从 GitHub 装出来的 bundle pnpm 层面 `exit 0`,但落地树**没有 `dist/`**——git 依赖只含已提交文件,`dist/` 是构建产物且被 gitignore,`package.json` 又没有 `prepare`/`prepack`。⇒ 「装成功」不等于「可用」。 + +#### 整改方案与实施(按 G50 方案的顺序,先做零发布的那一发) + +1. **打包**:`npm run build` 后 `npm pack`,产出 20 个文件。实测清单:`dist/` 14 个 js(含 `dist/index.js` 9.5 kB)、`skills/autogamer-flash/SKILL.md`、`locale/{en,zh}.json`、`icon.svg`、`cordis.patch.yml`、`package.json`、`README.md`;`src/`、`tests/`、`spike/`、`node_modules/` **全部被 `files` 正确过滤**。⇒ tarball 从**已构建的工作目录**取文件,`dist/` 天然在内,不需要任何构建脚本授权。 +2. **安装**:先按 #22 清掉同名 junction,再 `pnpm add <绝对路径 .tgz>` ⇒ `Packages: +17 … added 17, done`、exit 0,依赖记为 `file:…tgz`;落地树核对 `dist/index.js` 在、`skills/…/SKILL.md` 在、`src/` 不在。 +3. **真加载**(这步才算「可用」):起服务跑一条真机会话 ⇒ 工具面 7 个(`run_device_action`/`report_task_status`/`skill`/`plugin_manager`/`job_*`)、persona 契约在 system、`` 在上下文;会话日志显示 `run_device_action` 返回**带图片附件** → `turn/end` → **G44 的追问真的驱动出第 2 轮** → 模型补 `report_task_status` → 第 2 轮 `turn/end`。⇒ **packed 形态与开发态行为一致**,这是对交付形态最强的验证。 +4. **还原**:撤掉 packed 安装、删实体目录、用备份把 `package.json`/`pnpm-lock.yaml` 还原成 `link:`、`pnpm install` 重建,核对 reparse point 恢复且 `src/index.ts` 可透过链接访问。 + +#### 路线定稿 + +| 路线 | 结论 | 依据 | +|---|---|---| +| **③b GitHub Release 资产 tarball 的 URL** | **选它作为对外交付形状** | `parseInstallSpec` 把 `https://…x.tgz` 判成 `tarball` 并带 host ⇒ 地址可分享;`dist/` 天然在内;无需构建授权 | +| ③a 本机 tarball | 已验通(本条),作为发布前自检 | 同上 | +| ① `prepare` 脚本(git 自构建) | **不默认选** | 代价是每次都要用户批构建脚本:README:64「can grant permission … **after the user approves those scripts in the conversation**」「Approval … **permits commands with the host user's permissions**」;且 `host-plugin.md:7` 的官方取向是 bundle「needs no … install scripts, or build tool」 | +| ② 提交 `dist/` | 兜底,不推荐 | 与 README:50「Prepare the checkout with `pnpm install` and `pnpm run build`」暗示的开发态习惯冲突,且污染仓库 | + +#### 验收标准 + +- [x] `npm pack` 清单含 `dist/index.js` 与 `skills/**/SKILL.md`,`src`/`tests`/`spike` 被过滤 +- [x] `pnpm add ` ⇒ `added 17, done`、exit 0;落地树三事实核对(dist 在 / skills 在 / src 不在) +- [x] 服务加载 packed 拷贝:7 工具 + persona + ``,真机动作返回图片附件 +- [x] 终态链路在 packed 形态下同样成立(未汇报 → 追问 → 第 2 轮补汇报 → 回合终结) +- [x] 开发环境精确还原(`link:` + reparse point + 工作树干净) +- [ ] **③b 的 GitHub Release 那一发未做**:`gh release create` 是 GitHub 端写操作,按 R19 留给用户决定 + +### #24 G50 ③b 落地:Release 资产发布并从 URL 装通;顺带挖出两个环境级陷阱 + +- **日期**: 2026-10-12 +- **状态**: ✅ ③b 验通;另开 **G51**(本机 Node TLS 信任被本地代理接管) +- **优先级**: 高(交付路径的最后一发) +- **来源**: 一次真实发布 + 一次真实 URL 安装;`gh`/`openssl`/`node fetch` 三方对照 + +#### 做了什么 + +1. **发布**:`gh release create autogamer-device-v0.1.0 … --prerelease` ⇒ + `https://github.com/giveUphope/autoGamer/releases/tag/autogamer-device-v0.1.0`, + 资产 `autogamer-device-0.1.0.tgz` 24,162 B。**发布前自查**:无密钥(唯一 `token` 命中是 README 里 + 「含 token 的别提交」这句说明);绝对路径只在 `README.md:37` 的 CLI 示例里,而该文件**本就随仓库公开**; + **真正会执行的 `cordis.patch.yml` 里 0 处本地绝对路径**。 +2. **匿名取回核对**:`gh release download` 取回的字节与我们打包的 **SHA256 逐字节相同** + (`f978d03cc8ae56fcf9b75c1e…`)。注意 `releases/latest` 端点**故意排除 prerelease**, + 按 tag 查才有——第一次用 `latest` 拿到 404 不是没发布。 +3. **从 URL 装通**:`pnpm add <那条 URL>` ⇒ `Packages: +17 … added 17, done`、exit 0, + `dist/index.js` 在、`skills/…/SKILL.md` 在、`src/` 未泄漏,依赖值就是这条 URL; + 起宿主加载这份安装 ⇒ **0 启动错误**(资产与 #23 已验通的本地 tarball 哈希相同,故不再重跑整轮会话)。 + +#### 途中挖出的两个陷阱(都跟 DSH 无关,但都会咬人) + +**① `gh` 把仓库解析成了上游 `google/artemis`。** 第一次 `gh release create` 报 +「Failed to create release, `workflow` scope may be required」,而 `gh auth status` 显示 token **本来就有 `workflow`**—— +提示是 gh 的通用猜测,真因是本仓库同时挂了 `origin=giveUphope/autoGamer` 与 `upstream=google/artemis`, +`gh repo view` 默认解析到的是 **upstream**,那次请求打的是**别人的仓库**(因无写权限而失败)。 +**已核对上游 0 releases、0 相关 tag,没被写入任何东西。** ⇒ 立守则 **R26**:`gh` 的写操作一律显式 `--repo`。 + +**② 本机 Node 的 TLS 信任被本地中间代理接管。** 用 URL 装第一次直接 `TypeError: fetch failed`, +完整原因是 `unable to verify the first certificate; … try running Node.js with --use-system-ca`, +而 `openssl s_client` 显示 `api.github.com` 的证书签发者是 +**`CN=SteamTools Certificate, O=BeyondDimension`**——本机跑着 TLS 拦截型网络工具,它把根 CA 装进了 +**Windows 证书库**,所以 `git`/`gh` 能过,Node 用自己的 CA 列表就不过;`registry.npmmirror.com` 不受影响。 +⇒ 解法是 Node 官方开关 **`--use-system-ca`**(实测加上后两个 URL 都 200、安装 exit 0), +**不是** `NODE_TLS_REJECT_UNAUTHORIZED=0`——那等于关掉校验,本仓库明确不用这种"修法"。 +这条记成环境前置 **G51**。 + +#### 验收标准 + +- [x] Release 与资产存在,按 tag 可查;资产字节与本地打包 **SHA256 相同** +- [x] 发布前泄露自查完成(密钥 0、绝对路径仅在本已公开的 README 示例、`cordis.patch.yml` 干净) +- [x] `pnpm add ` ⇒ `added 17, done` exit 0,落地树三事实核对通过(dist 在 / skills 在 / src 不在) +- [x] 宿主加载 URL 安装出来的 bundle:0 启动错误 +- [x] 上游未被误写(0 releases、0 相关 tag);开发环境精确还原(`link:` + reparse point + `src` 可达 + 工作树干净) +- [x] 两个陷阱都落到台账:R26(`gh` 显式 `--repo`)与 G51(Node TLS 信任) + +### #25 G51 复验:TLS 前置在 DSH 自己的 install_bundle 路径上成立,并顺带暴露一个自举死结 + +- **日期**: 2026-10-12 +- **状态**: ✅ G51 结案(前置已写进 README);⚠️ 记一条**未获得**的证据与一个结构性死结 +- **优先级**: 中(环境前置 + 交付路径的可用边界) +- **来源**: 两次真实 DSH 侧 `install_bundle`(带/不带 flag 各一次)、`operations.ts:315` 与 + `subprocess/src/index.ts:66-80` 源码核对 + +#### 问题描述 + +#24 的 URL 安装是**我直接用 pnpm 跑的**,没走 DSH 的 `install_bundle`。G51 因此留了两件事: +把 TLS 前置变成可交付说明;以及确认**宿主自己**在这台机器上装不装得动。 + +#### 机制核对(源码,非推断) + +`operations.ts:315`:`const environment = { ...(options.execution === 'cli' ? process.env : scrubbedParentEnv()), ...options.env }` +——CLI 继承环境、服务路径先洗环境。而 `scrubbedParentEnv()`(`subprocess/src/index.ts:66-80`) +只剔除匹配 `/KEY|PASSWORD|SECRET|TOKEN/i` 的名字与 `DSH_*` 前缀,**`NODE_OPTIONS` 不在其中** +⇒ 只要宿主进程自己带着它启动,pnpm 子进程就能继承。这条决定了 README 该怎么写:是**启动前置**,不是随包配置。 + +#### 两次实测 + +1. **不带 flag**(宿主默认环境):`plugin_manager install_bundle ` ⇒ + `application:"failed"`、`changed:false`,pnpm 输出 + `[WARN] HEAD … error (UNABLE_TO_VERIFY_LEAF_SIGNATURE). Will retry in 10 seconds. 2 retries left.` + ⇒ **DSH 自己的路径同样被本机 TLS 信任卡住**,与 #24 的 pnpm 直跑结论一致,不是推断。 +2. **带 `NODE_OPTIONS=--use-system-ca` 启动宿主**:同一发 ⇒ **签名验证错误消失**, + 改报 `ERR_PNPM_MISSING_TARBALL_INTEGRITY`(lockfile 条目没有 integrity 字段)。 + 这个新错误**是我自己造成的**:前一轮反复倒换 profile 的 `package.json`/`pnpm-lock.yaml` 备份, + 留下了与 URL 对应的无 integrity 条目。 + ⇒ 诚实结论:**Test B 只证明「TLS 这一层被 flag 解决」,没有拿到一发干净的 DSH 路径全流程成功**。 + +#### 顺带挖到的结构性死结(交付路径的边界,值得记) + +> ⚠️ 本节「死结」的说法**被 #26 收窄**:自举的起点是官方 Creator preset(它本来就带 `plugin_manager`), +> 不需要我们的 bundle 先存在;真正剩下的只是「同一 preset 内自装同名新版本会撞 #22 的旧 junction」。 + +要让 agent 用 `plugin_manager` 自装**同一个包**,前提是 `plugin_manager` 已经在会话里—— +而它由我们的 preset 提供,preset 又依赖这个 bundle 已安装。叠加 #22 的结论 +(同名旧 `link:` junction 会让 `importPackage` 走进开发工作区并报 `EPERM`),得到: +**在开发机上「agent 自装同名新版本」这条路天然别扭**——先清 junction 就没工具可用,不清就撞 EPERM。 +⇒ 真实用户不受影响(首装走 Web 面板或 `install_bundle`,那时没有同名旧链接); +但这条边界要写进文档,别把自举说成无条件可用。 + +#### 收尾与验收标准 + +- [x] 机制有源码出处(`operations.ts:315` + `scrubbedParentEnv`),不是猜测 +- [x] 不带 flag 的 DSH 路径失败有**错误原文**(`UNABLE_TO_VERIFY_LEAF_SIGNATURE` + 两次重试) +- [x] 带 flag 后 TLS 错误消失(换成另一个由我 own 的脏 lock 造成的错误,已如实归因) +- [x] README 装上「⚠️ 本机 TLS 前置」段:启动带 `--use-system-ca`,并**明确禁止**用 `NODE_TLS_REJECT_UNAUTHORIZED=0` +- [x] profile 清干净:`pnpm-lock.yaml` 无 URL 残留条目、`autogamer-device` 回到 `link:` 且 `src/index.ts` 可透过链接访问 +- [ ] **未获得**:一发从零开始、走 DSH `install_bundle` 的完整成功安装(需要干净 profile 或换机器验证) + +### #26 「plugin_manager 不是 DSH 自带的吗,为什么要我们提供」——查完发现我写错了一个开关 + +- **日期**: 2026-10-12 +- **状态**: ✅ 问题澄清 + **修掉 D17 落地的一个真实隐患** + 更正 #25 的一处过度陈述 +- **优先级**: 高(隐患会让整条 preset 在非 profile-backed 启动下被拒挂) +- **来源**: `packages/bundle/base/cordis.patch.yml:16-22` 与其头部注释、`packages/bundle/web-app/presets/cordis.patch.yml`、本机 rc.2 `--dump-config` 转储、`plugin-manager/README.md` + +#### 问题的直接回答:是我们理解偏了,但偏的方向有讲究 + +`plugin_manager` **确实是 DSH 自带的**,两样东西都在 `dsh-base` 里随包出货: + +```yaml +# packages/bundle/base/cordis.patch.yml +- id: tool-plugin-manager + name: '@deepseek-ai/dsh-plugin-manager/tools' + disabled: true # ← 工具行:基线给的是「关」 +- id: plugin-manager + name: '@deepseek-ai/dsh-plugin-manager' + disabled: !!js "!ctx.get('profileContext')" # ← 服务行:只在 profile-backed 时开 +``` + +我们 preset 里那一行**不是「提供这个功能」,而是「拨我们自己的开关」**。为什么每个 preset 都要各自写一遍—— +base 补丁的头部注释给了官方理由:「A patch replaces the targeted row's whole `config` rather than merging into it, +so **a row whose value differs by mode does NOT live here: it belongs to each mode bundle** … +**each mode bundle restates its complete configuration**」。 +本机 rc.2 转储对得上:`preset-standard` / `preset-ptc` 各自重述 `disabled: true`, +而 Creator 用的 `preset-cordis` 重述成 `disabled: !!js "!ctx.get('profileContext')"`; +README 那句「Preset-backed sessions use their preset's `tool-plugin-manager` entry」就是这个设计的收口。 + +#### 顺着这个问题查出的**真隐患**(已修) + +D17 落地时我写的是 `disabled: false`——**无条件开**。但服务行自己是有条件的 +(`!ctx.get('profileContext')`),而 tools 行要注入 `ctx.pluginManager`(`docs/tool-catalog.md` 那行列出 +`ctx.tools`, `ctx.pluginManager`, `ctx.sandboxPolicy`)。⇒ 在**没有 profileContext 的启动**里, +服务是关的、我们的工具行却要它,而 **缺服务会 fail-fast 拒掉整条 preset 挂载(D16)**—— +不是这个工具没了,是**整个 AutoGamer preset 挂不上**。 + +修法就是照抄官方 Creator 的形状,让工具行**跟着服务的条件走**: + +```yaml +- id: tool-plugin-manager + name: "@deepseek-ai/dsh-plugin-manager/tools" + disabled: !!js "!ctx.get('profileContext')" +``` + +两份 YAML 同步改(`cordis.patch.yml` 与 `spike/web-live.patch.yml`),并留下注释说明**为什么不能写 `false`**。 + +#### 同时更正 #25 的一处过度陈述 + +#25 说「agent 用 `plugin_manager` 自装同名包」存在结构性死结。这句话**只对「用我们自己的 preset」成立**: +Creator preset(`preset-cordis`)本来就带着这个工具,所以**在 Creator 会话里 agent 可以装我们的 bundle, +不需要我们的 bundle 先存在**——自举的起点是官方就有的,不是我们提供的。 +真正剩下的边界只有一条:在**同一个 preset 内**自装同名新版本时,#22 那个旧 `link:` junction 仍会撞 `EPERM`。 + +#### 验收标准 + +- [x] 声明面:两份 patch 各跑 `--dump-config` ⇒ exit 0、0 unmatched;合成树里我们的行是 + `disabled: !!js '!ctx.get(''profileContext'')'`(与 Creator preset 逐字同形) +- [x] 一致性:`tests/deliveryShape.spec.ts` 3/3 绿(两处 preset 仍同形) +- [x] **live 复验修正没有关掉能力**:web profile(profileContext 存在)新会话工具面仍是 **7 个、含 `plugin_manager`**,宿主 0 启动错误 +- [x] #25 的过度陈述已在本条更正,并写清自举的真实起点在 Creator preset + +### #27 G5 的副产品:装上真实 DSH 包之后,手镜像的附件引用类型被 tsc 照出来了 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已修(类型层;运行时从未坏过) +- **优先级**: 中——不炸线上,但它是守则 **R21** 明确禁止的那类断言:手抄的宿主类型会随上游静默失真 +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +G5 Tier 1 要给插件加 4 个真实 `@deepseek-ai/*` devDeps(`dsh-system-prompt` / `dsh-invariants` / `dsh-agent` / `dsh-attachment`)。加完 `npx tsc --noEmit` 从 **0 错** 变成 1 错: + +``` +src/tools/runDeviceAction.ts(248,7): error TS2322 + ... Type '{ type: "image"; attachment: DshImageAttachment; }' is not assignable to type 'ImageBlock'. + The types of 'attachment.attachmentId' are incompatible between these types. + Type 'string' is not assignable to type 'AttachmentId'. + Type 'string' is not assignable to type '{ readonly [BRAND]: "AttachmentId"; }'. +``` + +根因在 `src/dsh-types.ts`:`DshImageAttachment` 是**手镜像**,文件头当时的说法是 "DSH ships no type declarations (dist JS only, verified)"。真实声明是 `@deepseek-ai/dsh-attachment` `lib/types/types.d.ts:7` 的 `ImageAttachmentRef`——字段逐个对得上,唯独 `attachmentId` 是品牌类型 `AttachmentId`(`brandString`,仅编译期存在),我们抄成了 `string`。 + +为什么以前不报:这条品牌在依赖没装齐时无法解析,tsc 把它当宽松类型放过;真包一到,品牌可解析,冲突立刻露出来。**基线实测**(不是推断):`git stash` 掉 `package.json`/`pnpm-lock.yaml` 后 `pnpm install --frozen-lockfile` + tsc = 0 错,恢复后 = 1 错。 + +运行时没有受损:`attachmentId` 我们只透传、从不解析(`src/index.ts:182` 的 `saveScreenshot`),所以这是纯类型缺陷。但「抄错一个品牌」正是 G5 要消灭的那种手镜像——上游一升级就变成假的安全感。 + +#### 整改方案与实施 + +- `plugins/autogamer-device/src/dsh-types.ts`:`DshImageAttachment` 从自写 interface 改为**真类型的别名**——`import type { ImageAttachmentRef, SaveImageAttachment } from "@deepseek-ai/dsh-attachment"`;`DshAttachments.saveImages` 直接用真签名 `readonly SaveImageAttachment[] → Promise`(原镜像的入参形状与真 `SaveImageAttachment` 逐字段相同,行为不变)。 +- 同文件头注释改成「两类条目」规则:**凡是公开 `@deepseek-ai/*` 包能声明的,一律 import 不手抄**;只有在 Host 里才看得到的形状才允许手镜像,且必须带版本与出处。旧的「DSH 不发类型」陈述已经作废——包内带 `lib/types/**/*.d.ts`,公开 registry 可取。 +- 调用点零改动:`DshImageAttachment["mediaType"]` 的索引访问在别名上仍成立,`runDeviceAction.ts` 四处引用原样。 + +#### 验收标准 + +- `cd plugins/autogamer-device && npx tsc -p tsconfig.json --noEmit` ⇒ 0 error +- `npm run build && npx vitest run` ⇒ 75 passed / 1 skipped(skipped 是 `AUTOGAMER_CONFORMANCE_PY=live` 的 opt-in 分支) +- 证伪口径(证明是修好而不是绕开):把 `DshImageAttachment` 换回 `attachmentId: string` 的手镜像 ⇒ 上面那条 TS2322 立刻回来 + +### #28 G5 落地后的测试复审:生成的 fixture 里躺着一条错事实 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已修(抽取器 + 三条新守卫 + 两处被证伪的陈述) +- **优先级**: 高(不是格式问题——是**自动化契约测试锁定了一个与自家文档矛盾的事实**) +- **分类**: 四 · DSH 插件轨道 +- **来源**: 本轮测试复审子代理报告,逐条自己复核后才动手 + +#### 问题描述 + +Tier 2 把旧控制台 SSE 契约改成从源码抽取,抽出来的 `sse-events.json` 写着: + +``` +frontendSubscribesButServerNeverEmits: ["checker_event"] +``` + +即「前端订阅 `checker_event`,服务端从不发射」——**假的**。发射点在 +`artemis/graph/checkpoints.py`: + +```python +CHECKER_EVENT = "checker_event" # :72 +publish = getattr(engine, "_publish", None) # :166 +publish(CHECKER_EVENT, {"ts": now, ...}) # :174 +``` + +callee 是 `getattr` 绑定的**别名**,首参是**模块常量**——抽取器两个条件都不满足, +于是这个事件根本没进 `serverEventNames`,反过来掉进「死信道」差集。 +`docs/migration/inventory/05-frontend-integration.md` 第 41 行早就写明 `checker_event` +是活的("走通用日志分支入 sessionLogs …二期 Checker 重写时是事件基线")。 + +三重放大:① `contracts.spec.ts` 把这条错事实**断言锁定**;② 脚本头注释写着 +"会响不会静默漏",而 `findUnreviewedEmitters` 同样只认字面量首参,对这条**一声不响**; +③ 更结构性的一条——测试是「fixture 对同一个生成器」比,生成器少认一个漏斗时两边一起错, +测试恒绿。 + +同轮另有两处同类:`contracts.spec.ts` 里 `expect(buildFixture(realTools).pyActionServerTools).toEqual(realTools)` +是自己比自己(恒成立,`56a7996` 已修);测试注释与 `3507d5c` 提交信息都写「CI 用 +`spike-mcp.mjs` 做同一比对」,而 `ci.yml` 里**根本没有那一步**——假出处陈述。 + +#### 整改方案与实施 + +- `plugins/autogamer-device/scripts/gen-sse-events.mjs`:抽取从「认字面量」升级为**先解析再匹配**—— + `readConstants`(仓库级 `NAME = "literal"` 常量表)、`readAliases`(`alias = getattr(obj, "")`)、 + `CALL_PATTERN`(首参允许字面量或全大写常量);`findUnreviewedEmitters` 改用同一解析, + 解析不出漏斗的发射形态一律报告;新增 `namesByEmitter` 导出每个漏斗的贡献名字集合。 +- `tests/fixtures/sse-events.json` 重新生成:服务端 **25 → 26**(`checker_event` 归位), + 反向死信道由 1 → **0**;notes 里那句「死信道是双向的」随实测改掉(现在是「前端订阅的 17 项 + 全部有真实发射方」)。 +- 三条新守卫补上「生成器坏了要能红」这一环(`contracts.spec.ts`): + ① 服务端事件名数量地板 `>= 20`;② `namesByEmitter()` 的键集合必须恰为三个已核实漏斗、 + **每个至少贡献 1 个名字**、且其名字都在 `serverEventNames` 里;③ `findUnreviewedEmitters()` 为空。 +- 覆盖空洞补上:`compare_ui_frames`(G39 路线 A)是 `src/index.ts:174` **直接调用**、不在 + `ACTION_TO_PY_TOOL` 里,此前任何断言都不覆盖它。新增测试从 `src/**/*.ts` 抽出 + `client.callTool("")` 全集,既断言等于已知清单(新增直接调用必须显式登记), + 又断言每个都在记录的 py 面上。 +- 方向补全:`inject` 此前只被证明「声明的服务本图都有」(多写会红),**少写不红**—— + 新增 `declares every Host service its own code touches`,从 `src/**/*.ts` 扫 `ctx.` + 消费面(扣掉 cordis 内建),断言等于 `inject` 声明面。 +- 两处假陈述就地更正:测试注释与 README 改为「live 比对是本地 opt-in + (`AUTOGAMER_CONFORMANCE_PY=live` 或 `node scripts/spike-mcp.mjs`),CI 不做」。 +- 共用一个 `tests/support/srcFiles.ts` 的递归枚举,避免两份 spec 各写一遍遍历 + (新增源文件时必须一起被看见)。 + +#### 验收标准 + +- `node plugins/autogamer-device/scripts/gen-sse-events.mjs` ⇒ `fixture matches the sources (26 server / 17 frontend names)`,exit 0 +- `node -e "...namesByEmitter()..."` ⇒ `_publish 14 / _broadcast_event 9 / _record_llm_event 5`;`findUnreviewedEmitters()` ⇒ `[]` +- `cd plugins/autogamer-device && npx vitest run` ⇒ **78 passed / 1 skipped**;`npx tsc --noEmit` ⇒ 0 error +- 变异校验(三条新守卫各自会红,均已实做): + 从 `EMITTERS` 摘掉 `_record_llm_event` ⇒ 数量地板与键集合两条同红; + 把 `callTool("compare_ui_frames")` 改名 ⇒ 直接调用清单红; + 从 `inject` 摘掉 `attachments` ⇒ 消费面对账红 + +### #29 G5 结案:conformance 契约改吃真实 DSH runtime,fixture 全部变成生成物 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(三层全部落地,CI 两个 OS 实测绿) +- **优先级**: 高(P1 的每一条宿主契约断言都建立在这上面) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「G5 conformance 吃真实 DSH runtime(2026-10-12 按 R22 三步序)」住在 `TODO.md`,结案即删,演进查 git 历史 + +#### 问题描述 + +`tests/conformance/contracts.spec.ts` 的头部自述就是差距本身: + +> "These tests lock the externally observable contracts this plugin depends on, using **fixtures recorded from live verification**. Re-capture the fixtures whenever the DSH runtime or the py action server upgrades" + +也就是**手镜像**:上游一升级就静默过期,而 S8 已写明上游发到 `0.2.1-alpha.2`。同一份 py 动作面声明当时存在**三处副本**(fixture JSON、`scripts/spike-mcp.mjs` 里的 `REAL_TOOLS` 字面量、`src/tools/runDeviceAction.ts` 的 `ACTION_TO_PY_TOOL`)。而 `ci.yml` 只有 `python-quality` / `frontend` / `package` 三个 job——**插件的契约测试从未在 CI 上跑过**。 + +官方一手依据(声明面 + 公开包,未碰 asar):`dsh --help` 给出 `--dump-config` / `--patch` 与 `dsh headless --json`;`docs/tool-catalog.md` 的生成器说明原文是 "this generator **BOOTS each tool plugin on a real context and reads `ctx.tools.schemas()`**, because a tool schema is not statically knowable"——**in-process 起真 runtime 读注册表是上游自己的做法**;`docs/subsystems/core.zh.md` 标明 "`scope/` 是这里唯一的非服务包"、"`dsh-base` 是默认产品组合,`dsh-sdk-minimal` 则声明一棵更小的独立配置树"。 + +#### 整改方案与实施 + +- **Tier 1 `tests/conformance/runtimeBoot.spec.ts`**:`new Context()` 上挂真实 DSH 服务包(`dsh-invariants` / `dsh-system-prompt` / `dsh-tools` / `dsh-agent` / `dsh-attachment`,均精确 `0.2.0-rc.2`)再挂本插件,契约由真 `ctx.tools.schemas()` 把关——工具名集合、`parameters.type === "object"`、`required ⊆ properties`、`ctx.tools.get` 可解析、**坏 config 必须由 loader reject**;再加一条从 `src/**/*.ts` 扫 `ctx.` 消费面与 `inject` 对账。全程无模型、无网络、无设备,约 40ms。 +- **Tier 2 三条 fixture 全部改成生成物**:py 动作面由 `scripts/spike-mcp.mjs --write` 从真实 MCP server 生成(期望面 = `ACTION_TO_PY_TOOL`,`REAL_TOOLS` 副本删除);旧控制台 SSE 由 `scripts/gen-sse-events.mjs` 从三个已核实发射漏斗(读实现确认:`engine.py:541` / `task_queue_service.py:256` / `llm.py:231`)与前端订阅面抽取;版本 pin 读**实际装好的** `@deepseek-ai/dsh-*` 版本并交叉校验 `$DSH_HOME/dsh-runtimes/*/runtime.json` 的 `desktopVersion`;事件词汇改读 `@deepseek-ai/dsh-session` 的 `KNOWN_SESSION_EVENT_TYPES`(59 项)与 `SESSION_FORMAT_VERSION`(=4)。 +- **Tier 3 `scripts/conformance-live.mjs`**:declared(`--dump-config`)与 live(`session.v4.jsonl.zstd`,R24)两层分开输出,注册与否明确标注只能靠 `cordis_inspect_query`/roster(R9)。 +- **CI 新增 `plugin-contracts` job**(ubuntu + windows):`pnpm install --frozen-lockfile` → `gen-sse-events.mjs` 漂移检查 → `build` → `test`。 +- 落地过程中被真实包照出并一并修掉的:附件引用手镜像(**#27**)、抽取器看不见别名+常量导致 fixture 锁住假事实(**#28**)。 +- ⚠️ 两个坑写在这儿的理由:公开 `@deepseek-ai/*` 包的 `latest` 标签停在 `0.0.1-rc.1`,正确代际在 **`next`**——devDeps 必须写死精确版本;`pnpm` 装包在本机需要 `NODE_OPTIONS=--use-system-ca`(G51)。 + +#### 验收标准 + +- `cd plugins/autogamer-device && pnpm install --frozen-lockfile && pnpm run build && pnpm test` ⇒ **78 passed / 1 skipped**,`npx tsc --noEmit` ⇒ 0 error +- `node scripts/gen-sse-events.mjs` ⇒ exit 0(`fixture matches the sources (26 server / 17 frontend names)`);`ARTEMIS_MOCK_DRIVER=1 node scripts/spike-mcp.mjs` ⇒ exit 0;`AUTOGAMER_CONFORMANCE_PY=live npx vitest run tests/conformance` ⇒ 15 passed(真服务比对) +- **CI 证据**:run `38167498398`(commit `5232265`)的 `DSH plugin contracts / ubuntu-latest` 与 `/ windows-latest` 均 **success**。⚠️ 该 run 是 `gh workflow run` 手动派发的——本 fork 的 `on: push` 从不触发 run(派发前历史 `total_count=0`),已按 **G54** 另立差距;同 run 里两个 `Python 3.12` job 失败于 `check_dependency_sources`(前端 lockfile 全镜像 URL),属既有状态,按 **G53** 另立,与本条无关 +- 变异校验(五条,各自确实变红):`inject` 多写本图没有的服务 ⇒ 挂载红;摘掉 `Config` 导出 ⇒ reject 红;映射值改成不存在的工具名 ⇒ fixture 档与 live 档都红;`EMITTERS` 摘掉一个漏斗 ⇒ 覆盖守卫红;`inject` 少写 `attachments` ⇒ 消费面对账红 + +### #30 文档漂移:入口页与索引把「编号范围」和「当前状态」各抄了一份第二权威 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(描述性复述改成引用,体量数字全表重测) +- **优先级**: 中(不阻塞实现,但入口页会把新人直接导到已结案的差距上) +- **分类**: 四 · DSH 插件轨道 +- **关联**: 守则 R25(一次只测一个变量、先读机制)、`TODO.md` §五「文档索引」与 `migration/README.md` §五 改文档规则 + +#### 问题描述 + +拉取 `61f23a1` 后通读文档层,机械对照查出**六处**描述与现实不符,全部出自同一个形状——**可推导的事实被抄成了第二份权威**: + +1. `TODO.md` 文档索引的 registry 行写「D1-D17 / S1-S11 / **G1-G44(含 18 条已关闭)** / **R1-R19**」。实测:registry 里最大 `G54` / 最大 `R26`,`CHANGELOGS.md` 的「已关闭差距史」表有 **33** 行。 +2. 同一张索引表的守则行写「插件契约守则 **R1-R22**」,而 `AGENTS.md` 已按 R1-R26 引用。 +3. `migration/README.md` §二「这个项目现在在做什么」第 3 句写「**当前卡在 G30**:工具注册成功但没进 agent 的工具面」。G30 早在 **#10** 结案(双根因:`effect()` 语义反转 + 裸注册未编译)。入口页把进度抄了一份,结案时没人回改。 +4. 同页 §三 文档清单 11 行的体量数字全是旧快照:`TODO.md` 写 177 行 / 34.9 KB,实测 **410 行 / 76.0 KB**;`plugin-contract-rules.md` 写 166 行,实测 248 行——且**整张表缺 `docs/CHANGELOGS.md` 这一行**(`TODO.md` 与守则都按「已关闭差距的正文去处」引用它)。 +5. `plugin-contract-rules.md` 首行「覆盖范围:**R1-R19** 已改写 TODO.md 的 …」把那一轮的回写清单写成了条款全集,R20-R26 的同批回写在结构上无处可寻。 +6. `TODO.md` 状态表「下一步(按序)」那一格内存在**两套 `2)/3)/4)`**:前一套是 G5 结案后的现行序列,后一套是结案前的旧序(还写着「G35、G32 已结」这种当时算新消息的尾巴),读者无法判断哪套作数。 + +另查出 `inventory/03-device-media-diagnostics.md` 有 **15 处** `TODO.md:NNN` 行号引用,指向 2026-10-09 基线 `cc6b20c` 那一版;`TODO.md` 是活文档,行早已错位。 + +**为什么两份门禁都没拦住**:`check_doc_registry.py` 只验「引用能否解析 + 本表与来源集合是否一致」,`check_doc_tables.py` 只验表格形状。两者都不读「描述文字里复述的范围」,所以范围类漂移在机制上是**无人看守**的一类。 + +#### 整改方案与实施 + +原则:**范围只写 `registry.md`,进度只写 `TODO.md` §零;其它地方一律改成引用,不复制值。** + +- `TODO.md` 文档索引两行改成「全部 D / S / G / R 编号一览」「现行范围以 registry 为准」,并把 R20-R26 的要点补进守则行的枚举里(枚举的是**稳定 id + 一句话**,不是范围数字);§五「P0 执行状态」里那条 2026-10-10 的历史记录保留事实,只把「R1-R19」改成「那一轮成文到 R19;现行条款数看 registry」。 +- `TODO.md` 「下一步(按序)」删掉结案前遗留的第二套序号尾段,保留 1)-5) 现行序列(含 G52/G53/G54、G43、低成本债、P1、两条未闭环观察)。 +- `migration/README.md`:§二 第 3 句不再写「当前卡在 X」,改成「P0 已收官 / D9 成立」这一里程碑级事实 + 「进度与阻塞看 `TODO.md` §零」,并把这次事故本身写进 §五 新增的「编号范围与当前阻塞点各只在一处写」规则;§三 全表体量按 `wc -l -c` 重测,**补上 `docs/CHANGELOGS.md` 行**,registry / 守则两行的描述改范围无关;页首「体量数字是 `check_doc_registry.py` / 尺子实测」那句改成实际做法(`wc`),因为它虚指了脚本能力。 +- `plugin-contract-rules.md` 首行改成「R1-R19 那一轮成文时改写了 …;R20-R26 是此后陆续新增,回写位置记在各条自己的「判据」段」,并声明现行范围只看 registry。 +- `inventory/03` 顶部按既有「编号警示」的同形加一条**行号警示**,不去逐条改写那 15 个指针——逐条改写会造出第三份需要维护的映射,而盘点文档的可信度口径本就是「内容可用、引用不可照用」。 +- 顺手补上一处**门禁本身的缺口**:`scripts/check_doc_tables.py` 的 `DEFAULTS` 里没有 `docs/CHANGELOGS.md`——它是「已关闭差距史」表的所在,`check_doc_registry.py` 按它读结案编号,它的表格形状却从未被尺子量过(§五 的规则本就要求表格文档进 DEFAULTS)。加入后 `--selftest` 六条注入缺陷仍能全部报警。 + +#### 验收标准 + +- 复测命令(数字即事实,可直接重跑): + + ```bash + python - <<'PY' + import re + reg=open('docs/migration/registry.md',encoding='utf-8').read() + cl=open('docs/CHANGELOGS.md',encoding='utf-8').read() + closed=re.findall(r'^\|\s*(G\d+)\s*\|',cl.split('## 已关闭差距史')[1],re.M) + print('max G',max(int(x) for x in re.findall(r'G(\d+)',reg)), + 'max R',max(int(x) for x in re.findall(r'R(\d+)',reg)), + 'closed G',len(closed)) + PY + ``` + ⇒ `max G 54 / max R 26 / closed G 33` +- **范围复述归零**(描述性文档不再自称条款全集): + `grep -rn "R1-R[0-9]\|G1-G[0-9]" docs/TODO.md docs/migration/README.md docs/migration/plugin-contract-rules.md` ⇒ **无输出**(改前 5 处)。 + 两个刻意保留的形状要写清:**①** 指向性写法 `R2-R8` / `R6-R15`(「去做这类事前读这几条」)引用的是具体条款,不是全集,保留;**②** 守则首行改成 `R1…R19 首轮成文` 这种**点名那一轮**的省略号形态,同句已声明「不是条款全集,范围只看 registry」,故不入本判据的连字符形态。`CHANGELOGS.md`(本文件)与 `registry.md` 不在判据范围内——前者要原样引用改前文字,后者本就是权威处。 +- **`AGENTS.md` 是已知的例外,不是漏网**:它还写着「守则 R1-R26」。该行当前为真,且它把 R20-R26 的要点整段内联给 agent(作业入口要自足),所以本次不动它;**新增条款时必须同步这一行**——它是唯一被有意维护的复述处。 + ⚠️ **这条豁免已被 `#31` 撤回**:把例外留给「人工记得去改」正是它腐烂的方式;现在 `AGENTS.md` 也改成指向 registry,并由门禁机械拦。 +- **入口页不再自称当前阻塞点**:`grep -rn "当前卡在" docs/migration/README.md` ⇒ **无输出**(事故经过只留在本条,§五 的规则行改引 `changelogs #30`)。 +- **旧计数不再出现**:`grep -rn "含 18 条已关闭" docs/ AGENTS.md | grep -v CHANGELOGS` ⇒ **无输出**。 +- `docs/migration/README.md` §三 表新增 `docs/CHANGELOGS.md` 行(行数 11 → 12),且**每一行体量数字须与实测对上**: + `wc -l -c docs/TODO.md docs/CHANGELOGS.md docs/migration/registry.md docs/migration/glossary.md docs/migration/plugin-contract-rules.md docs/migration/upstream-plugin-forms.md docs/migration/dsh-verification.md docs/migration/feature-inventory.md docs/migration/upstream-rethink.md plugins/autogamer-device/README.md AGENTS.md`;`inventory/01…05` 按五份求和。KB = 字节 / 1024,见同表下「体量口径」。 +- **`inventory/03` 的行号警示在场**:`grep -c "行号警示" docs/migration/inventory/03-device-media-diagnostics.md` ⇒ 1(声明那 15 处 `TODO.md:NNN` 是 2026-10-09 `cc6b20c` 版快照,按段名定位)。 +- **状态表只剩一套序号**:`grep -o "\\*\\*[0-9])\\*\\*" docs/TODO.md | sort | uniq -c` ⇒ `**1)**`…`**5)**` **各恰好一次**(改前 `**2)**` / `**3)**` / `**4)**` 各两次,两套序号在同一段里并存)。 +- 两份门禁脚本均 exit 0:`python -X utf8 scripts/check_doc_registry.py`、`python -X utf8 scripts/check_doc_tables.py`(表格行数变了,尺子必须重跑)。 +- **尺子现在也量 CHANGELOGS 自己**:`python -X utf8 scripts/check_doc_tables.py` 的输出含 `OK: …\docs\CHANGELOGS.md`(8 table block(s) checked);`python -X utf8 scripts/check_doc_tables.py --selftest` ⇒ `SELFTEST: ruler fires on all six injected defects`(证明加文件没把报警能力加钝)。 +- `uv run ruff check scripts/check_doc_tables.py` / `uv run ruff format --check scripts/check_doc_tables.py` 通过。 +- `TODO.md` 状态表「下一步(按序)」内 `**2)**` 恰好出现一次:`grep -o "\*\*2)\*\*" docs/TODO.md | wc -l` ⇒ 该行计数为 1。 + +### #31 把 #30 的教训做成规则与判据:派生事实只有一处权威 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(规则入 `AGENTS.md`,两条判据入门禁,现存复述全部拆除) +- **优先级**: 中(不修就会每轮复发:#30 一轮修了六处,实现本条的过程中又冒出五处) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「文档层经验固化:派生事实只有一处权威,且必须有机械看守」住在 `TODO.md` §五(spec 提交 `6e2bfb8` 早于实现提交,按 R22),结案即删 + +#### 问题描述 + +#30 修的是症状:六处复述被逐个改回引用,但**没有任何东西阻止下一轮再长出六处**。判据面缺位的证据在本条实现过程中当场出现: + +- 我在 #30 里手工重测了一遍体量数字,随后自己写方案、改守则,README 的数字**又错了 5 行**——新加的体量校验第一次运行即抓到:`docs/TODO.md` 写 410 行(实测 438)、`AGENTS.md` 写 175 行(实测 191)、守则 KB 少算、`inventory` 求和差 1 行,外加一处**路径基准不成立**:表里写 `../plugins/autogamer-device/README.md`,从 `docs/migration/` 出发指向 `docs/plugins/`,根本不存在。 +- `AGENTS.md` 与 `plugins/autogamer-device/README.md` 各自持有一份「条款全集」和一份「当前状态」。包 README 那条尤其说明问题:它写着「当前状态(2026-10-10):🔴 **P0 未完,卡在 G30**」——G30 在 **#10** 就结案了,P0 也已收官,而包首页一直挂着它。 +- #30 的验收里我把 `AGENTS.md` 判成「有意维护的例外」,理由是它要自足。**这条判断被本条撤回**:自足靠的是指向权威 + 内联单条 id(`R6`/`R20` 这种),不是抄一个会腐烂的总数。 + +**共同形状**:可推导的值被抄成第二份权威,而两份门禁只验「引用能否解析」和「表格形状」——抄来的值即使与源头不符也照样全绿。 + +#### 整改方案与实施 + +1. **规则成文**(`AGENTS.md` 新增一节 "Docs: a derived fact has exactly one authority"):可推导的东西(编号范围/条数、文档体量、当前阻塞点)只许存在一处,别处一律指针;并给出四条去处(范围→`registry.md`、状态→`TODO.md` §零、结案正文→`CHANGELOGS.md`、体量→生成)与两条门禁名。 +2. **`scripts/check_doc_registry.py` 判定 6**:`\b([DSGR])1-\1?(\d+)` 命中的「从 1 号到当前最大号」全集形态,只允许出现在 `registry.md`(权威)、`CHANGELOGS.md`(存档须原样引用改前文字)、以及**定义该序列的 `## ` 标题行**(如 `## 二、决策记录(现行有效,D1-D17)`);其余按 `相对路径:行号` 报错。 + 指向性写法 `R2-R8`、`R6-R15` 不在此列——它们引用具体条款而非总数,文本稳定。例外走**文件级白名单**,不设行内标记:标记清单本身会变成第二份要维护的东西。 +3. **`scripts/check_doc_tables.py` 接管体量**:新增 `check_sizes()`,默认运行即逐行核对 `docs/migration/README.md` §三 的「N 行 / X KB」与真实 stat(KB 统一一位小数);`--refresh-sizes` 直接改写该列,从此**没有人手抄数字**。表内路径统一改成仓库根相对并在「体量口径」note 里写明约定;解析不到的行是 FAIL 而不是跳过。 +4. **现存复述全部拆除**:`AGENTS.md` 文档地图两行(`D1-D17`/`S1-S11`/`R1-R26` → `D*`/`S*` + 指向 registry)、包 README 的守则范围行与「当前状态」行(状态改指针)、`migration/README.md` §一 的规则提示行。 +5. **报警能力自证**:registry selftest 加第三条注入(`CAUGHT [total-range restatement]`,锚点从差距表现取,不写死具体编号——沿 #16 的教训);tables selftest 加 (g) 体量数字过期、(h) 体量表整块消失两类注入,末行改为 `all {FIRED} injected defects` 用计数器而非硬编码条数。 + +#### 验收标准 + +- `python -X utf8 scripts/check_doc_registry.py` ⇒ exit 0,输出含 `total-range restatements outside the authority: 0`;`--selftest` ⇒ `SELFTEST: reference gate fires on all 3 injected defects` 且 registry/TODO 均字节级还原 +- `python -X utf8 scripts/check_doc_tables.py` ⇒ exit 0 含 `12 size row(s) checked`;`--selftest` ⇒ `SELFTEST: ruler fires on all 8 injected defects`;`--refresh-sizes` ⇒ 无漂移时 `0 of 12 row(s) rewritten` +- **判据真的会红(实做)**:临时把 `AGENTS.md` 的指向写法改回「从 1 号到当前最大号」的全集形态 ⇒ `PROBLEM: AGENTS.md:31 restates the total id range ...`,exit 1,随后字节级还原(`restored identical: True`) +- **不再有第二份状态**:`grep -rn "当前状态" plugins/autogamer-device/README.md` ⇒ 只命中指针句;`grep -rn "体量数字用" docs/migration/README.md` ⇒ 无输出(旧口径已换成脚本口径) +- `uv run ruff check scripts/` ⇒ All checks passed;`uv run ruff format --check scripts/check_doc_registry.py scripts/check_doc_tables.py` ⇒ already formatted。 + **但 `uv run ruff format --check scripts/` 整体仍有 1 处红**——那是既有债(`scripts/check_dependency_sources.py:55` 过长,非本轮引入,本轮未顺手改以免混进无关提交),已按 **G55** 登记 +- **偏差如实记**:① 体量数字第一次跑就纠正了我自己在 #30 手抄的值,KB 口径由「整数与一位小数混用」统一为一位小数(`inventory` 行 217 → 216.9 KB);② #30 关于「`AGENTS.md` 是有意例外」的判断已由本条撤回,#30 原文加了一行取代标注;③ 未新增守则条款——文档卫生的作用域是全仓库而非插件契约,按 `registry.md` 的规则不占 `R*` 号 + +### #32 G52 结案:追问路径改吃真实 `turn/end`,并摘掉追问消息的手镜像 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(新 Tier 1 五条全绿,插件测试面 78 → 83) +- **优先级**: 高(P1 的终态与熔断判据都建立在这条路径真的能被宿主触发上) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「G52 修复轮:追问路径补进真实 runtime」住在 `TODO.md` §五(spec 提交 `63bd39c` 早于实现提交,按 R22),结案即删 + +#### 问题描述 + +G44 的兜底追问(`turn/end` → 至多一次 `followup`)此前只有一种证据:`apply.spec.ts` 自己造的假 ctx,`emit("turn/end")` 由测试亲手调用,`ctx.agents.get` 是返回固定对象的假函数。宿主里真正决定成败的三件事一件没被证过:① `dsh-session` 提交的 `turn/end` 会不会派发到插件监听;② `session/event` 的 scope 筛选会不会把它挡掉(G44 v1 正是死在这儿);③ `ctx.agents.get(session.id)` 那句「agent id 就是 session id」的键值假设。 + +查声明面(R20:先官方一手资料;全部为已安装的 rc.2 包)时另出三条: + +- `Agent.followup` **确实在声明面**——`@deepseek-ai/dsh-agent/lib/types/runtime-types.d.ts:139-212` 的模块扩展里(`followup/steer/inject/send/cancel/whenIdle/runMaintenance`)。首轮 grep 报「声明面没有 followup」是**取证方法错**:pnpm 的 `node_modules` 全是指向 `.pnpm/` 的符号链接而 `grep -r` 默认不跟随。 +- G52 原句「agent handle 需 `ctx.agents.setFactory`」**是错的**:`setFactory` 只服务 `create/resume`(注释写明 factory 由 `dsh-agent-loop` 提供),自带句柄走 `AgentRegistry.register(agent)` 即可,且「Await the registration before using the agent」。 +- 真 `UserMessage` 的 `MessageBase` 要求 `id: MessageId`(branded)与 `source: MessageSource`;`MessageSourceMap` 官方原文「Merge-extensible sum type — **each producer declares its own `kind` in its own module**; there is no shared catch-all `plugin` kind」。我们的 `DshUserMessage` 手写镜像**没有 `id`**,`kind:'autogamer'` 也从没按官方形态声明——live 里宿主容忍了它(#15 的追问确实落进日志),但「碰巧能用」不是契约。 + +#### 整改方案与实施 + +- **新增 `tests/conformance/followup.spec.ts`(5 条)**:真实服务图(六个 `@deepseek-ai/*` 包,含 `dsh-session`)→ **两个 realm**(agent realm 进入会话、preset realm 挂对照监听)→ 真 `ToolRuntime.execute` 套在 `ctx.agents.withInitiator(...)` 里标记 acted → 真 `session.append("turn/end", { turn: 1, reason: { kind: "completed" } })`。断言四件:追问恰一次;**同步阶段绝不追问**(R23 第一次在真 emit 路径上被证);消息满足真 `UserMessage`(含 `id`);同 realm 的非 global 对照监听收不到而 global 收到——`{global:true}` 从此是「观察到的机制」而不是「被锁的选项」。 +- **`src/dsh-types.ts` 去镜像**:`DshAgent = Pick`、`DshUserMessage = UserMessage`、`DshSessionRef.id: SessionId`;`kind:'autogamer'` 用 `declare module "@deepseek-ai/dsh-llm"` 扩展 `MessageSourceMap`。全部 `import type`,**运行时零新增依赖**:官方 `createUserMessage` / `MessageId()` 是运行时导入,会把 devDep 变成宿主依赖,所以本地铸 id 后按 R21 在注释里标「单测证明 / live 证明」分级。 +- **`src/index.ts`**:追问消息补 `id: autogamer-nudge--`(进程内单调,跨 agent 唯一)。 +- **`AGENTS.md`**:记本机 pnpm 11 的两条必需旗标。本轮撞到过一次代价:不带 `--config.verify-deps-before-run=false` 时每次 `pnpm run` 前会隐式 install 并**重写 `pnpm-lock.yaml`**(实测 +507 行、`autoInstallPeers` 由 false 翻成 true),已回滚、未提交。 +- **偏差与连带新差距(如实记)**:方案原设想「把插件挂进 preset realm」以直接证 `{global:true}`,实测该形态下 `ctx.tools.schemas()` 在 realm 与 root **都返回空**(挂载不报错但注册不落)⇒ 开 **G57**(测试台装配问题,不影响已 live 验证的 bundle 挂载形态),`{global:true}` 的注册选项锁因此仍留在 `apply.spec.ts`。另开 **G56**:`scripts/mock-action-server.mjs` 与 `ActionClient` 握手超时(mock 自己打出 `ready`,客户端首个请求却 `SdkError: Request timed out`,随后熔断进冷却)⇒ acted 的标记改用「立即退出的桩命令」;探针同时证了 `markActed` 发生在 `execute` 入口、与设备结果无关。 + +#### 验收标准 + +- `cd plugins/autogamer-device && npx tsc --noEmit -p tsconfig.json` ⇒ 0;`npx vitest run` ⇒ **83 passed / 1 skipped**(G52 净增 5) +- 三条变异校验各自会红(均已实做、验完还原):`setTimeout(…, 0)` 改同步 ⇒ 新 spec 的「同步阶段绝不追问」红;删 `id:` ⇒ `nudge carries no message id` 红;摘 `{ global: true }` ⇒ `apply.spec.ts` 选项锁红 +- 对照断言在场:同 realm 的 plain 监听长度为 0,global 监听含 `g52-session/turn/end` +- 门禁:`python -X utf8 scripts/check_doc_registry.py` 与 `check_doc_tables.py` exit 0;G52 在 `registry.md` 已关闭表与本文件「已关闭差距史」各一行;`spike/probe-g52*.mjs` 三个探针已删除,`git status` 无遗留 + +### #33 G34/G38:输入语义校验补齐,内部错误不再记成任务失败 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(纯离线:无 VLM、无设备;G33 的 guard 迁移另行) +- **优先级**: 中(校验缺口是模型误用时的体验问题;归类污染的是熔断语义正确性) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「G34/G38 修复轮」住在 `TODO.md`(spec 提交早于实现提交,按 R22),结案即删 + +#### 问题描述 + +G34:S5 的代价条款写明裸 schema 注册的工具「自己负责输入校验」,但 `buildCall` 只判了 +`target.coordinate` 元素是否为 number——`text` 未判空、`duration_ms` 未判正、`key` 未判枚举成员、 +`swipe` 起止只判数组不断言对、残缺 coordinate 会静默回退到层级搜索(调用方 typo 做出意外动作)。 +G38:`dispatch` 的 `buildCall` catch 按 `environment:` 前缀二分,无前缀一律 task——插件自身的缺陷 +(如无前缀抛错)被记成任务级失败、进熔断语义,实测模型连试三次(TODO §四 G38)。 + +#### 整改方案与实施 + +1. `plugins/autogamer-device/src/tools/runDeviceAction.ts`:加 `isNormalizedPair`/`assertNormalizedPair`/ + `assertNonEmptyString`/`assertPositiveDuration`;`resolvePixelTarget` 入口对在场的 coordinate 存在即判; + `input_text`/`press_key`/`swipe`/`launch-stop-app`/`open_link`/`wait` 各补对应断言(失败一律 `task:`, + 在任何设备往返之前抛出)。`DispatchResult` 加 `"internal"`:`buildCall` catch 里无前缀异常走 internal; + `execute` 把 `dispatch` 逃逸的意外抛出转 internal(`signal.aborted` 时仍报取消),internal 不记 breaker、 + 抛 `internal:` 并带“插件缺陷、勿原样重试”信号。传输层 catch 保持 environment(S4 fail-safe,不确定继续熔断)。 + `classify.ts` 不动(它是设备文本分类)。 +2. `plugins/autogamer-device/tests/router-errors.spec.ts`(新建,11 条):直调 `defineRunDeviceAction` 配真 + `DeviceGate`/`DeviceBreaker` + 记账 stub client——不走 `apply()` 的 `defineTool` DSL(DSL 自带形状校验会抢先)。 + G34 每条断言 `rejects /^task:/` 且零设备调用;G38 断言 `rejects /^internal:/`、文案含 do-not-retry、 + `breaker.stateOf` 仍 closed、随后 `wait` 仍可执行。 + +#### 验收标准 + +- [x] `pnpm run build` 0 error;`pnpm test` ⇒ **94 passed / 1 skipped**(此前 83/1,净增 11) +- [x] 变异校验(均已实做、验完还原):删掉 `text` 校验 ⇒ 空 text 单测红(且暴露出无校验时会走到层级分支报另一个 task 错); + 把 internal 改回 task ⇒ 两条 G38 单测红 +- [x] `contracts.spec.ts` 不受影响(无 schema 改动);文档门禁双 exit 0 + +### #34 G57:裸 scope 注册本就生效,“双空”是无 scope 读法 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(测试台问题,零产品代码改动;纯离线) +- **优先级**: 低(Tier 1 现有 root 装配本就保真;结论是 D16 realm 与 P3 的前置知识) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「G57 判别轮」住在 `TODO.md`(spec 提交早于实现提交,按 R22),结案即删 + +#### 问题描述 + +G52 探针(已删)留下一句未判定的观察:`presetScope.ctx.plugin(...)` 挂载不报错,但 scope 与 root 的 +`schemas()` 双空,而裸 `new Context()` 挂载双工具齐在。按字面会导向「`createScope` 的 realm 里注册不落」 +这个机制结论——但探针的读法从未经审查:`scope.ctx.tools` 直接 GET 会抛 +`cannot get property "tools" without inject`(读法无效),经 `inject` 读仍无参(见下)。 + +#### 整改方案与实施 + +1. 单变量判别探针六步(`spike/probe-g57*.mjs`,gitignored 现场,用完即删): + 复现双空(有效读法下)→ counter 插件 apply 跑 1 次(`plugin()` 机能好)→ ours apply 跑 1 次 + (激活执行了)→ 同 scope trivial 注册亦双空(非 ours 特有)→ 圈内传 `scopeOf(ctx)` 即见双工具。 +2. 机制点名(已安装产物,R1 允许):`dsh-tools/lib/index.js:2886` 的 `register()` 经 + `layers.effect(this.ctx, …)` 把条目打上调用方 scope 标签;而 `schemas(scope)`/`get(name, scope)` + 无参即全局视图(`:3023-3025`、`:2995-2997`)。`dsh-scope/README.zh.md` §父链:注册视图沿链向下继承。 + ⇒ 注册一直生效;root 空是 D16 隔离语义(正确);旧 scope 空是读法——无参调用读的是全局视图。 +3. 测试台定论:Tier 1 的 root 挂载仍是全局工具面的保真装配,无需 realm 挂载;realm 挂载须 scope-keyed 读。 + 新增 `tests/conformance/scopeVisibility.spec.ts` 锁死:scope-keyed 见双工具、全局视图为空。 + +#### 验收标准 + +- [x] `pnpm test` ⇒ **95 passed / 1 skipped**(净增 1);`pnpm run build` 0 error +- [x] 变异校验:读法去 `key` 即红——恰好复刻旧探针的误读(已还原) +- [x] 探针六个文件已删,`git status` 无遗留;文档门禁双 exit 0 + +### #35 G56:mock 应答缺 `jsonrpc` 帧字段,真 SDK 读侧拒收致握手超时 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(纯离线;`ActionClient` 未动) +- **优先级**: 高(S10 ③ 与熔断矩阵的无设备后端被它堵着,Tier 1 靠桩命令绕行) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「G56 修复轮」住在 `TODO.md`(spec 提交早于实现提交,按 R22),结案即删 + +#### 问题描述 + +`scripts/mock-action-server.mjs` 手拼 `initialize`/`tools/list`/`tools/call` 应答且 stderr 打出 `ready`, +但真 SDK client 首个请求即 `SdkError: Request timed out`,随后熔断进冷却(TODO §零 G56)。 +根因在 SDK 读侧:`@modelcontextprotocol/client@2.0.0/dist/src-D_zzAWoS.mjs:6655-6676` 的 +`ReadBuffer.readMessage()` 对每行跑 `JSONRPCMessageSchema.parse`,catch 里**只有 `SyntaxError` 才跳过**; +消息 schema 要求 `jsonrpc: z.literal("2.0")`(`:3179`),而 mock 的三个应答都没有该字段——zod 拒收、 +`throw` 出读循环,挂起的 `initialize` 永不 resolve。py 真服务走官方 SDK 自带该字段,故只有 mock 超时。 + +#### 整改方案与实施 + +1. `scripts/mock-action-server.mjs`:`handle()` 四个返回分支(`initialize`/`tools/list`/`tools/call` 同步分支/ + `ping`,含 `MOCK_DELAY_MS` 异步回写与默认 `method not found`)统一铺 `jsonrpc: "2.0"`——唯一改动。 +2. `tests/mockServer.spec.ts`(新建,3 条):真 `ActionClient` + 真 stdio transport 直连 mock——`listTools` + 含 13 工具、`take_screenshot` 解出 1080x2400 真 PNG 头、`tap` 回 `Success`。`apply.spec.ts` 的 + `vi.mock(actionClient)` 按文件隔离,不影响本文件。 + +#### 验收标准 + +- [x] `pnpm test` ⇒ **98 passed / 1 skipped**(净增 3);`pnpm run build` 0 error +- [x] 变异校验:帧字段删掉 ⇒ 三条全挂(mock 只打 `ready`、无请求完成,即 G56 原样复现;已还原) +- [x] 文档门禁双 exit 0 + +### #36 G33:熔断拒绝对迁 guard,只读动作声明可并发 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(纯离线;超时与审批不在本轮:前者 R15 明许自实现,后者无 ask 路径) +- **优先级**: 中(R13 义务项;顺带挖出两个真 bug) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「G33 迁移轮」住在 `TODO.md`(spec 提交早于实现提交,按 R22),结案即删 + +#### 问题描述 + +`run_device_action` 正文内同时做三件事:闸门串行(执行机制,应留)、熔断记录(状态,应留,S4)与 +冷却期拒绝 `throw environment: …cooling down`(部署策略,按 R13 该出工具)。另 `defineTool` 的并发 +分类器位一直空着(S3 只读可并发未声明,R14 欠真名)。 + +#### 整改方案与实施 + +1. `src/index.ts`:注册 `ctx.tools.guard`——只看 `run_device_action`(别家与 `report_task_status` 永不拦), + key 取 `device_serial` 或 default,`canAdmit` 假则拒(文案沿用);`src/tools/runDeviceAction.ts` 删拒绝分支 + (记录保留),加 `isConcurrencySafe: take_screenshot/get_ui_hierarchy → true`;真名出自已安装产物 + (`dsh-tools` types:`ToolGuard`、`DefineToolOptions.isConcurrencySafe`),S3 落字。 + `DshContext.tools` 补 `guard` 镜像(`import type ToolGuard`);`apply.spec.ts` fake 补 guard stub。 +2. 测试:`tests/conformance/breakerGuard.spec.ts`(真 registry:坏命令开闸→同 key 拒 `cooling down`、 + 异 key 放行报传输失败、report 直通、分类器四断言)+ fake 侧注册/放行两锁。 +3. 偏差(实施中按 R25 单变量查清的两个真 bug,同轮修掉、各有变异签名): + - `ActionClient.start()` 失败不清 `starting` 缓存→此后每次重抛同一 stale `Connection closed`(注释称会重建, + 实则失败路径永不):catch 里清缓存 + 关 transport。锁 `tests/actionClient.spec.ts`(计数文件 `xx`;去修法即 `x`)。 + - 组装期三处直调传输(层级取值、截图分支)绕过 environment 分类→误走 internal:加 `callTransport` 包 + `environment:` 前缀。锁即异 key 用例(修前报 `internal: Connection closed`)。 + - 另:`defineTool` 对分类器先验参(非法入参直接 false)——分类测试须传合法参,另加非法参断言锁 fail-closed。 + +#### 验收标准 + +- [x] `pnpm test` ⇒ **105 passed / 1 skipped**(净增 7);`pnpm run build` 0 error +- [x] 变异校验:摘 guard 注册 ⇒ 拒测红;分类器改全 true ⇒ 分类测红;`starting` 不清 ⇒ 计数 `x`; + `contracts.spec.ts` 的直接调用提取器同步跟进 `callTransport` 拼写(#28 守卫按设计变红一次) +- [x] `grep -n "cooling down" src/tools/runDeviceAction.ts` 无命中;文档门禁双 exit 0 + +### #37 G53:前端 lockfile 221 个镜像 resolved 改回官方域 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(纯离线;版本/integrity 零漂移) +- **优先级**: 中(既有状态,CI 首次真跑才暴露;`check_dependency_sources` 门禁红) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「G53 修复轮」住在 `TODO.md`(spec 提交早于实现提交,按 R22),结案即删 + +#### 问题描述 + +`apps/showcase_ui_v2/package-lock.json` 的 221 个 `resolved` 全是 `https://registry.npmmirror.com/…`, +`scripts/check_dependency_sources.py:55-58` 要求 `registry.npmjs.org`——CI `python-quality` 第一步即红。 +写入来源是用户级 npm registry(无项目级 `.npmrc`),lockfile 自 `f9a7081` 未动。 + +#### 整改方案与实施 + +不断言整份重取(`npm install --package-lock-only` 会抖版本与元数据):文本级只换 host, +`npmjs` 同路径等价(镜像原样代理)且 `integrity` 221/221 全在、取包内容不变。解析级断言钉死: +包集合、字段集合一致,221 处变更全为 host swap,其余逐字节相同。 + +#### 验收标准 + +- [x] `python scripts/check_dependency_sources.py` ⇒ `Dependency sources use public PyPI and npm registries.` exit 0 +- [x] `git diff --stat` 仅 1 文件(221+/221-);`npmmirror` 全仓该文件零残留 +- [x] 前端 `npm test`(`node_modules` 已在)⇒ **305 passed**,零影响 +- [x] 复发条件记案:带镜像 registry 跑 `npm install` 会再次写回(本次不加项目级 `.npmrc`,不做网络策略决策) + +### #38 G55:`check_dependency_sources.py` 单行格式修复关账 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(实现提交 `e1de835` 早在本轮,本条只关账;与 G53 各有证据,不合并) +- **优先级**: 低(一行) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +`scripts/check_dependency_sources.py:55` 过长,`ruff format --check scripts/` 报 +`1 file would be reformatted`,而 CI `python-quality` 跑的是仓级 `ruff format --check .`。 + +#### 整改方案与实施 + +`uv run ruff format scripts/check_dependency_sources.py` 一条命令(`e1de835`)。 + +#### 验收标准 + +- [x] `uv run ruff format --check scripts/` 全绿;`uv run ruff check scripts/` 全绿 +- [x] 如实注明:仓级 `ruff format --check .` 另有 9 处既有红(`apps/admin_console` 2 / `artemis` 3 / + `setup.py` / `tests` 2 / `third_party` 1),已按偶发规则另立 **G58**,不在本条内 + +### #39 G4 熔断矩阵落定:键同源、四解除、不对称引用既有锁 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(纯离线;G3 auto 池/parking 仍归 P2) +- **优先级**: 高(P1 设备治理闭环;旧盘点 drift 风险 #7 的对应项) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「熔断矩阵轮」住在 `TODO.md`(spec 提交早于实现提交,按 R22),结案即删 + +#### 问题描述 + +`inventory/01-console-queue.md:70-74` 的旧矩阵(解除 4 路径 + fail-safe/fail-open 不对称 + auto 默认键 + +`allowed_fails` 未决)在 R2' 后只有 S4 一句设计稿承载。单变量核实现状:核心迁移(open/half-open/单探测/ +重挂/成功关/key 隔离、默认 1/30s、guard 拒/放、internal 不记)全已有锁;真缺口仅三处——键推导两处分叉 +(空串在 execute 落空键、在 guard 回 default)、`reset()` 与 `allowedFails:2` 计数无锁、S4 解除句未修订。 + +#### 整改方案与实施 + +1. 导出 `deviceKeyOf(args, fallback)`(省略/非字符串/空串→fallback),`execute` 与 guard 同源; + `tests/deviceKey.spec.ts`(3 断言)+ Tier1 空串调用点锁(空串开闸→省略 serial 见同 hold;回退 `??` 即红, + 签名恰为分叉复现)。 +2. `breaker.spec.ts` 加 `reset()` 清开闸(重启即 fresh = 旧 stop 效果)与 `allowedFails:2` 两次才开。 +3. S4 解除句修订:①探测成功关②冷却到期半开③探测环境失败重挂新窗④新提交/显式 resume 不旁路健康态 + (与旧世界及 S4 原句的显式偏差,理由见 TODO)⑤cancel 不碰;marker 差集记为设备工具域内有意差集,不改代码。 + +#### 验收标准 + +- [x] `pnpm test` ⇒ **111 passed / 1 skipped**(净增 6);`pnpm run build` 0 error +- [x] 变异校验:调用点回 `??` ⇒ Tier1 空串锁红;`reset` 删 ⇒ 新增红 +- [x] 文档门禁双 exit 0 + +### #40 P1 智能层离线片:validator 梯 + prompt 级 checker + skills + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭离线片( checker 载体用户已决:prompt 级;live 服从验收另行;无 G 号,属 checklist 分片) +- **优先级**: 高(P1 质量主体) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「P1 智能层方案」住在 `TODO.md`(spec 提交早于实现提交,按 R22),结案即删 + +#### 问题描述 + +S11 的单层路由只有骨架:目标找不到即失败、无执行后升级、无连败 incident、无严格度开关; +checker 审计无纪律文本、无可回放事实;skill 无梯级与脚手架。上游保真数: +预检 3 次/`XML_BYPASSED` 转 pixel/burst 与 `launch_app` 不重试/连败 3 开 incident/ +`disappeared/shifted/occupied` 分类;checker 三不变量。 + +#### 整改方案与实施 + +1. P1-a(`src/tools/runDeviceAction.ts` + `src/index.ts`):预执行 index/text 解析失败重取层级共 3 次, + 不可解析层级速败、无 target 零取数速败;`effect=unchanged`(仅变更类)→ 新层级坐标重解一次 + (index/text 专属;坐标/无目标动作沿上游不重试)→ 仍 unchanged → `pixel_guidance` + category + (`disappeared`/其余 `general`;`shifted/occupied` 需像素信号,留位);同设备连败计数到 3 挂 + `incident` + 审计文本(`checkpoints`/`strict` 加里程碑措辞),成功清零,`unknown`/cancel/环境/内部不碰计数; + `strictness` Config(`off/final/checkpoints/strict`,默认 `final` 对齐上游;`off` 单次无梯无 incident; + `strict` 变更成功带 `self_check`)。`tests/validatorLadder.spec.ts` 17 条。 +2. P1-b:两工具 `output.presentationMeta` 落 verdict/终局(R16,unknown 形状投 `{}` 不抛); + 两处 preset `persona` 加规则 6 审计纪律(逐字一致,`deliveryShape` 锁);`JsonValue` 用结构相同的 + 本地别名(`dsh-util-values` 非直接依赖,pnpm 解析不到;封闭通用形,不腐)。 +3. P1-c:`SKILL.md` 加严格度四档 + plan 里程碑/`verify`/incident 脚手架;`deliveryShape` 锁文本。 + 不建 goal 工具(S11 可选,另立项)。 + +#### 验收标准 + +- [x] `pnpm test` ⇒ **132 passed / 1 skipped**(净增 21);`pnpm run build` 0 error +- [x] 变异校验:预 3→1(2 红)、阈值 999(3 红)、去 meta(1 红)、`JsonValue` 命名导入(构建红两次, + 一次是具名实为他物、一次是默认导出他物——本地别名终解) +- [x] R6 未跑(本环境无 DSH 宿主,`D:` 盘不存在):YAML 改动以 `deliveryShape` 字节一致 + 单测为据, + live 复验待补;`contracts.spec.ts` 仅增字段/默认值断言,无 schema 形变;文档门禁双 exit 0 + +### #41 测试同步审查跟进:三处实现 bug 与覆盖锁 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(AGENTS 测试同步习惯的跟进提交;无 G 号) +- **优先级**: 高(含一处 load-bearing:incident 永不到阈值) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +按 AGENTS 习惯派 review 子代理扫本轮触及的插件单测(`ses_ed0b26b6`,只读审查、未改文件), +全量当时 132/1 绿。报告三处有证据的实现 bug + 若干覆盖缺口: + +- **D1(load-bearing)**:`unchangedVerdict` 计了 incident,但 `execute` 成功分支无条件 `noteSuccess` + (`effect !== "unknown"` 即清)——unchanged 连败每次被清零,`incident` 永不到 3,checker persona 的 + incident 回放永不触发。 +- **D2**:`reresolveAsCoordinate` 内的传输死(`callTransport`/`requireScreenSize` 抛 `environment:`) + 发生在 `buildCall` 的 try 之外,直达 `execute` catch 判成 `internal`(双前缀 `internal: environment: …`, + 模型被告知勿重试、熔断不记)。 +- **D3**:`index.ts` 探针截图直调传输,死机时无前缀抛错→ `internal`,默认配置(无 `screenSize`)下首动作 + 报 internal 且熔断不开。 +- 另:`defineTool` 对分类器先验参(非法直返 false)——分类测试须传合法参;legacy `"Failed"` → environment + 属 S4 fail-safe 已有语义,值得钉死。 + +#### 整改方案与实施 + +1. D1:成功分支 `effect` 为 `unchanged` 时跳过 `noteSuccess`(`unknown` 早已跳过)。 +2. D2:`reresolveAsCoordinate` 调用包 try/catch——`environment:` 前缀转环境失败返回,真无前缀才重抛走 internal。 +3. D3:探针截图传输异常返回 `undefined`(探针 miss),由 `requireScreenSize` 按既有 `environment` 文案分类开闸。 +4. 锁:unchanged 累积到 incident(Tier1 同形已有 G33 底)、mid-ladder/截图分支/D3 探针三处传输死 environment、 + `Failed`→environment fail-safe 钉、`swipe` 缺 target/`stop_app` 空包/未知动作/合法 key 映射四组、 + incident 里程碑正反、前缀钉死(`task:`/`environment:`,cooling 无前缀系 guard 原生形态,不断言它)。 + +#### 验收标准 + +- [x] `pnpm test` ⇒ **141 passed / 1 skipped**(净增 9);`pnpm run build` 0 error +- [x] 变异校验:D1 回退(成功分支无条件清零)⇒ 两红(恰为 D1 签名);D2 去 catch ⇒ `internal: environment:` 红; + D3 去 catch ⇒ `internal: Connection closed` 红 +- [x] 未做项(如实记):review 提的 harness 四合一收敛、假定时器、py 侧 FastMCP 面行为测试、 + 超时-dispose-respawn 锁—— Consolidation 计划见 review 会话记录,TODO 测试债行已覆盖方向,不另开工; + `classify.spec.ts` 成功断言 vacuous 状况保留(改函数超出本轮) + +### #42 G43:spike 复位与判据固化成脚本(live deferred) + +- **日期**: 2026-10-12 +- **状态**: ✅ 脚本交付并锁定;live 跑通 deferred(本机无 adb/模拟器,P1-live 接) +- **优先级**: 高(判据错会颠倒黑白;P1 多任务验收的前置) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「G43 固化轮」住在 `TODO.md`(spec 提交早于实现提交,按 R22),结案即删 + +#### 问题描述 + +spike 复位用 `adb shell settings put secure ui_night_mode`,在 MuMu ROM 上根本不生效; +`settings get` 读 2 而屏已深——判据错把成功记成失败。且复位/判据散在对话与 TODO 里,无可复跑载体。 + +#### 整改方案与实施 + +1. `scripts/spike_reset.py`(stdlib only):`reset-home`(am start HOME + `LawnchairLauncher` + 断言)、`reset-light`(`cmd uimode night no`,`settings put` 刻意缺席)、`shot`(`exec-out screencap`)、 + `oracle`(只打 `settings get` 旁证 + 截图核对清单,不判像素);`--serial`(默认 `$ADB_DEVICE_SERIAL`)、 + `--dry-run`(CI/无设备可跑);T1/T2 目标文本逐字进 `--help`。 +2. `tests/unit/scripts/test_spike_reset.py`(3 条):dry-run 全命令在场、`settings put` 永不出现、 + 缺 serial 非 dry-run 报 2。 + +#### 验收标准 + +- [x] `--help`/`--dry-run` exit 0;`ruff check` + `format --check` 绿;pytest 3 passed +- [x] live 未跑(如实记):本机无 adb;P1-live 用此脚本复位 +- [x] 文档门禁双 exit 0 + +### #43 G58:9 文件纯格式 churn 关账 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(另 3 处 E731 同轮顺手修,见下) +- **优先级**: 低(CI 门禁) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 无独立 spec(机械改写,查现状即方案:221 域现状见 #37 轮;本轮 `format --check` 点名 9 文件) + +#### 问题描述 + +锁内 ruff 下 9 文件报 would-be-reformatted(`apps/admin_console` 2 / `artemis` 3 / `setup.py` / +`tests` 2 / `third_party` 1),CI `python-quality` 的仓级格式检查必红。另 `ruff check .` 在 +`test_task_queue_service.py` 报 3 处 E731(HEAD 版字节级核对,`capture = lambda` 既有)。 + +#### 整改方案与实施 + +1. `uv run ruff format .`(9 文件,零语义);3 处 lambda 改 `def`(同文件已动,零语义;否则 lint job 仍红)。 +2. 受影响 py 面回归:`tests/unit/admin_console/test_task_queue_service.py` + `tests/unit/mcp`。 + +#### 验收标准 + +- [x] `ruff format --check .` 663 绿;`ruff check .` 全绿 +- [x] pytest ⇒ **279 passed / 1 skipped**(上两文件集) +- [x] 文档门禁双 exit 0 + +### #44 细碎六项联关:G8/G11/G14/G24 验锁直关,G10 信号透传,G13 在飞去重 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(G26 PID 语义另立项;spec 提交 `36f5205` 早于实现,关账时段落已随结案删除) +- **优先级**: 中(收尾;含一次真实现 bug 修法过程见下) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「细碎收尾轮」住在 `TODO.md`(spec 提交早于实现提交,按 R22),结案即删 + +#### 问题描述 + +- G8/G11/G14/G24 是收缩/保留语义,实现早已存在:G8 = 汇报双轴 + `concludeTurn`(G41 锁), + exit-code 映射随 worker 消失(无残留);G11 = 工具层错误分类(`classify.ts` + G38/G33/callTransport 锁); + G14 = 汇报 enum + 值锁;G24 = 归一域/端侧索引(`coord`/`hierarchy` + G42 七锁 + 真机/mock 双证)。 +- G10:`exec.signal` 只事后判,取消要等应答或自超时;SDK 2.0.0 `callTool(params, options?)` 承 signal。 +- G13:同 goal 短防抖在工具内无载体;逐动作 1s 窗会误伤合法连击,故取 in-flight 去重。 + +#### 整改方案与实施 + +1. G10:`signal` 穿透 `dispatch → buildCall/resolvePixelTarget/callTransport → ActionClient.callTool → SDK` + (`index.ts` 无 exec 的探针/帧路径不动);`mockServer` + router 两层取消锁。 +2. G13:`execute` 内 in-flight Set(变更类专属,同 agent 同签名在飞即拒,`finally` 清;只读与串行重复永不误伤; + `agentIdOfCall` 经 RouterOptions 进,缺省 anonymous)。`tests/dedup.spec.ts` 五锁。 +3. G8/G11/G14/G24:验锁直关,不动码。 + +#### 验收标准 + +- [x] `pnpm test` ⇒ **148 passed / 1 skipped**(净增 7);`pnpm run build` 0 error +- [x] 变异校验:信号不透传 ⇒ 取消用例 5s 红;去重摘 ⇒ 并发用例双成(超时红) +- [x] 修法过程如实记:实现中漏写 `finally` 清理致一切重复(合法串行亦)被拒—— admit 系单测全红定位 + (并发拒对、串行/异 agent/异 target 全错),补 `delete` 后转绿;另 admit 系两测初版误把闸门排队读成 + 拒绝,已改按“排队非拒绝”断言 +- [x] 文档门禁双 exit 0 + +### #45 派生事实第三处漂移:抄件是陈的,而量它的尺子绑在 checkout 的换行符上 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(抄件按实测更正 + 量法与 EOL 解耦并两处锁死);**G60/G61 新立未办** +- **优先级**: 中(不改产品行为,但它是 `#30`/`#31` 那条规则自己的失效) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「偏差关账轮」住在 `TODO.md`(spec 提交 `4f7868a` 早于实现提交,按 R22),结案即删 + +#### 问题描述 + +拉取 29 个提交(`61f23a1` → `419b9df`)后逐条核对状态页,量出三处**已发布文档里的不符**: + +1. `docs/TODO.md` §零「测试面」行写插件 vitest `83 passed / 1 skipped`;同一份 `419b9df` 上实跑 + `pnpm --config.verify-deps-before-run=false test` 得 **148 passed / 1 skipped(18 个 test file)**。 + `#40` 净增 21、`#44` 净增 7,两轮都写了自己的验收数却没有回抄 §零——即 `#31` 立规则要防的那种抄件。 + py 侧同一行写 `tests/unit/mcp` `224 passed`,实跑为 **224 passed + 1 skipped**(漏了 skipped 那格)。 +2. `docs/migration/README.md` §三 体量表 **9 行**与实测不符,`scripts/check_doc_tables.py` 在 HEAD + `419b9df` **就是红的**(FAIL: size table)。而 `#44` 的验收框写着「文档门禁双 exit 0」—— + **那条框在它自己的提交上即假**。#44 正文按「编号一经发布不得改写」保持原样,错记在此。 +3. ②为什么刷一次还会漂:`measured()` 取的是**工作树字节数**,而工作树换行符取决于最后写它的进程—— + 本机 `core.autocrlf=true`、仓库无 `.gitattributes`,实测 `docs/migration/inventory/03-*.md` + 工作树 46472 字节 / blob 46162 字节(差 310 个 CR),其余文件工作树与 blob 相同。 + 表里的数字出自 `2ab17cb`(体量改为脚本生成那次)的 **CRLF 工作树**,之后那些文件以 LF 重写, + 1406 行 ≈ 1.4 KB 就差了出。**同一条 commit 在两台机器量出两个数**——规范化后复测, + `inventory/01…05` 那一格从 215.9 KB 变成 215.6 KB,正是那 310 个 CR。 +4. 附带查出**为什么没人发现**:两枚文档门禁(`check_doc_registry.py` / `check_doc_tables.py`) + 在两处文档里被称做「门禁」,但 `.github/workflows/*` 里**没有任何 step 跑它们**(搜索命中 0)。 + 红的体量表可以存活两日,靠的就是人工自证。登记为 **G60**。 +5. 同一根因在测试面也咬了一口:`uv run ruff format --check` 报 `tests/unit/test_config_package.py` + 「would be reformatted」,而该文件 blob 是**纯 LF**(37083 字节、CR=0),工作树却是 37932 字节、 + 849 个 CR 与 57 行 LF **混在同一文件里**——CI 检出纯 LF 所以不报,这条红是本机独有的, + 不是代码缺陷。登记为 **G61**(根因层:仓库无 `.gitattributes`,字节口径随 checkout 变)。 + 本轮**没有**顺手 `ruff format` 那个文件:那会把 849 行换行符一起写进提交,属未登记的越界改动。 + +#### 整改方案与实施 + +- **`scripts/check_doc_tables.py`**:`measured()` 先 `raw.replace(b"\r\n", b"\n")` 再数行与字节, + 把派生量与 checkout 换行符解耦——只 `--refresh-sizes` 是把漂移推到下一台机器上,不换量法修不好。 + 模块 docstring 补上这条口径与出处;`--selftest` 增 **(i) 不变量例**(同一内容写成 CRLF 与 LF 两份临时文件, + 必须量出同一行数与 KB,且行数仍为 4——防「剥 CR」把行数也剥错)。 +- **两处可执行判据**:`--selftest` 的 (i) 是一处;另一处是新增 `tests/unit/scripts/test_doc_table_sizes.py` + 三条(EOL 无关 / 多文件行求和 / 改旧值必报),走 CI 已有的 `uv run pytest tests/unit`——这是本口径目前唯一进得了 CI 的路径。 +- **`docs/migration/README.md`**:§三 表下「体量口径」那句原文是「**KB** 按文件**真实**字节数除以 1024」, + 「真实」正是把工作树绑进度量的那个词,改为「按 **LF 规范化后**的字节数」;9 行由 `--refresh-sizes` 生成,不手打 + (上一回合 `e7f3589` 就是手打了 `TODO.md` 一格,其余没动)。 +- **`docs/TODO.md`**:§零 测试面行按实测改写并保留复测命令;§零「下一步」补第 7 条(本轮结案 + G60 指针); + §四 差距清单加 **G60** 行,`docs/migration/registry.md` 现行在册同步一行(取号按 registry)。 + +#### 验收标准 + +- [x] `uv run python -X utf8 scripts/check_doc_tables.py` ⇒ 全 OK(含 `(size table)` 那一行) +- [x] `uv run python -X utf8 scripts/check_doc_tables.py --selftest` ⇒ 注入缺陷全过 + `OK [i]` 不变量例 +- [x] **变异校验**:把 `measured()` 改回 `len(raw)` ⇒ 新 pytest 两条变红 + (`test_size_does_not_track_line_endings`、`test_multi_file_row_is_a_sum_across_line_endings`), + `--selftest` 的 (i) 报 `((200, '1.0') vs (200, '1.2'))`——做出一次才恢复 +- [x] **变异校验顺手照出两处「虚锁」,都已修**:① 第一版两处判据都用 4 行样本,KB 只保留一位小数 + (一步 = 102.4 字节),于是 CRLF 与 LF **量出同一个数**,摘掉规范化也照样绿——样本加宽到 200/400 行才咬得住; + ② (i) 原先排在 (a)–(h) 之后,而未规范化的测量会让前面那些读真实文档的检查先红先退出, + **根本走不到 (i)**——已提到 `selftest()` 开头。两处都是「测试通过但不证明任何东西」的形态 +- [x] `uv run python -X utf8 scripts/check_doc_registry.py` ⇒ OK(现行 114 号,registry 行数与来源一致) +- [x] `uv run python -X utf8 -m pytest tests/unit/scripts -q` ⇒ 绿;`uv run ruff check` ⇒ 干净 +- [x] `git grep -n "83 passed" -- docs/TODO.md AGENTS.md plugins docs/migration/README.md` ⇒ 空 + (陈旧值只可能留在已结案条目的正文里:`#32` 那格记的是它当时的实测数,编号不得改写,属正确保留); + §零 的两个数与实跑一致 +- [ ] **G60 未办**:两枚门禁接进 CI 需改 `.github/workflows/*`,且要先定「deterministic Python tests」那条 + `if: github.event_name == 'workflow_dispatch'` 的门槛(受 **G54** 牵制)——**待用户决策** + +### #46 G60:两枚文档门禁接进 CI(此前它们只存在于人工自证里) + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(step 已落 `python-quality` job,无 `if`;真 run 尚未观测到,受 G54 限制,如实写明) +- **优先级**: 中(不改产品行为,但它决定 `#31`/`#45` 那两条规则是否真的有人守) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「G60 + G61 实施轮」住在 `TODO.md`(spec 提交 `9479b38` 早于实现提交,按 R22),结案即删 + +#### 问题描述 + +`#45` 量出:`scripts/check_doc_registry.py` 与 `scripts/check_doc_tables.py` 在 `AGENTS.md` 与两份迁移文档里 +被称做「门禁」,但 `.github/workflows/ci.yml` 里**没有任何 step 跑它们**(搜索命中 0)。后果不是理论上的: +体量表按 blob 内容(与 checkout 换行符无关)复测,自它**被脚本生成的那次提交** `2ab17cb` 起就从未在任何 LF 检出上匹配过 +(`inventory/01…05` 写 216.9 KB 而内容 215.6 KB,`upstream-plugin-forms.md` 写 11.8 而内容 11.7), +而 `#32`–`#44` 里 6 条验收勾着「文档门禁双 exit 0」——**没有机器执行、又没有复跑命令的自证,可以连续假两日**。 + +#### 整改方案与实施 + +- `.github/workflows/ci.yml`:在 `python-quality` job 的 `Lint Python` 之后、`Enforce quality ratchets` 之前 + 新增一步 `Check documentation gates`,依次跑 + `check_doc_registry.py` / `check_doc_tables.py` / `check_doc_tables.py --selftest`(三条都带 `-X utf8`,与 `AGENTS.md` 口径一致)。 +- **不加 `if`**(用户已决「沿用 dispatch」= 不动现有那条 `if: github.event_name == 'workflow_dispatch'`): + 门禁本身对所有能产出 run 的事件都执行;**G54 未解时该 fork 只有手动派发会产出 run**, + 所以本轮的真实收益是「派发时有机器复核 + 验收可引用 run id」,不是 push 拦截——这句必须写在这里, + 否则就是把 `#45` 批评过的「写成由 CI 保证」重犯一遍(**R27 第 4 条**)。 +- 顺序不可颠倒:门禁进 CI 的前提是它在 LF 检出下也绿,而这是 `#45` 把体量改成 LF 规范化测量才成立的 + (改之前它是绿的 Windows、红的 Linux——把它接进 CI 只会天天假红)。 + +#### 验收标准 + +- [x] YAML 可解析,且新 step 确实落在 `python-quality`、`if` 为空: + `uv run python -c "import yaml,io; ..."` ⇒ `job: python-quality | step: Check documentation gates | if: (none)` +- [x] 三条命令本地各自 exit 0(`check_doc_registry.py` 现行 116 号、`check_doc_tables.py` 12 行体量、`--selftest` 8 例全触发) +- [x] **R27 变异校验**:把任一文档改一个字节而不 `--refresh-sizes` ⇒ `check_doc_tables.py` 变红 + (此路由 `--selftest` 的 (g) 例常驻守着,且新增的 `tests/unit/scripts/test_doc_table_sizes.py` 会随 + CI 既有的 `uv run pytest tests/unit` 一起执行) +- [ ] **未验证到真 run**:本轮不推送(推送须用户明示),且 G54 使 `on: push` 不产出 run; + 下一次 `gh workflow run` 派发才是这条 step 的第一手证据,届时须把 run id 记进台账 + +### #47 G61:`.gitattributes` 让行尾成为仓库事实(本机 `ruff format --check` 的假红到此为止) + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(`.gitattributes` 落地;实测 renormalize 对文本 blob **零改动**) +- **优先级**: 中(与 #46 同轮;它是 #45 那个「同一条 commit 两台机器两个数」的根因) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 与 **#46** 共用 `TODO.md` 那条「G60 + G61 实施轮」(spec 提交 `9479b38`),结案即删 + +#### 问题描述 + +`#45` 的根因是量法把工作树字节当事实,而**工作树换行符由机器决定**:本机 `core.autocrlf=true`、 +仓库没有 `.gitattributes`。动手前先量了波及面(不按「renormalize 会炸全仓」的想象决策): + +- `git ls-files --eol`:**757 个 `i/lf w/crlf`、147 个 `w/lf`、2 个 `w/mixed`、28 个 `-text`**; +- 逐 blob 查 CR:**带 CR 的 blob 恰为那 28 个,且全是二进制**(png/jpg/ico/gif/mp4/mp3/apk/keystore) + ⇒ 文本 blob 本来就是纯 LF; +- 混行尾只存在于 2 个本机文件:`docs/migration/README.md`(48 个 CR)与 + `tests/unit/test_config_package.py`(849 个 CR 与 57 行 LF 混在同一文件), + 后者就是 `uv run ruff format --check` 唯一报红的那一个——**CI 上是纯 LF 检出,从来不红**。 + +#### 整改方案与实施 + +- 新增 `.gitattributes`:`* text=auto eol=lf`;`*.bat`/`*.cmd` 显式 `eol=crlf` + (仓库里有 `start.bat` 与 `plugins/autogamer-device/spike/run-headless.cmd`,cmd.exe 对 LF 批处理的标签/`goto` 有已知故障面); + 8 种实际存在的二进制扩展显式 `binary`。 +- `git add --renormalize .` 后**先看暂存区再提交**:`git diff --cached --stat HEAD` 只有 `.gitattributes` 一个新增文件 + ⇒ **实测零个文本 blob 被重写**,历史与既有分支不受影响(这条是本项改动安全性的关键证据)。 +- 那 2 个混行尾的工作树副本按 blob 规范化(剥 CR),`git diff` 显示**内容无变化**(blob 本就是 LF), + `git ls-files --eol` 的 `w/mixed` 计数 2 ⇒ 0。 + +#### 验收标准 + +- [x] `git ls-files --eol | grep -c "w/mixed"` ⇒ **0**(原先 2) +- [x] `uv run ruff format --check` ⇒ **本机转绿**:`666 files already formatted`(原为 1 个假红文件) +- [x] `git diff --cached --stat HEAD` ⇒ 仅 `.gitattributes`(+18 行),**无文本 blob 变化** +- [x] 两枚文档门禁 + `--selftest` 仍绿;`uv run python -X utf8 -m pytest tests/unit/scripts tests/unit/mcp -q` ⇒ **230 passed, 1 skipped** +- [x] `git ls-files --eol` 里 28 个仍为 `-text`,`*.bat`/`*.cmd` 为 `w/crlf` +- [ ] 遗留(不在本轮范围):757 个 `w/crlf` 的本机副本要等**重新检出**才变 LF, + 本轮不强推(`git restore`/`checkout-index -f` 对"内容已等同 blob"的文件是 no-op); + 新克隆与 CI 从此固定为 LF + +### #48 更正 #47 验收里一条勾早了的框:`*.cmd` 并非都是 `w/crlf` + +- **日期**: 2026-10-12 +- **状态**: ✅ 已更正(提交 `03993bf` 落地后立即实测发现) +- **优先级**: 低(一条验收判据的精确性,无产品影响) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +#47 的验收勾了「`git ls-files --eol` 里 `.bat`/`.cmd` 显示 `w/crlf`」,实测只有一个成立: + +``` +i/lf w/crlf attr/text eol=crlf start.bat +i/lf w/lf attr/text eol=crlf plugins/autogamer-device/spike/run-headless.cmd +``` + +`run-headless.cmd` 的**工作树副本是 LF**(早前有工具就地写过它),而属性要求 CRLF—— +按 R27 第 1 条,这条勾当时不该打:它没有对应的一条已跑命令。#47 编号已发布,正文不改写,错记在此。 + +#### 整改方案与实施 + +不新增代码改动,只把判据改成**可复跑且当前为真**的形式:`attr/text eol=crlf` 已对两个文件生效 +(属性侧正确,这是 `.gitattributes` 真正的交付物),工作树副本何时变 CRLF 属"重新检出"那一条遗留项 +(#47 已勾 `[ ]` 写明:757 个 `w/crlf` 与这 1 个反向差异都要等新检出)。 +顺带记下这条观察:spike 的 `.cmd` 在 LF 下由 cmd.exe 执行,标签/`goto` 有已知故障面—— +若 spike 复跑出现"文件能跑但流程不对",先看这里。 + +#### 验收标准 + +- [x] `git ls-files --eol | grep -E "\.(bat|cmd)$"` ⇒ 两行都是 `attr/text eol=crlf`(属性生效,与检出状态无关) +- [x] `git ls-files --eol | grep -c -- "-text"` ⇒ 28(在库二进制未被误当文本) +- [x] `git ls-files --eol | grep -c "w/mixed"` ⇒ 0 +- [x] 复跑命令写进本条,后续任何人可自行核对 + +### #49 判据红了却照样提交:表格行里写了换行,而我的命令链用管道吞掉了退出码 + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(表格修复 + 判据命令形状改成可看退出码,并补进 R27 第 5 条) +- **优先级**: 中(不是产品缺陷,但它让 #46 刚接进 CI 的那类门禁在人工路径上失效) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +改 `docs/TODO.md` 的 G54 行时,我把一段带**换行**的长文本贴进了 markdown 表格单元: +表格行不能跨物理行,于是那一行被劈成 **8 个物理行**,`scripts/check_doc_tables.py` 立刻报两条—— + +``` +PROBLEM: docs/TODO.md: line 119 does not close with a pipe +PROBLEM: docs/TODO.md: block at line 127 row 2 is not a separator row +FAIL: docs/TODO.md +``` + +**门禁是对的,是我的调用方式没牙**:那条链写成 +`uv run python -X utf8 scripts/check_doc_tables.py docs/TODO.md 2>&1 | tail -1 && ... && git commit ...`, +管道末端的退出码属于 `tail`(0),`&&` 于是按"通过"继续走,**塌掉的表被提交进 `a311bb9`**。 +这正是 R27 批评过的形态,只不过这次红的是执行判据的那只手。 + +#### 整改方案与实施 + +- `docs/TODO.md`:把 G54 行折叠回**单一物理行**(脚本按 `| G54 |` 起点到首个以 `|` 结尾的行合并), + 复核该单元格仍是 **3 cells**、`**` 计数 60(偶)、反引号计数 104(偶),无残留换行。 +- 命令形状统一改为 `cmd > /tmp/out 2>&1; echo "exit=$?"` 再读输出;本轮起所有门禁复跑都用这个形状。 +- 把这条教训补进 [守则 R27](migration/plugin-contract-rules.md) 的**判据第 5 条**: + 判据命令不许拿过滤器/分页器的退出码当判据(`set -o pipefail` 或重定向后读 `$?`)。 + +#### 验收标准 + +- [x] `uv run python -X utf8 scripts/check_doc_tables.py; echo $?` ⇒ **exit=0**(不带管道) +- [x] `uv run python -X utf8 scripts/check_doc_registry.py; echo $?` ⇒ **exit=0** +- [x] `uv run python -X utf8 scripts/check_doc_tables.py --selftest; echo $?` ⇒ **exit=0**,8 例注入全触发 +- [x] G54 行单物理行、3 cells;`git status` 干净 +- [x] **变异校验**:同一行内再插一个换行 ⇒ `check_doc_tables.py` 报 `does not close with a pipe` + 且 `echo $?` 为 1(证明修复后的判据仍有牙,且这次不会被管道吃掉) + +### #50 离线 action 面悄悄少了 `compare_ui_frames`:漂移锁改吃生成的 fixture + +- **日期**: 2026-10-12 +- **状态**: ✅ 已关闭(mock 补第 14 个工具 + 三种判定分支,锁换成吃生成的 fixture 并做过变异校验) +- **优先级**: 中(不影响真机,但影响「离线能验的东西」与 P2 的 mock 交付) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +为把离线端到端演练真跑一遍,用插件自己的 `ActionClient` 打了两遍同一个脚本: + +``` +ARTEMIS_MOCK_DRIVER=1 node plugins/autogamer-device/scripts/spike-mcp.mjs + [spike] tools (14): back, compare_ui_frames, ... tap ← 真 py 面 + take_screenshot → environment error: connect to adb server failed [WinError 10061] + +SPIKE_COMMAND="node plugins/autogamer-device/scripts/mock-action-server.mjs" node .../spike-mcp.mjs + [spike] tools (13) ← mock 面(修之前) +``` + +两件事一起暴露:① **`ARTEMIS_MOCK_DRIVER=1` 并不能让 `artemis mcp --type adb` 离线**——那一面仍要连 +adb server(`127.0.0.1:5037` 被拒即环境级失败),所以「离线 action 面」的唯一去处是插件自带的 +`scripts/mock-action-server.mjs`;② 那个 mock 只列 **13 个工具,缺 `compare_ui_frames`**,而 py 面自 +**#12**(G39 路线 A)起就是 14 个。也就是说**效果验证这条路径在离线环境里根本不存在**,而 +`tests/mockServer.spec.ts` 当初的断言是一份**手抄的 13 名列表**,与 mock 一起腐在同一处—— +正是 `#30`/`#45` 反复处理的那类「第四份抄件」。 + +#### 整改方案与实施 + +- `scripts/mock-action-server.mjs`:TOOLS 补 `compare_ui_frames`;新增 `effectVerdict()`, + 按 py 契约形状返回 `{"changed": bool, "ratio": number}`(插件 `compareFrames` 就 `JSON.parse` 后读布尔 + `changed`,见 `src/index.ts:204`),判定由 `MOCK_EFFECT=changed|unchanged|error` 选,**默认 changed**。 + 这样离线也能跑正例、反例与拒绝分支,不再只能验「有管道」。 +- `tests/mockServer.spec.ts`:把那份手抄列表换成**吃生成的 fixture** + `tests/fixtures/py-action-tools.json`(唯一权威是 `spike-mcp.mjs --write` 从真 py 面抓的 14 项), + 比较两侧排序后的集合;另加两条锁:mock 返回形状符合 G39 契约、`unchanged` 与 `error` 两分支。 + `MOCK_EFFECT` 经构造器 `env` 传(`ActionClient` 是**替换**子进程 env 而非合并,第一版我用 + `process.env` 赋值,测出来 verdict 仍是 `true`,据此改成显式传 env)。 + +#### 验收标准 + +- [x] `SPIKE_COMMAND="node plugins/autogamer-device/scripts/mock-action-server.mjs" node plugins/autogamer-device/scripts/spike-mcp.mjs` ⇒ + `[spike] tools (14): …`、`take_screenshot → OK: iVBOR…`、exit 0(全程离线) +- [x] `pnpm --config.verify-deps-before-run=false test` ⇒ **150 passed / 1 skipped**(18 文件,净增 2) +- [x] **变异校验**:从 mock 的 TOOLS 里摘掉 `compare_ui_frames` ⇒ + `expected [ 'back', 'erase_one_char', …(11) ] to deeply equal [ 'back', 'compare_ui_frames', …(12) ]` + 该锁变红(`exit=1`),恢复后转绿——证明这条锁真在观察 +- [x] `pnpm run build` ⇒ exit 0 +- [x] 两枚文档门禁 + `--selftest` ⇒ exit 0(按 **#49** 的形状跑,不拿管道退出码当判据) +- [ ] 未做(明确留白):真机那一侧未复跑(本机无设备/无 `dsh`);mock 的 PNG 仍是 33 字节 IHDR 桩, + 不含真实像素,所以像素级 ROI 分支在离线路径上仍只能靠单测 + +### #51 更正 #50:JS stub 给 `compare_ui_frames` 发明了 py 没有的 `ratio` 键 + +- **日期**: 2026-10-13 +- **状态**: ✅ 已更正(stub 与 py 逐字同形,断言改锁精确键集合) +- **优先级**: 低(不改变产品行为,但它是一条被测试锁住的**假契约**) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +`#50` 给插件自带 JS stub 补 `compare_ui_frames` 时,返回写成 `{changed, ratio}` 并加了一条 +`typeof parsed.ratio === "number"` 的断言。读 py 源码后发现**真实形状只有一个键**: +`artemis/mcp/adb_server.py` 里 `return json.dumps({"changed": changed})`, +失败分支是 `Error: frame decode failed: …` 与 `Error: {e}`。既有 +`tests/unit/mcp/test_adb_server_contract.py` 早就把 `{"changed": False}` / `{"changed": True}` 逐字锁住。 +⇒ stub 与它自己那条测试**共同虚构了一份 py 从不产生的契约**,与 `#28`(fixture 锁住假死信道)同类。 +#50 编号已发布,正文不改写,错记在此。 + +#### 整改方案与实施 + +- `plugins/autogamer-device/scripts/mock-action-server.mjs`:`effectVerdict()` 改为 + `JSON.stringify({ changed: mode !== "unchanged" })`,注释写明「extra field = inventing contract」并指回本条。 +- `plugins/autogamer-device/tests/mockServer.spec.ts`:断言由「读 `changed` + 判 `ratio` 是数字」 + 改为 `toEqual({ changed: true })`——**键集合相等**,多一个键就会红。 + +#### 验收标准 + +- [x] `pnpm --config.verify-deps-before-run=false test` ⇒ **150 passed / 1 skipped**(数量不变:本条是改判形状,不是加测) +- [x] 与 py 侧实测对齐:`--type mock` 的 `compare_ui_frames` 同帧返回 `{"changed": false}`(见 **#52** 的端到端跑) +- [x] **变异校验**:`py` 侧 `{"changed": …}` 之外若将来多出任何键,`toEqual` 立刻红; + stub 侧手工塞回 `ratio` 时该测试也红(做过一次才恢复) +- [x] 两枚文档门禁 + `--selftest` ⇒ exit 0 + +### #52 G62 结案:`artemis mcp --type mock` —— 插件终于能吃 py 实现,而不是 JS 桩 + +- **日期**: 2026-10-13 +- **状态**: ✅ 已关闭(新增 1 个 py 文件 + CLI 一个分支,**未改任何现有 py 文件的行为**) +- **优先级**: 高(P2「mock driver」栏目的真实交付;也是本机无设备/无宿主时唯一还能推进的主干) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「G62 离线 action 面」住在 `TODO.md`(spec 提交 `3fccb4f` 早于实现提交,按 R22),结案即删 + +#### 问题描述 + +`artemis mcp --type adb` 在没有 adb server 的机器上**列得出工具、调不动**: +`_get_controller` 在 `adb_server.py:155-159` 无条件 `AdbClient(host, port)` + `device_list()`, +无设备即 `raise`。`ARTEMIS_MOCK_DRIVER=1` 也救不了——该 env 在旧 py 栈的 `drivers/factory.py:52` 生效, +而 adb 面此前没有任何入口用到它(实测:`take_screenshot` 仍报 +`Controller lazy initialization failed: connect to adb server failed: [WinError 10061]`)。 +⇒ 14 工具契约与效果验证此前**只能在插件自带的 JS stub 上验**,py 实现本身离线跑不起来。 + +#### 整改方案与实施 + +- **新增 `artemis/mcp/mock_server.py`**:`seed_mock_controllers()` 用仓库现成的 `MockActuator` + (`actuators/mock.py:32`,即「`AdbActuator` 绑 `MockDeviceDriver`,走同一段生产坐标换算与 controller 分发」) + 造出 controller,塞进 `adb_server` 的两条缓存短路。 + **关键坑(探针第一版栽过,如实记)**:只置 `_GLOBAL_CONTROLLER` 不生效——应用自己加载 `.env`, + 本机 `ADB_DEVICE_SERIAL=127.0.0.1:16384` 恒有值,`_get_controller` 走的是 `_CONTROLLERS[serial]` 那条。 + ⇒ 实现把 `ARTEMIS_DEVICE_ID` 钉成 `MOCK_SERIAL`(它在查找链里**先于** `ADB_DEVICE_SERIAL`), + 并同时登记 `MOCK_SERIAL` 与现有 `ADB_DEVICE_SERIAL` 两个键,使 mock 与 `.env` 内容无关。 +- **`artemis/interfaces/cli/commands/mcp.py`**:加 `elif st == "mock"`(复用同一个 `adb_mcp` 对象与 + `configure_stdio_mode`,后者只做 logging 改写与 `ARTEMIS_IPC_PORT=""`,**不探设备**), + 并更新 `--type` 帮助文本与不支持类型的报错。 +- **`tests/unit/mcp/test_mock_server.py`(4 条)**:fixture 里把 `AdbClient` 换成 + `pytest.fail("mock surface reached real adb")`,于是「离线」不是靠机器恰好没设备,而是**被强制证明**。 + 未预置 ⇒ 解析设备即红;预置 ⇒ 截图/点击/返回键全通,`action_history` 记到坐标; + 并把 **mock 设备自己产出的帧**喂给真比较器(`compare_ui_frames` 的形状本身已由 + `test_adb_server_contract.py` 锁死,这里刻意**不重复锁**,见「同一事实只在一处」)。 +- **顺带更正 #51**(stub 的 `ratio` 假契约)。 + +#### 验收标准 + +- [x] 等价性(同一权威,不是另起一个面):同一个 spike 分别打两面,工具清单**逐字节相同**—— + `[spike] tools (14): back, compare_ui_frames, … tap`(`--type adb` 与 `--type mock` 的输出行 `=` 比较通过) +- [x] `SPIKE_COMMAND="uv run artemis mcp --type mock" node plugins/autogamer-device/scripts/spike-mcp.mjs` ⇒ exit 0; + `take_screenshot → OK` 解出来是**真 PNG**:magic `89504e470d0a1a0a`、`IHDR 1080x2400`; + `get_ui_hierarchy → [{"text": "Settings", "bounds": [0, 0, 1080, 100]}]`。对照:同机 `--type adb` 只得 adb 拒连 +- [x] `uv run pytest tests/unit/mcp -q` ⇒ **228 passed + 1 skipped**(基线 224+1,净增 4) +- [x] `uv run pytest tests/unit -q` ⇒ **2597 passed, 7 skipped**(基线 2593/7 + 4,**零回归**) +- [x] **变异校验**:摘掉 `_CONTROLLERS[MOCK_SERIAL]` 那一行 ⇒ 3 条测试红并报 `mock surface reached real adb`(做出一次才恢复) +- [x] 插件侧 `pnpm test` ⇒ 150 passed / 1 skipped;`run build` exit 0;`uv run ruff check` 干净、`ruff format --check` 668 files 全净 +- [x] G62 从 `TODO.md` §四 移除、`registry.md` 已关闭表加行、§五「一期改造登记」第 2 项落地、两枚文档门禁重跑 exit 0 +- [ ] 留白(如实):mock 帧是驱动给的占位图(75 字节合法 PNG,尺寸真、内容空), + 所以**像素级 ROI 分支**在离线路径上仍只能靠单测;真机正反例仍待设备在场时复跑 + +### #53 更正 §零:「CI 用 `spike-mcp.mjs` 做同一比对」是一条从没成立过的陈述 + +- **日期**: 2026-10-13 +- **状态**: ✅ 已更正(并由此立 **#54** 把它变成真的) +- **优先级**: 中(它是插件契约唯一那份 py 工具面产物的可信度问题) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +`docs/TODO.md` §零「测试面」行写着:skipped 那条是 `AUTOGAMER_CONFORMANCE_PY=live` 的 opt-in 比对, +「**CI 用 `node scripts/spike-mcp.mjs` 做同一比对**」。核对工作流后为假: + +- `.github/workflows/ci.yml` 里 `spike-mcp` **命中 0 次**; +- `plugin-contracts` job 的步骤只有 `pnpm install --frozen-lockfile` → `node scripts/gen-sse-events.mjs` + → `pnpm run build` → `pnpm test`,**没有任何 Python/uv 环境**,根本跑不动那条命令。 + +⇒ `plugins/autogamer-device/tests/fixtures/py-action-tools.json`(插件路由转发的 14 个 py 工具名) +一直是**人工带着走的产物**:它由 `spike-mcp.mjs --write` 从活服务导出,但没有任何机器在之后重新对过它。 +这条假陈述的来源与 `#45` 同族——把「应该如此」写成了「已经如此」;也与 **G60** 相互印证:门禁与比对 +不在 CI 里,人工自证就会漂。#50/#52 的行文没有引用它,故无需改写已发布条目。 + +#### 整改方案与实施 + +§零 该行改为如实描述(CI 不重导 py 面),并由 **#54** 把这句话变成真的。 +不在本轮悄悄改掉文字就完事:先按 registry 取号 **G63** 登记为未关差距,再在同轮结案, +这样「何时为假、何时为真」在台账里可查。 + +#### 验收标准 + +- [x] `grep -n "spike-mcp" .github/workflows/ci.yml`(修 #54 **之前**)⇒ 无命中,假陈述证实 +- [x] `grep -n "CI 用 .node scripts/spike-mcp" docs/TODO.md` ⇒ 空(虚假表述已撤) +- [x] G63 先登记后结案:`registry.md` 现行在册与已关闭表各一行、本文件已关闭差距史一行 +- [x] 两枚文档门禁 + `--selftest` ⇒ exit 0 + +### #54 G63 结案:py 动作面 fixture 由 CI 每次重导比对,不再是人工产物 + +- **日期**: 2026-10-13 +- **状态**: ✅ 已关闭(CI 步骤已落 `python-quality`,本地用同一条命令验过,变异校验做过) +- **优先级**: 高(它把 #50/#52 那类「面与实现漂移」变成机器可拦) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「G63 把 py 动作面的 fixture 变成 CI 机器可验」住在 `TODO.md`(spec 提交 `06da044` 早于实现提交,按 R22),结案即删 + +#### 问题描述 + +见 **#53**:那份 fixture 从未在 CI 被重新比对。此前想接也接不了—— +`artemis mcp --type adb` 在 runner 上没有 adb 也没有设备,`tools/list` 可以、任何调用都环境级失败, +把它接进 CI 只会得到一个天天红的步骤。**#52** 之后这条路才成立。 + +#### 整改方案与实施 + +- `.github/workflows/ci.yml` 的 `python-quality` job 新增两步(都**不带 `if`**): + `Set up Node.js`(显式 `22.22.3`,与 `plugin-contracts` 同一版本;不依赖 runner 预装的 node), + 以及 `Check the py action surface against the plugin fixture`: + `SPIKE_COMMAND="uv run artemis mcp --type mock"` + `node plugins/autogamer-device/scripts/spike-mcp.mjs`。 + **不改 `spike-mcp.mjs`**:它的默认模式本来就是漂移检查(不匹配即 `console.error` + 非零退出,只有 `--write` 才重写)。 +- 为什么用 `--type mock`:runner 无 adb 无设备,`--type adb` 只能列面;而 `--type mock` 全部 14 个工具都跑得动, + 且两面工具清单**逐字节相同**(同一个 `adb_mcp` 对象,#52 已实测)⇒ 校验的是 py 实现本身,不是第二个面。 +- 为什么放 `python-quality` 而不是给 `plugin-contracts` 再装一套 uv:语义上这是 py 面的事,且该 job 已有 + `uv sync --dev --locked`,重复安装只是浪费时间。 + +#### 验收标准 + +- [x] YAML 可解析,两步确实落在 `python-quality` 且无 `if`:`step: Check the py action surface against the plugin fixture | if: (none) | env: {'SPIKE_COMMAND': 'uv run artemis mcp --type mock'}` +- [x] **本地跑 CI 将要执行的同一条命令** ⇒ exit 0,`[spike] tools (14): back, compare_ui_frames, … tap` +- [x] **变异校验**:从 fixture 删掉 `compare_ui_frames` ⇒ 同一条命令 **exit=1** 并报 + `fixture is out of date with the live server: compare_ui_frames — re-run with --write`;还原后转绿 +- [x] 还原后 `git status` 只剩预期改动(fixture 未被这次改动写过) +- [x] 两枚文档门禁 + `--selftest` ⇒ exit 0 +- [ ] 留白:这一步**尚未在真 run 里执行过**(G54 使 `on: push` 不产出 run;本轮推送与否不改变这一点), + 首次派发跑绿之前,它的效力是「本地已证 + 命令与 CI 逐字相同」,不得写成「CI 已保证」(R27 第 4 条) + +### #55 G3 + G28 A 片:设备池闸门(parking / claimed-only / 全局并发 N) + +- **日期**: 2026-10-13 +- **状态**: ✅ A 片已关闭(机制在 TS、roster 由 config 注入);**G64(roster 真来源)留在 §四** +- **优先级**: 高(D3「共享真机 + 完整闸门」的设备治理主体;P1 live 之前的最后一块离线大头) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「G3 + G28 设备调度 A 片」住在 `TODO.md`(spec 提交 `93cc463` 早于实现提交,按 R22),结案即删 + +#### 问题描述 + +`run_device_action` 只有一把 per-key FIFO 闸门,而键是 `deviceKeyOf(args, defaultDeviceKey)` +(`src/tools/runDeviceAction.ts:131`):**没有 `device_serial` 的调用全部落到同一个 `default` 键上串行**。 +G3 与 G28 要的空闲池 / parking / 全局并发上限因此一直不存在。三条权威口径都点名这件事不能被简化掉: + +- `glossary.md:65` **parking** =「没有指定设备时,让一条消息先占住一台空闲设备」(G28); +- `glossary.md:66` **auto 走空闲池,显式走 serial 队列**(G3); +- py `artemis/runtime/device_lock.py:332` 原文「unclaimed `pending`/`any` ticket is *parked* on at most + one device … **was counted into every device's queue**, so a single unclaimed [ticket blocked them all]」, + `inventory/02-runtime-persistence.md:265` 要求这条**必须有回归测试**, + `inventory/02:236` 又把「auto-serial fail-open 与 claimed-only」列为 TS 重写最容易被顺手 clean 掉的防事故语义。 + +#### 整改方案与实施 + +- **新增 `src/devicePool.ts`**(包一层 `DeviceGate`,不替换它):显式 serial 走该设备链;auto 从 roster 里 + 挑**第一台未冷却、未被占用、gate 空闲**的设备并只占这一台;`pump()` 按到达序扫描但**跳过当下不可满足的 + 票据继续发牌**——这正是 `device_lock.py:332` 那条反面教训的直接对应(一个未认领票据不占全局槽、 + 也不 idle 掉别的设备)。全局上限 `maxConcurrentDevices` 计的是**已 admit 的设备数**(显式与 auto 同计), + 达上限时新票据留在队列里、不预占槽。 +- **配置面两个新键**(`src/index.ts`):`devices`(默认 `[]`)与 `maxConcurrentDevices`(默认 `0`)—— + **两个默认值都等于现状**,不配 roster 的部署行为逐字不变,由 `contracts.spec.ts` 的默认值断言与 + 池测试第一条锁住。 +- **S4 拒绝出口跟着改**(`ctx.tools.guard`):显式 serial 仍按该设备冷却拒绝;auto 在**所有** roster 设备 + 都冷却时才拒绝(措辞也不同),因为 auto 在入队前根本没有设备——原来拿 `deviceKeyOf` 的预设键当设备判定, + 配了池之后会误拒。 +- **路由器的 `key` 改为「实际分配到的设备」**:日志与 G13 dedup 用预设键(发牌前尚无设备), + 熔断记账用真设备——比原先记预设键更准(失败的是那台机器,不是配置里的第一个名字)。 +- **顺手修掉一处 G13 真泄漏**(实现时才发现,不是重构):`inflight.add(dedup)` 之后 `gate.acquire` 若在 + 排队中被 abort,原代码的 `finally` 还没进入,**dedup 标记永久滞留**,此后同 agent 的同一动作都被当成 + 「duplicate」永远拒绝。改为 acquire 包 try/catch,异常路径同样 `inflight.delete`。 +- **测试装配收敛**:5 处手写的 `gate: new DeviceGate()` 装配换成 `tests/support/pool.ts` 的 helper, + 顺手还掉 §零 记着的一条测试债。 + +#### 验收标准 + +- [x] `pnpm --config.verify-deps-before-run=false test` ⇒ **156 passed / 1 skipped**(19 文件;基线 150/1,净增 6 条池锁) +- [x] `pnpm run build` ⇒ 0 error;`uv run ruff format --check` ⇒ 668 files 全净(纯 TS 改动,py 面未动) +- [x] 六条语义锁:空 roster 退回 default 键(现状不变)/ auto 至多占一台 / **未认领不 idle 掉车队** + (含 `pool.busyDevices === 2` 证明它没预占槽)/ 全局上限 / 跳过冷却设备 / 释放后归最早排队者 +- [x] **变异校验三条都做出红**(脚本跑完即还原,还原后 exit 0): + ① auto 改回一律占 default ⇒ 3 条池测试超时红;② 摘掉全局上限计数 ⇒ + `honours the global concurrency ceiling` 红(`expected true to be false`); + ③ 把到达序扫描改成反序 ⇒ 排队序那条锁红 +- [x] **如实记:第三条变异第一次是绿的**——我先写的 claimed-only 锁在反序下发不了言(只有一个排队票据), + 是虚锁。补写成「两台占满 + 两个 auto 排队 + 只释放一台」才咬得住。 + 与 **#49**(样本太小跨不过口径步长)、**#45**(判据绑机器)同一类,本条是第三次撞到同一堵墙 +- [x] 既有闸门锁与 S10 ③ 的跨会话串行结论未被削弱(`tests/gate.spec.ts` 全绿) +- [ ] 明确未做:**G64** roster 真来源(py 已有 `device_pool.py` 的 `list_devices_async` + 缓存 + 超时 + + fail-open;重复实现=多一份抄件,加只读 py 工具=D8 须登记的 py 改动,等用户点头); + 多设备 live 未验(本机无设备无宿主),A 片的正确性目前只由单测与内存设备支撑 + +### #56 G64 结案:py 只读 `list_devices` 工具,插件的 roster 有了真来源 + +- **日期**: 2026-10-13 +- **状态**: ✅ 已关闭(py 面 14→15 工具,四处契约同步完,三条变异校验全红) +- **优先级**: 高(#55 的池闸门此前只能吃人手写的 roster;P2 的诊断 admission 也要同一份设备状态) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「G64 结案轮」住在 `TODO.md`(spec 提交 `50f26b5` 早于实现提交,按 R22),结案即删 + +#### 问题描述 + +设备枚举**在 py 里早就有实现,但没有任何 MCP 工具面出口**——本轮 grep:`artemis/mcp/action_server.py` +与 `action_manifest.py` 里 `list_devices` **0 命中**,而能力在 `artemis/runtime/device_pool.py` +(`list_devices_async` / `try_list_devices_async`,自带快照缓存;文件底部有模块级单例 `device_pool`)。 +后果是 **#55** 的池闸门只能用人手配置的 roster:设备上下线要改配置,且看不见 +`offline`/`unauthorized`/`is_busy` 这些状态。另一条路(TS 自己 `adb devices`)就是本仓库禁止的 +第二份抄件,`inventory/02:236` 点名的 fail-open 语义还会在复制时丢掉。 + +#### 整改方案与实施 + +- **py**(`artemis/mcp/adb_server.py`,D8 一期改造登记**第 3 项**):只读工具 `list_devices`, + 复用现成单例的 `try_list_devices_async()`,返回 `json.dumps({"enumerated": bool, "devices": [...]})`。 + `DeviceStatus` 十个字段原样透传(`device_pool.py:36-47`)。**核心是那条可辨性**: + `try_list_devices_async` 的原文(`device_pool.py:354-356`)是「returns None when enumeration failed + and no usable snapshot exists, so callers can distinguish *could not ask adb* from + *adb answered: no devices attached*」⇒ 枚举不成写 `enumerated:false`,真没插设备写 + `enumerated:true, devices:[]`,两者**永不塌成一个**;枚举抛异常也收敛成 `enumerated:false` + 而不是把回合炸掉(工具面失败要结构化,S6 附带口径)。 +- **插件**:`src/devicePool.ts` 加 `rosterSource` / `rosterTtlMs` / 可注入 `now`, + `poolDevices` = 「人手 `devices` 优先,否则用发现的」;`refreshRoster()` 带节流, + **拿到 `undefined` 时保留上一份好 roster**(fail-open 的调用方半边),且**配置了 roster 就完全不发 RPC**。 + `src/index.ts` 的 `enumerateRoster()` 解析 py 的返回,任何不确定的形状(非 JSON、`Error:`、传输异常) + 一律回 `undefined`=「不知道」,而不是猜一个空池。 +- **默认零行为变化**:新配置 `discoverDevices` 默认 **`false`**。列举一开就把 auto 从共享键散到多台, + 属调度语义变化,必须是显式配置——这条锁在 `contracts.spec.ts` 的默认值断言里。 +- **契约同步四处**:py pin fixture(`tests/fixtures/action_surfaces/adb_server_manifest.json` 重生成, + 14→15,按 LF 写回以免整文件假 diff)、插件 fixture(`--write` 走 `--type mock`)、 + 插件「映射之外的直接调用」清单(加 `list_devices`)、文档。 +- **两条既有锁当场咬住新漂移**(值得记,这是它们的存在理由): + ① `mockServer.spec.ts` 的「与真 py 面同一清单」立刻红——JS stub 又少了一个工具(**一天内第二例同类漂移**); + ② `contracts.spec.ts` 的「插件调用的每个 py 工具都要登记」立刻红——新调用点不进清单就过不去。 +- **两处自我纠正(本轮写文档时)**:给 CHANGELOGS 索引行与 §零 计数打的两处 `str.replace(...)` + **没有校验是否命中**,于是静默空转——索引区间已推进到 `#9–#55`,描述文字却还停在 `#52`; + 是核对 `repr(行尾)` 时才抓到的。与 **#49**(管道吞掉退出码)、**#51**(勾了没跑过的判据)同一族: + **不校验命中的替换等于没有替换**。本轮之后的文档改写一律「先断言 anchor 唯一存在,再替换」。 +- **偏差如实记**:方案第②条写「在 `action_manifest.py` 把该工具登记进与 `take_screenshot` 同一档能力门」, + 实做时查明 **adb 面根本没有能力门**(该 server 的工具恒全暴露,`test_adb_server_contract.py` 的投影检查 + 也印证 `compare_ui_frames` 当初就不在 manifest 里——#12 的偏差段已记过一次)。 + ⇒ 这一项**不是遗漏,是不存在**,方案文字按实际作废。 + +#### 验收标准 + +- [x] `uv run python -X utf8 -m pytest tests/unit/mcp -q` ⇒ **233 passed + 1 skipped**(基线 228+1,新增 `tests/unit/mcp/test_device_listing.py` 5 条) +- [x] `uv run python -X utf8 -m pytest tests/unit -q` ⇒ **2602 passed / 7 skipped**(基线 2597/7,**零回归**) +- [x] 插件 `pnpm test` ⇒ **159 passed / 1 skipped**(#55 后基线 156/1,净增 3 条 roster 锁);`run build` 0 error +- [x] `uv run ruff check` 全过;`uv run ruff format --check` ⇒ 669 files 全净 +- [x] **变异校验三条全红**(脚本 `finally` 还原,还原后 py mcp 与 spike 均 exit 0): + ① 把「没问成 adb」塌成空设备 ⇒ `test_enumerated_false_is_not_an_empty_fleet` 红; + ② 把 `discoverDevices` 默认改 `true` ⇒ `applies declared defaults…` 红 + (`expected true to be false`,第一次只拿到 exit=1,单独复跑取到测试名才勾); + ③ 从插件 fixture 删 `list_devices` ⇒ **#54** 那条 CI 命令红并报 + `fixture is out of date with the live server: list_devices`(顺带证明那步真有牙) +- [x] 等价性仍成立:`--type adb` 与 `--type mock` 两面清单一致(都由同一个 `adb_mcp` 对象导出) +- [ ] 留白(按 R21 区分证据层级):本机无设备,`enumerated:true` 的**真实枚举**只有 monkeypatch 级证据; + 多设备 live 调度与 `is_busy`/锁屏状态的实际取值都要等设备在场 + +- [x] 两枚文档门禁 + `--selftest` ⇒ exit 0;`uv run ruff check` / `format --check` ⇒ 669 files 全净 + +### #57 P2 诊断 admission A 片:把 py 提交门禁的第一道门移植进插件 + +- **日期**: 2026-10-13 +- **状态**: ✅ A 片已关闭;**锁屏半边留在 G65**(需要一次只读 py 探针,形状待定) +- **优先级**: 高(D3「共享真机 + 完整闸门」的准入半边) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「P2 诊断 admission A 片」住在 `TODO.md`(spec 提交 `0540523` 早于实现提交,按 R22),结案即删 + +#### 问题描述 + +插件此前**没有任何提交前准入**:`run_device_action` 只有一个熔断冷却的 guard。 +读码(本轮子代理)确认 py 的提交门禁是**两道独立的门**,顺序固定: + +1. 严格 serial 绑定 —— `apps/admin_console/routers/tasks.py:140` + → `DevicePool.validate_explicit_serial_async` → `_explicit_serial_error`(`runtime/device_pool.py:366-385`); +2. 有界就绪探测 —— `tasks.py:160` → `ReadinessEngine.run_device_submission_probe` + → `core/diagnostics/probes/adb_probe.py:354`,summary 命中 `{"Device Locked","Lock State Unknown"}` + 即 409(`tasks.py:174`)。 + +`inventory/03 §1.7` 与 `:294` 点名的正是这条:admission 若只查 `adb devices` 不查 Keyguard, +就会向锁屏设备派任务(现状视为不可提交)。而 **`DeviceStatus` 里没有 `is_locked`** +(键集合被 `tests/unit/mcp/test_device_listing.py:63-73` 钉住),锁屏判定来自 +`dumpsys window policy` ⊕ `dumpsys trust` + 1s 上界 + 双采样(`adb_probe.py:136-253,420-424`), +且「状态不可判」也 **fail-closed**(`adb_probe.py:452`)。⇒ 在 TS 重写这段就是 +`inventory/02:236` 点名最易被顺手 clean 掉的抄件,本片**不做**,立为 **G65**。 + +#### 整改方案与实施 + +- **roster 条目升级**:`enumerateRoster` 之前在解析 `DeviceStatus` 时把 `state`/`is_busy` 丢了, + 只剩 serial。改为带回 `{serial, state, isBusy}`(`PoolDevice`); + 人手 `devices` 配置只有名字 ⇒ `state === undefined`,按「不知道」处理,绝不等于「未就绪」。 +- **移植第一道门**:`DevicePool.admissibleExplicit(serial)` 逐条照 `_explicit_serial_error`—— + 只在**枚举真答上了**(`enumerated === true`)且 roster 非空时才判;「不在 roster」=未连接、 + 「在但 `state !== "device"`」=已连但未就绪(拒绝理由带上 state 原文); + **其余一律放行**,包括 `enumerated:false` 与空 roster——`:370-373` 的 docstring 明说这是故意的 + fail-open(「枚举不确定/为空时提交继续,之后以下游清晰的无设备错误失败,而不是假的硬拒」)。 +- **auto 选取次序照 `device_pool.py:461-472` 的梯子**:ready 且 idle → ready → 第一台 ready → + 任意未冷却设备(最后一级是 py 自己「none ready 时退回 any attached」的对应), + 取代 #55 里只看 `busyCount === 0` 的简化版。 +- **guard 仍是唯一出口**(R13):同步、**不发 RPC**,读 `refreshRoster()` 缓存的最近一份快照 + (TTL 节流,同 py `device_pool.py:71` 的 `CACHE_TTL = 1.0` 意图);顺序与 py 一致——先就绪后冷却。 + 准入拒绝**不记熔断**(设备没坏,是没连/没就绪),文案要求模型换设备或报 blocked。 +- 三条变异校验先于结案(见下),并按 **#56** 立的规矩:文档改写一律先断言 anchor 唯一存在。 + +#### 验收标准 + +- [x] `pnpm --config.verify-deps-before-run=false test` ⇒ **165 passed / 1 skipped**(基线 159/1,净增 6 条准入锁); + `run build` ⇒ 0 error。py 侧本片零改动:`tests/unit` 仍 **2602 passed / 7 skipped** +- [x] 六条语义:未连接被拒 / 已连未就绪被拒且理由含 state / 枚举问不成→放行 / + 空 roster→放行(不拒任何 serial)/ 人手 roster(无 state)→不拒 / ready+idle 优先于 ready+busy +- [x] **变异校验三条全红**(脚本 `finally` 还原,还原后全量 exit 0,各条报出具体断言): + ① 把「问不成」当拒绝 ⇒ `expected 'device enumeration is unavailable' to be undefined`; + ② 撤掉 idle 优先那一级 ⇒ `expected 'busy-one' to be 'idle-one'`; + ③ 空 roster 改成拒绝 ⇒ `expected 'no devices are attached' to be undefined` +- [x] 镜像的是 py 的场景而非实现:`tests/unit/runtime/test_device_pool.py:134,150,176,248,259,269,287`、 + `tests/unit/admin_console/test_task_device_readiness.py:31,55,77,108` +- [x] 两枚文档门禁 + `--selftest` ⇒ exit 0 +- [ ] **G65 未做**(明确留白):锁屏与「状态不可判」的 fail-closed 半边进不来, + 需一次只读 py 探针;形状二选一待定——给 `list_devices` 条目加 `is_locked`(一次 RPC 拿全, + 但要扩 `DeviceStatus` 键集合,连带 `test_device_listing.py` 的钉) + vs 独立 `get_device_lock_state(serial)` 三态(改动小,多一次往返,且 guard 同步 ⇒ 必须走快照缓存)。 + 属 py 改动,按 D8 须用户确认 + +### #58 立 R28:调研结论没回写台账,差点让下一步建立在「CI 已复核」的假前提上 + +- **日期**: 2026-10-13 +- **状态**: ✅ 已成文(`R28` + registry + `AGENTS.md` 指针) +- **优先级**: 高(它是这条流水线自己的安全网) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +三种失效在同一会话里各自发生一次,共同点是**结论没有落到下一个 agent 会读的地方**: + +1. **未核实就叙述"已完成"**:我曾在本会话写「方案甲已落地 / CHANGELOGS #50 / 新立 G63 / 插件 188 项测试」。 + 用 `git log`、`grep -c "^### #"`、`grep G63 registry.md` 核对后**全部不存在**(当时 HEAD `8eecb39`、 + 条目 49 条、`G63` 0 命中)。这些说法只留在对话里,台账未被污染,但**照它继续做就会去验收不存在的东西**。 +2. **新事实推翻了旧行而旧行还在**:§零 长期写着「CI 用 `node scripts/spike-mcp.mjs` 做同一比对」—— + grep 工作流命中 0 次;又写「CI 侧全绿」——唯一那次 run 整体 `failure`。G60 接线时我差点把 + "已经有机器复核" 当前提(**#53** 已更正)。 +3. **派生数不回抄 / 替换不校验命中**:§零 测试数停在 `83 passed`(实跑 148,现 165); + 两处 `str.replace` 静默空转(**#45/#56**)。 + +#### 整改方案与实施 + +`docs/migration/plugin-contract-rules.md` 新增 **R28**(含三行实例表 + 四条判据), +`registry.md` 与 `AGENTS.md` 文档地图同轮加指针。四条判据的要点:当轮必落一次文档提交并改到受影响那一行; +新结论否定旧表述时必须改掉或标「已作废 + 出处」;**说「已完成 X」要能指到 commit hash 或命令输出**, +指不到就写「待做」;派生计数只留复测命令不写死数字。 + +#### 验收标准 + +- [x] `grep -n "^## R28" docs/migration/plugin-contract-rules.md` ⇒ 命中 1;覆盖范围行推进到 R20…R28 +- [x] `uv run python -X utf8 scripts/check_doc_registry.py; echo $?` ⇒ exit 0(现行条款数与 registry 行数一致,含 R28) +- [x] `AGENTS.md` 文档地图那行含 R28 指针(`grep -c "R28" AGENTS.md` ⇒ 1) +- [x] 本条自身即按新判据写:三处失效各带命令与数字,未核实的叙述按"没做"记录而非"已做" + +### #59 G65 结案:锁屏三态进 `list_devices`,准入的 fail-closed 半边补齐 + +- **日期**: 2026-10-13 +- **状态**: ✅ 已关闭(py 面工具名不变、加一个默认关的参数;插件三态可辨) +- **优先级**: 高(#57 的准入只会判「在不在」,判不了「能不能派」) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「G65 B 片」住在 `TODO.md`(spec 提交 `dc5ef6b` 早于实现提交,按 R22),结案即删 + +#### 问题描述 + +`inventory/03 §1.7/:294` 点名:admission 只查 `adb devices` 不查 Keyguard 就会向锁屏设备派任务。 +读码定下三条事实(都有行号):三态来自 `_get_confirmed_device_lock_state` +(`adb_probe.py:235-253`,**正判要双采样**防一次抖动翻转),提交路径用 **1s 上界** +(`:419-424`);`"Device Locked"` 与 `"Lock State Unknown"` **同样 fail-closed** +(`:452` → `apps/admin_console/routers/tasks.py:174` 409);而 `_explicit_serial_error` 的 +「没问成」是**故意 fail-open**(`runtime/device_pool.py:370-373`)。⇒ 「不可判」与「没问」必须可区分, +这是本次最容易写错的一处。 + +#### 整改方案与实施 + +- **`AdbDeviceProbe.device_lock_state(serial, timeout_seconds=1.0)`**:一个薄的公开方法,委托现成的 + 双采样确认版;公开它而不是让调用方戳私有函数,也不新写任何 `dumpsys` 解析或 `_parse_device_lock_state` 复制。 +- **`list_devices(include_lock_state: bool = false)`**:为真时逐台并行 gather,条目多一个 `is_locked` + (`true` / `false` / **`null` 不可判**);默认关 ⇒ 便宜面不变、现有调用方零改动。 + 为什么不做成独立 `get_device_lock_state` 工具:guard 必须同步(R13)⇒ 只能读快照 ⇒ + 每设备一次往返会逼插件再建一层节流缓存(=第二份事实)。 +- **插件侧**:`PoolDevice.isLocked?: boolean | null`,`undefined`=没问过 ⇒ **不拒**; + `null`=问过但不可判 ⇒ **拒**(fail-closed,对齐 py);`true` ⇒ 拒。 + auto 选设备时跳过 `true` 与 `null`(对应 `adb_probe.py:427-446` 的「换个设备试」而不是硬派)。 + `enumerateRoster` 索取 `include_lock_state: true`——它存在的目的就是喂准入。 +- **JS stub 同形**:`is_locked` 只在被索取时出现(键缺席 ≠ null),`MOCK_LOCK=locked|unknown|open` 选值。 + #56 立的那条「payload 键集合必须等于 `DeviceStatus.to_dict()`」检查随之按索取与否计数。 +- **两处自我纠正(实现过程中)**: + ① `adb_server.py` 里我用了 `asyncio.gather` 却**没有 import asyncio**——这条只在真的走 + `include_lock_state=true` 时才炸,是「写了测试才暴露」的又一例(先跑的默认路径测试全绿); + ② 第 3 条变异第一次改出了**语法错**(留下悬空 `&&`),报的是集合失败而不是断言红, + 不算证据;改成语法合法的「只跳不可判、不跳锁着」之后才拿到真判据。 + +#### 验收标准 + +- [x] py:`uv run python -X utf8 -m pytest tests/unit/mcp -q` ⇒ **235 passed / 1 skipped**(基线 233; + 只读断言由「无参数」改判为「只有 `include_lock_state` 这一个布尔参数、默认 false、仍无 serial 入参」, + 另加两条:默认**不查**锁态(把探针换成会 raise 的函数)、索取时三态可辨) +- [x] py 全量:`uv run python -X utf8 -m pytest tests/unit -q` ⇒ **2604 passed / 7 skipped**(基线 2602,零回归); + `tests/unit/core/test_diagnostics.py` 原有 8 处锁全绿 +- [x] pin fixture 重生成:工具**名集合不变**(脚本内断言 `set(new)==set(old)`),只有 `list_devices` schema 变 + ⇒ 插件那份 15 项清单不动,`#54` 的 CI 比对仍 exit 0 +- [x] 插件:`pnpm test` ⇒ **169 passed / 1 skipped**(基线 165,净增 4:锁着拒 / 不可判拒且与「没问」不同 / + auto 跳过锁与不可判 / stub 键集合按索取与否);`run build` 0 error +- [x] lint:`uv run ruff check` 全过;`uv run ruff format --check` ⇒ 669 files 全净(本轮 3 个文件先被格式化) +- [x] **变异校验三条,各自报出断言**(跑完还原并复验全绿:插件 169、py mcp 235): + ① 「不可判」当「没问」放行 ⇒ `expected undefined to be 'device A lock state is unknown'`; + ② 默认改成索取锁态 ⇒ `assert True is False` 与键集合断言双红; + ③ auto 不再跳过锁着的 ⇒ `expected 'locked-one' to be 'open-one'` +- [x] 两枚文档门禁 + `--selftest` ⇒ exit 0 +- [ ] 留白(R21 分层):本机无设备 ⇒ 锁态的**真实取值**只有 monkeypatch 级证据; + 「真机上锁屏设备确实被拒、且 unknown 与 unlocked 表现不同」要等设备在场 + +### #60 G67 结案:`{index}` 句柄改按节点身份解析,「stale 句柄永不可点」从文案变成代码 + +- **日期**: 2026-10-13 +- **状态**: ✅ 已关闭(离线面;真机/宿主留白见验收最后一条) +- **优先级**: 高(这是一条被代码违反的**安全**不变量,不是保真度装饰) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「G67」住在 `TODO.md` §五(spec 提交 `e8030c9` 早于实现提交,按 R22),结案即删 + +#### 问题描述 + +`inventory/04-agent-stack-config.md:203` 的原文口径是「一有更新观察,旧观察的 UI 列表……即删—— +**刻意浅:只有活观察带索引元素列表,stale [n] 索引永不成为可点目标**」,并且该文档自己标注 +「(安全理由,重写时必须保留)」。本插件此前的实现正面临方违反:`runDeviceAction.ts` 的两条梯 +(预执行重取、`effect=unchanged` 后重解)拿到**新鲜**层级后按**数组位置**取第 N 个节点, +屏幕一变就静默点到「现在坐在位置 N 的那个别的元素」——与不变量要求的恰好相反,而且完全不打字。 +`#40` 结案时把这条写作「新层级坐标重解一次」,是**当时没跟安全不变量对表**(#40 正文不改写, +在此登记它被 #60 更正的那半句)。 + +本轮另有两处**自己写错并当场回写**的判断(R28):① 前身条目 G27-a 设计成「层级指纹比对 + 拒」, +实现前发现它会否掉 #40 已结的功能,停手未写一行码;② 停手时我在 G66 行写了「`parsed_bounds` +是身份重解的先决条件,顺序必须 ①bounds→②身份→③守卫」——**这条不成立**:身份用 +text/content-desc/resource-id(无标签退到 center+class),bounds 只是 `shifted`/`occupied` +包含判定的消费者,两条链彼此独立。两处都已在 `TODO.md` 原句上改回。 + +#### 整改方案与实施 + +- **`src/observations.ts`(新)**:`LiveObservations` 按 `(agent, device)` 只存**最新一次模型可见的** + 元素列表;一次新观察即替换旧的,不累积、不设 TTL——安全性来自执行时对新层级复核身份, + 不来自过期时间。写入点**只有** `get_ui_hierarchy` 的工具结果出口(真正交给模型渲染那份); + validator 梯内部的重取不写,否则等于执行自己给自己的句柄续期。 +- **`src/hierarchy.ts`**:新增 `resolveHandle(observed, fresh, index)`,四类出口 + (`unobserved` / `unknown` / `gone` / `resolved`)。身份签名取 `labelOf()`—— + 与 `formatVisibleElements` 渲染给模型看的标签**同一个函数**(派生事实只留一处权威, + 模型看到的和代码解析的不会分家)。`text/content-desc/resource-id` 是**锚定**标签,跟到哪儿都算同一节点; + 只有 class 名不锚定,位移超过 `MAX_HANDLE_SHIFT = 100` 设备像素就判 `gone`——上界取自 + py 侧 `_is_valid_shift` 的 `100.0 * scale_factor`(`precondition_xml.py:396`), + `scale_factor` 在此恒为 1,因为插件从不工作在缩放截图空间;无标签节点退化签名 `@x,y`, + 一旦移动即认不出并按 py「宁可误判为 disappeared」(`:392-395`)落回拒绝。 + 删掉 `findNodeCenterByIndex`(位置解析本身就是 hazard,不留第二扇门); + `findNodeCenterBySubstring` 改吃已解析列表,梯子不再二次解析同一 payload。 +- **`src/tools/runDeviceAction.ts`**:两条梯共用 `resolveTarget()`,`{index}` 一律走身份; + `handlePrecheck()` 把「从未观察该设备」与「`#N` 不在活观察里」判为**不可重试**并在取数之前拒绝 + (重取修不好调用方的 bug,白烧 3 次 RPC),`gone` 与 `{text}` 未命中仍可重取——#40 那两条 + 已结行为(晚出现的目标在 3 次重取内命中、目标移动后按新坐标重试)因此原样成立。 + 拒绝文案只指向「重新 `get_ui_hierarchy`」或「改用 `{coordinate}` 并说明依据」,不静默退回位置。 +- **测试同步**:既有 `{index}` 用例改为「先观察再点」——`validatorLadder` 加 `observe()` helper + 并插入 12 处、`dedup` 5 处、`apply` 1 处直接调 `get_ui_hierarchy`;helper 观察后清空调用计数, + 使 `hierarchyCalls()` 仍只数执行期重取。`dedup` 的「另一个 agent」用例一度**超时死锁**: + 我把第二方的观察插在第一方 still-holding 设备之后,而闸门对同设备串行 ⇒ 观察排队不返回; + 改成两方各自先观察(也正是真实用法)。 + +#### 验收标准 + +- [x] `pnpm --config.verify-deps-before-run=false test` ⇒ **180 passed / 1 skipped** + (基线 169,净增 11:`tests/liveObservation.spec.ts` 9 条 + `hierarchy.spec.ts` 2 条身份解析锁); + `run build` ⇒ 0 error +- [x] py 侧本片零改动 ⇒ `uv run python -X utf8 -m pytest tests/unit -q` ⇒ **2604 passed / 7 skipped**(同基线,零回归) +- [x] 主锁是「树在前面插了节点 ⇒ 仍点同一 text 的节点」与「`#4` 在新树里存在但从未被观察 ⇒ 拒且**零次取数**」, + 两条都是位置解析会静默放行形状的东西 +- [x] **变异校验五条,各自报出断言**(逐条改、逐条跑、跑完还原并复验 180 全绿;R27): + ① 回到 #40 的做法(路由不预检 + 句柄无观察也按数组位置点)⇒ 4 条红,含 + `refuses before fetching anything, and never reaches the device` 与 + `refuses a handle that is in the fresh tree but never was in the observation`; + ② 去掉「句柄不在活观察里」这一拒 ⇒ 同后一条红;③ 梯子的内部重取给快照续期 ⇒ 6 条红(含 + `does not let the ladder's own re-fetch extend the observation`);④ 找不到就静默点旧位置 ⇒ + `hierarchy.spec` 与 5 条 ladder/observation 锁红;⑤ 取消 class 名的位移上界 ⇒ + `only follows a bare class name a short way` 红 +- [x] **①一开始两次改错,都不算证据**(如实记,这是 R27 第 5 条的同类):第一次插入的代码有**语法错** + (三元表达式少一个 `}`),跑出来的红是编译失败而不是断言;第二次重构后把改动落在 + `handleFromObservation` 里 `fresh.find(...)`——但那个函数根本看不见 `fresh`,等价于没改, + 于是「仍然全绿」被我自己读成「锁是虚的」。第三次把改动落回**行为**(路由去掉预检 + 解析处 + 带回退)才拿到 4 条断言名。⇒ 变异校验的判据是「这条改动在语义上真的反转了行为」, + 红与绿都必须先确认改动生效(这条已是第二次踩到,成文进 **R27 第 6 条**) +- [x] 两枚文档门禁 + `--selftest` ⇒ exit 0(`registry.md` 的 G67 行随结案从「现行在册」移入「已关闭」) +- [ ] 留白(R21 分层):以上是**单测证明**。`{index}` 在真机上是否真的跟随移动后的同一节点、 + 以及模型被拒后的下一步行为,要等 P1 live 验收(前置 **G59** 重装 bundle + 本机 `dsh` 不在 PATH) + +### #61 README 的 Layout 抄件已漏三个模块;加一枚双向锁让它不能再腐烂 + +- **日期**: 2026-10-13 +- **状态**: ✅ 已关闭(文档层派生抄件;无 G 号,属既有规则「派生事实只留一处权威」的落地) +- **优先级**: 中(不改变行为,但 README 是包内形状的入口页,抄件比没有更误导) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +`AGENTS.md` 把 `plugins/autogamer-device/README.md` 定为「安装流与**包内形状**说明」,其 Layout 块逐文件列举 +`src/`。那份清单是文件树的**手抄件**,没有任何东西在它过期时改变它:本轮 G67 新增 `src/observations.ts` 时 +实测(`comm -13` 比对提交版 README 与磁盘)漏的是 **三个**:`devicePool.ts`(**#55** 就该进)、 +`turnLedger.ts`(G44)、`observations.ts`(**#60** 本轮新增)——`dsh-types.ts` 本就在文件另一节列着,不是漏项。 +计数:提交版 README 出现 11 个 `src/(tools/)?\*.ts` 名,磁盘 14 个文件。 +同一条规则此前已因别的抄件立过两次案(`#30` 的状态副本、`#45` 的体量表)。 + +#### 整改方案与实施 + +- 补齐清单(`README.md` Layout):`devicePool.ts` / `turnLedger.ts` / `observations.ts`, + 并把 `hierarchy.ts` 那行从「层级索引定位」改成「`#N` 按**节点身份**跟随到当前位置(G67)」—— + #60 之后按位置定位这句话本身就是错的。 +- 新增 `tests/readmeLayout.spec.ts` 两个方向的锁:**每个** `src/`、`src/tools/` 下的 `.ts` 都要在 README 出现, + 且 README 提到的 `src/*.ts` 名字都必须在磁盘上存在。测试直接读文件系统与 README,不引新依赖。 + +#### 验收标准 + +- [x] `pnpm --config.verify-deps-before-run=false test` ⇒ **182 passed / 1 skipped**(基线 180,净增这 2 条) +- [x] **变异校验两条**(R27,且按新加的第 6 条先自证变异生效):① 抹掉 Layout 里 `src/observations.ts` 那一行 ⇒ + `lists every module under src/ and src/tools/` 红;② 插入一个不存在的 `src/wasNeverRenamed.ts` ⇒ + `names no module that has since been renamed or removed` 红。两条都改完跑、跑完还原并复验全绿 +- [x] 两枚文档门禁 + `--selftest` ⇒ exit 0(README 体量行随 `--refresh-sizes` 重生成) + +### #62 G66 小项两桩:incident **关闭**通知补上,`run_outcome` 双轴补齐锁 + +- **日期**: 2026-10-13 +- **状态**: ✅ 已关闭(G66 的第③④两项;①②分类片与⑤⑥另计,G66 本身仍开放) +- **优先级**: 中(不改判定正确性,但一条是模型收口的唯一信号,另一条是双轴的对外口径) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「G66 小项两桩」住在 `TODO.md` §五(spec 提交 `8b16c22` 早于实现提交,按 R22),结案即删 + +#### 问题描述 + +**③ incident 关闭通知缺失。** py 的口径是三处一致的:字段定义 +(`artemis/graph/state.py:136-141`——`last_closed_incident`「被最近一次成功的终态动作关掉的 incident, +渲染**一轮**,让 Operator 回到原意图收口」)、写入条件(`validator/execution_loop.py:477-488`—— +只在 `success` 且 `open_incident` 有 `kind` 时写,并补 `closed_at_step`)、渲染文案 +(`operator/prompts.py:576-592`——`--- Execution Incident (CLOSED) ---` + +「Settle its original intent … already served, still pending, or no longer needed」,且 open 块每轮重渲、 +closed 只一轮)。本插件 `ActionIncidents.noteSuccess()` 只有一句 `counts.delete(key)` ⇒ +模型被告知「连败三次,开 incident」之后,**永远不知道那条 incident 已经因为后面的成功结束了**, +也就没有那句收口提示。 + +**④ `run_outcome` 双轴只算不锁。** `src/tools/reportTaskStatus.ts` 把 `completed + tests_failed>0` +拼成 `"completed (with assertion failures on record)"`,而全仓唯一碰这支的测试 +(`tests/apply.spec.ts` 的 `projects the terminal outcome for exit audits`)是**手工构造**那条记录再喂给 +`presentationMeta`——它锁的是投影,不是拼装;execute 出来的三支字符串一条都没锁 ⇒ 改坏拼装全绿。 + +**顺带记一处方案错判(R28)**:spec 里写的理由是「重试成功绕过 `finishSuccess`,所以在模型唯一出口附著」。 +读码后更正:重试路径外层照样会走 `finishSuccess`,真正会被漏掉的是 `wait` 这类**没有底层工具**的动作 +(`dispatch` 直接 `return { ok: true, value: { waited_ms } }`,从不经过 `finishSuccess`)。 +结论不变(附著点就该在唯一的模型出口),但理由换成了实测的那条——变异 C 正是照它红的。 + +#### 整改方案与实施 + +- `ActionIncidents` 由「一个计数」变成三份状态:`counts` / `announced`(越阈値宣布过的设备)/ + `closures`(待发的关闭记录)。`noteFailure` 在越过 `INCIDENT_THRESHOLD` 时标「已宣布」; + `noteSuccess` **只在已宣布时**留一条 `IncidentClosure` 再清零;`takeClosure(key)` 取走即消费, + 所以通知天然只一轮。 +- 附著点放在 `execute` 成功返回处(模型看得见的结果只在这里产生),不在 `finishSuccess` / execute 级 + 那两处清零点拼——清零有两处而出口只有一处,在清零点拼必漏 `wait`。 +- 文案 `closurePrompt()` 逐句对齐 py 的收口三选项,但**带 `consecutive_failures` 而不带 Step 号**: + 本插件没有 step 概念(回合计数在宿主),这属 R21 要标注的载体差异,不是同一件事。 +- `presentationMeta` 不动(R16:只放小元数据,本轮没有新的审计需求)。 +- ④只加测试:三支 outcome 全锁(含 `tests_failed` 缺省与 0 的边界、blocked 不许被读成 completed)。 + +#### 验收标准 + +- [x] `pnpm --config.verify-deps-before-run=false test` ⇒ **188 passed / 1 skipped** + (基线 182,净增 6:3 条 CLOSED 锁 + 3 条 outcome 锁);`run build` ⇒ 0 error +- [x] 三条 CLOSED 锁的形状:宣布过 incident 后成功 ⇒ 带 `incident_closed:{consecutive_failures:3}` 且**再成功一次不带**; + 从未越过阈值的成功 ⇒ 不带;`wait` 这种无底层工具的成功 ⇒ 也带(这条就是附著点的锁) +- [x] **变异校验四条,各自报出断言**(逐条改、逐条跑、跑完还原并复验 188 全绿;R27 含第 6 条自证生效): + A `noteSuccess` 无条件留关闭记录 ⇒ 4 条红(含「从未宣布的成功不带通知」); + B `takeClosure` 取走不消费 ⇒ 「只发一轮」红; + C 把附著从模型出口挪回 `finishSuccess` ⇒ `wait` 那条红; + D 抹掉 outcome 里 `tests_failed > 0` 那支 ⇒ `expected { status: 'completed', …(2) } to deeply equal …` 红 +- [x] py 侧本片零改动 ⇒ `uv run python -X utf8 -m pytest tests/unit -q` 仍 **2604 passed / 7 skipped** + (第一次跑出 `1 error`:与本会话另一个后台 pytest 争用同一个 `traces/data_engine.db`;单跑该用例通过、 + 串行重跑全量 exit 0 ⇒ 判为环境产物而非回归。**并发跑两套 `tests/unit` 会得到假红**,值得记一笔) +- [x] 两枚文档门禁 + `--selftest` ⇒ exit 0 +- [ ] 留白(R21):以上是单测证明。真机上模型收到 CLOSED 后是否真的去收口原意图,属 P1 live 验收 + +### #63 G68 结案:复审轮四处缺陷——收口文案不再被信封吃掉、快照声明补上第二级的锁、句柄远跟按上界判缺失、不可解析的自读不再放行 + +- **日期**: 2026-10-13 +- **状态**: ✅ 已关闭(G68;离线面) +- **优先级**: 高(③⑤ 是会被静默误点的形状,①是「写了却没送到」的交付缺陷) +- **分类**: 四 · DSH 插件轨道 +- **方案原文**: 本节曾以「G68 复审轮」住在 `TODO.md` §五(spec 提交 `a551b30` 早于实现提交,按 R22),结案即删 + +#### 问题描述 + +按 AGENTS.md 的习惯给 #60/#62 派了两个只读复审子代理,报回 11 条,逐条复验后确认 5 条是代码/口径缺陷: + +① **模型看到的收口提示是被截断的半句**。`closurePrompt` 实测 216 字符(`d0`)/ 227(长 serial), +而结果信封 `render` 对**除 `hierarchy` 外的每个字符串字段**都砍到 `RENDER_TEXT_BUDGET = 200` +(`runDeviceAction.ts:105`、`:587-588`)⇒ 模型读到 `…still pending, or n…`。 +根因比这一条更宽:**全仓没有一条测试穿过 `output.render` 验模型可见文案**(唯一的渲染测试 +`apply.spec.ts` 的 G42 那条锁的是「hierarchy 不被砍 + 别的字段被砍」,正好是被漏掉的反面)。 +② **#60 的声明只锁了一半**(对已发布条目的更正,#60 正文不改写):#60 写「validator 梯内部的重取 +(预执行 `:365`、执行后 `:742`)不写快照」,复审核实时把 `observations.record(...)` 插进 +`reresolveAsCoordinate` 再跑全量 ⇒ **188 条全绿**。预执行那一级有锁,执行后那一级没有。 +③ **锚定标签无位移上界** ⇒ 观察到的节点消失后,新树里另一个同名元素会被静默当成它。 +且 `hierarchy.ts` 的注释当时把 py 的 100px 界说成「只约束无锚定标签」——读回上游原文才发现: +`precondition_xml.py:396-399` 对**每一次** shift 都有界(text/desc 100,resource-id 300), +`:392-395` 还明写「宁可把 shift 误判成 disappeared,也不把 disappeared 误判成 shift」。 +`validatorLadder.spec.ts` 的 `MOVED_TARGET`(411px)把这条无界远跟钉成了期望行为。 +④ **无标签的 `@x,y` 退化签名零覆盖**:所有测试构造器都带 `class`,那条分支从未被执行过—— +#60 声称的「无标签退 center+class」当时没有锁。 +⑤ **模型自己那次 `get_ui_hierarchy` 读到不可解析载荷时按成功原样放行**(落到 `finishSuccess(observed)`), +而同样的载荷在执行梯里是速败的(`hierarchy unreadable`)。两处口径不一致,松动的那一侧正是安全侧: +一次失败的读屏被当成「我看过了」。 + +#### 整改方案与实施 + +- ①:文案压进预算(`Execution incident CLOSED on ${key} after ${n} failures: settle the original intent — + already served, still pending, or no longer needed.`,最长 serial(`192.168.1.100:5555`)下 146 字符,`d0` 130), + **并**新增一条走 `output.render` 的锁:把四条模型可见文案(`pixel_guidance`/`self_check`/ + `audit_request`/`closure_request`)先各自断言长度 ≤ 200,再断言渲染结果里**逐字出现**。 + 预算数字本身仍只在 `runDeviceAction.ts` 一处,不进文档抄件。 +- ②:补上缺的那条锁——先观察 T1,跑一次**执行后**重解到 T2 的梯子,再拿只在 T1 里存在的 `#0` 动作 ⇒ + 仍按 T1 的身份判「不在了」并零次 tap。这条锁就是复审变异原本跑绿的那一处,现在会红。 +- ③:改成**每种标签都有自己的可达上界**(`identityOf` 带 `bound`:text/desc 100、resource-id 300、 + 只有 class 100、什么标签都没有 0=位置必须一致),同名候选取最近的一个,超出上界一律判 `gone`。 + 这同时推翻了 #40 的另一半:411px 的远跟不再是期望行为——`MOVED_TARGET` 改成 80px 的界内移动 + (#40「目标移动后按新坐标重试」这条功能保住),另加一条「界外不追」的锁断言 `category: disappeared` + 且只点一次。标签判定与渲染共用 `labelOf`,不会出现「模型看到的」和「代码认的」分家。 +- ④:造一个只有 bounds、无任何标签的节点,锁两半:位置未变 ⇒ 命中;位置变了 ⇒ 拒(`bound = 0`)。 +- ⑤:模型可见的 `get_ui_hierarchy` 结果为不可解析载荷时**速败**,文案与执行梯同一条口径 + (`task: hierarchy unreadable … re-run get_ui_hierarchy, or take a screenshot and use {coordinate}`)。 + **没有**顺手把该设备的活观察作废:spec 里原本打算作废,核过机制后判定多余——句柄在执行时本来就要 + 对**新鲜**层级复核身份,读废一次屏并不改变「上一次真正看到的列表」,作废只会把安全论证换成额外拒绝。 + 这条改在 #63 里如实记下(原方案在 `a551b30` 的条目文本中)。 + +#### 验收标准 + +- [x] `pnpm --config.verify-deps-before-run=false test` ⇒ **194 passed / 1 skipped** + (#62 结案时 188,本轮 +6:远跟不追 1、同名歧义 2、无标签 1、执行后不写快照 1、不可解析自读 1、渲染预算 1; + 其中 `MOVED_TARGET` 由 411px 改为 80px 属期望变更,不是新增) +- [x] `pnpm --config.verify-deps-before-run=false run build` ⇒ 0 error +- [x] **变异校验四条,各自报出断言名**(逐条改、逐条跑、跑完还原并复验全绿;R27 含第 6 条自证生效): + A 执行后那一级也写快照 ⇒ 2 条红(含新补的 `does not let the post-execution re-resolution claim a fresh observation`); + B 摘掉位移上界 ⇒ 3 条红(同名歧义两条 + `does not chase a same-labelled node that moved further than the label can claim`); + C 不可解析的自读原样放行 ⇒ `refuses a model-visible hierarchy read that is not a node list` 红; + D 收口文案改回长句 ⇒ 渲染预算锁红 +- [x] py 侧本片零改动 ⇒ `uv run python -X utf8 -m pytest tests/unit -q` ⇒ **2604 passed / 7 skipped**(与基线同数) +- [x] 两枚文档门禁 + `--selftest` ⇒ exit 0 +- [ ] 留白(R21):全部为单测证明。真机上「同名孪生是否真的没被点到」属 P1 live 验收(前置 **G59**) +- [ ] 复审报回但**本轮不夹带**的台账/装配卫生四项,另记 **#64**:`readmeLayout.spec.ts` 没复用 + `tests/support/srcFiles.ts` 且用全文 `includes`;`TODO.md` G66 行三处行号指针已漂; + §零 记的「四份重复注册装配」实际已是 5 份 `CONFIG` + 2 份 `reportWith`;`agentIdOfCall` 抛错也落进 + `"anonymous"` 车道而注释说那条车道仅直测用 + +### #64 复审轮的台账卫生四项;README 锁改吃共享文件遍历并只看 Layout 块 + +- **日期**: 2026-10-13 +- **状态**: ✅ 已关闭(台账/装配卫生;无 G 号,#63 里已登记为「本轮不夹带」的余下项) +- **优先级**: 低-中(不改行为,但一处会让锁被正文糊过去,一处是被证伪的旧说法) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +#63 报回但不属代码缺陷的四项,逐条核实: +① `tests/readmeLayout.spec.ts` 用 `readme.includes(name)` 扫**整份 README**,而正文别处也会提模块名 ⇒ + Layout 少一行照样绿。这不是假想:`src/dsh-types.ts` 当时就只在「契约要点」正文里出现过, + #61 那枚锁放行了它(本轮实测:删掉 Layout 行,旧形状无感、新形状红)。同一文件还自己手搓 + 两目录 `readdirSync`,而仓库早有 `tests/support/srcFiles.ts` 的递归遍历被两枚 conformance 锁在用 ⇒ + `src/` 下新增子目录的模块对这份锁**不可见**。 +② `TODO.md` G66 行两处行号指针已漂:`hierarchy.ts:118-131` 现在是 `handleFromObservation`, + `runDeviceAction.ts:364-371` 现在是 `captureFrame/compareFrames` 选项——**内容仍成立、指针不成立**(#60/#63 之后必然漂)。 +③ §零 记的测试债「四份重复的注册装配」实际已是五份(本轮 #62 又加了 `report()`),行没跟着改。 +④ `RouterOptions.agentIdOfCall` 的注释说 `"anonymous"` 车道只是直测产物,但宿主侧 + `ctx.agents.currentInitiator()` **抛错**时也落进同一条车道(`index.ts` 的 `agentIdOfCall` 有 try/catch)⇒ + 那是运行期真实存在的共享车道,注释把它写成测试专用是错的。 +⑤(自查)§零 第 5 条把 G67/#60 的**机制正文**抄了一份,而规则要求已结案差距在 TODO 只留一行指针。 + +#### 整改方案与实施 + +- ①:`readmeLayout.spec.ts` 改成复用 `typescriptFiles()`,并把比较范围收到 README 的 **Layout 围栏块**; + 顺带把 `src/dsh-types.ts` 补进 Layout 行(它本就是模块树的成员,正文那句是另一件事)。 +- ②:G66 行的行号指针换成**符号名**(`centerOf()` / `resolvePixelTarget` / `reresolveAsCoordinate` + + 测试组名),符号比行号活得久。 +- ③:测试债行按实测改成「五份」并点名 `report()` 与「各 describe 还各自 `const CONFIG`」。 +- ④:注释改成如实两条来源(未设置 / 宿主查询抛错),并写清这条车道的最坏后果是共享一次观察与一个去重键, + **不是**静默按位置点。 +- ⑤:§零 那段机制叙述压成指向 #60/#63 的一行指针,计数继续只认「测试面」那一行。 +- 另记一条自纠(R28):本轮第一段补丁脚本**不幂等**,重跑一次就把 `src/dsh-types.ts` 那行写了两遍, + 是 `grep -c` 复核时才发现的。改文件的脚本要么设计成幂等,要么每次重跑前先断言当前状态。 + +#### 验收标准 + +- [x] `pnpm --config.verify-deps-before-run=false test` ⇒ **194 passed / 1 skipped**(数量不变:本轮改的是锁的形状,不是条数) +- [x] **变异校验三条**:A 从 Layout 删一行 ⇒ 红;B 在 Layout 写一个不存在的模块名 ⇒ 红; + C 删掉 `src/dsh-types.ts` 的 Layout 行但正文仍提到它 ⇒ 新锁红、#61 的旧形状无感(这正是本轮要补的那个洞); + 跑完还原并复验全绿 +- [x] 两枚文档门禁 + `--selftest` ⇒ exit 0(`docs/TODO.md` 体量下降,README 体量行随 `--refresh-sizes` 重生成) + +### #65 #63 改了可达上界之后,工具描述对模型的承诺变成过度声称 + +- **日期**: 2026-10-13 +- **状态**: ✅ 已关闭(口径修正;无 G 号) +- **优先级**: 中(这是模型据以决策的一句话,说大了比说小了更有害) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +`run_device_action` 的 `target` 描述在 #60 那一轮写成「`{index}` … it follows that element **even if the screen +moved it**」。#63 把可达上界落实成每类标签各有界(text/desc/class 100、resource-id 300、无标签 0,越界判缺失) +之后,这句话就**比代码强**:屏幕真把目标挪到 400px 外时,工具是拒绝而不是跟随。同一个形状在 +`README.md` 的 Layout 行也有一份(「按节点身份跟随到当前位置」)。 + +#### 整改方案与实施 + +- 工具描述改成与实现等价的承诺:跟随的是「**还能被认出来的同一个元素**」,认不出就**拒绝而不是猜**, + 并且明写「没看过的句柄不存在」——模型据这句话决定要不要重新观察,宁可少承诺。 +- README 的 Layout 行补「在可达界内」,与 `hierarchy.ts` 的 `identityOf().bound` 同源。 +- 如实留白:描述文本与 `MAX_HANDLE_SHIFT` 的具体数字之间**没有**机械锁(那需要一个数值写进散文, + 正是本仓库禁止的第二份事实)。本轮靠「改上界必须同时改这句」的人工纪律,且 #63 的界外不追锁 + 保证了行为侧不会被误当成跟随。 + +#### 验收标准 + +- [x] `pnpm --config.verify-deps-before-run=false run build` ⇒ 0 error;`test` ⇒ **194 passed / 1 skipped**(无测试锁这段文案,数量不变) +- [x] `grep -c "even if the screen moved it" plugins/autogamer-device/src/tools/runDeviceAction.ts` ⇒ **0**(旧那句已不在) +- [x] 两枚文档门禁 + `--selftest` ⇒ exit 0 + +### #66 我自己加错的提交署名尾注:就地重写message抹掉,并证明树内容一字未改 + +- **日期**: 2026-10-13 +- **状态**: ✅ 已关闭(提交卫生;无 G 号) +- **优先级**: 中(不改行为,但署名是往仓库历史里写**假的归属**,留着比写错更糟) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +实现提交 `7d79864`(G68 结案)的信息末尾有一行 `Co-Authored-By: Claude `。 +两条都使它站不住:①本仓库历史从不带署名尾注——`git log -12 --format="%h%(trailers:key=Co-authored-by,valueonly,separator=)"` +除我这条外**全空**;②署名归属不该由 agent 自行写进用户仓库的历史。发现于我自己复核提交列表时, +先按 R28 记进 `TODO.md` §零(含抹法与判据),再动历史。 + +#### 整改方案与实施 + +提交**未推送** ⇒ 就地重写,且只改 message、不碰任何文件内容: +`git branch backup-trailer-fix`(留旧栈)→ `git checkout --detach HEAD~3` → `git commit --amend -F msg_clean.txt` +→ `git cherry-pick` 其后 3 条 → `git branch -f main HEAD`。 +新栈顶四条:`8178eb1`(原 7d79864,信息已净)→ `eedef8f` → `ed776e3` → `da28369`。 +**没有**用 `filter-branch`(要整仓扫,面大),也没有 squash(会把四条独立结案并成一条,丢历史形状)。 + +#### 验收标准 + +- [x] `git diff backup-trailer-fix HEAD --quiet` ⇒ exit 0 **且 `--stat` 无输出** ⇒ 重写前后**树内容逐字节相同**, + 变的只有提交信息与因信息而变的哈希 +- [x] `git log -25 --format="%h %(trailers:key=Co-authored-by,valueonly,separator=)" | grep -c Claude` ⇒ **0** + (判据本身按 R27 反向可用:同一条命令在重写前对 `7d79864` 就报出该行) +- [x] `git status` 干净;`pnpm --config.verify-deps-before-run=false test` / `run build` 未重跑—— + 树内容与 #65 结案时实测的 **194 passed / 1 skipped、0 error** 是同一份(由上一条 diff 为空证明) +- [ ] 安全网 `backup-trailer-fix` 分支仍在本地:**待用户确认后删除**(删分支属不可逆动作,不擅自动手) + +### #67 用户授权推送后 run 立刻产出:G54 的前提「push 从不触发」被一次直接观察推翻 + +- **日期**: 2026-10-13 +- **状态**: ✅ 已关闭(G54 以「前提不成立」关闭,**成因未查明**且不作为结论) +- **优先级**: 高(这条一直挡着 #46/#54 两处「CI 从未执行」的留白) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +G54 的在册表述是「本 fork 的 `on: push` **从不**触发 workflow run」。支撑它的是实测:6 次 push 全部 +收到(含 `e8c2204..4201e66` 之前那批),`gh api .../commits//check-runs` 一律空;账号级被 +`llama-launcher` 同日 push run 否证、org 政策被同组织兄弟 fork 否证、fork 默认关闭被官方文本削弱 +(fork 关的是 `schedule:`,本仓库没有)。结论一直写死在 `TODO.md` §四、`registry.md`、`AGENTS.md` +与 `ci.yml` 的注释里。 + +本轮用户说「提交并推送」⇒ `git push origin main` 把 `e8c2204..4201e66`(29 条)推上去,**run 立刻出现**: +`databaseId=38296414103`,`event=push`,`headSha=4201e66e4bdf…`,`createdAt=2026-10-12T19:15:18Z`, +6 条 job 全部执行。⇒ **「从不」这条被一次直接观察否证**。同一次 run 还顺手关掉两处留白: + +- #46 写的「`Check documentation gates` 至今一次都没执行过」——两条 python job 里该 step 都不在失败清单中, + 即**在真 run 里 exit 0**; +- §零 第 4 条写的「修完 G53 之后没有再跑过任何 run」——现已过期。 + +**没有**同时得到的是**成因**:这 6 次与这一次之间的差别,我没有受控证据(Actions 开关被谁动过、 +分钟数/计费窗口、还是别的),所以本条只登记「前提不成立」,不写「原来是因为 X」——写不出证据的因果 +正是 R28 要防的那类。 + +#### 整改方案与实施 + +只回写台账,不猜原因,也不动 `.github/workflows/ci.yml` 的触发条件(`on: push` 本来就在,也确实生效): +`TODO.md` §四 的 G54 行移除并结案、`registry.md` 从「现行在册」移入「已关闭」并把 G60 那行的 +「真 run 未观测(G54)」改成已观测、`AGENTS.md` 与 `ci.yml` 里那句「G54 means push produces no run」 +改成本次的实测口径。唯一红的那条 job 是另一件事,见 **#68**。 + +#### 验收标准 + +- [x] `gh run list --repo giveUphope/autoGamer --limit 2 --json databaseId,event,headSha,status,conclusion` + ⇒ 首条为 `{38296414103, push, 4201e66e…, completed, failure}`(`failure` 的原因见 #68,不是没跑) +- [x] `gh run view 38296414103 --repo giveUphope/autoGamer --json jobs --jq '.jobs[]|[.name,.conclusion]'` + ⇒ `Frontend success` / `Python 3.12 ubuntu|windows = failure(步骤名只有 fixture 那一条)` / + `DSH plugin contracts ubuntu|windows success` / `Build and smoke-test success` +- [x] `gh api repos/giveUphope/autoGamer/commits/4201e66/check-runs` ⇒ `total: 6`(此前每个被推的 sha 都是 0) +- [x] 两枚文档门禁在**该 run 里**通过(不是本地自证) + +### #68 那次 run 唯一红的 job 是我自己加的 fixture 重导步:它吃 dist/,而 python job 从不构建插件 + +- **日期**: 2026-10-13 +- **状态**: ✅ 已关闭(CI 侧待下一个 run 复验) +- **优先级**: 高(一条 un-gated 的门禁步骤在 CI 里恒红,等于给仓库加了不可用的红灯) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +`gh run view … --log-failed` 两条 OS 同因: + +``` +Error [ERR_MODULE_NOT_FOUND]: Cannot find module '…/plugins/autogamer-device/dist/actionClient.js' +imported from …/plugins/autogamer-device/scripts/spike-mcp.mjs +``` + +`spike-mcp.mjs` 要用**插件自己的** MCP stdio 客户端去连 py 动作面(这正是 #54 的用意:期望面由 +`ACTION_TO_PY_TOOL` 派生,不再手抄),而 `dist/` 是**构建产物**、被 gitignore。`python-quality` job +装了 uv、装了 Node,却**从不构建插件**——那一步是我在 #54 加的,当时只能在本地验(本地有 dist), +于是「本地绿」被当成「CI 有这条门禁」。#54 结案时这半句本来就被 G54 挡住无法验证, +现在 run 能跑了,它立刻露红——正是 R27 第 4/5 条说的形状:**没跑过的门禁不许当验收**。 + +#### 整改方案与实施 + +`.github/workflows/ci.yml` 的 `python-quality` job: + +- 在 `Set up Node.js` **之前**加 `Set up pnpm`(`pnpm/action-setup@v4` version 10),并把 setup-node + 改成带 `cache: pnpm` + `cache-dependency-path: plugins/autogamer-device/pnpm-lock.yaml` + ——顺序照 `plugin-contracts` job 的注释来,pnpm 先装才挂得上 store cache; +- 新增一步 `Build the plugin for the fixture check`(`working-directory: plugins/autogamer-device`, + `pnpm install --frozen-lockfile` + `pnpm run build`),放在那步比对**之前**; +- 把「怎么在本地看到 runner 看到的东西」写进步骤注释:先 `rm -rf …/dist` 再跑那条命令。 + +#### 验收标准 + +- [x] **互为证据的本地两条**(本轮实测): + ① `rm -rf plugins/autogamer-device/dist` 后直接 + `SPIKE_COMMAND="uv run artemis mcp --type mock" node plugins/autogamer-device/scripts/spike-mcp.mjs` + ⇒ **exit 1**,报的就是 CI 那条 `ERR_MODULE_NOT_FOUND`(复现成立); + ② 补上 `npm --prefix plugins/autogamer-device run build` ⇒ **exit 0**,末行 + `[spike] OK — pipe, tool surface, and normalization verified` +- [x] `python -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml'))"` ⇒ 解析通过, + `python-quality` 步骤数 **13 → 15**(新增 `Set up pnpm` 与 `Build the plugin for the fixture check`) +- [ ] **CI 复验留白**:推送后**下一个 run** 里这一步必须绿;在那之前这条只能算「本地复现 + 静态检查」, + 不许勾成 CI 已证(R27 第 4 条) + +### #69 修好 fixture 步之后,ratchet 第一次被执行:基线 754 已腐了六周,且它的唯一本地执行点从未安装 + +- **日期**: 2026-10-13 +- **状态**: ✅ 已关闭(本轮的可修部分);执行点缺失另立 **G69** 待裁定 +- **优先级**: 高(一条声称在管债务的门禁,从被写下来那天起就没跑过) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +#68 推上去之后 run `38297129950` 变绿了 fixture 那步,却红在下一步 `Enforce quality ratchets`: + +``` +quality regression: broad_exception_handlers increased from 754 to 763 +``` + +本地一条命令复现同数字(`uv run python scripts/quality_ratchet.py` ⇒ exit 1)。往下追,找到**两道**执行缺口: + +1. **CI 侧**:`.github/workflows/ci.yml` 的 `python-quality` job 里,`Enforce quality ratchets` 排在 + Node/pnpm/build/fixture 那组**之后**。GitHub Actions 的 step 失败会中止整个 job ⇒ 只要 fixture 步红, + ratchet 就永远不被执行;而 fixture 步在 #68 之前一直是红的(#67 之前根本没有 run)。 +2. **本地侧**:`.pre-commit-config.yaml` 第 16-19 行**声明了** `quality-ratchet` 这个 hook, + 但本检出的 `.git/hooks/pre-commit` **不存在**(`ls` 无、`git config core.hooksPath` 未设)⇒ + 声明的执行点从未安装。AGENTS.md 写着「pre-commit also runs scripts/quality_ratchet.py」, + 那句话在本机上是**不成立的**——这正是 R27 第 4 条的形状:没跑过的门禁不许当验收。 + +**归属实测**(不是推测):基线最后一次改动是 `67248c8`(2026-09-02)。用临时 worktree 在本会话起点 +`8eecb39` 上重跑计数 ⇒ **762**;也就是说 754→762 这 8 个**早于本会话**,本会话只加了 1 个 +(`artemis/mcp/adb_server.py` 的 `list_devices`,`except Exception as e` ⇒ `return f"Error: {e}"`, +与该文件其余 21 处同款错误面;不是新引入的坏味道)。 + +#### 整改方案与实施 + +- `.github/workflows/ci.yml`:把 `Enforce quality ratchets` 与 `Type-check protected core modules` + **提到** Node/pnpm 那组之前(两条都是纯 python、秒级),并写明理由——排在会中止的贵步后面, + 等于给门禁加了「看不见」的失败模式。 +- `.quality-baseline.json`:`broad_exception_handlers` 抬到**实测值 763**(不是四舍五入,不是「留点余量」), + 并把归属写进本条;顺手把 `type_ignore_comments` 的上界从 18 **收到实测 16**——脚本把当前值低于基线 + 报成 improvement 却不自动收紧,那这 2 个的下降就得由人**明确**收紧,否则基线只是个宽松的天花板。 +- `silent_broad_exception_handlers` 保持 0(当前实测就是 0,没被抬)。 +- 执行点缺失(pre-commit 没装)**不在本轮擅自补**:`pre-commit install` 会往 `.git/hooks/` 写东西, + 改变此后每次提交的行为,属用户决定 ⇒ 立 **G69** 记着。 + +#### 验收标准 + +- [x] 本地两条互为证据:改基线前 `uv run python -X utf8 scripts/quality_ratchet.py` ⇒ **exit 1** + 并打印 `increased from 754 to 763`;改后 ⇒ **exit 0** 且回显 `763 / 0 / 16` +- [x] `uv run python -X utf8 scripts/quality_ratchet.py --print-current` ⇒ 与写入基线的三个数**逐字相同** + (基线不许是人手估的) +- [x] `yaml.safe_load` 解析 `ci.yml` 通过;`python-quality` 步骤顺序实测为 + `… Check documentation gates → Enforce quality ratchets → Type-check protected core modules → Set up pnpm → …` +- [x] 两枚文档门禁 + `--selftest` ⇒ exit 0 +- [ ] **CI 复验留给下一个 run**(本轮只能证明「本地」):两条 python job 里 ratchet 步必须绿 + +### #70 本 fork 第一次整体绿 CI:run 38297953839(push,6/6 job),三处「CI 从未执行」的留白同时闭合 + +- **日期**: 2026-10-13 +- **状态**: ✅ 已关闭(push 面);`workflow_dispatch` 那条测试步另计,见本页末与 **G70** +- **优先级**: 高(此前所有「门禁已接进 CI」的说法都缺一手证据) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +#46 接进文档门禁时留了一句白:「未验证到真 run」;#54 接进 fixture 比对时同样只能本地自证; +#68/#69 修的那两步也必须由 run 复验。仓库里有过的唯一一次 run 是 `38167498398` +(`workflow_dispatch`,2026-10-11),整体 `conclusion=failure`。 + +#### 整改方案与实施 + +无需新代码——把前面几轮的修法推上去拿证据: + +- `git push origin main`(`bbf6f1b..9c9c968`)⇒ run **`38297953839`**,`event=push`, + `conclusion=success`,6 条 job 全绿:`Python 3.12 / ubuntu-latest`、`Python 3.12 / windows-latest`、 + `DSH plugin contracts / ubuntu|windows`、`Frontend tests and production build`、 + `Build and smoke-test Python packages`。 +- 逐步取证(`gh run view … --json jobs`):两条 python job 里 + `Check documentation gates`、`Enforce quality ratchets`、`Check the py action surface against the plugin fixture` + **全部 success** ⇒ #46 / #54 / #68 / #69 各自那条「等真 run 复验」的白,至此有了一手证据。 + 已发布的条目正文不改写(编号发布后不得改写),闭合事实记在本条。 + +#### 验收标准 + +- [x] `gh run view 38297953839 --repo giveUphope/autoGamer --json conclusion,jobs` + ⇒ `success` + 6 条 job 全 `success` +- [x] `gh run view 38297953839 --json jobs --jq '…steps…|test("documentation gates|quality ratchets|py action surface")'` + ⇒ 两台 OS 各 3 行 `-> success` +- [x] `gh api repos/giveUphope/autoGamer/commits/9c9c968/check-runs` ⇒ 该 sha 的 check suite 存在(对照 #67: + 此前每个被推的 sha 都是 0) +- [ ] **反面证据同页留着**:同 sha 的一次 `workflow_dispatch`(run `38298278053`)**failure**, + 红在唯一一条 dispatch-only 步骤 `Run deterministic Python tests`(两台 OS 同因, + 失败清单含 `tests/unit/test_cli.py` 的一组 help 断言与 `tests/unit/sdk/test_agent_config_builder_tuning.py` 9 条)。 + 这条不是本轮改坏的:它从未在 CI 跑过。本地复现(同命令同 cov 旗标)起了但被用户叫停 + (「先强推不考虑 ci」),所以**成因未定**,现象与取证缺口登记在 **G70**,不在本条下结论 + +### #71 清空旧待办:P4 阶段 A/B 归档,阶段 C/D 与 10 个未关闭差距直接放弃 + +- **日期**: 2026-10-14 +- **状态**: ✅ 已关闭(归档 + 放弃面) +- **优先级**: 高(旧方案收口,新方案的前置) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +用户要求清空 TODO、按新方案重来。旧树在本地分支 `dsh-plugin-contraction` 上有 6 笔未推送的 +P4 提交(阶段 A/B 完成、阶段 C/D 未做),从未写过归档;TODO 另有 10 个未关闭差距 +(G17/G23/G25/G26/G27/G36 余部/G59/G66 余部/G69/G70)、未闭环的 P1–P3 里程碑与大量已结案 +spec 正文。分支无 upstream 配置(未推送),CI 不受影响。 + +#### 整改方案与实施 + +1. **P4 A/B 归档**:`2a65211`(py 收缩)/`75680fe`(插件清理)/`caf93a7`+`e0e7556`(文档回写)/ + `85d6622`(B.6 二次删除)/`fdf6581`(helper smoke)/`ea5e661`(收尾); + `git rev-parse pre-contraction` 核为 `3fab0ee`,一字未动。阶段 C/D(pyproject/入口/文档失效描述) + 与 10 个差距直接放弃,不再承接。阶段 C 放弃的后果:pyproject 仍引用已删的 workspace + 成员,本分支 `uv run` 不可用,门禁与测试改走 `.venv\Scripts\python.exe` 直连(本条验收即此)。 +2. **TODO 清空**:删差距表、里程碑 checklist、P4 专题与全部历史 spec 正文;`D1-D18`/`S1-S11` + 冻结保留——删表会红两枚文档门禁(定义源)并孤立已发布条目里不可改写的引用, + 此处与「全清」原话的偏差如实记录(R28)。 +3. **registry 同步**:9 个差距行移入已关闭(关闭方式=放弃),G36 行同步 UI 卡片侧放弃;现行在册清零。 +4. **门禁自测维修**:`check_doc_tables.py --selftest` 在干净树已 exit 1((e) 锚点 `D1-D17` + 在 D18 登记后失效;(b) 与 registry 自测的 gap 锚点假设 TODO 永有差距行,清空后不成立)。 + 锚点改为动态(D 表行兜底、D 上界读 heading/registry 现值),并随自带变异注入复验。 +5. `migration/README.md` 体量表 `--refresh-sizes` 重生成;两处指向已删 TODO 节的活引用 + (README 入口表、glossary P0-P4 行、registry 末节)同步改指。 + +#### 验收标准 + +- [x] `.venv\Scripts\python.exe -m pytest tests/unit -q` ⇒ 447 passed / 1 skipped(本轮实跑) +- [x] 插件 `pnpm --config.verify-deps-before-run=false run test` ⇒ 191 passed / 1 skipped(本轮实跑) +- [x] `dsh --profile web --patch --dump-config` ⇒ exit 0 且无 `unmatched`(本轮实跑; + `--dump-config-schema` 仍败,见放弃的 G59) +- [x] 三枚门禁 ⇒ exit 0:registry、tables、`tables --selftest`(含自带变异注入全数命中) +- [x] `git status --porcelain=v1` 干净、`pre-contraction` 仍为 `3fab0ee`、本分支无 upstream(未推送) +- [ ] 新方案到达后按 R22 落条目(本条只关旧账) + +### #72 Phase 1 落地:骨架矫正、预检与只读设备状态工具 + +- **日期**: 2026-10-14 +- **状态**: ✅ 已关闭(离线验收面;live 待外部提供方) +- **优先级**: 高(新方案首个实施阶段) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +新方案 Phase 1(骨架与预检)须按 Phase 0 的十项核实结论实施:规格里 +`{en,zh}` 描述、`installSection`、`contribute`、`ready`/`dispose` 事件、CLI 后端等写法 +均已被证否或限用,照抄等于把已撤回的猜测写进代码(R20)。 + +#### 整改方案与实施 + +新增 `src/artemis/types.ts`(最小后端接口)、`src/artemis/mcpBackend.ts` +(外部 ARTEMIS 的 MCP stdio 懒连接,传输失败 `environment:`、载荷漂移 `internal:`)、 +`src/env/preflight.ts`(7 项预检,60 秒缓存,永不抛、永不弹窗)、 +`src/tools/getDeviceState.ts`(`autogamer_get_device_state`,预检门禁 + 串号清洗 + +只读并发声明);`index.ts` 扩展 Config(无 description)、apply 内装配后端与预检、 +注册第三工具、双客户端同 effect 析构;inject 保持不变(settings 待 Phase 2 活证明)。 +既有三处锁同步更新(双 conformance 三工具断言、Config 全字段、G30 寿命断言)与 +README Layout;全部偏差记在 TODO §六「Phase 1 偏差与落地」。 + +#### 验收标准 + +- [x] `pnpm run build` exit 0;vitest 24 文件 220 passed / 1 skipped(净增 29,本轮实跑) +- [x] R6 `--dump-config` exit 0 且 0 unmatched(本轮实跑;patch 文件未改,Config 属代码层) +- [x] 文档双门禁 + 自测 exit 0;`ruff` 无 py 改动不涉及 +- [ ] live:真实设备状态与 R7 新会话验证,待外部 ARTEMIS 提供方配置后补 + +### #73 Phase 2 落地:完整工具集(后端真映射、适配器工厂、诊断合并预检) + +- **日期**: 2026-10-14 +- **状态**: ✅ 已关闭(离线验收面;live 待外部提供方) +- **优先级**: 高(新方案第二个实施阶段) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +Phase 2 的四个工具须映射外部 ARTEMIS,但动工前重核旧 face 发现规格 §4 的后端假设 +多处与真 face 不符(run 取 `task_desc`/`trace_id`、device state 取必填 view_type 且答纯 +文本、inspect/diagnose 的动作与开关对不上)。按未经核实的假设写映射,等于复刻 Phase 0 +已裁掉的那类猜测实现(R20)。 + +#### 整改方案与实施 + +映射一律按 `pre-contraction:mcp_server/tools/*.py` 落锁(偏差表见 TODO §六「Phase 2 +偏差与落地」):后端补齐 start/manage/inspect/diagnose 四方法(同错误词汇),适配器为 +工厂(CLI 已砍),`envGate` 统一拒绝口径,四个工具各按真契约实现(5 步预算、summary +切片、诊断合并本地预检)。Phase 1 的 device-state JSON 映射按真契约重写。三处旧锁同步 +到七工具,README Layout 同步。 + +#### 验收标准 + +- [x] `pnpm run build` exit 0;vitest 29 文件 250 passed / 1 skipped(净增 30,本轮实跑) +- [x] R6 `--dump-config` exit 0 且 0 unmatched(本轮实跑;patch 文件未改) +- [x] 文档双门禁 + 自测 exit 0 +- [ ] live:七工具真调用与 R7 新会话验证,待外部 ARTEMIS 提供方配置后补 + +### #74 Phase 3 落地:浏览器半(toolview 卡片、手写 ModuleLoader 形态) + +- **日期**: 2026-10-14 +- **状态**: ✅ 已关闭(离线验收面;挂载实证待真会话) +- **优先级**: 高(新方案第三个实施阶段) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +Phase 3 的对话流 UI 须落在 DSH 真实存在的槽与通道上。规格 §8 的 +`conversation.card`/`ctx.rpc`/`settings.plugin.item`/二进制推送已在 Phase 0 查无, +而浏览器构件格式(ModuleLoader)、注册形态、keyed 分发、组件输入等规则散在上游 +模板与 slots.md 里,不先读就写必然复刻模板猜测。 + +#### 整改方案与实施 + +按三份上游一手原文实施:`package.json` 加 `dsh.client` 与 `./client` 导出; +`client.js` 手写 ModuleLoader 形态(React 来自浏览器模块表,`inject: ['slots']`), +五个任务工具各注册一格 `tool.call.toolview`(按 wire 名 key),卡片只渲染调用自带 +数据;DeviceMirror 即设备状态格占位;轮询/控制/设置卡片推迟到挂载实证后。单测锁 +manifest、形态、五 key、import 白名单与六项否定锁。偏差表见 TODO §六。 + +#### 验收标准 + +- [x] `pnpm run build` exit 0;vitest 30 文件 256 passed / 1 skipped(净增 6,本轮实跑) +- [x] R6 `--dump-config` exit 0 且 0 unmatched(本轮实跑;patch 文件未改) +- [x] 文档双门禁 + 自测 exit 0 +- [ ] live:槽解析、key 生效与卡片渲染需真会话挂载实证(R7/R9) + +### #75 Phase 4 落地:镜像 Host 半(scrcpy 启停、触摸降级;流与下载明确不做) + +- **日期**: 2026-10-14 +- **状态**: ✅ 已关闭(离线验收面;live 全部推迟) +- **优先级**: 高(新方案第四个实施阶段) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +Phase 4 的设备实时画面撞上两道硬边界:DSH 无二进制通道(Phase 0 已裁 H.264 推送), +且 scrcpy 的设备侧协议(版本严格一致、开关表、启停序列)不先读一手文档就会写成猜测 +(R20)。本机亦无 scrcpy/adb,任何真机断言都无从执行。 + +#### 整改方案与实施 + +先读 scrcpy README(Apache-2.0 与官方源警告)、`doc/develop.md`(执行章)、 +`Options.java`(开关表),再定诚实子集:`src/scrcpy/server.ts`(resolve 拒猜版本、 +文档原样 push/forward/launch、stop 最佳努力、fehler 全懒)、`src/scrcpy/input.ts` +(触摸转 adb input 纯函数);`stream.ts` 与下载脚本明确不建(原因见 TODO §六); +Config 加 `scrcpyServerPath`;index 只构造加析构;README 手动安装说明。单测 15 条锁 +resolve/argv/启停顺序/输入换算。 + +#### 验收标准 + +- [x] `pnpm run build` exit 0;vitest 31 文件 271 passed / 1 skipped(净增 15,本轮实跑) +- [x] R6 `--dump-config` exit 0 且 0 unmatched(本轮实跑;patch 文件未改) +- [x] 文档双门禁 + 自测 exit 0 +- [ ] live:20fps/触摸延迟/无残留进程,需真机 + scrcpy + 可用通道(通道尚不存在) + +### #76 Phase 5 落地:rules 注入与收尾(错误路径补齐、README 完整化) + +- **日期**: 2026-10-14 +- **状态**: ✅ 已关闭(离线验收面;文本到达模型上下文待真会话) +- **优先级**: 高(新方案第五个实施阶段;五阶段离线面至此齐) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +收尾须同时关三件事:rules 注入须走真 API(`contribute` 已被证否),P5 前 review +提出的覆盖缺口与 stale 须清,README 须完整。另 `scrcpyEnabled` 等三个无消费者 +Config 须定去留,不能留在"以后再说"。 + +#### 整改方案与实施 + +新增 `src/prompt/rulesInjector.ts`(每次挂载重读,缺什么都 warn 跳过); +dsh-types 加 systemPrompt 镜像(含双出处);inject 加 `systemPrompt`,可解性由 +Tier-1 真 runtime 挂载实证(新增显式断言);`scrcpyEnabled` 接通(关则不建 manager), +另两个保留并注释;review 修 4 stale、补 5 覆盖、留白总账 16 项进条目;README 加 +rules 段落与 Layout 行。偏差表见 TODO §六。 + +#### 验收标准 + +- [x] `pnpm run build` exit 0;vitest 32 文件 284 passed / 1 skipped(净增 13,本轮实跑) +- [x] R6 `--dump-config` exit 0 且 0 unmatched(本轮实跑;patch 文件未改) +- [x] 文档双门禁 + 自测 exit 0 +- [ ] live:文本到达模型上下文、全部留白总账项,需真会话 + 外部提供方 + 真机 + +### #77 CI 修绿:把 CI 改成描述收缩后的树 + +- **日期**: 2026-10-14 +- **状态**: ✅ 已关闭(以新 run 全绿为验收) +- **优先级**: 高(收尾:分支可合入的最后门槛) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +首个 push run(`38371033938`)6/6 红。逐条读日志定根因,无一条指向新方案代码, +全部是 CI 配置仍描述旧树:前端/smoke-test 的 setup-node 缓存路径、依赖源门禁的文件 +glob、fixture 步调用的生成器,分别指向 P4 已删的控制台、lockfile 与脚本。另有一条 +真产品因:P4 重写 `artemis/__init__.py` 时丢了 `_legacy_imports` 寻址器(含其模块), +旧 TODO 曾写"原样保留"(随旧 TODO 删除而不再流传,此处如实更正)——致安装后上下文 +`import third_party` 全灭,fixture 步在隔离环境以 `ModuleNotFoundError` 红。 + +#### 整改方案与实施 + +1. ci.yml:删 frontend 整 job(新方案无控制台)、删 package 整 job(轮子主题已亡: + 控制台资源、client 包、smoke 断言皆无所指)、删 fixture 步里的 gen-sse 调用 + (生成器随其 fixture 在 B.5 同亡)。dispatch-only 步不动(G70 已放弃)。 +2. pyproject:去 client 依赖/workspace 成员/sources/admin 入口/testpaths 与 pythonpath + 的 apps 项;构建声明 `artemis*` + `third_party*` 命名空间包(代换已丢的寻址器, + 并堵 flat-layout 对未清残留目录的误发现);uv.lock 重锁(15 行净减,无镜像源)。 +3. 门禁与范围同步:依赖源脚本去 npm 半与 client 行;pyright-core 去 6 个已删路径。 +4. 本地按 CI 顺序实证:dep 门禁、ruff 双项、文档双门禁与自测、ratchet、pyright、 + pytest 447/1、隔离环境 `uv sync --locked` + fixture 步(`uv run artemis mcp + --type mock` 零 diff)——`uv run` 在本分支复活。 + +#### 验收标准 + +- [x] 上述本地链条全绿(本轮实跑,含隔离环境) +- [x] 新 push run 全绿:run `38376354428`(`d8a6d2e`,4/4 job success,双 OS) + +补记(首个绿前 run 又红以后查明):`src/env/preflight.ts` 从未进过仓库——根 +`.gitignore` 的 `env/` 在 Windows 大小写不敏感匹配下吞掉了整个 `src/env/` 目录, +`git add -A` 静默跳过、本地全绿掩盖了它,CI(Linux 大小写敏感)以 +`Cannot find module './env/preflight.js'` 全红构建步。修法:venv 类规则一律锚定 +到仓库根;同类全树排查仅此一处。教训:加文件后必须看 `git status` 确认新文件在列。 + +### #78 仓库卫生与主线定位:清死分支、删安全网、默认分支迁到插件主线 + +- **日期**: 2026-10-13 +- **状态**: ✅ 已关闭(本机可验部分已验;在办余项另立 **G71**) +- **优先级**: 中(不阻塞功能,但决定「以哪条分支为基面」) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +1. `origin` 上挂着三条死分支。机械核对:`playground-submit` 相对 `origin/main` **ahead 0** + (`1d82d59`,9-11,已是 main 的祖先);`dependabot/npm_and_yarn/apps/showcase_ui/…` 落后 296, + 且它改的 `apps/showcase_ui/` 在 main 上**已不存在**(`git ls-tree main:apps` 只剩 + `admin_console`、`showcase_ui_v2`);`dependabot/uv/uv-2118ef368f` 落后 262,唯一未落地项是 + `uv.lock` 的 pyjwt `2.13.0 → 2.15.0`(main 仍钉 `2.13.0`)。`gh pr list --state all` 返回空数组 + —— 本 fork 历史上**没有任何 PR**,这三条是连 PR 都不存在的孤儿。 +2. 本地 `backup-trailer-fix`(`06a7f14`)是 **#66** 抹署名尾注时留的安全网,4 个提交从未 push。 +3. 主线定位错位:远端默认分支仍是 `main`,而 `main` 已被用户裁定**不再同步**,插件主线在 + `dsh-plugin-contraction`(领先 26、落后 0)。 +4. 改名提交 `70cc7af` 后,工作树里 `plugins/autogamer-device/` 只剩 116M 未跟踪残骸 + (`git ls-files` 计数 0),其内容除 `node_modules`/`dist` 外是约 40M gitignored spike 转储。 + +#### 整改方案与实施 + +1. **只动 `origin`**(本 fork);`upstream` = `google/artemis` 是别人的仓库,一条没碰。 + `git push origin --delete` 三条死分支。仓库内**没有** `.github/dependabot.yml`, + 所以删掉也不会「再长回来」;那条例外信息(pyjwt 未落地)在本条留证即可,要补跑 + `uv lock --upgrade-package pyjwt`,不必找回分支。 +2. `git branch -D backup-trailer-fix`:删前用 `git patch-id --stable` 逐条证伪「独有工作」—— + 4 个提交与 main 里的重写版一一对偶(`06a7f14→da28369`、`8a7d4f3→ed776e3`、 + `3fc3b62→eedef8f`、`7d79864→8178eb1`,提交主题逐字相同)。 +3. `git checkout -b dsh-plugin-contraction --track origin/dsh-plugin-contraction`, + **main 不快进**(用户裁定)。 +4. 残骸目录走**带门禁的删除**:仅当 `git ls-files <旧目录> | wc -l` 为 0 才 `rm -rf`; + 被跟踪的 spike 证据(`web-live.patch.yml`、`wire-tools.py` 等)在新目录里完好。 +5. 插件本机环境按 AGENTS.md 旗标组合装(本机 pnpm 10.12.1 = CI 同版本),见下方验收。 +6. 默认分支:`gh api repos/giveUphope/autoGamer -X PATCH -f default_branch=dsh-plugin-contraction`。 + 第一次误加 `--repo`,`gh api` 报 `unknown flag` 而**未生效**(远端仍 `main`,复验才发现)—— + `gh api` 的仓库权威就是 endpoint 路径,**R26 要防的「打到上游别人的仓库」并未发生**。 +7. 调研结论按 **R28** 当轮回写 [upstream-plugin-forms](migration/upstream-plugin-forms.md) 八; + 未关闭的那件事立 **G71**,本条不重述其正文。 + +#### 验收标准 + +- [x] `git ls-remote --symref origin HEAD` → `ref: refs/heads/dsh-plugin-contraction` +- [x] `git branch -r` → `origin/` 只剩 `main` 与 `dsh-plugin-contraction`;`upstream/*` 未变 +- [x] `git status --porcelain` 为空(含 build/test 之后;`dist/`、`node_modules/` 均被 ignore 覆盖) +- [x] `pnpm --config.verify-deps-before-run=false install --frozen-lockfile --config.autoInstallPeers=false` + 成功且 `pnpm-lock.yaml` 零改动 +- [x] 插件 `pnpm run build` 通过(27 个 `dist/*.js`);`pnpm run test` 真实 `$?`=0, + vitest **32 files / 284 passed / 1 skipped** +- [x] R6:`dsh --profile web --patch <绝对路径> --dump-config` rc 0、`grep -ci unmatched` = 0, + 且输出含 6 处 `dsh-autogamer`(证明非空过) +- [ ] profile 组合包按官方管理面重做 —— 未完成,记 **G71** + +教训两条:① 管道末端 `tail` 的退出码不是被测命令的退出码,判红绿要取未进管道的 `$?` +(与 **#49** 同源);② 说「某文件被这次运行改了」要能指到 mtime——本条复核后更正了一处 +口头归因:`web/package.json` mtime 仍是 `2026-10-12 02:10`,被每次 `dsh` 调用重写的只是 +生成的 `cordis.yml`(内容 `[]` 模板)。 + +### #79 预检承认 project-venv 提供方形态(G77 结案) + +- **日期**: 2026-10-13 +- **状态**: ✅ 已关闭(真机真环境 + 三条变异均验) +- **优先级**: 高(预检是每次工具调用的门禁,红则全链不可用) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +在有设备的机器上跑真预检(§零 Step 2):七项里 **`artemis-installed` 与 `helper` 两项同因红**。 +`installedCheck` 把「装过」等同于 `uv tool list` 里有 artemis(本机实测输出 `No tools installed`), +`helperCheck` 又假定 PATH 上存在 `artemis` 可执行文件;同一台机器上 `uv run artemis mcp --type adb` +却完全可用(§零 Step 0 已验)。根因是提供方的第二种安装形态——**project venv / editable**(`uv sync`) +不留 PATH 二进制,也不是 `uv tool` 的记录对象。旧文件头注释还把「不用 `uv run`」的理由写错过: +它担心"bare `uv run` 在调用方目录执行",而 `uv run --project ` 恰好就是消除该问题的形态。 + +#### 整改方案与实施 + +`plugins/dsh-autogamer/src/env/preflight.ts`: + +1. 新增共享探针 `venvProbe()` = `[uvPath, "run", "--project", , "--no-sync", "artemis", …]`。 + **`--no-sync` 是刻意的**:预检只读,不得改动提供方环境。 +2. `installedCheck`:先 `uv tool list`;未命中且配了 repo 才走探针;**两者都不含 artemis 才失败**, + 失败文案同时给两条安装命令(`uv tool install -e` 与 `uv sync --project`)。两处 `pass` 的 detail + 标明是哪种形态答的(`uv tool:` / `project venv:`),便于远程诊断不猜。 +3. `helperCheck` 接 `input`:先 PATH,失败再走同一探针。 +4. `firstArtemisLine()`:真 banner 的第一行是 `┌──── ARTEMIS ────┐`,只 `trim()` 会把边框当详情; + 故剥掉 box-drawing 字符后取**最长的含名行**(那行才带版本)。 +5. 文件头的偏差说明按上述事实重写(旧表述不静默保留,R28)。 + +`tests/preflight.spec.ts`:新增 5 条锁、改 2 条旧锁。必须改的原因是测试夹具 `scripted()` 对**未登记的命令 +默认返回 `{code:0}`**——不显式脚本化 venv 探针,那两条旧「应当失败」的用例会因假绿而失去意义。 + +#### 验收标准 + +- [x] `pnpm run build` rc 0;`pnpm run test` = **32 files / 289 passed / 1 skipped**(原 284/1) +- [x] **R27 变异校验三条全部证伪**(每条先断言锚点命中数 = 1,再注入): + M1 去掉 venv 分支 → 红在 `preflight.spec.ts:145`;M2 只看退出码不要求输出含 artemis → 红 2 条, + 含 `:166` 的「`exit 0` 但无 artemis 不算过」;M3 去掉 helper 的 venv 回退 → 红在 `:218`。 + 还原后 `diff -q` 与备份逐字节相同,全套复绿 +- [x] 真机真环境:预检 `artemis-installed` → **`PASS project venv: ARTEMIS device-action core v1.0`** +- [x] 文档双门禁 rc 0 + +遗留(不在本条关闭范围):`helper` 项现在因**另一个原因**红——`Several devices are ready; pass --serial, +or --all to target every idle one`,即 **G75** 的多传输歧义打到了预检层,且该项未传设备序列号 → 另立 **G79**。 + +### #80 doctor 收尾文案不再提示已删命令(G73 结案) + +- **日期**: 2026-10-13 +- **状态**: ✅ 已关闭 +- **优先级**: 低(文案,但它是用户看完 `doctor` 后唯一照做的指令) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +`uv run artemis --help` 的 Commands 在本分支只有 `doctor` / `mcp` / `helper`(§零 Step 0 实测), +而 `artemis doctor` 的 ready 与 degraded 两个收尾 Panel 都写着 +`Run your first automation task now: artemis run "Open Settings and check Battery level"` +(`artemis/interfaces/cli/commands/doctor.py:271-273`、`:284-286`)——`run` 随智能栈一起删掉了 +(`tests/unit/test_cli.py:53` 的 `test_removed_commands_are_unknown` 早就把 `run` 列为未知命令)。 +更糟的是**这条 stale 文案被一条测试锁着**:`test_cli_doctor_all_pass_renders_table_in_fix_order` +断言输出里含那句 `artemis run "…"`,于是旧文案既有实现又有锁,谁改谁红。 + +#### 整改方案与实施 + +1. 文案:两个 Panel 都改成指向真实存在且此刻可用的东西—— + `Start the device-action surface the DSH plugin calls:` + `artemis mcp --type adb` + (收缩后插件就是这个动作面的唯一消费方,这也是用户下一步真正要做的事)。 +2. 那条锁着旧文案的断言同批改成新句(**行为变了,锁必须在同一个提交里跟着变**)。 +3. 新增一条**不会腐烂**的锁 `test_cli_doctor_footer_advertises_only_existing_commands`: + 用 `typer.main.get_command(app).commands` 当场取出已注册命令集,正则扫输出里每个 `artemis <命令>`, + 断言它是已注册集的子集;ready 与 degraded 两条路径都验。允许名单不手抄—— + 命令面以后再变,这条锁自己跟上。 + +#### 验收标准 + +- [x] `pytest tests/unit/test_cli.py -q` → 16 passed;全量 `pytest tests/unit tests/integration -q` → + **448 passed / 1 skipped**(原 447/1,+1 为新增锁) +- [x] **R27 变异校验**:把 `artemis run "Open Settings"` 注回两处 Panel(锚点命中数 = 2)→ + `test_cli_doctor_footer_advertises_only_existing_commands` 与那条改过的旧锁**双双变红**(2 failed / 7 passed); + 还原后 `diff -q` 与备份相同、复绿 +- [x] `uv run ruff check` / `ruff format --check` 两个触达文件均通过;`scripts/quality_ratchet.py` 通过 +- [x] 文档双门禁 rc 0 + +#### 附带记录(不在本条关闭) + +`grep -rn 'artemis run' docs/` 仍命中 `feature-inventory.md:24` 与 `inventory/02:87,88,160` —— 那些是 +**对已退役 py 世界的历史描述**(S5 env 缺口、G7 双跑挂点),不是对现行 CLI 的断言,按原样保留。 + +### #81 门禁自测的注入变异会腐烂:悬空 id 改为从登记表推导 + +- **日期**: 2026-10-13 +- **状态**: ✅ 已关闭 +- **优先级**: 高(这是"判据会不会红"的判据本身) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +Step 4 之后跑 `scripts/check_doc_registry.py --selftest` 报 +`FAIL: ruler is blind to a renamed/dangling id`,而正常门禁 rc 0。读代码定位到 +`selftest()` 的第二个注入变异(原 `:253`): + +```python +tampered = todo.replace("G30", "G77", 1) +... +caught = [p for p in problems if "G77" in p] +``` + +它把 TODO 里的 `G30` 换成**字面量 `G77`**,假定 `G77` 是个未登记的悬空编号。但 `G77` 在本轮 +被正式登记(预检差距,#79 结案)⇒ 注入出来的引用**变成了合法引用**,门禁正确地不报警, +自测因此判"尺子瞎了"。**门禁没坏,是判据的变异失效了**——正是守则 **R27** 点名的 +「变异本身没生效也能把红绿读反」那一类,只不过这次发生在看守判据自己的自测里。 +同一处的锚点 `G30` 也有同样的腐烂风险(TODO 里不再有 `G30` 时自测直接 `anchor not found` 而停摆)。 + +#### 整改方案与实施 + +`scripts/check_doc_registry.py` `selftest()`: + +1. 悬空 id 不再写死,改为**从登记表推导**:`known = {int(m) for m in re.findall(r"^\| G(\d+) \|", original, re.M)}`, + `ghost = f"G{max(known)+1}"`(表空时退回一个四位数占位)。 +2. 锚点也不再写死:用 `re.sub(r"\bG\d+\b", ghost, todo, count=1)` 命中第一个真实 G 引用。 +3. 新增两道**自证**:注入前断言 `ghost not in original`(它必须真的悬空)、替换后断言文本确实变了。 + 以后编号增长只会让推导跟着走,不会再让变异变成哑弹。 + +#### 验收标准 + +- [x] `uv run python -X utf8 scripts/check_doc_registry.py --selftest` → rc 0, + 三条注入全部 `CAUGHT`,且这次悬空 id 打印为**推导值**(文案形如 `dangling reference <登记表最大 G 号 + 1>`, + 不再是写死的字面量);两个文件 `restored byte-for-byte` +- [x] **反向验证变异确实会红**:修复前的同一注入在 `G77` 已登记时判 `FAIL`(本轮实测输出即证据), + 修复后 `SELFTEST: reference gate fires on all 3 injected defects` +- [x] `uv run ruff check` / `ruff format --check` 对 `scripts/check_doc_registry.py` 均通过 +- [x] 正常门禁 `check_doc_registry.py` rc 0(137 ids)、`check_doc_tables.py` rc 0 + +教训:判据的自测里**任何写死的编号都是定时炸弹**——它会在那一天真的被登记时安静地停止工作。 +推导 + 自证(断言变异目标确实处于"非法"状态)才是可复跑的证明。 + +### #82 预检按配置设备问 helper,rules 注入把「为什么没注入」暴露出来(G79 + G80 结案) + +- **日期**: 2026-10-13 +- **状态**: ✅ 已关闭(两条差距同批) +- **优先级**: 高(预检是每次工具调用的门禁;rules 缺失此前不可见) +- **分类**: 四 · DSH 插件轨道 + +#### 问题描述 + +两条都是 G77/#79 修完后在同一台真机上暴露的: + +1. **G79**:`helperCheck` 跑 `artemis helper status` **不传设备**。一台 MuMu 暴露 4 条 adb 传输(G75), + 提供方直接拒绝:实测 detail = `Several devices are ready; pass --serial, or --all to target every idle one: + 127.0.0.1:16384, 127.0.0.1:5555, 127.0.0.1:7555, emulator-5554`。`artemis helper status` 吃 `--serial` + (`helper.py:39-42,71-77`)但**没有 `--all`**。 +2. **G80**:`rulesInjector.ts` 读 `/mcp_server/rules.md`,本分支该文件不存在 + (`main` 上有),而失败策略是「warn + 跳过、永不抛」,`index.ts` 又把 `setupRulesInjection` 的 + `boolean` 返回值**丢掉了** ⇒ 四种不同原因(关掉 / 没配仓库 / 没文件 / 读失败 / 空)在外部全都长得一样, + 「rules 注入已落地」这句话在 live 侧无从判断。 + +#### 整改方案与实施 + +1. `PreflightInput` 加 **必填** `defaultDeviceSerial`(必填是刻意的:可选就是这次漏传的成因), + `helperCheck` 在值非空时给 **PATH 与 project-venv 两条形态**都追加 `--serial <值>`,为空保持现行为; + `index.ts` 用 `config.defaultDeviceSerial` 填。 +2. `setupRulesInjection` 返回值 `boolean` → 结构化结果 + `injected / disabled / no-repo / missing-file / read-failed / empty`;`index.ts` 记下它, + `defineDiagnose` 新增 `rulesStatus()` 与 `artemisRepoPath` 两个依赖,非 `injected` 且非 `disabled` 时 + 往 `recommendations` 追加一条写明原因与路径的告警。**没有新增第 8 项阻塞预检**——七项 id 集有锁, + 且缺 rules 不该阻断设备动作;`disabled` 是操作者的选择,保持安静。 + +#### 验收标准 + +- [x] `pnpm run build` rc 0;`pnpm run test` = **32 files / 293 passed / 1 skipped**(原 289,+4 条新锁) +- [x] **R27 变异三条**:MA 去掉告警追加 → 红 1 条;MB 把 `disabled` 也纳入告警 → 红 1 条; + MC `helperCheck` 永远不带 `--serial` → **红 2 条**(两条 G79 锁各一)。三条均先断言锚点命中数 = 1,还原后比对相同 +- [x] **真机验收**:`defaultDeviceSerial=""` → `FAIL helper`(仍是那句 fleet 拒绝); + `="127.0.0.1:16384"` → **`PASS helper`,且七项预检 `overall ok: true`** —— 这条链上第一次全绿 +- [x] G80 的 live 证据:对 `D:/DEV/autoGamer` 真跑一次注入,结果 = **`missing-file`**(可机读,不再是一个 `false`) +- [x] 文档双门禁 rc 0 + +附带发现(不在本条修):`tsconfig.json` 的 `include` 只有 `src` ⇒ **插件的 32 个 spec 从未被类型检查过**; +把 tests 纳入一次 `tsc` 实测 **144 条错误**(`apply.spec.ts` 一个文件 45 条)。这直接削弱「必填字段」这类守卫, +另立 **G81**。 + +### #83 headless 死链移除、web 模型端点同步、G71 以 wire 取证结案 + +#### 问题描述 +- G71 最后一条 Done means(新会话工具面出现本插件工具)此前未证:Step 7 只有管理面「2 组件运行中」读数, + 而 G30 的教训是「注册成功 ≠ Agent 看得见」。取证先后被两个环境问题挡住: + 1. 本机四个 dsh profile 相互独立,`headless` 仍挂改名前旧名 `autogamer-device` 的死链 + (`link:` 靶子 `plugins/autogamer-device` 已不存在,`node_modules` 亦为死 junction); + 2. web profile 的模型端点配置过时:`~/.dsh/profiles/web/cordis.patch.yml` 的 `local` provider + 指向 `127.0.0.1:8080/`(LM Studio 实际在 `1234`),真会话首跑 5/5 重试 `Connection error. TRANSPORT`。 +- 模型端点的存放位置实测查明:每 profile 一份 `cordis.patch.yml`(宿主设置投影), + `~/.dsh/settings.yaml.imported` 只是旧快照,不是活配置。 + +#### 整改方案与实施 +- `dsh plugin --profile headless remove autogamer-device`(0 审批 CLI;`reconcile()` 把名字从 + bundles 剔除);pnpm 11 留下的死 junction 按 **#22**(G49)机制以 reparse point 删除。 +- web 的模型两项(`llm-pi-ai` providers + `agent-default-model`)按用户指示同步 desktop 现行配置: + `local` → `http://127.0.0.1:1234/v1`、模型 `qwen3.6-35b-a3b-mtp`(`input: [text, image]` 声明随行, + S5 负能力坑不踩);非模型条目不动。rc.2 `patchReload` 无实现(D7),冷重启生效; + 杀 wrapper 后 exe 子进程仍持 8090(EADDRINUSE),须按监听 PID 清理后再起。 +- 取证走 **wire 抓包**(S5/G30 明文认可的「插件侧 wire 差分」):`scripts/dump-proxy.mjs` + (1235→1234 日志代理)临时接管 profile 的 `local` baseURL,AutoGamer 新会话真跑一轮后由 + `spike/wire-tools.py` 解析请求体 `tools`。检查器路线(`cordis_inspect_query`)不可用—— + 该工具由 `@deepseek-ai/dsh-tool-cordis` 提供、只有官方 `cordis` 预设挂它,本包预设不挂该行 + ⇒ AutoGamer 会话面本就没有检查器(调研见 TODO §零 Step 10,上游 agent-preset-registry + README.zh 与两个 web-app preset patch 一手)。 + +#### 验收标准 +- `Test-Path ~/.dsh/profiles/headless/node_modules/autogamer-device` 为 False; + `~/.dsh/profiles/headless/package.json` 的 bundles 为 `[dsh-base, dsh-headless]` 且无 dependencies 节。 +- 抓包复跑:`node plugins/dsh-autogamer/scripts/dump-proxy.mjs` → profile patch 的 `local` baseURL + 临时指 `http://127.0.0.1:1235/v1` → 冷重启宿主 → AutoGamer 预设新会话发一条消息 → + `uv run python plugins/dsh-autogamer/spike/wire-tools.py`。实测:chat/completions 请求体 + `tools=12`,其中本包 7 个全在(`run_device_action` / `report_task_status` / `autogamer_run_task` / + `autogamer_manage_task` / `autogamer_inspect_trace` / `autogamer_get_device_state` / + `autogamer_diagnose`),另 5 个为宿主行(`job_*`×3、`plugin_manager`、`skill`),与出货预设组成一致。 + +## 已关闭差距史(G*) + +> 编号唯一权威仍是 `migration/registry.md`;本节承载「当初是什么问题、怎么关掉的、证据在哪」,由 `scripts/check_doc_registry.py` 作为已关闭编号的定义来源之一。 + +| 编号 | 原短语 | 关闭方式 | 出处 | +|---|---|---|---| +| G1 | 模式矛盾 | 随 R2' 定稿消失 | git 历史 + 三份研究文档 | +| G2 | UI 降级 | 随 DSH 原生 roster/QueueDock 与旧控制台只读消失;**只覆盖展示降级面,未覆盖 catch-up(那是 G20)** | 同上 | +| G6 | Windows 沙箱 | 随 R2' 不 spawn job 消失 | 同上 | +| G7 | 双跑 | 转为 D9 的回退件 | 同上 | +| G9 | worker env | 随 R1 遗产消失 | 同上 | +| G12 | LLM 暂停 | 随 py 智能栈不迁移消失 | 同上 | +| G15 | 共享锁 | 由 D3 裁定吸收 | 同上 | +| G16 | notes | 由 D2/S1 的 DSH 原生会话记忆承接 | 同上 | +| G18 | SSE | 随 D10 收缩;事件名全集保留为 conformance fixture(`tests/fixtures/sse-events.json`) | 同上 | +| G19 | 轮次视图 | 旧控制台只读,DSH 原生承接 | 同上 | +| G20 | catch-up | 核实为 DSH 原生支持(`readSessionState` 等),无需自建 | `docs/migration/dsh-verification.md` | +| G21 | 通知 | 改为按需再建(D10) | 同上 | +| G22 | SDK | 随 D10 删除 artemis-client | 同上 | +| G29 | 注入 | 由 S9 以 DSH 原生承接 | 同上 | +| G30 | preset 工具可见性(曾是 P0 阻塞点) | 双根因修复:cordis `effect()` 语义反转自注销 + 裸 `register()` 未经 `defineTool()` 编译 | 本文件 **#10** | +| G36 | 截图媒体展示(模型侧半边) | 附件服务 `saveImages` + `[text, image]` 结果 + provider `input: [text, image]` 模态声明;UI 卡片半边仍开放并归 P3;UI 侧未实现,随 **#71** 放弃 | 本文件 **#11**、**#71** | +| G37 | live overlay 停在二分实验形态 | overlay 收回 `inner + tool-jobs` 交付形态,R6 exit 0 且 R7 新会话实测工具面恰为 5 个 | 本文件 **#11** | +| G39 | 效果判定归属层(真机回 JPEG 令 TS 侧 PNG 差分失效) | 裁定路线 A:py 动作面新增只读 `compare_ui_frames`,判定委托仓库内上游实现 `check_ui_change` | 本文件 **#12** | +| G40 | 运行中 UI 不接受第二条消息(**当日撤回的错误归因**) | 探针按固定 `aria-label="发送消息"` 找按钮,把运行中主按钮切成「排队发送」误读成入口消失;改按按钮位 class 枚举后实测 QueueDock 原生可用、回合结束按序执行 | TODO「S10 ③/④」条 + 守则 R20 取证方法 | +| G42 | `{index}` 元素定位整条不可用 | 按数组位置统一编号(模型所见与解析同源)+ 修掉 render 的 200 字符截断;7 条单测 + live 两轮命中 `#28`/`#17` | 本文件 **#11** 之后的修复轮(TODO「G41/G42 修复轮」条) | +| G45 | 交付 preset 没有指令投递行,flash 契约从未进过模型 | `persona` 行(`config.prefix` 五条硬约束,文本内无 `{{}}`)同时进交付形态与 overlay;新会话 wire 断言 system 4052 字符、契约起于第 50 字符 | 本文件 **#14** | +| G41 | 终态不收敛:`report_task_status` 之后回合继续跑 | `exec.concludeTurn()` live 实证:汇报的 tool/call 之后直接 `turn/end`、无后续 `step/start`(会话日志 `session-429a5560`) | 本文件 **#15** | +| G44 | 模型可以不调 report_task_status 就结束回合 | `{global:true}` 监听 + 边界外 `followup()` 的兜底追问端到端可用:追问落进日志、第二轮模型补出汇报并终结 | 本文件 **#15** | +| G46 | 追问从未触发(**当日撤回的错误归因**) | 拿 wire 请求体数 `report_task_status`——而终结回合的那次调用永不出现在请求体里;改读会话日志后机制完好,据此立守则 R24 | 本文件 **#15** | +| G35 | 文档门禁脚本默认路径写死 | `DEFAULTS` 改从脚本自身位置推导仓库根;自测的注入锚点改为动态取差距表里第一条 G 行,不再绑定任何具体差距编号 | 本文件 **#16** | +| G32 | bundle 展示元信息缺失(locale meta 与 icon) | 补 locale/en.json + locale/zh.json 的 meta.title/description、原创 icon.svg(684 B)、exports 与 files 登记;面板 live 已渲染中文标题与图标 | 本文件 **#17** | +| G31 | 引入流仍靠手工(要迁 install_bundle) | 用一次性空 bundle 端到端验通 `install_bundle`(`application:"applied"`、dump-config 里注册、remove_bundle 干净撤除);随包 skill 根路径改官方 `!!js` + `createRequire(baseUrl)` 解析 | 本文件 **#18** | +| G5 | conformance 需要真实 DSH runtime(CI 起 DSH 跑契约测试) | 三层:Tier 1 在裸 cordis Context 上挂真实 DSH 服务包与本插件、契约由真 `ctx.tools.schemas()` 把关;Tier 2 三条 fixture 全改为生成物(live 抓取 / 源码抽取 / 真实安装态);Tier 3 live 取证脚本;并新增 `ci.yml` 里此前不存在的 `plugin-contracts` job(ubuntu + windows 实测 success) | 本文件 **#29**(过程与变异校验见 #27/#28) | +| G52 | 追问路径仍只有 fake 事件总线的证据 | 新增 Tier 1:真 `dsh-session` 提交 `turn/end`、真 `AgentRegistry.register/get` 解析句柄、真 `ToolRuntime.execute` 标记 acted,并用两个 realm 的对照监听观察到 scope 筛选本身;顺带摘掉追问消息的手镜像(补 `UserMessage.id`,`kind:'autogamer'` 改官方 merge-extensible 声明) | 本文件 **#32** | +| G34 | 裸 schema 注册的输入自校验缺口 | 输入语义校验入工具(coordinate 存在即判、不静默回退,text 非空、duration 正数、key 可解、swipe 起止成对、package/url 非空),11 条单测锁死 | 本文件 **#33** | +| G38 | 插件内部错误被归类成任务级失败 | 第三类 `internal`:无前缀异常与逃逸抛出走 internal,不记 breaker,抛 `internal:` 并带勿重试信号;传输层保持 environment(fail-safe) | 本文件 **#33** | +| G57 | 插件挂进 `createScope` realm 后其工具对注册表视图不可见 | 裸 scope 注册本就生效:`register()` 经 `layers.effect` 打 scope 标签,`schemas()/get()` 须传 scope 读;旧「双空」是无参全局视图误读,root 空是 D16 隔离语义 | 本文件 **#34** | +| G56 | mock 与 `ActionClient` 握手超时(`SdkError: Request timed out`) | 手拼应答缺 `jsonrpc` 字段被 SDK 读侧 zod 拒收;补帧字段后真 SDK 握手通过(list/call/截图尺寸三锁) | 本文件 **#35** | +| G33 | 策略内建在工具正文里,与官方「不要内建策略」相反 | 冷却拒绝迁 `ctx.tools.guard()`(只看本工具,终态出口永不拦),只读动作声明 `isConcurrencySafe`;顺带修掉 `starting` 失败缓存与组装期传输分类两 bug | 本文件 **#36** | +| G53 | 前端 lockfile 全镜像 URL,`check_dependency_sources` 门禁红 | 221 个 `resolved` 只换 host(版本/integrity 零漂移,解析级断言),门禁绿,前端 305 用例绿 | 本文件 **#37** | +| G55 | `check_dependency_sources.py` 不合 `ruff format` | 一条 `ruff format` 命令(`e1de835`);仓级另 9 处既有红另立 G58 | 本文件 **#38** | +| G43 | spike 复位与判据不可靠(`settings put` 无效) | `scripts/spike_reset.py`(UI/平台复位 + 截图判据 + dry-run 锁;live deferred) | 本文件 **#42** | +| G58 | 仓级另 9 处格式红 | `ruff format .` 零语义;同文件 3 处 E731 顺手改 `def` | 本文件 **#43** | +| G8 | 终态权威(收缩) | 汇报双轴 + `concludeTurn`(G41 锁);exit-code 映射随 worker 消失 | 本文件 **#44** | +| G10 | 动作中断语义 | `exec.signal` 穿透到 SDK 请求(闸门释放早已在 `finally`) | 本文件 **#44** | +| G11 | 熔断判定信号 | 工具层错误分类(`classify` + G38/G33/callTransport 锁) | 本文件 **#44** | +| G13 | 幂等/去抖 | in-flight 去重(变更类专属,只读与串行重复不误伤) | 本文件 **#44** | +| G14 | 状态词汇 | 汇报 `completed/blocked + tests.failed`(enum + 值锁) | 本文件 **#44** | +| G24 | 坐标契约 | 归一域/端侧索引(`coord`/`hierarchy` + G42 锁 + 双机证) | 本文件 **#44** | +| G4 | 熔断探测体(矩阵落定) | 键同源 `deviceKeyOf`(空串分叉修掉,Tier1 调用点锁),`reset`/计数锁,S4 解除句修订(新提交不旁路等显式偏差) | 本文件 **#39** | +| G47 | `install_bundle` 两项未验(失败回滚 / packed file list) | 三轮一次性包实测:`not-bundle` 与不存在的 GitHub 仓库两例 profile 两文件 SHA256 前后相同;tarball 安装落地树恰等于 `files` 白名单。另记「失败与 remove_bundle 都会留下 node_modules 符号链接」 | 本文件 **#19** | +| G48 | 成功的 git 安装无证据;`files` 与 `path:` 子目录在 git 形态下是否生效未测 | 用 `git+file://` 本地仓库摘掉「需要 push」前提:根目录与 `#HEAD&path:/packages/inner` 两形态都 `application:"applied"`,落地树恰等于 `files` 白名单;对 GitHub 走网络另开 G49 | 本文件 **#20** | +| G49 | GitHub 安装失败于 `ERR_PNPM_EPERM`,根因未定 | 单变量跑完:停服务报错不变(否证占用假说);清掉同名 junction 后 `added 17` exit 0(成立)。机制=importPackage 顺着旧 link 走进开发工作区重建符号链接被 Windows 拒。真实用户干净机器本就可用(CHANGELOGS **#22**)| 本文件 **#22** | +| G50 | git 形态装出来的 bundle 缺 `dist/`(构建产物不在提交里) | tarball 路线端到端验通:`npm pack` 清单含 dist 与 skills、安装 exit 0、服务加载后 7 工具 + persona + 追问链路完整;对外形状定为 GitHub Release 资产 tarball 的 URL | 本文件 **#23** | +| G51 | 本机 Node TLS 信任被本地中间代理接管,走 https 的依赖安装要 `--use-system-ca` | 机制有源码出处(`scrubbedParentEnv` 保留 `NODE_OPTIONS`);不带 flag 时 DSH 自己的 install_bundle 报 `UNABLE_TO_VERIFY_LEAF_SIGNATURE`,带 flag 后该错误消失;前置已写进 README,并禁止用 `NODE_TLS_REJECT_UNAUTHORIZED=0` | 本文件 **#25** | +| G60 | 两枚文档门禁从未接进 CI,抄件漂移只靠人工自证 | `ci.yml` 的 `python-quality` job 新增**无 `if`** 的 `Check documentation gates` step(registry + tables + `--selftest` 三条)。前提是先按 **#45** 把体量口径做成 LF 规范化,否则接进 CI 只会天天假红。**真 run 未观测**:G54 使 `on: push` 不产出 run,本轮不推送 | 本文件 **#46** | +| G61 | 仓库无 `.gitattributes`,工作树字节随 checkout 变,字节类判据在本机不可信 | 新增 `.gitattributes`(`* text=auto eol=lf`、`.bat`/`.cmd` 显式 `eol=crlf`、8 种在库二进制显式 `binary`);实测 renormalize **零个文本 blob 被重写**(带 CR 的 28 个 blob 全是二进制),2 个混行尾文件按 blob 规范化后 `w/mixed` 归 0,本机 `ruff format --check` 假红转绿 | 本文件 **#47** | +| G62 | py action 面无「无设备可跑」路径,离线契约只能吃插件的 JS 桩 | 新增 `artemis/mcp/mock_server.py`(`MockActuator` + 预置 `adb_server` 两条缓存短路绕开 adb I/O)与 CLI `--type mock` 分支,未改任何现有 py 文件行为;与 `--type adb` 工具清单逐字相同、截图为真 PNG | 本文件 **#52**(含 **#51** 的 stub 更正) | +| G3 | auto-serial 闸门缺口(显式 serial 队列 + auto 空闲池) | A 片落地:`src/devicePool.ts` 包一层现有 `DeviceGate`,auto 从 roster 挑第一台「未冷却 + 未占用 + gate 空闲」的设备且只占一台;显式 serial 仍走该设备链;roster 由 config 注入(默认空=现状不变)。真枚举来源另立 **G64** 未决 | 本文件 **#55** | +| G28 | parking + 全局并发上限的内部语义 | parking 按 `device_lock.py:332` 的口径实现(未认领票据不占全局槽、不 idle 掉别的设备),全局上限按已 admit 设备数计;六条语义锁 + 三条变异校验(其中一条第一版是虚锁,已如实记) | 本文件 **#55** | +| G63 | py 动作面 fixture 从未在 CI 被重新比对(§零 的旧表述是假陈述,见 #53) | `python-quality` 加 `Set up Node.js` + 一步用 `--type mock` 重导比对;`spike-mcp.mjs` 未改(默认模式本就是漂移检查) | 本文件 **#54** | +| G65 | 锁屏判定进不了插件(admission 的 fail-closed 半边) | `list_devices` 加默认关的 `include_lock_state`,复用现成双采样确认探针出三态;插件按「没问≠不可判」区分放行与拒绝 | 本文件 **#59** | +| G64 | 设备 roster 的真来源(TS 重复枚举 vs 加只读 py 工具) | 用户选「加只读 py 工具」:`adb_server.py` 新增只读 `list_devices`(复用现成 device_pool 与 try_list_devices 的 fail-open),插件 `rosterSource` + `discoverDevices`(默认 false,保住 #55 的零变化承诺) | 本文件 **#56** | +| G67 | `{index}` 句柄按数组位置重解(违反 `inventory/04:203` 的安全不变量) | `LiveObservations` 存模型最后一次所见的列表,`resolveHandle` 按节点身份(text/desc/resource-id,无标签退 center+class,class 名带 100px 位移上界)跟随到当前位置;从未观察/句柄不在列表 ⇒ 取数之前就拒;更正 #40 的「按位置重解」半句 | 本文件 **#60** | +| G68 | #60/#62 复审轮的缺陷:收口文案被信封截断、快照声明只锁了一级、句柄无界远跟、不可解析自读仍放行 | 文案压进预算并加走 `render` 的长度锁;补执行后一级的快照锁;每类标签配可达上界(text 100 / resource-id 300 / class 100 / 无标签 0)并更正 #40 的远跟半句;自读不可解析改为与执行梯同口径速败 | 本文件 **#63** | +| G54 | 本 fork 的 `on: push` 从不触发 workflow run | **前提被一次直接观察否证**:`4201e66` 的 push 立刻产出 run 38296414103(6 条 job 全跑,`Check documentation gates` 在真 run 里 exit 0);**成因未查明**,不作为结论 | 本文件 **#67** | +| G17 | 共存鉴权 | 放弃:旧控制台已物理删除,原「只读共存」前提消失,DSH 认证面未实现 | 本文件 **#71** | +| G23 | 录像产物契约 | 放弃:录屏未接回,验收规格无人消费 | 本文件 **#71** | +| G25 | 层级后端互斥 | 放弃:helper 复刻未启动 | 本文件 **#71** | +| G26 | PID-liveness 协议 | 放弃:仅规划用于 action server 进程管理,未落地 | 本文件 **#71** | +| G27 | 智能栈暗规则 | 放弃:pro 保真清单不再作为验收口径 | 本文件 **#71** | +| G59 | 本机 `--dump-config-schema` 常驻失败 | 放弃:live 前重装 bundle 需动用户 profile,未做;`--dump-config` 不受影响 | 本文件 **#71** | +| G66 | G27 保真差集余下项 | ③④已随 **#62** 关闭;①②⑤⑥放弃(分类/subagent 化/记忆裁定未实现) | 本文件 **#71** | +| G69 | pre-commit 钩子从未安装 | 放弃:写 git hooks 需用户点头,未做;CI 侧遮挡已随 #69 修完 | 本文件 **#71** | +| G70 | dispatch-only py 测试步从未绿过 | 放弃:成因未定(复现被叫停),整仓三集命令面未收敛 | 本文件 **#71** | +| G77 | 预检把「已安装」等同于 `uv tool list` 命中,且 helper 项假定 PATH 上有 `artemis` | 两项都承认 project-venv / editable 形态,共用只读探针 `uv run --project --no-sync artemis …`;5 条新锁 + 3 条变异证伪 + 真机预检转 PASS | 本文件 **#79** | +| G73 | `artemis doctor` 收尾提示已删命令 `artemis run` | 两处 Panel 改指 `artemis mcp --type adb`;旧锁同批改;新增一条吃「已注册命令集」的防腐烂锁,变异证伪成立 | 本文件 **#80** | +| G79 | 预检 `helper` 不传设备序列号,多传输同机被提供方拒绝 | `PreflightInput` 加**必填** `defaultDeviceSerial`,PATH 与 venv 两条形态都追加 `--serial`;真机七项预检首次全绿 | 本文件 **#82** | +| G80 | rules 注入缺文件时静默 no-op,`boolean` 返回值被丢弃、四种原因不可分 | 返回值改结构化结果,经 `autogamer_diagnose` 的 `recommendations` 暴露原因与路径;不新增第 8 项阻塞预检 | 本文件 **#82** | +| G71 | web profile 组合包仍指旧名 `autogamer-device`(挂载失败) | CLI `remove`→`add link:` 重装(Step 5);headless 同缺陷死链移除、pnpm 死 junction 摘除;wire 抓包证 7 工具全在 AutoGamer 预设新会话请求体(检查器不在本包预设面,见 TODO §零 Step 10) | 本文件 **#83** | diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 000000000..c1fb9c831 --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1,833 @@ +# TODO · 待办(新方案:D19 TS 全栈迁移) + +> 2026-10-14:旧待办一次结清(去向见 CHANGELOGS **#71**);同日收到新方案——**D19:TS 全栈、py 归零**, +> 规格落 [ts-fullstack-spec](migration/ts-fullstack-spec.md),执行流程见本文件 **§七**。 +> 按守则 R22(查文档 → 写方案 → 才写码),每期开工前先在本文件落自足条目。 +> 编号查 [registry](migration/registry.md),词查 [glossary](migration/glossary.md), +> 导航查 [导航](migration/README.md)。 + +## 零、当前状态(2026-10-14,进行中里程碑:TS 全栈迁移 D19 + 溯源归零 D20) + +- **2026-10-14 分支改名(生效,本文档其余处的旧名是当时记录)**:本分支 `dsh-plugin-contraction` → + **`legacy-fork-v2-dsh-plugin`**(第二次尝试:DSH 插件形态,参考归档);旧 `main`(初版二开,未达效果)→ + **`legacy-fork-v1`**;新主线由 `ts-fullstack` 接手并改名 **`main`**(TS 全栈、py 归零的干净重实现)。 + 远端默认分支待用户切到 `main`(本机 token 无 admin 权限;切完才能删旧名 ref `dsh-plugin-contraction`)。 + +- 当前未关闭差距 **`G72` / `G74` / `G75` / `G76` / `G78` / `G81` / `G82` / `G83` / `G84` / `G85` / `G86`** + (正文在第四节;**计数只查 `migration/registry.md`**,此处不写数字)。 + **进行中里程碑 = D19 TS 全栈迁移 + D20 溯源归零**(§七 流程,未开工)。 + `G77` → CHANGELOGS **#79**,`G73` → **#80**,`G79` + `G80` → **#82**,`G71` → **#83** 均已结案。 +- **2026-10-14 git 历史处置已裁定(用户)**:**新建空白(orphan)分支 `ts-fullstack`** —— 即规格 §八 待裁项 1 + 的选项 (c):新分支**无历史**,故上游内容在**历史层也不再留存**;本分支 `dsh-plugin-contraction` 原地转为 + **参考归档**(ref 与内容都不动)。随迁清单与不随迁清单见下条。**随之 D20 判据从新分支起覆盖全层(含历史)**, + 归档分支保留完整调研台账(registry / CHANGELOGS / inventory / 守则全文)供查阅。 +- **随迁内容(新分支 `ts-fullstack`)**:`LICENSE`、`.gitattributes`、裁剪版 `AGENTS.md`、最小 `README.md`、 + `docs/TODO.md`(骨架:D19/D20 决议 + 流程勾选 + 开放差距)、`docs/migration/ts-fullstack-spec.md`、 + `docs/migration/plugin-contract-rules.md`(守则正文,py 判据已换占位)。 + **不随迁**:全部 py 树(含 vendored `third_party` 及其自带 LICENSE/NOTICE)、上游派生文档(`inventory/*`、 + `research/*`、`artemis/agents/*.md`)、py 文档门禁脚本(`scripts/check_doc_*.py`;**记债**:新分支暂无机械门禁, + 见新分支 TODO 开放项)、本分支的 `CHANGELOGS.md` 与完整 `registry.md`(编号权威随之转到新分支的 `docs/TODO.md`)。 +- **仍未裁定(唯一剩项)**:`upstream` remote 去留。**注意它不随分支走**(remote 是仓库级配置), + 所以新分支与归档分支共享同一份 remote 表;未裁定前 `upstream`(`github.com/google/artemis`)仍在。 +- 2026-10-13 **Step 0 现场核实**(用户起了 LM Studio 与 MuMu,本轮只核现场、不写实现): + - 设备:`artemis doctor` 判 **Ready**(Python 3.12.13 ✔、设备 `24031PN0DC` / `900x1600` ✔、`scrcpy` 缺 = 可选缺项)。 + `.env:23` 已钉 `ADB_DEVICE_SERIAL=127.0.0.1:16384`;前台 `app.lawnchair`(亮屏未锁);Android 15。 + **一个 MuMu 实例暴露 4 条 adb 传输**(`emulator-5554`、`127.0.0.1:16384`、`:5555`、`:7555`), + 同机证据 = `settings get secure android_id` 四条全等(`c0021066f3fbaf47`)+ MemTotal/battery 一致; + `ro.serialno` 四条**都是空串**(MuMu 不设),所以按 serial 分键的闸门会把它们当不同 endpoint、又可能同时落进 + `defaultDeviceKey`——roster 真枚举「一台算几台」列为 Step 1 首要待测(G64/G65 当年是在无真机的机器上验的)。 + - 模型端点:`127.0.0.1:1234` 在听(PID 17476)。`/api/v0/models` 里 `qwen3.6-35b-a3b-mtp` + **`state: loaded`**、`type: vlm`、`quantization: IQ1_M`、`loaded_context_length: 262144`, + 而 `capabilities` **只列 `[tool_use]`、无 vision 项** → 按 S5 记过的「显式省略 = 负能力」坑, + 图像附件路径在这个端点上是否被降级为文本,属**待实测**,不许当已证。 + - 提供方:入口是 console script(`pyproject.toml:89` → `artemis.interfaces.cli.main:cli`; + 用 `python -m artemis` 会报 `No module named artemis.__main__`,那是调用姿势错,不是功能缺)。 + `uv run artemis --help` 的 Commands = `doctor` / `mcp` / `helper`;`artemis mcp --help` 给 + `--type adb`(默认)/ `mock` / `xml`、`--transport stdio`(默认)/ `sse`(`:8001`)。 + **`mcp_server/` 的 `.py` 计数为 0**(只剩 `__pycache__` 与空目录)⇒ 智能栈确实不在本树,据此立 **G72**。 + - 仍未具备的触发条件:`scrcpy` 不在 PATH(Phase 4 的 20fps / 触摸回传 / 无残留三项继续 live 不可跑)。 +- 2026-10-13 **Step 1 动作面实测**(真 MuMu,`uv run artemis mcp --type adb`,`ARTEMIS_MOCK_DRIVER=0` 走真 driver; + 现场用现成客户端 `plugins/dsh-autogamer/scripts/spike-mcp.mjs` + `dist/` 里的 `ActionClient`,未新增仓库内脚手架): + - **工具面 15 个与已提交 fixture 逐字一致**(`spike` rc 0;没用 `--write`,fixture `git diff` 为空)——G63 那条 + 「fixture 必须在真面上重新比对」第一次是在**有设备的机器上**过的,此前只在 `--type mock` 上验过。 + - **roster 把一台 MuMu 报成 4 台**:`list_devices()` 默认 → `127.0.0.1:16384` / `:5555` / `:7555` / `emulator-5554` + 四条全 `state: device`、`is_emulator: true`、同 `model 24031PN0DC`,而 `settings get secure android_id` 四条全等 + (`c0021066f3fbaf47`)、`ro.serialno` 四条全空 ⇒ **按 endpoint 分键的闸门/并发上限/熔断会 4 倍计数** → 立 **G75**。 + - **锁屏三态**:`include_lock_state=true` 时四条都 `is_locked: false`;默认 false 时**不带该字段**。 + `null`(unknown) 与 `true` 两个分支本轮**未触达**,别当已验。 + - **device state 的真返回形状与 Phase 2 记录不一致**:`take_screenshot` 返回**裸 base64 JPEG** + (226856 字符,解出 170142 字节,`image/jpeg`),且 py 侧 docstring 自己就写着 + “Returns the screenshot as a base64 encoded JPEG string”;`get_ui_hierarchy` 返回 JSON 数组字符串, + 字段值全是字符串(`"index":"2"`、`clickable:"true"`、`bounds:"[x0,y1][x1,y1]"`)。 + **`file://…` 那条属已删的 pre-contraction 5 工具面** → 映射层要按真面重锁,立 **G74**。 + - **坐标空间陷阱(本轮最重要)**:当前帧空间是 **1600x900**(独立金标准 = `adb exec-out screencap -p` 的 PNG IHDR; + `dumpsys input` 主视口 `Orientation: Rotation90`),而 `wm size` 报 **Physical size: 900x1600**。 + 插件的 `coord.imageSizeFromBase64(真截图)` 得到 `{width:1600,height:900}`,**与金标准一致**(取源路径正确, + 我先前怀疑的「解析器宽高互换」被这条独立测量否证);`toPixels` 往返精确(归一 `(500,97)` → `(800,87)`); + 真点 Settings 搜索栏中心 `(800,87)` → driver 打 `input tap 800 87` → 焦点变 + `com.android.settings.intelligence/.search.SearchActivity` 且 `compare_ui_frames` 报 `{"changed": true}` + ⇒ **tap 吃的就是这个帧空间的像素**;若按 `wm size` 归一,同一点会落到 `(450,827)`,偏 **350x / 362y** → 立 **G76**。 + - `swipe` 生效(Success + `{"changed": true}`)。 + - **「Success ≠ 生效」抓到实例**:`back` 返回 `Success` 而焦点未变、`compare_ui_frames` 报 `{"changed": false}`; + 同一时刻 raw `adb input keyevent 4` **却生效**(焦点回到 `com.android.settings/.Settings`)。 + 而 HOME 方向相反:`input keyevent 3` / `KEYCODE_HOME` 当时**未**改变焦点,`am start -a MAIN -c HOME` 生效。 + ⇒ 键事件投递在这台 MuMu 上**可靠性未定性**(每向 n=1~2,样本量不足以定红绿,按 R27 记为待测)。 + - **真机环境级告警**(喂给 classify/熔断 vocabulary 的现场):`set-wakelock` 不支持(`Unknown command`)、 + USB stay-awake 启用失败(`setting=2`,退回 5 秒宿主心跳)、**accessibility helper 不应答**(device port 18888) + → 自动降级到 UIAutomator2 并 30 秒重试。`launch_app` 的 smart polling 会打印一大段 foreground-task 归属诊断 + 但仍返回 `Success`。 + - 现场已复位:HOME intent → `app.lawnchair/app.lawnchair.LawnchairLauncher`。 +- 2026-10-13 **Step 2 · 复审「可验的都验了吗」+ 补调研**(结论:不止没验完,还有两处我记错了): + - **py 全套在有设备的机器上仍全绿**:`pytest tests/unit tests/integration -q` = **447 passed / 1 skipped**,rc 0。 + **#77** 记的那次 447/1 是在**无设备**的机器上跑的,所以这条是新增证据:设备在场不改变离线面结果。 + - **层级稀疏是设计行为,不是故障,也早有文档**:`get_ui_hierarchy` → `filter_ui_hierarchy` + 默认 `semantic_pruning="hoist"`(`artemis/utils/ui_filter.py:425-433`),语义空节点被 `return filtered_node.get("children", [])` + 丢弃,而 **UIAutomator2 的 XML 解析是扁平的、没有 `children` 键**(`third_party/mobile_use/clients/ui_automator_client.py:106-145`), + 所以容器与无 text/content-desc/非可点的节点整条消失 → 桌面 5 个、Settings 36 个属预期。 + helper 可用时走**同一个 filter**(`artemis/clients/screen_client_factory.py:221-228` 的降级分支只换来源不换过滤) + ⇒ **不是 helper 缺位造成的**;口径早已写在 `inventory/03-device-media-diagnostics.md:199`,本轮只是补上真机确认。 + - **更正我自己 G74 的表述**:节点**并非「字段值全为字符串」**——除字符串 `bounds` 外还带 + **`parsed_bounds`(`{left,top,right,bottom}` 整数对象),真机 36/36 全带**。解析层有现成数值可用,不必自解字符串。 + - **键事件定论(n=10,单变量)**:raw `input keyevent KEYCODE_HOME` **0/10 生效**,raw `input keyevent 4`(BACK) + **10/10 生效**,`am start -a MAIN -c HOME` 每次都生效 ⇒ HOME 注入在这台 MuMu 上**确定性失效**,不是抖动。 + 且 `adb_driver.py:372-383` 的 `press_key` 是 `await device.shell(...)` 后**无条件 `return True`** + ⇒ **py 的按键面没有效果语义**,`Success` 只代表命令发出;按键类动作的效果判定只能走 `compare_ui_frames`(喂 S4/S11 的词汇边界)。 + - **VLM 端点侧风险排除**:向 `1234/v1/chat/completions` 发带 `image_url` 的请求**被接受** + (`prompt_tokens: 27`,无 vision 拒绝;`finish_reason: length` 是我给的 `max_tokens=8` 太小所致) + ⇒ 留白 18 的「端点不收图」这一半**否证**;剩下的风险只在 **DSH provider 的 `models[].input` 声明**,仍被 G71 挡。 + - **G77 范围比原记的一行更大(实测取证)**:在有设备的机器上跑真预检(`createPreflight().run({uvPath:"uv", artemisRepoPath:"D:/DEV/autoGamer"})`) + 七项结果 = `uv` PASS(0.12.19)、`python` PASS(python3 3.14)、`artemis-repo` PASS、 + **`artemis-installed` FAIL**(detail 正是 `uv tool list` names no artemis install)、`adb` PASS、`adb-devices` PASS、 + **`helper` FAIL(`no artemis binary on PATH`)** ⇒ 同一个根因打掉**两项**:project-venv / editable 形态不会在 PATH 上 + 留下 `artemis` 可执行文件。只修 `installedCheck` 预检仍整体红、每次工具调用仍被拒,所以 G77 的范围含 `helperCheck`。 + 另:`adb-devices` 这项把四个 serial 当 fleet 列出(`127.0.0.1:16384, :5555, :7555, emulator-5554`), + 是从预检层对 **G75** 的独立佐证。 + 可用的第二种探针已验存在且只读:`uv run --project --no-sync artemis --version` → 打出 + `ARTEMIS device-action core v1.0`,rc 0(`--no-sync` 保证预检不改动提供方环境)。 + - **新立 G78**:`adb forward --list` 实测残留 **`127.0.0.1:16384 tcp:54254 tcp:18888`**(device 18888 = helper 端口, + 由 py 侧建 forward),而 MCP server 进程已退出 ⇒ Done means 里那句「卸载无残留(adb forward 清理)」 + **不是「等真机」而是已被证伪**,且残留产生方是 py 动作面,插件的 stop/dispose 管不到它。 +- 2026-10-13 **Step 3 · 两个修复落地**(`G77` → CHANGELOGS **#79**;`G73` → **#80**;新暴露 `G79`): + 预检承认 project-venv 形态后,真机真环境跑通 `artemis-installed → PASS project venv: ARTEMIS device-action core v1.0`; + 插件全套 **32 files / 289 passed / 1 skipped**(+5 锁),三条变异逐条证伪(红点 `preflight.spec.ts:145` / `:166` / `:218`)。 + `doctor` 收尾不再提示已删的 `artemis run`,并补了一条**吃「已注册命令集」的防腐烂锁**(旧文案原本被一条测试锁着, + 谁改谁红);py 全套 **448 passed / 1 skipped**(+1)。两轮的变异各自注回旧文案/去掉分支,均转红后还原。 + `helper` 项修完仍红,但**原因换了且是真红**:提供方要求 `--serial`(多传输歧义)→ 立 **G79**。 +- 2026-10-13 仓库卫生与主线定位(正文见 CHANGELOGS **#78**):`origin` 上三条死分支已删;本地安全网分支 + `backup-trailer-fix` 已删(4 个提交按 patch-id 证明与 main 里的重写版对偶);**远端默认分支已从 `main` 改为 + `dsh-plugin-contraction`**,`main` 按用户裁定不再同步;插件本机环境已装并复验(install 未改 lockfile、tsc 通过、 + vitest 32 files / 284 passed / 1 skipped、R6 `--dump-config` rc 0 且非空过)。 +- 2026-10-13 **Step 4 · 收益优先级调研**(四路并行:本仓改动面审计 / Android-adb 一手口径 / DSH 交付与 live 路径 / 提供方形状)。 + 四件事改变了排序,其中**两条是收回我自己写错的结论**: + - **收回**:「`dsh plugin remove` 只删依赖、CLI 侧不存在卸载命令」——`operations.ts:88-97` 的 `reconcile()` 在 + pnpm 成功后就把消失的名字从 `dsh.profile.bundles` 剔除(且仅在 `exitCode===0 && !stalled`)。 + ⇒ **G71 存在 0 审批路径**,它不再是"要用户在 UI 里点"的独占门槛,成本评级从「需人 + 需宿主」降到「一条命令」。 + - **收回**:G74 里「`{index}` 改吃 `parsed_bounds`」——`parsed_bounds` **早已被优先采用并有锁** + (`apply.spec.ts:62`、`dedup.spec.ts:17`)。写待办前没核现状,正是守则 R25 禁的那种事。 + - **新立 G80**:`rulesInjector.ts:16` 读 `/mcp_server/rules.md`,本分支没有该文件而失败策略是 warn-skip + ⇒ rules 注入在真环境**静默 no-op**,Phase 5「注入已落地」只在 fake-repo 夹具下成立。 + - **口径纠正**:§四 与 registry 里写的「13 工具动作面」不准 —— 实测与 fixture 都是 **15**; + 13 是 `ACTION_TO_PY_TOOL` 的非 null 映射数(`runDeviceAction.ts:66-81`)。两处已按各自语境改正。 + - **账实不符**:§零 旧行写「`pre-contraction` 为收缩前基线(`3fab0ee`,本地存档,不推送)」,但现在 + `git branch -a --list '*pre-contraction*'` 为空、`git rev-parse pre-contraction` 报 fatal ⇒ 该 ref 现在不可解析。 + 等价内容仍可经 **`main`**(同仓,含 23 个 `mcp_server/*.py` 与 `mcp_server/rules.md`)取得——这也是 G72 的第三条路。 +- 一处状态更正(别当矛盾):`--dump-config` 绿而 `--dump-config-schema` 红是**两条独立通道**—— + 前者合成 profile 层与 `--patch` overlay、后者还要真的把包解析出来;官方卸载/安装契约与本机实测见 + [upstream-plugin-forms](migration/upstream-plugin-forms.md) 八,carrier 错的归属见同节 Step 5 段。 +- 2026-10-13 **Step 5 · G71 走 CLI 路线执行完毕(profile 层),并定案 G59**: + `dsh plugin --profile web remove autogamer-device` → `add link:D:/DEV/autoGamer/plugins/dsh-autogamer`, + 两条 rc 均 0、**全程 0 次审批**。改前状态已备份(`package.json` / `pnpm-lock.yaml`)。实测结果: + `dsh.profile.bundles` 现为 `[dsh-base, dsh-web-app, dsh-autogamer]`(旧名消失)、`dependencies` 只剩新名、 + `node_modules/dsh-autogamer -> /d/DEV/autoGamer/plugins/dsh-autogamer` 可解析(含 `dist/` 与 `cordis.patch.yml`)。 + **判据是宿主自己的**:不带 `--patch` 的 `dsh --profile web --dump-config` rc 0、`unmatched` 0, + 且合成树里出现 `# == dsh-autogamer` + `- id: dsh-autogamer`(183) + `preset-autogamer`(184) ⇒ **bundle 由 profile 自身加载**。 + 两个残留:**G71 未结案**——R7/R9 要求开新会话用免审批的 `cordis_inspect_query` 证「工具真的在 Agent 面上」, + 这一步要宿主进程起来(现在没在跑),我没代启;以及 pnpm 11(宿主 shim 用的是 11.7.0,不是本仓的 10.12.1) + 删依赖后把死 junction `node_modules/autogamer-device` 留在原地,已按 G49 的机制手动摘除(那是它记过的致害形态)。 + 顺带把 **G59** 定案了:schema dump 仍 rc=1,但 5 条 carrier 错里**四个是宿主自带 preset**、第五个才是我们, + 且 headless/desktop 两个 profile 同样 rc=1 却 0 条 carrier ⇒ 属宿主 dumper 走 preset 子项的固有行为, + **不是本包形状问题**。证据与对位表见 [upstream-plugin-forms](migration/upstream-plugin-forms.md) 八。 +- 2026-10-13 **Step 6 · G79 + G80 修复(CHANGELOGS #82)**,并拿到这条链上第一个全绿预检: + `helperCheck` 在 `defaultDeviceSerial` 非空时给 PATH 与 venv 两条形态都追加 `--serial`(必填字段是刻意的, + 可选就是这次漏传的成因);`setupRulesInjection` 返回值改结构化结果并由 `autogamer_diagnose` 暴露原因。 + 真机对照:`defaultDeviceSerial=""` → `FAIL helper`(仍是那句 fleet 拒绝); + `="127.0.0.1:16384"` → **`PASS helper` + 七项 `overall ok: true`**。 + G80 的 live 证据:对本仓路径真跑注入,结果 = **`missing-file`**(可机读,不再是一个被丢掉的 `false`)。 + 插件全套 **32 files / 293 passed / 1 skipped**(+4 锁),变异 MA/MB/MC 分别红 1/1/2 条。 + **附带发现立 G81**:改完顺手把 tests 纳入一次 `tsc`,实测 **144 条类型错误** —— + 插件的 spec 从来没被类型检查过,这意味着我这条「必填」守卫只在 `src` 一侧生效。 + helper 现在报的那句是 `Accessibility helper on 127.0.0.1:16384`,但**别把它读成「helper 已装好」**: + 同一次真机动作面运行仍打印 `Accessibility helper unavailable ... serving from UIAutomator2`(§零 Step 1), + 两者关系属未证。 +- 2026-10-13 **Step 7 · G71 的宿主侧取证**(用户授权启宿主):`dsh --profile web --no-open --port 8090` + 起宿主(**URL 带会话 token,不入库**),在 agent 内置浏览器里读官方管理面: + 「插件」页 已安装 **1** = `AutoGamer 设备执行` v0.1.0 / `dsh-autogamer`、启用为开; + 详情页 **包含的组件 共 2 个 · 2 运行中**(`dsh-autogamer` + `preset-autogamer` 均运行中,无 error); + 预设菜单出现 `AutoGamer` 且可选为新会话预设 ⇒ **挂载层与注册表层都通了**。 + **仍未证的一条**:工具是否真在 AutoGamer agent 的可调用面上——右侧栏没有会话前的工具清单, + 而 G30 的教训正是「注册成功 ≠ Agent 看得见」,故 G71 **不结案**。 + 另记一条口径:宿主 stdout 只有 3 行、无报错,但按 **R24** 插件 logger 不进 stdout, + 所以"日志里没有错"**既不能证明也不能否证**,本节全部结论只取管理面读数。 + 已推送(`0adb7f5`,run `38371033938`,CI 行为见下);`pre-contraction` 为收缩前基线 + (`3fab0ee`,本地存档,不推送)—— **该 ref 现已不可解析**(`git rev-parse pre-contraction` = fatal), + 等价内容经 `main` 取得,见本节 Step 4。 +- 2026-10-13 **Step 7 的范围更正(重要,别把 G71 读成"本机都修好了")**:Step 7 的全部读数 + **只属于 `web` 这一个 profile**。本机实际有四个 profile,各自的组合包选择是独立的: + `desktop`(`base + web-app + experimental-agent-team-profile`,**没有本包**)、 + `headless`(**仍挂着旧名死链** `autogamer-device` → `link:D:/DEV/autoGamer/plugins/autogamer-device`, + 与 G71 修掉的是同一个缺陷,未修)、`sdk`(干净)、`web`(本轮修好的)。 + 用户 22:56:27 起的 5 个 `DeepSeek Harness.exe` 进程走的是 **desktop**(其 `cordis.yml` 同一时刻被重写) + ⇒ **G71 的"生效"证据不适用于用户日常环境**,要装到哪个 profile 得先问。 + 另澄清一条排除项:本机**只有一个 dsh 安装**(`D:\DeepSeek Harness`,exe 构建于 Sep 29 18:34, + CLI `0.2.0-rc.2`),`where.exe dsh` 与 npm/pnpm 全局 bin 均无第二个 dsh ⇒ 不存在"另装了一个 dsh", + 差异全在 profile 层。 +- 2026-10-13 **Step 8 · 本轮收束(用户指示"先不动",只落文档)**: + + **进度快照**(细节在第四节与各 Step 段,此处只报位置与状态): + - 本轮结案 4 条差距:`G77`(#79)、`G73`(#80)、`G79` + `G80`(#82);外加修掉门禁自测的哑弹变异(#81)。 + - 未关闭 7 条:`G71` / `G72` / `G74` / `G75` / `G76` / `G78` / `G81`。 + - 已验证到 live 的:真机动作面 15 工具面(fixture 逐字一致)、坐标帧空间与 `toPixels` 往返、tap 命中、 + `swipe` 生效、roster 一台报四台、`take_screenshot` 裸 base64、`helper --serial` 后七项预检全绿、 + `web` profile 的挂载层与注册表层(2 组件 2 运行中 + 预设可选)。 + - **仍未验证**的:本包工具是否真在 AutoGamer agent 的可调用面上(G30 教训:注册成功 ≠ Agent 看得见); + `run_task` 那条链(缺提供方,G72);scrcpy 三项(缺二进制);键事件与锁屏分支的边角(留白 19/20)。 + - **按"先不动"承诺保留的现场**:`desktop` 未装本包、`headless` 旧名死链未清、`web` profile 的安装未回滚、 + 我起的 web 宿主(PID 22060 / `127.0.0.1:8090`)仍在运行、wire/模型取证未开始。 + + **待用户裁定清单**(每条只留决策点 + 我的建议,正文都在对应行,不在此重述): + 1. 本包该装进哪个 profile(`desktop` 是日常用的那个;`headless` 那条旧名死链要不要一并清)→ 影响 **G71**。 + 2. 提供方走哪条路:外部 checkout / 本树动作面加映射 / **`main` 挂 worktree**(我推荐第三条)→ 影响 **G72**,并连带 + **G74** 的正确形状与 **G80** 的实质。 + 3. `G75` 的路线(我建议 ①+③:钉显式 roster + 明示"计数单位是 adb 传输")→ 连带 **G79** 的"默认设备从哪来"。 + 4. `G76` 与帧空间不一致时的行为:拒绝还是告警(我倾向告警并采用截图值)。 + 5. `G78` 的清理归属:py 侧(我推荐,`detach_all` 已存在)还是插件侧兜底。 + 6. `G81`(144 条 spec 类型错)现在收还是排队。 + 7. `G71` 最后一条证据用哪条路取证:代理抓 `tools` 请求体,或创造模式会话的 `cordis_inspect_query`(我倾向前者)。 + 8. 8090 的 web 宿主留着还是关掉。 + 已推送(`0adb7f5`,run `38371033938`,CI 行为见下);`pre-contraction` 为收缩前基线 + (`3fab0ee`,本地存档,不推送)—— **该 ref 现已不可解析**(`git rev-parse pre-contraction` = fatal), + 等价内容经 `main` 取得,见本节 Step 4。 + +- 2026-10-13 **Step 10 · 取证复测(用户指出上轮「没切模式」,R9 证据降级重取)**: + - **调研(上游一手)**:会话「模式」= preset 选择(内置 `standard`/`ptc`/`minimal`/`cordis`, + 见 agent-preset-registry README.zh)。`cordis_inspect_query` 由 `@deepseek-ai/dsh-tool-cordis` 提供, + **只有 `cordis` 预设挂它**;`standard` 与本包 `preset-autogamer` 都没有 ⇒ 上轮 Step 9 的会话面 + **本就不存在检查器**,模型是照自己上下文复述工具清单,不是 R9 取证 —— Step 9 的取证表述**作废**, + G71 结案依据改由本轮 wire 抓包承接(CHANGELOGS #83 的「官方检查器」字样以本轮实测为准修订)。 + - **What**:不改任何插件代码(验的就是出货形态),用 S10 现成抓包装置 + `scripts/dump-proxy.mjs`(1235→1234 日志代理)+ `spike/wire-tools.py`(数请求体 `tools`)。 + - **How**:旧 proxy-dump.log 移作 .bak → 起 proxy → profile patch 的 `local` baseURL 临时 + 1234→1235 → 冷重启宿主(按监听 PID 清)→ AutoGamer 新会话真跑一轮 → `wire-tools.py` 解析 + → 端点改回 1234、宿主恢复(防代理单点:不还原则每轮必死 ECONNREFUSED)。 + - **Done means**:chat/completions 请求体的 `tools` 数组**恰含 7 个本包工具名** + (`run_device_action` / `report_task_status` / `autogamer_run_task` / `autogamer_manage_task` / + `autogamer_inspect_trace` / `autogamer_get_device_state` / `autogamer_diagnose`); + 抓包命令可复跑;结果回写本条并同步 #83 的取证表述。 + - **结果(同日实测,Done means 达成)**:AutoGamer 预设新会话(qwen3.6-35b-a3b-mtp,6 秒无报错)经代理 + 落到 `scripts/proxy-dump.log` 的 chat/completions 请求体 **`tools=12`**:本包 **7 个全在 wire 上** + (`run_device_action` / `report_task_status` / `autogamer_run_task` / `autogamer_manage_task` / + `autogamer_inspect_trace` / `autogamer_get_device_state` / `autogamer_diagnose`),另 5 个为宿主行 + (`job_kill`/`job_list`/`job_output`=tool-jobs、`plugin_manager`、`skill`),与出货预设组成一致; + 第二个请求 `tools=0` 为标题生成类,符合预期。G71 结案依据由 **wire 差分**承接(S5/G30 明文认可的 + 「插件侧 wire 差分」)。环境已还原:端点回 1234、代理已停、宿主已重启。 +- 2026-10-13 **Step 11 · `preset-autogamer` 增挂 `tool-cordis`(用户裁定「可以增加」,R22 方案先行)**: + - **背景**:Step 10 调研证实 `cordis_inspect_query`/`cordis_inspect_list` 由 `@deepseek-ai/dsh-tool-cordis` + 提供、只有官方 `cordis` 预设挂它 ⇒ AutoGamer 会话无法自查工具面(R9 检查器不可用)。 + 官方 `cordis.patch.yml` 的挂法 = 无 config 平行一行,照抄即可。用户 2026-10-13 批准。 + - **What**:给 `preset-autogamer` 加一行 `tool-cordis`(会话面自动多出 `cordis_inspect_list` / + `cordis_inspect_query` 两工具,wire 面 12→14)。不做:改 `src/`(插件自身仍是七个注册工具)、 + 改 `spike/headless-spike.patch.yml`(历史 spike 装置,非出货形状、无锁读它)。 + - **How(两处同步 + 一把锁)**:`cordis.patch.yml` 与 `spike/web-live.patch.yml` 各加同一行 + (位置对齐官方 cordis 预设:skill-filesystem 之前);`tests/deliveryShape.spec.ts` 加 + `toContain("@deepseek-ai/dsh-tool-cordis")`——G37/G45 的教训:两份文件必须描述同一交付形状。 + README 的「七个工具」指 `apply()` 注册面,不受影响。 + - **Verify**:`pnpm --config.verify-deps-before-run=false run build|test` 全绿; + R6:`dsh --profile web --patch --dump-config` rc 0 且 grep 不到 `unmatched`; + live(R7):冷重启宿主后 AutoGamer 新会话按 Step 10 装置复跑 wire 抓包,应见 `tools=14` + (12 + 检查器两件)——**待 web profile 模型端点就绪后执行**,live 部分挂起不阻塞离线面落地; + 注意冷重启前宿主仍跑旧 preset 代际(配置层只影响之后创建的 Agent,R7)。 + - **Done means**: + - [x] 两份 patch 各含 tool-cordis 行,deliveryShape 锁更新 + - [x] build 不需要(未改 `src/`,dist 不变);vitest 全绿 **32 files / 293 passed / 1 skipped** + - [x] R6 rc 0 且无 `unmatched`(合成树 1420 行含 `dsh-tool-cordis`) + - [x] **live wire 抓包 `tools=14` 达成(2026-10-13,端点就绪后)**:AutoGamer 新会话(qwen3.6-35b-a3b-mtp, + 10 秒无报错)里 agent **真实调用** `cordis_inspect_list` → `cordis_inspect_query`(UI 工具卡片可见), + 自查清单 14 个 = 本包 7 + `job_*`×3 + `skill` + `plugin_manager` + 检查器两件;wire 侧三个回合请求 + 全部 `tools=14` 且名单一致(双侧交叉验证)。R9 检查器从此在本包会话面可用。 + 复跑装置 = Step 10 的 dump-proxy + wire-tools.py + 端点临时切 1235 + - [x] 结果回写本条并按方案/实现两次提交(`972845d` 方案 → `5d44cd9` 实现) + - 环境已还原:端点回 1234、代理已停、宿主已重启在跑(新 preset 代际已生效)。desktop 按用户裁定暂不动。 +- 2026-10-13 **Step 12 · G72 路线④:分支本地提供方(用户裁定:不依赖 main、在本分支实现)**: + - **裁定与定位**:原推荐路线③(`main` 挂 worktree 当提供方)被用户否决——main 不再演进、不得依赖; + 改为把提供方**复活到 `dsh-plugin-contraction` 分支**(`git restore --source=main` 只是复制,main 本身不动)。 + 决策增量记档:D8「智能栈不迁移」原指插件不内嵌智能栈;本条按 §六 规格把智能栈以**外部提供方进程** + 形态放回本仓(`artemisMcpCommand` 拉 `python -m mcp_server`),与 S5「插件作 MCP client 转发」一致。 + - **实测依据(2026-10-13 摸底)**:main 的 `mcp_server` 26 文件(含 `rules.md` ⇒ 恢复后 G80 的 rules + 注入同时变真);它**不直接 import 智能栈**,依赖扇面 = `artemis.runtime`(缺 `trace_store` + + daemon 四件套:`daemon_client`/`server_lifecycle`/`supervisor` + `runtime/__init__` 收窄的五个导出名 + `ensure_daemon_running`/`submit_task_to_daemon`/`is_daemon_running`/`stop_task_on_daemon`/`process_supervisor`)、 + `artemis.data_engine.history_reader`、`artemis.tools.history`、`artemis.utils.{task_tree,ocr_xml_fusion,ocr_api,visualization}`、 + `artemis.core.diagnostics` 缺件、`artemis.sdk`(9 文件,worker 子进程经 `from artemis.sdk import Agent` + 懒加载)→ `artemis.agents`(67 文件,图与 LLM 栈)。agent 执行 = `background/task_runner.py` 子进程。 + HEAD 现存 72 个 artemis 文件与 main 的 diff 是**删除为主**(删 5.67 万行 / 加 205 行),存活文件基本同源 ⇒ + 恢复兼容风险集中在 `runtime/__init__.py`(HEAD 收窄版,保其现有导出、**增补**缺名,不整文件替换)。 + 运行时依赖(langgraph/langchain/google 系)在 pyproject **从未被删**;`paths.py` 在;`.env` 与 + `endpoint_library.json`(均 gitignored)在本机还在,缺 `config/artemis.jsonc`(main 跟踪该文件)。 + - **What**:恢复 ≈114 文件到本分支(`mcp_server/**` + `artemis/sdk/**` + `artemis/agents/**` + 上述散件)、 + `runtime/__init__.py` 增补导出、`uv sync` 补环境、`config/artemis.jsonc` 就绪、提供方工具面与 + 插件 `types.ts` 契约逐字对齐(run=`task_desc`/`model`→`trace_id`;device_state 必填 `view_type` 答纯文本; + manage 三动作;inspect 四动作;diagnose 仅 device_serial)。不做:不改 main、不改插件 TS/patch、 + 不动 third_party、不迁 UI 控制台。 + - **How**:① `git restore --source=main -- mcp_server artemis/sdk artemis/agents` + 散件清单; + ② `runtime/__init__.py` 增补五个导出名(保 HEAD 全部现有导出);③ `uv sync`(uv.lock 变更要提交); + ④ import 追链 `uv run python -c "import mcp_server.server"` 迭代到通;⑤ `config/artemis.jsonc` + default 块指 LM Studio(**本地端点值不提交**,按 main 原文是否含端点定提交形态); + ⑥ 起服务探针验 5 工具面(一次性探针脚本放 `$env:TEMP`,不入库);⑦ py 全套 pytest 基线不回退; + ⑧ live:profile Config 指本仓(`artemisMcpCommand` 用 `.venv/Scripts/python.exe` 绝对路径 + + `artemisRepoPath`),`autogamer_diagnose`/`run_task` 真调用各一证(待端点就绪)。 + - **Verify**:`import mcp_server.server` rc 0;工具面清单与 `types.ts` 头注逐字一致; + `uv run python -m pytest tests/unit tests/integration -q` 基线不回退; + `mcp_server/rules.md` 在场 ⇒ 插件 rules 注入从 `missing-file` 转 ok(G80 live 面)。 + - **Done means**: + - [x] 提供方子树恢复完毕且 import 通(`a7b85fd`,260 文件 / +6.4 万行,逐字来自 main; + `import mcp_server.server` 与 `from artemis.sdk import Agent` 均 rc 0。 + 追链修的四处幸存者兼容:`artemis/__init__` PEP 562 惰性出 `Agent`/`Builders`; + `artemis/config/__init__` 惰性出 agent/llm/output 三组提供方名字(动作面导入仍不拖 LLM 包); + `artemis/runtime/__init__` 增补 daemon 五名 + `ProcessSupervisor`; + `artemis/mcp/action_types.py` 并回 `ObserveResult`(分支重写版原样保留)。 + P4 边界守卫 `test_third_party_boundary.py` 参数随恢复树增长并全绿——它就是这次的判据) + - [x] py 测试基线不回退:**685 passed / 1 skipped**(原 448 基线含在 内,增量 = 边界守卫新参数); + `ruff check` + `ruff format --check` 全绿 + - [x] 5 工具面与契约一致:`mobile_run_task` / `mobile_manage_task` / `mobile_inspect_trace` / + `mobile_get_device_state` / `mobile_diagnose`(FastMCP `list_tools()` 实测)——与插件 + `mcpBackend.ts` 五处 `call` 名字**逐字一致**(Phase 2 锁的就是这份真面,零映射工作) + - [x] `config/artemis.jsonc` 恢复为跟踪文件(main 原文:default 块即本机 LM Studio + `127.0.0.1:1234/v1` + `qwen3.6-35b-a3b-mtp`,无凭据字面量,可直接提交) + - [ ] live diagnose/run_task 各一证:**装置已齐**(提供方进程 + 配置就绪;`mcp_server/rules.md` + 回归也使 G80 的 rules 注入可转 ok)。挂起点 = profile 侧 Config(`artemisRepoPath` + + `artemisMcpCommand` 指本仓 venv python)——待用户在宿主侧接好线后跑会话取证 +- 2026-10-14 **Step 13 · 提供方收缩(用户裁定:D18 TS 主栈方向重申,py 只能以模块形态存在)**: + - **背景与认账**:Step 12 的闭包恢复是「保证能 import」的机械方法,**拉回了远超提供方所需的内容** + (255 文件含死代码)。用户指出这与仓库预期(D18:TS 主栈,py 仅以模块/库形态引入)相差太远。 + - **What**:收缩为**提供方真引用面 + 引擎本体**:删除 38 个零引用文件——旧控制台构建资产 + `artemis/resources/showcase_ui/**`(13)、旧 CLI 命令七件(`cli/commands/{batch,init,run,server, + server_lifecycle,trace,ui}.py`,HEAD 动作面本就不依赖它们)、`artemis/interfaces/sdk/**`(3,D10 + 已裁的 artemis-client 残 world)、legacy 垫片三件(`main.py`/`_legacy_imports.py`/`__main__.py`)、 + 旧独立 `mcp/action_server.py`(新 15 工具面 = `adb_server.py`)、`artemis/telemetry/**`(5)、 + `artemis/drivers/cloud/cloud_driver.py`、`artemis/config/core/**`(3)、`third_party/mobile_use/ + {testing,utils/cli_helpers}.py`。**保留**:agents 引用的老动作面执行层(`action_executor`/`action_specs`/ + `action_session`/`action_names`/`observation`——grep 实证它们是引擎本体,flash/validator/operator 直接 + import)、全部 drivers、全部 package `__init__`。 + - **How**:逐 pattern `git grep -E` 零引用验证(含排除自身的负向过滤)后才删;删后四重验证 = + `import mcp_server.server` + `from artemis.sdk import Agent` + 提供方工具面 + pytest 全套 + ruff 双检。 + - **边界声明(D18 语义落档)**:py 侧 = **按模块引入的 vendored 提供方**(`mcp_server` 进程 + + 其 agent 引擎闭包,代码冻结自上游、不手写演进);TS 侧(DSH 插件)是主栈与唯一开发面。 + 引擎体量(agents/graph/llm ≈ 一百余文件)是「任务执行语义」的最小闭包——再缩只能重定义任务语义 + (如未来 TS agent 替代,属 D18 演进项,另立方案)。 + - **Done means**: + - [x] **37 文件**(showcase_ui 实数 12,原记 38 误)删除且四重验证全绿(`6ef32f5`,-3264 行): + `import mcp_server.server` + `from artemis.sdk import Agent` rc 0、提供方工具面五名不变、 + pytest **664 passed / 1 skipped**(685→664 的差 = 被删文件的边界守卫参数,符合预期)、 + ruff check + format 全绿 + - [x] 边界声明落档本条(见上) + - [x] 结果回写并提交(`9af4bbe` 方案 → `6ef32f5` 实现) +- 2026-10-13 **Step 11 · `preset-autogamer` 增挂 `tool-cordis`(用户裁定「可以增加」,R22 方案先行)**: + - **背景**:Step 10 调研证实 `cordis_inspect_query`/`cordis_inspect_list` 由 `@deepseek-ai/dsh-tool-cordis` + 提供、只有官方 `cordis` 预设挂它 ⇒ AutoGamer 会话无法自查工具面(R9 检查器不可用)。 + 官方 `cordis.patch.yml` 的挂法 = 无 config 平行一行,照抄即可。用户 2026-10-13 批准。 + - **What**:给 `preset-autogamer` 加一行 `tool-cordis`(会话面自动多出 `cordis_inspect_list` / + `cordis_inspect_query` 两工具,wire 面 12→14)。不做:改 `src/`(插件自身仍是七个注册工具)、 + 改 `spike/headless-spike.patch.yml`(历史 spike 装置,非出货形状、无锁读它)。 + - **How(两处同步 + 一把锁)**:`cordis.patch.yml` 与 `spike/web-live.patch.yml` 各加同一行 + (位置对齐官方 cordis 预设:skill-filesystem 之前);`tests/deliveryShape.spec.ts` 加 + `toContain("@deepseek-ai/dsh-tool-cordis")`——G37/G45 的教训:两份文件必须描述同一交付形状。 + README 的「七个工具」指 `apply()` 注册面,不受影响。 + - **Verify**:`pnpm --config.verify-deps-before-run=false run build|test` 全绿; + R6:`dsh --profile web --patch --dump-config` rc 0 且 grep 不到 `unmatched`; + live(R7):冷重启宿主后 AutoGamer 新会话按 Step 10 装置复跑 wire 抓包,应见 `tools=14`。 + - **Done means**: + - [x] 两份 patch 各含 tool-cordis 行,deliveryShape 锁更新 + - [x] build 不需要(未改 `src/`,dist 不变);vitest 全绿 **32 files / 293 passed / 1 skipped** + - [x] R6 rc 0 且无 `unmatched`(合成树 1420 行含 `dsh-tool-cordis`) + - [x] **live wire 抓包 `tools=14` 达成**:AutoGamer 新会话(qwen3.6-35b-a3b-mtp,10 秒无报错)里 + agent **真实调用** `cordis_inspect_list` → `cordis_inspect_query`(UI 工具卡片可见),自查清单 + 14 个 = 本包 7 + `job_*`×3 + `skill` + `plugin_manager` + 检查器两件;wire 侧三个回合请求全部 + `tools=14` 且名单一致(双侧交叉验证)。R9 检查器从此在本包会话面可用。 + - [x] 结果回写本条并按方案/实现两次提交(`972845d` 方案 → `5d44cd9` 实现) + - 环境已还原:端点回 1234、代理已停、宿主已重启在跑(新 preset 代际已生效)。desktop 按用户裁定暂不动。 +- 2026-10-13 **Step 9 · 用户裁定落地,G71 结案(**#83**)**:待裁清单第 1、7 条已决——headless 死链 + **移除不重装**(rc 0,bundles 现为 `[dsh-base, dsh-headless]`;pnpm 11 又按 G49 机制留死 junction, + 已按 reparse point 摘除);web 走完取证,desktop 不动;~~取证路用了**免审批的 `cordis_inspect_query`** + (非代理抓包)~~(**此取证表述已被 Step 10 作废**:该会话面没有检查器,模型只是复述清单)。 + 本机 dsh 现状复核:安装唯一(`where.exe dsh` 唯一命中 `D:\DeepSeek Harness`, + CLI 0.2.0-rc.2),4 profile 并存。 + 取证前置修了两处环境:① web profile 的**模型端点**——活配置是每 profile 一份 `cordis.patch.yml` + (宿主设置投影;`settings.yaml.imported` 只是旧快照),web 的 `local` provider 停在已死的 `8080`, + 真会话首跑 5/5 重试 `Connection error. TRANSPORT`;按用户指示两次同步 desktop 现行值,终态 + `local` → `http://127.0.0.1:1234/v1` + `qwen3.6-35b-a3b-mtp`(`input: [text, image]` 声明随行, + S5 负能力坑不踩),非模型条目不动。② rc.2 `patchReload` 无实现(D7)⇒ 冷重启生效;且杀 wrapper 后 + exe 子进程仍持 8090(`EADDRINUSE`),须按**监听 PID** 清理后再起。 + ~~取证结果:AutoGamer 预设新会话(qwen3.6-35b-a3b-mtp,18 秒往返、无报错)里 agent 调 + `cordis_inspect_query` 列出的工具面**恰含全部 7 个**~~(同上,复述不算取证;有效部分只剩: + AutoGamer 预设可被选为新会话预设、模型往返 18 秒无报错——挂载层与传输层证据仍成立)。 + **G71 → CHANGELOGS #83**(正文与验收命令在彼;§四 的 G71 行与两个在办条目已按 + 结案协议移除;#83 的取证表述由 Step 10 的 wire 抓包承接)。8090 宿主重启后仍在运行, + 待裁清单第 8 条(留/关)仍留给用户。 + +- 旧账去向:P4 A/B 归档与放弃清单见 CHANGELOGS **#71**;分支 `dsh-plugin-contraction` + 已推送(`0adb7f5`,run `38371033938`,CI 行为见下);`pre-contraction` 为收缩前基线 + (`3fab0ee`,本地存档,不推送)—— **该 ref 现已不可解析**(`git rev-parse pre-contraction` = fatal), + 等价内容经 `main` 取得,见本节 Step 4。 + +- CI 修绿(run `38371033938` 全红之后):首个 push run 6/6 红,根因全是旧树残留—— + 前端/smoke-test 的 setup-node 缓存路径、依赖源门禁的文件 glob、fixture 步调的生成器, + 皆指向 P4 已删之物。修法(CHANGELOGS **#77**,含首绿前又红一次的补记):删 + frontend/package 两 job、去 gen-sse 步、门禁去 npm 半与 client 行、pyproject 去 + client/workspace/admin/testpaths 并显式声明 `artemis*` + `third_party*` 命名空间包、 + pyright 范围同步、uv.lock 重锁;另修根 `.gitignore` 大小写陷阱(吞过 `src/env/`)。 + 另挖出一真因:P4 重写 `artemis/__init__.py` 时丢了 `_legacy_imports` 寻址器(含其模块), + 安装后上下文 `import third_party` 全灭(`python -m`/pytest 因根在 path 上幸免)—— + 即 fixture 步在隔离环境红的原因,命名空间映射已代换该机制。 + dispatch-only 那步仍指已删路径(G70 已放弃,不碰)。run `38376354428` 4/4 绿(双 OS), + 收缩后树第一次全绿。 +- 下一步:离线实现齐(Phase 0–5,逐阶段条见 §六);live 验证待条件具备——提供方形状记 **G72**、 + scrcpy 与真机项都在 §六 留白总账里按触发条件登记(**条目数不抄在这里**,那是一份会腐烂的副本), + 或听用户下一步指令。 + +## 一、最终形态 + +已清空,见「零、当前状态」。产品方向暂以第二节与第三节的冻结基线为准,新方案到达后改写本节。 + +## 二、决策记录(冻结基线,D1-D20) + +2026-10-14 冻结:清空时保留作基线,新方案到达后按 R22 修订。**D19 / D20(2026-10-14)为现行终态决议**:D19 收紧 D18,D20 是独立于 D19 的另一根轴(内容来源)。 + +| # | 决策点 | 结论 | +|---|---|---| +| D1 | 战略路线 | 项目以 **DSH 插件形态重生**;方案基线 = google/artemis 上游(upstream-rethink) | +| D2 | 队列底座 | 会话队列 = **DSH Inbox**(durable + QueueDock);禁止自建队列容器/引 Redis;「autogamer-queue」插件概念已取消 | +| D3 | 设备模型 | **共享真机 + 完整闸门**(用户已决):闸门/熔断/准入 = run_device_action 工具内部逻辑;每会话专属模拟器(上游 playground 模型)= 二期演进项 | +| D4 | 积压策略 | Inbox 无上限原生,零自建 | +| D5 | 重启恢复 | Inbox pending 原生恢复待派 + 插件启动显式 kick(已核实的唯一缺口);动作无断点续跑(与上游一致) | +| D6 | 版本管控 | peerDependencies 声明 + runtime 强制校验 + conformance 契约测试;自研包精确 pin | +| D7 | 引入形态 | **三层分开**(守则 R2):① 禁——手写 profile 目录内的 `package.json` / `cordis.patch.yml` 或在 profile 目录跑 pnpm;② 允许且为开发期主路径——`dsh --patch `(`dsh --help` 明列的官方 overlay,可重复);③ 交付形态——`plugin_manager install_bundle` 或 Web 侧边栏「插件」面板(面板具备 install/enable/disable/retry/compose 能力)。preset 声明由 bundle 补丁承载(注册表不扫目录、不接受路径);**配置层热重载、代码层冷重启**(`patchReload` 字段 rc.2 无实现);**声明只影响之后创建的 Agent**,复验必须开新会话(守则 R7) | +| D8 | 技术栈 | 插件 TS;py 复用动作面/驱动/诊断/helper/录屏(后补);**langgraph 智能栈不迁移**(由 S11 单层替代) | +| D9 | 迁移路径 | **R2' 直通插件**(P0 即插件形态,质量 spike 把关;不过→回退三段式双跑,历史方案见 git);一期红线=旧控制台**原地只读保留**(不删除) | +| D10 | 产品形态 | 项目=DSH 插件,**无独立外部 HTTP API 面**:不建 HTTP→SDK 桥、artemis-client 直接删、webhook 通知按需再建 | +| D11 | 审批 | 常规动作 never;**run_adb_command 整工具 ask**(allowed-once,唯一授权粒度)起步;headless 需显式 `policy: never` | +| D12 | 录屏 | 后续补齐(一期无录像) | +| D13 | helper | 二期 TS 复刻(保留与 Mobly/Appium 共存差异化能力) | +| D14 | 智能层 | **单层执行路由(S11)**:单 agent + 代码强制路由器 + validator/checker 只读 subagent;pro 一期内实现、调用逻辑保真;不做反思式自检(业界 SOTA 立场) | +| D15 | 契约核实口径 | **只用声明面**:`dsh --dump-config-schema` / `--help` / 随包 README / 已安装 npm 包产物;**禁止解包 app.asar** 去读宿主内部代码(2026-10-10 用户裁定)。既有 [dsh-verification](migration/dsh-verification.md) 里 37 处包内代码行引用属旧口径,沿用前须按声明面重验;README 与 CLI 旗标类证据(28 处)本已合规 | +| D16 | 插件分层与 realm | 官方口径([upstream-plugin-forms](migration/upstream-plugin-forms.md) 二.6):Host 插件提供共享服务(tools registry、agent loop、sessions 等),**preset 插件只向这些 registry 贡献 scoped tools / persona / prompt 片段 / policy**;preset 内提供服务的插件**必须与其全部消费方同处一个 `cordis:group` + `isolate` realm**,条目局部隔离不沿 Agent 注册 scope 传播;注册表 `mountRevision` 对「行激活失败」与「服务泄漏进 root realm」都是**直接拒绝整个 preset 挂载**(源码口径 `mount.ts:193,212-213`) | +| D17 | 自举引入 | 给 `preset-autogamer` 挂 `@deepseek-ai/dsh-plugin-manager/tools`(官方 shipped preset 里该行的 disabled 写作 `!!js '!ctx.get('profileContext')'`,即 UI 会话中可用),让 AutoGamer agent 能对自己执行 `install_bundle`。这是上游「让 Agent 给自己造插件」的现成路径,也是把引入流从手工迁到自动的落点;执行细节与红线见 [守则 R2 / R9](migration/plugin-contract-rules.md)。**2026-10-12 已落地**:preset 的 plugins 加 `- id: tool-plugin-manager`,`disabled: !!js "!ctx.get('profileContext')"`(**先写成 `false` 是错的,见 CHANGELOGS #26**:服务行 `plugin-manager` 自己就是 `!ctx.get('profileContext')`,工具行无条件开会要求一个不存在的服务,而缺服务按 D16 拒挂整条 preset;官方 Creator preset 用的正是这个表达式)(**不是**原设想照抄 `!!js '!ctx.get("profileContext")'`——官方 README 给的启用形状就是 `disabled: false`,且写明「Preset-backed sessions use their preset's tool-plugin-manager entry」;shipped `standard`/`ptc` 都是 `disabled: true`。⚠️ 但「照抄 `false`」的写法已在 #26 修成跟随服务条件)。live 已证:R7 新会话工具面从 5 个变 7 个、多出 `plugin_manager` 与 `skill`。⚠️ 代价按官方原文记在案:「installed Host code executes in-process outside the workspace sandbox」,且 plugin_manager 的**每个**动作(含 list_plugins/list_bundles)都要 danger-full-access 或该次审批;本仓库不替用户放宽权限模式(R19)。 | +| D18 | 长期技术栈方向 | **用户 2026-10-12 定:以 TS 为主,py 仅在「特定库成熟度高于 TS 同类库」时保留**(典型如 adb/uiautomator2 等设备库、视觉/CV 库)。新增与重构逻辑默认落 TS;py 是「库成熟度占优」的例外而非默认。本次 P4 收缩**不触发** py→TS 重写,只把保留 py 压缩到最小设备能力面(动作为主、零智能栈),作为未来 TS 迁移前的可验证基线。是 D8 之后的演进方向,不改变 D8 当下的动作面保留 | +| D19 | 终态(现行) | **用户 2026-10-14 定:TS 全栈、py 归零**。仓库内不再承载任何 py 实现(`artemis/`、`mcp_server/`、`third_party/`、`pyproject.toml`、`uv.lock` 全删,CI 无 python job);上游 ARTEMIS 的能力逐步改由 TS 承载;外部 ARTEMIS **只保留为可选提供方**(用户自行安装、经 adapter 显式接入),不内置、不 vendored、不开开发依赖。**收紧 D18**——其「库成熟度例外」经实测不成立(`langchain` 出现在 54 个 py 文件而 LLM 绑定由 DSH 宿主提供;OCR 是 HTTP 调用;`cv2`/`PIL` 有成熟 TS 同类;`uiautomator2` 是 on-device 协议客户端)。规格与四期路线见 [ts-fullstack-spec](migration/ts-fullstack-spec.md),流程见 §七 | +| D20 | 溯源终态(现行) | **用户 2026-10-14 定:除 LICENSE 外不留存任何上游信息,包括代码——本项目仅从上游获取设计灵感后独立重实现**。清理面 = py 代码、copyright 头(实测 319 个被跟踪文件带 `Google LLC`)、vendored `third_party/mobile_use`(自带其 **另一份** LICENSE 与 NOTICE,来自另一上游)、上游派生文档(`docs/research`、`inventory/*`、`agents/*.md` prompt 正文)、致谢文案、指向 `google/artemis` 的 `upstream` remote。**硬约束**:TS 重实现只借鉴设计、不搬运文本(逐字照抄 = 保留派生内容,与 D20 冲突),贯穿 T1–T3。**已定与未定分开记**:工作树层判据已定(见规格 §八);**git 历史处置与 `upstream` remote 去留待用户裁定**——历史仍含上游全部内容,不裁定则「不留存」只在工作树层成立。规格 §八 | + +## 三、对接语义(冻结基线,S1-S11) + +2026-10-14 冻结:清空时保留作基线,新方案到达后按 R22 修订。 + +**S1 会话映射** +- ARTEMIS `conversation_id` ≡ DSH `SessionId`(✅ 核实:外部指定 + 幂等 create-or-adopt;id 会转义为磁盘目录名) +- 用户消息 = `followup` 进 Inbox(原生会话 FIFO + QueueDock 排队 UI);会话记忆 = DSH 原生对话延续(py notes 继承机制消失) + +**S2 任务 = turn 内动作序列(R2' 修订,取代「任务=job」)** +- 不再 spawn py worker job(R1 遗产):设备动作 = `run_device_action` 工具调用,内部转发 py action server +- 长任务 = 单 turn 内多动作(工具内 await);kill = `agent.cancel`(工具经 exec.signal 取消转发、释放闸门) +- 终态 = 汇报工具的 `completed/blocked + tests.failed`(run_outcome 双轴语义,盘点 04) + +**S3 设备闸门(工具内的执行机制;策略出工具)** +- 闸门 = per-device 弱表 promise 链(官方样板 tool-bash-persistent:WeakMap + tracked pending 支持取消)+ `dsh-deque`;lockKey = endpoint+serial +- 阻塞获取(排队以 turn 进行中呈现);parking(auto 单 ticket park 一台空闲设备)与全局并发上限(G28)内部化 +- **与宿主并发约定对齐(守则 R14)**:官方已经规定「独立只读调用可并发,变更类调用独占执行并按提交顺序」,所以我们只补「同一设备跨会话串行」这半边;`take_screenshot` / `get_ui_hierarchy` 已声明为可并发,字段名为 `isConcurrencySafe`(`@deepseek-ai/dsh-tools` 类型原文,G33 落地时从已安装产物取得) + +**S4 设备熔断(状态在插件,拒绝出口用 guard)** +- closed →open(连续环境级失败 ≥ allowed_fails,工程默认 1)→ half-open(下一条真实消息动作即探测)→ 成功 closed +- 环境级判定(R2' 改写):**工具层错误分类**——action server 连接失败/adb 不可达/设备离线/锁屏 = 环境级;模型语义失败 = 任务级;枚举不确定→继续熔断(fail-safe)、探测失败→不挂(fail-open)不对称保留 +- **冷却期的拒绝走 `ctx.tools.guard()`**(守则 R13:它是加在 `tools/pre-execute` waterfall 之后的单调同步守卫,后续监听器无法把拒绝翻回允许,且不受注册顺序影响);只有需要 await 的决策才在 `tools/pre-execute` 返回 `ask`。官方原文「尽量不要把部署策略内建到工具中」,所以熔断**状态**归插件、**拒绝出口**归 guard +- 挂起期间新消息保留在 Inbox(QueueDock 可见);解除矩阵(旧 inventory/01 四路径的无队列映射,矩阵轮定稿): + ①探测成功关(`recordSuccess`)②冷却到期半开(单探测,`probeInFlight`)③探测环境失败重挂新窗(非永久,时间必再开; + 此处的 fail-open 指**判定失败**——internal/unknown 不记(G38)——而非探测失败)④新提交/显式 resume**不旁路**健康态 + (与旧世界及本条旧句的显式偏差:健康态非批次态;半开保证冷却后首动作即探测,消息永不饿死;旧 resume 为逃永挂而设, + 此处无永挂故不需要);cancel 不碰健康态(中止无健康信息;重启即 fresh breaker = 旧 stop 效果)。 + 省略/空 serial 一律落 `defaultDeviceKey`(`deviceKeyOf` 三处同源);`allowed_fails` 默认 1(contracts 锁死)。 + +**S5 执行层契约(py action server,原样复用)** +- 载体:`artemis mcp --server adb` 动作面(13 工具:tap/long_press/swipe/back/launch_app/stop_app/open_link/focus_and_input_text/focus_and_clear_text/erase_one_char/press_key/take_screenshot/get_ui_hierarchy) +- autogamer TS 工具内部作 MCP client 转发(**不裸用 dsh-mcp-client patch**——会绕过闸门);进程由插件生命周期管理 +- env 极简(对齐上游容器契约):`ADB_DEVICE_SERIAL`/`ADB_HOST/PORT/SERVER_SOCKET`/`ARTEMIS_HIERARCHY_BACKEND` + helper 相关 +- 返回契约:ActionResult(`isError=False + ok=False` = 设备拒绝,观察非异常);0-1000 归一坐标(G24);**超时不许指望 `timeoutMs`**——注册表绝不强制执行定义里的它(守则 R15),现状是由 `ActionClient` 的 `Promise.race` 自实现并观测 `exec.signal`(主体前取消 = `ABORTED_BEFORE_DISPATCH`,主体后取消只能把成功换成 `ABORTED`,超时为 `TOOL_TIMEOUT`);要交给宿主执行须挂 `dsh-tool-call-timeout-policy` 包装层,其入参官方文本未列(守则待补 2) +- **裸 schema 注册是合法形态,但前提是给的东西已经是完整 JSON Schema**(守则 R12/R13 补正 + G30 实证修正):`ctx.tools.register()` 直接收 schema 定义,官方自己就这么接 MCP——「MCP 服务器发现工具后,用服务器的 schema 调用 `ctx.tools.register()`」;`defineTool` 是类型化 DSL 糖,**但命名 spec 映射 → object schema 的编译只发生在 `defineTool` 里**(`parameterSchemaSpecToJsonSchema`)。我们曾用命名 spec 直接裸注册,LLM API 以 `parameters.type` 非 `"object"` 拒绝整个请求(400)。现状:两处定义都走 `defineTool()`,object 节点显式 `additionalProperties`(G34)。代价照旧成立:**工具自己负责输入校验**,非空字符串、正数、跨字段也要自查——现在的 `target` / `text` / `key` 校验就是这部分 +- **宿主服务依赖与图像结果(G36 实证)**:`inject` 是**属性 GET 的闸门**,不是启动偏好——没声明的服务读一下就抛 `cannot get property "attachments" without inject`(不是返回 undefined)。工具要让模型看图的正道:`ctx.attachments.saveImages([{data: Uint8Array, mediaType, name}])` 换内容寻址引用,结果 `render` 成 `[text 信封, image 块]`(官方 `read_image` 就是这个形状),**字节绝不进 value / 会话日志**;宿主会自行把图提升成带真实 base64 的后续 user 消息,**不需要**给 preset 挂 fs 或 `read_image` 去开那个只读路径。两个必踩的坑:provider 配置 `models[].input` 的 schema 默认 `[]`,而声明面口径是「absent=unknown,显式省略=负能力」,于是图被替成 `[image omitted because this model accepts text only]`——要显式写 `[text, image]`;附件准入有上限(单边 8192px / 64M 像素 / 单图 20MiB,宿主还会重编码成 JPEG),被拒时降级成文本而不是失败整个动作 +- **注册可见性(G30,已定性并关闭)**:`ctx.tools.register()` 返回 disposer **不等于 Agent 能看见该工具**——真正的坑不在 scope,而在**注册的生命周期**:cordis `effect()` 是「回调立即执行、返回值才是清理函数」,把 teardown 当回调体传进去,工具就在注册后数毫秒内被自己注销,而 roster/插件页/清单一切正常(G30 根因 ①,changelogs #10)。判「生效」仍只认官方检查器(`cordis_inspect_query` 的 `Tool` 项、roster 的 `enabled`/`fiberPhase`/诊断、`inspectCompositions()` 的 leakedServices),但**最快的一步是插件侧 wire 差分**:看新会话里「哪几个工具在/不在」,比读挂载诊断收敛得快 + +**S6 审批映射** +- 常规动作 never;破坏性面全部集中在 run_adb_command → **整工具 ask**(D11);helper APK 安装/Chrome 强制标志 = 任务前置隐式授权(ARTEMIS_HELPER_AUTO_INSTALL 可关);fail-closed 默认 +- **授权红线(守则 R19)**:批准工具调用、回答 agent 提问、放宽策略这类「授予或确认权限」的动作**永远 user-only**,不得做成 agent 可调的工具,也不许由插件代答;同一操作只在 Host service 方法里实现一次,UI 动作与 agent 工具共用它(官方「one operation, two callers」),不许两边各写一份逻辑 +- 附带观测口径:普通工具失败要收敛成结果返回,**不能中止本轮**(`UNKNOWN_TOOL` 等都是结构化错误而非 throw 出轮次) + +**S7 重启对账** +- 插件启动:拉起/校验 action server → 扫 pending inbox 非空会话显式 kick(D5)→ 无孤儿 worker 概念(R1 遗产消失) + +**S8 版本门禁**:conformance 契约测试锁事件/工具面/session 格式,接 `dsh plugin` 升级流。**版本差要盯住**:spike 全程跑 `0.2.0-rc.2`(2026-09-29),上游已发 `dsh-v0.2.1-alpha.1`(10-03)与 `dsh-v0.2.1-alpha.2`(10-09),alpha.1 的主题就是「让 Agent 给自己造插件」(即本次调研引用的两个 skill);升级前 conformance fixture 视为过期 + +**S9 中途指导注入**:py 机制消失;DSH 侧 = `steer`/`followup` 原生(已核实)。按守则 R17 分两种唤醒语义:`agent.inject({content, source:{kind}})` 追加持久上下文但**不唤醒**(空闲 agent 保持空闲,下一次准入的 step 才看到),定时器要驱动工作必须调 `agent.followup()`;`source.kind` 要在插件里经 `MessageSourceMap` 声明——session format V4 在消息准入处**拒绝**已退役的 `{kind:'plugin', plugin:''}` 包装;对已 dispose 的 agent 要 try/catch。另注意 `whenIdle()` 不等于一个 followup 结束(多个输入可共享同一次运行区间),S3/S4 的等待判据要按此写 + +**S10 turn 执行(已裁定乙)**:用户消息 → Inbox → agent turn → 工具直调。R2' 下无 shim 需求(无 job、无 py worker 分支);已核实的通用前提仍然有效:composition 挂 `dsh-tool-jobs`、`maxConcurrentJobsPerOwner`(默认 10)、创建 agent 不强制 model + +**S11 单层执行路由(D14)** +- 结构:单 agent loop + `run_device_action` 路由工具 + validator/checker 只读 subagent + goal 工具(可选计划脚手架) +- 路由器按序判定(**代码强制,模型不可绕过**): + 1. 目标置信:元素索引可解析且无歧义 → 短路径直执行(3-5s/步保持) + 2. 风险等级:破坏性/不可逆 → validator 前置 + approval ask + 3. 执行后效果确认(✅ 业界采纳 action-effect verification):屏幕差分/dHash 确认生效 → 未生效 → validator 自愈梯(XML→坐标→pixel)→ 连败达阈值 → checker 审计通过才许重试 + 4. 计划检查点:有 plan(goal 工具)→ 里程碑 checker 审计;无 plan = 仅出口审计 + 5. 严格度梯级 off/final/checkpoints/strict = 路由器配置(run_device_task 入参) +- 保真对照(盘点 04 = 验收清单):validator 升级梯原样;checker 三不变量按守则 R16 落地——**「判定 append-only 落 session log」不许用自写事件类型实现**(带新 `type` 的 append 会让 Session 拒开,`appendPluginRecord()` 虽写 ignorable 记录但 reserved for DSH 实验包),改走 `output.presentationMeta(args, value)` 把判定持久化进 `tool/result` 的 `meta`(可回放、且 `presentResult` 能重建卡片)或 storage service;只读与「释放与判定分离」两条不变;run_outcome 双轴;预算 = goal `maxGoalRounds` + checker 重试上限 +- **PTC 让三期能力提前到一期可选**(守则 R14/R18 的 `mode: ptc`):每个可见已注册工具都能 `await tools.(args)` 直接调用并重入正常执行流水线,成功返回**策略处理后的规范 JSON 值**而非渲染文本,失败以 `ToolCallError` reject(只能读 `name` / `toolName` / `message`)→ validator 的确定性升级梯与 checker 的结构化复核都有现成载体;但呈现方式**按 agent 而非按工具**,同一 agent 内不能让一个工具只用 Native、另一个只用 PTC,且 PTC 中间值不落日志、无法回放 +- 业界对齐(dsh-verification §八):单 agent + 外部确定性控制是收敛方向,反对反思式自检;DSH workflow/PTC = 三期确定性 burst 现成机制 + +## 四、差距清单 + +| 编号 | 差距 | 阻塞点 / 下一步 | +|---|---|---| +| G72 | 本机提供方形状未定:插件默认 `artemisMcpCommand = ["python","-m","mcp_server"]`(`src/index.ts:76,179`,按 Phase 2 重核的 pre-contraction 5 工具面落的),而**本分支 `mcp_server/` 的 `.py` 计数为 0**,只有 `uv run artemis mcp --type adb` 的 **15 工具动作面**(13 是 `ACTION_TO_PY_TOOL` 的非 null 映射数,两者不是一回事) | 三条路,**②已被证据排除一半**:① Config 指到仍含 `mcp_server` 的外部 checkout;② 给本树动作面加映射层——动作面**没有 task/trace 类工具**(fixture 15 个里无 `run`/`status`/`stop`/`inject`/`view_summary`),`run_task`/`manage_task`/`inspect_trace` 三个工具无处可接,本分支也没有 REST 兜底(`git ls-files apps` = 0);③ **用同仓的 `main` 挂一个 worktree 当提供方**(`main:mcp_server` 有 23 个 `.py`,含那 5 个工具模块与 `rules.md`),零外部依赖,代价是要为它单独 `uv sync` 一份环境并吃智能栈的重依赖。2026-10-13 Step 0/4 实测 | +| G74 | **映射层按已删的 5 工具面锁形状**,与本树动作面真返回不符:`take_screenshot` 实测是裸 base64 JPEG(226856 字符 / 170142 字节 / `image/jpeg`,py docstring 亦写 “base64 encoded JPEG string”),Phase 2 却记成 `file://…` 纯文本 | **只有 provider 那半边要改**:`artemis/mcpBackend.ts:170-196`(`view_type` 与 `screenshotPath: text`)、`artemis/types.ts:22-30`、`tools/getDeviceState.ts:105` + 两个 spec(`artemisMcpBackend.spec.ts:60-87`、`getDeviceState.spec.ts:36-48,104-117`),约 60 LOC。**`{index}` 侧不重复做**——它早已优先吃 `parsed_bounds` 并有锁(`apply.spec.ts:62`、`dedup.spec.ts:17`),上一轮把这条写成待办是我没核现状(R25)。**先定 G72 走哪个面**,两面的形状本就不同 | +| G75 | **一台 MuMu 被 roster 报成 4 台**:`list_devices()` 返回 `127.0.0.1:16384` / `:5555` / `:7555` / `emulator-5554`,四条 `android_id` 全等(`c0021066f3fbaf47`)、`ro.serialno` 全空 ⇒ 按 endpoint 分键的闸门、全局并发上限与熔断**4 倍计数** | **R20 已查证:两个上游都没有这个口径**(详见 [upstream-plugin-forms](migration/upstream-plugin-forms.md) 九),且 §十 补了一条更硬的约束:adb 文档里**只有 `emulator-5554` 的 console↔adb 端口配对有身份含义**,`127.0.0.1:*` 是裸 TCP 传输、端口可能随启动变化 ⇒ **钉 TCP serial 的白名单会静默丢设备**。三条诚实路线(钉 roster / py 增发身份 / 只明示计数单位)与代价在 §九;路线 ① 的改动面极小(`cordis.patch.yml` 加 `devices`,`devicePool.ts:166` 已实现「钉住就不枚举」),但要接受"多实例时操作者手工维护"。待裁定后按 R22 写方案再动码 | +| G76 | **坐标空间口径缺一道防护**:本机主视口 `Rotation90`,帧空间 **1600x900**(独立金标准 = `screencap` 的 PNG IHDR),而 `wm size` 报 **900x1600**;插件从截图取尺寸这条路已验正确(`imageSizeFromBase64` → `{1600,900}`,`toPixels` 往返精确,真点 `(800,87)` 命中搜索栏),但 Config 的 `screenSize` 手填项(`configuredScreenSize`)若按 `wm size` 填,同一点偏 **350x / 362y** | 二选一:删掉/显式标注该手填项「必须是帧空间尺寸」,或加一致性校验(与截图 IHDR 比对后拒绝或告警)。测量见 §零 Step 1 | +| G81 | `tsconfig.json` 的 `include` 只含 `src` ⇒ 插件的 **32 个 spec 从未被类型检查**。把 tests 纳入一次 `tsc` 实测 **144 条错误**(`apply.spec.ts` 独占 45、`preflight.spec.ts` 21、`conformance/contracts.spec.ts` 15);vitest 只转译不查类型,所以「必填字段」这类守卫在测试里不咬人 —— G79 正是靠必填才成立的守卫,它的守卫强度现在只在 `src` 一侧 | 先量后修:加一个 `check:tests` 脚本(临时配置需覆盖 `rootDir`,否则 TS6059 全量爆),144 条不可能一把改完 ⇒ 按文件分批收敛并同步 CI;**不要在 G79/G80 的收尾提交里顺手改** | +| G78 | **`adb forward` 残留已实测证实**:`adb forward --list` → `127.0.0.1:16384 tcp:54254 tcp:18888`(18888 = helper 端口),而 MCP server 进程早已退出 ⇒ Done means 的「卸载无残留」**不是待验,是已被证伪**;建 forward 的一方是 py 动作面,插件 stop/dispose 管不到它 | 归属待定:插件侧兜底按 `host:port` 精确删(`--remove-all` 会误伤别处),还是 py 侧在自己生命周期内清(更接近 canonical 契约)。scrcpy 那一半仍缺二进制(留白 6/7) | +| G82 | **py 引擎仍是运行消费者**:langgraph 图(planner/operator/validator/checker)与 langchain 绑定层被 `autogamer_run_task` / `autogamer_manage_task` / `autogamer_inspect_trace` 经 `mobile_*` 消费;但 D8/D14/S11 早就声明这层「不迁移、由单层执行路由替代」 | 期 **T1**([ts-fullstack-spec](migration/ts-fullstack-spec.md) §三):把三条工具改 TS 侧编排,S11 路由驱动 `run_device_action`;任务状态先查 DSH 原生载体(R20),不自建容器。Done = py 引擎在插件侧可达性为空 | +| G83 | **15 工具动作面 py-only**:实现在 `artemis/mcp/adb_server.py`(adb / uiautomator2 客户端);插件 `ActionClient` 目前只是 MCP 转发 | 期 **T2**:TS 原生实现 + 与现面**同一输入同一输出**逐条对照(fixture 用现成 `tests/fixtures/py-action-tools.json`)。前置 = §四 的 wire 实测(层级 dump 形态、helper 协议) | +| G84 | **效果判定 / OCR / 标注 py-only**:`compare_ui_frames` 委托 py 侧 `check_ui_change`;OCR 走 py `httpx` → Google Vision;overlay 用 py `PIL`/`cv2` | 期 **T3**:dHash 类差分 + `fetch` 直译 OCR + sharp 标注;算法与阈值从 py 读设计(属「无思路从上游取设计」)。对照表留档 | +| G85 | **py 归零收尾未做**:`artemis/` `mcp_server/` `third_party/` `pyproject.toml` `uv.lock` 与 py 测试树仍在;CI 仍有 python job | 期 **T4**:分批删 + 每批后跑插件全套与 R6;文档口径同步(AGENTS 命令表、README 导览、§零/§六)。Done = `git ls-files '*.py'` 为空且 CI 无 python job | +| G86 | **溯源清理未做(D20 线,独立于 G85)**:仓库仍含上游派生内容——实测 319 个被跟踪文件带 `Google LLC` copyright 头(`docs` 3 / `scripts` 1 / `tests` 30 / `Makefile`·`pyproject` 各 1);vendored `third_party/mobile_use`(自带**另一份** LICENSE + NOTICE,来自另一上游);上游派生文档(`docs/research/*`、`docs/migration/inventory/*`、`artemis/agents/*.md` prompt 正文);致谢文案;`upstream` remote 指向 `github.com/google/artemis` | 期 **T4 同步**([ts-fullstack-spec](migration/ts-fullstack-spec.md) §八):按清理面逐项处置;**贯穿约束**「只借鉴设计、不搬运文本」在 T1–T3 就已生效。**git 历史处置与 remote 去留待用户裁定**(不裁定则判据只覆盖工作树层) | + +旧表 10 项的去向见 CHANGELOGS「已关闭差距史」(9 项放弃 + G36 行同步 UI 侧放弃),编号权威仍是 [registry](migration/registry.md)。 + +## 五、文档索引 + +已清空,改读 [导航](migration/README.md)(导航唯一权威)。 + +## 六、新方案:dsh-autogamer(用户方案,待核实后实施) + +背景:用户 2026-10-14 提供完整规格(原标题 DSH-ARTEMIS,实现名已更正为 dsh-autogamer; +2026-10-14 修订版同步工具名前缀 `artemis_*` → `autogamer_*`,与插件命名空间一致, +description 注明底层由 Google ARTEMIS 驱动;此为用户定稿,推翻需明示)。 +定位:不是另起插件,而是对当前实现(`plugins/dsh-autogamer`)的矫正;规格内 `dsh-artemis` +一律读作 `dsh-autogamer`(插件 id 已由 `autogamer-device` 改名,见本条 Phase 0 结论)。 + +### What(做 / 不做) + +做:工具注册、配置投影、环境预检、任务生命周期管理、UI 卡片、设备画面、rules 注入。 +不做:重写 py 逻辑、改 ARTEMIS 源码、内嵌 ARTEMIS Web 控制台、复制 ARTEMIS 代码。 +形态:TS Host 半 + 浏览器半;ARTEMIS 作外部进程/服务调用,适配器统一 MCP 与 CLI 两后端, +运行时按可用性选择;插件自身 Apache-2.0 并在 README 致谢 ARTEMIS。 +工具面(5 个,前缀 `autogamer_*`):`autogamer_run_task`(500ms 内返回 task_id,不阻塞)、`artemis_manage_task` +(`status`/`stop`/`inject_instruction`,`recent_actions` 默认 5 步)、`autogamer_get_device_state`、 +`autogamer_inspect_trace`(summary 默认首尾各 3 步加失败步)、`autogamer_diagnose`。 +Config:仓库路径/uv 路径、默认 profile 与超时与设备、`injectRules`/`autoPreflight` 开关、 +scrcpy 三参数、`traceRetentionDays`;全部经设置区声明,不碰原生设置页代码。 +预检 `runPreflight`:uv、Python 3.12 以上、仓库路径、已安装、adb、设备、Helper 七项; +失败不阻断加载,工具调用时返回带修复命令的结构化错误;结果缓存 60 秒。 +rules 注入:每次加载重读外部 `rules.md`,不缓存;主路径为系统提示贡献 API, +降级为工具描述尾部摘要(前 2000 字符)。 +浏览器半:任务进度卡片(2 秒轮询、终态停)、设备画面(scrcpy H.264 加 WebCodecs, +降级 PNG 流,触摸回传转 adb input)、设置卡片(含预检面板)。 + +### How(顺序不可跳,R22) + +Phase 0 核实(先做,无代码):规格 §12 十项对照官方一手资料逐项确认,输出每项 +「确认/降级/砍掉」结论回写本条;下述硬冲突未解不进 Phase 1。 +Phase 1 骨架与预检 → Phase 2 完整工具集与配置 → Phase 3 对话流 UI → +Phase 4 设备实时画面 → Phase 5 rules 注入与打磨;每阶段独立验证通过再进下一阶段。 +已知硬冲突(Phase 0 必须裁决): +1. 浏览器半槽与 RPC/流通道(规格 §8 的对话卡片槽、设置槽、`autogamer.taskStatus` 轮询、二进制通道) + 与守则 R18 冲突——R18 只认 keyed toolview 槽,Web 不消费 Host 展示方法; + 未经官方出处证实的槽一律不得实现,先证实或走降级。 +2. rules 注入 API 与 R16——证实不存在则按规格降级路径走。 +3. settings 区 API 与 R19——同上,先证实。 +4. `defineTool` 签名、description 国际化形态、`output.schema` 字段——以 G30 教训为准, + schema 必须是完整 JSON Schema,否则 wire 400;先对已安装产物核实。 +5. ARTEMIS 作外部依赖与本仓是其 fork 的现实冲突——规格 §1 的 5 个 MCP 工具面即已删的顶层 + MCP 面;MCP/CLI 后端需要外部 ARTEMIS 提供方(路径配置),本分支 uv 破损, + CLI 路径的 JSON 输出格式一并在 Phase 0 核实。 +6. 插件 id 改名(`autogamer-device` → `dsh-autogamer`)已执行(用户定稿):目录、包名、 + patch 根行与 inner 行、MCP 客户端名、测试与脚本路径、CI 路径、文档引用同步改,R6 重跑。 +全程门禁:改 patch 跑 R6,新会话验跑 R7,判生效只认 R9;任何降级必须在 UI 或错误信息中明示, +不静默失败。 + +### Phase 0 核实结论(分批回写,2026-10-14) + +浏览器半轨道(上游 master 一手:包产物、slots.md 全树、三个官方 skill、扩展 README): +- 包形态(规格 §12-6):确认。`dsh.client`(platform/immediately/inject)加 `./client` export, + 有模板与 ui-cordis 实证;bundle patch 本身无 client 行(manifest 驱动);`dsh.bundle.patch` + 字段存在。 +- 槽(规格 §8.1):`conversation.card` 不存在(slots 全树、slot-catalog、ui-cordis 注册均无), + 规格 §8.1/§8.2/§8.4 原样不可实现。可用:`tool.call.toolview`(按 wire 工具名 keyed, + 与 R18 一致)加 `tool.call.images`、`conversation.chat.node`(经事件注册加 kind)、 + `conversation.composer.dock`、`shell.overlay`、`sidebar.footer.action`。卡片落点 Phase 3 定。 +- 数据通道(规格 §8.2):`ctx.rpc.*` 不存在;改走 Typert Remote(`ctx.remote` 命名空间加 + @Remote 方法)或 `host.call`/`invoke`,或 `wire.view` 投影加事件订阅;2 秒轮询可用 remote + 方法实现。 +- 画面通道(规格 §8.3):H.264/二进制推送不存在(stream 与事件限 JSON);可用 unary + Uint8Array(multipart)与 exact Fetch routes。DeviceMirror 降级为截图轮询卡; + 触摸回传有 uplink stream(256KB 上限),Phase 3 定。 +- 设置槽(规格 §8.4):`settings.plugin.item` 不存在;可用 `settings.section`、 + `settings.general.item`、`settings.plugins.tab`、`plugins.item` 系。落点 Phase 2 定。 +- ClientContext 即 `@deepseek-ai/cordis` 的 Context(用处别名)。 +- 硬冲突 1 裁决:§8 原样砍掉,改用上述已证实通道(R18 保持)。 + +ARTEMIS 侧(本仓 git 历史,一手): +- 后端启动(规格 §12-7):`python -m mcp_server`,stdio 默认、SSE 8001 可选,FastMCP 标准协议; + 5 工具名与规格一致;工具返回字符串化 JSON,插件侧需解析层;提供方须为外部 checkout + (本树已删对应智能栈)。 +- CLI 路径(规格 §12-8):旧 `artemis run` 无 JSON 契约(`--json` 仅 doctor/helper 有), + 「解析 CLI 的 JSON 输出」无契约可依 → CLI 后端按规格不可实现,砍掉(适配器只留 MCP 后端)。 +- scrcpy(规格 §12-9):仓内无 server 获取链,版本约束未知 → 用用户 PATH 的 scrcpy 或官方 + release 加 SHA 校验,Phase 4 定。 +- rules 路径纠正:fork 实证在 `mcp_server/rules.md`(非仓库根);外部上游根有无待提供方确定后复核。 + +### Phase 0 核实结论(续,2026-10-14:工具/设置/提示三路,已安装产物与上游一手) + +工具定义(dsh-tools 0.2.0-rc.2 的类型产物): +- `defineTool` 确认:选项含 name/description/parameters/output/execute,另有 + isConcurrencySafe、deferLoading、timeoutMs(声明 only,R15 不执行)、output 内 render + 与 presentationMeta。description 为纯 string,无 en/zh 对象形态 → 规格 §3.2/§4/§5 的 + description 对象写法砍掉,改纯 string(语言 Phase 1 定)。 +- output 字段名即 output(含 schema),与规格一致。 +- 硬冲突 4 裁决:确认(按真实签名实现;schema 须完整 JSON Schema,G30 不变)。 + +设置区(上游 settings 包一手): +- `installSection` 查无;真实 API 为 configure(页面策略)加 revisioned form edits, + Config 表单元由投影自动渲染。规格 §5 的 installSection 写法砍掉;预检面板改用已证实的 + settings 系槽(Phase 2 落点)。 +- 硬冲突 3 裁决:确认(改用真实 API;R19 义务不变)。 + +系统提示(dsh-system-prompt 0.2.0-rc.2 加上游 practices 一手): +- `contribute` 查无;真实 API 为 section(name/order/text,数字 order 排序)。rules 注入 + 改用 section(),每次加载重读外部文件(规格 §7 主路径成立,仅 API 名修正);这是官方 + 提示扩展位,非自写会话事件,与 R16 不冲突。 +- 硬冲突 2 裁决:确认(改用 section())。 + +服务与生命周期(已安装 typings 与 cordis 4.0.4 一手): +- inject 服务表确认:tools、systemPrompt、sessions、agents、attachments、llm、sandbox、 + invariants(settings、skills 只在上游);pluginManager 不是服务(是 agent 工具名), + 不影响既有 tool-plugin-manager 工具行。规格 §3.1 的 tools/settings 组合可用 + (settings 类型来源 Phase 1 定)。 +- `ctx.on('ready')`/`ctx.on('dispose')` 在 typings 里查无(Events 仅 internal 成员); + 生命周期改用 `ctx.effect` 清理函数,预检保持首次工具调用时懒执行。规格 §3.1 入口形状 + 据此改写。 +- 插件 id 改名(`autogamer-device` → `dsh-autogamer`)已执行,用户定稿,见本条 Done means。 + +Phase 0 总账:§12 十项全部有结论——确认 6(包形态、defineTool、settings 真实 API、 +systemPrompt section、inject 服务表、MCP 启动),砍掉 4(对话卡片槽系、RPC、H.264 +推送、CLI 后端),降级 2(DeviceMirror 改截图轮询、scrcpy 外部提供),纠正 4 +(description 纯 string、installSection 改真实 API、contribute 改 section()、 +ready/dispose 改 effect)。六条硬冲突全部裁决完毕。 + +### Phase 1 偏差与落地(2026-10-14) + +相对规格的偏差(均有出处,均已落锁): +1. CLI 后端砍掉(Phase 0 已裁决,此处归档):旧 `run` 无 JSON 契约,适配器只留 MCP 后端。 +2. Config 无 description:schemastery builder 形态未证实,设置自动页不依赖它。 +3. 预检暖机经 apply 内 fire-and-forget(typings 无 ready 事件);失败只进缓存,工具调用时重判。 +4. Helper 经 PATH 的 `artemis` 探测(非 `uv run artemis`,后者在调用方目录执行,探错环境)。 +5. settings inject 与 section 推迟到 Phase 2(待 live 挂载证明,G30 形态风险)。 +6. 后端接口最小化(其余 4 方法 Phase 2 随工具落地,不写抛错桩)。 +7. 新增 `artemisMcpCommand` 字段(规格假设固定启动命令,提供方安装形态各异)。 +8. live 验收推迟:无外部 ARTEMIS 提供方,真实设备状态与 R7 新会话验证待提供方配置后补; + mock/单测覆盖全部新路径(预检 7 项、后端映射与错误词汇、工具门禁与渲染)。 +新文件:`src/artemis/types.ts`、`src/artemis/mcpBackend.ts`、`src/env/preflight.ts`、 +`src/tools/getDeviceState.ts`;既有三处锁同步更新(双 conformance 断言、Config 全字段、 +G30 寿命断言)+ README Layout。 + +### Phase 2 偏差与落地(2026-10-14) + +动工前重核旧 face(`pre-contraction:mcp_server/tools/*.py`),规格 §4 的后端假设多处不成立, +映射一律按真 face 落锁(出处逐条写进 `src/artemis/types.ts` 头注): +1. run 取 `task_desc`/`model`(Flash/Pro),答 `trace_id`;`timeout_seconds`/`max_steps` + 无提供方对应——承认但不转交(含注释与单测)。 +2. device state 取必填 `view_type`(screenshot/hierarchy),答纯文本(`file://…`/ + 层级转储/`Error: …`)而非 JSON —— Phase 1 的 JSON 映射已按真契约重写,旧断言同步修正。 +3. manage 即 status/stop/inject_instruction over `trace_id`(与规格一致)。 +4. inspect 固定调 `view_summary`,summary/full 在工具侧切片(首尾 3 加未覆盖失败步, + 按 index 去重;失败启发式:fail/error 状态或显式 false)。 +5. diagnose 只转交 device_serial(旧 face 无 logcat/screenshot 开关);工具合并本地预检 + 修复进 recommendations,不抛环境错(诊断即手动预检入口);双坏时预检文本优先。 +6. settings:Host 侧无需代码(Config 投影即全部);`installSection` 已裁,client 设置卡片 + 并入 Phase 3(client 基建在那里一次落地)。 +7. 适配器退化为工厂(CLI 已砍,仅剩一种后端;选择规则留给第二后端)。 +新文件:`src/artemis/adapter.ts`、`src/tools/envGate.ts`、`src/tools/runTask.ts`、 +`src/tools/manageTask.ts`、`src/tools/inspectTrace.ts`、`src/tools/diagnose.ts`; +三处旧锁同步到七工具(双 conformance、G30 寿命、Phase 1 新增断言)+ README Layout。 +live 仍缺外部提供方(映射 refinements 待真证据)。 + +### Phase 3 偏差与落地(2026-10-14) + +浏览器半按上游一手原文落地(decoration 模板、slots.md、client 构建面): +1. `dsh.client`(platform/immediately/inject)加 `./client` export,缺一不可;bundle patch + 本来就没有 client 行(manifest 驱动)。 +2. client.js 手写 ModuleLoader 形态(tsc 发不出该格式;部署侧 tsdown 链未被文档为插件 + API,不盲接):React 取自浏览器模块表,`inject: ['slots']` 为模板证实的最小集。 +3. 卡片全部落在已证实的 `tool.call.toolview`(keyed,按 wire 工具名),五个任务工具各一格; + DeviceMirror 即设备状态格(path 行占位,无二进制通道即无真画面)。 +4. 卡片只渲染调用自带 args/result(owner props 宽容读取);轮询、控制、设置卡片无已证实 + 通道,全部推迟到挂载实证之后(远程服务挂载路径同样未证实,不自建)。 +5. 零样式属性(token 名未证实,纯元素继承宿主主题);除 react 外零 require。 +单测:manifest 形态、ModuleLoader 形态、五 key、import 白名单、六项 cut-API 否定锁。 +live 缺挂载实证(槽解析、key 生效、卡片渲染均需真会话)。 + +### Phase 4 偏差与落地(2026-10-14) + +动工前先读 scrcpy 一手文档(README 许可证与官方源警告、`doc/develop.md` 执行章、 +`Options.java` 开关表),再定诚实子集: +1. 推送序列即文档原样:push 到 `/data/local/tmp/scrcpy-server.jar`、forward + `tcp:{port}`(默认 27183)到 `localabstract:scrcpy`、CLASSPATH app_process 加版本 + 首参(严格一致,不兼容即拒跑)加 key=value 开关。 +2. 只用文档证实的开关:tunnel_forward/audio/control/cleanup/raw_stream/max_size/ + max_fps/video_bit_rate;control=false(画面-only,走文档 raw recipe),触摸走 + `adb shell input` 降级(`src/scrcpy/input.ts` 纯函数)。 +3. `stream.ts` 不存在(无 DSH 二进制通道,写了就是死代码);`fetch-*.ts` 不存在 + (无已核实分发端点,手动安装见 README);stop 的 pkill 为最佳努力(上游无 kill + 文档,warn-only);启动 forward/握手竞态如实标注未解(需真机)。 +4. 版本来源:显式路径文件名,无版本名则拒;sidecar 先读 jar 文件名,读不出再读同级 + `scrcpy --version`(绝不猜)。 +新文件:`src/scrcpy/server.ts`(resolve/start/stop/dispose 全懒)、`src/scrcpy/input.ts`; +Config 加 `scrcpyServerPath`;index 只构造加析构(无工具驱动,注释写明)。 +live 全部推迟(本机无 scrcpy/adb,20fps/触摸/无残留均需真机)。 + +### Review(P5 前,2026-10-14) + +账实核对(全绿 276/1 前提下):修 4 处 stale——index 头注 P0 字样、Config 注释 scrcpy +已接线、settings 注释 Phase 2 已过、README 的 `disabled: false`(#26 早已改跟随条件); +补 5 条覆盖(stop-warn、structured 非对象、无文本载荷、uv 双坏、helper 缺二进制)。 +有意未覆盖:各模块的真执行器(defaultRun/defaultWhich/spawnDetached)——环境相关, +mock 侧全覆盖,单测跑在无 adb 无 scrcpy 的机器上仍全绿。 +两次编辑差点吃掉相邻用例头(锚定只取头行是大忌,见本次两个修复提交意外的 diff 习惯: +新 `it` 必须连同被挤的旧头一起重写)。 + +留白总账(定位用,补白时直达;触发条件写明): +1. 外部提供方缺席 → 七工具真调用与 R7(触发:artemisRepoPath 指向可用 checkout。**2026-10-13 Step 0:本树只剩 + 15 工具动作面(`uv run artemis mcp --type adb`),`mcp_server/` 的 `.py` 计数为 0 → 形状之争已立 G72**)。 +2. toolview 挂载实证:槽解析、key 生效、卡片渲染(client.js;触发:真会话 R7/R9)。 +3. 轮询/控制/设置卡片通道(触发:挂载实证 + remote 挂载路径证明)。 +4. H.264 转发:无通道,砍(重开条件:DSH 二进制通道出现)。 +5. scrcpy 下载脚本:不建(手动安装见插件 README;重开条件:已核实分发端点)。 +6. 无残留进程:stop 的 pkill 最佳努力(server.ts;触发:真机 **且 scrcpy 装好**——2026-10-13 Step 0:MuMu 已就位, + 但 `scrcpy` 不在 PATH,`artemis doctor` 报 `Missing: scrcpy`,此项与 7 仍不可跑)。 +7. forward/握手竞态(server.ts start 注释;触发:真机 + scrcpy)。 +8. 后端映射 refinements(P2 记录;触发:真提供方证据)。 +9. run_task 500ms 对真提供方(触发:真提供方)。 +10. `uv tool list` 解析假设(preflight.ts installedCheck;触发:真输出)。 +11. defaultTimeoutSeconds 无消费者(Config;P5 定去留)。 +12. traceRetentionDays 无消费者(Config;P5 定去留)。 +13. scrcpyEnabled 无读取处(manager 按需启动;P5 定去留)。 +14. settings inject/section 活证明(触发:真会话挂载)。 +15. README release URL 旧名(历史资产,保持;下版本换新)。 +16. dsh.client 挂载实证(package.json;触发:真会话)。 +17. ~~roster 真枚举「一台算几台」~~ **已测(Step 1):一台 MuMu 报成 4 台**,后果与去重方案记 **G75**。 +18. ~~图像附件是否被降级成文本~~ **端点侧已排除(Step 2)**:真发一次带 `image_url` 的请求被接受(`prompt_tokens: 27`, + 无 vision 拒绝),`capabilities` 不列 vision ≠ 不收图。**剩余风险只在 DSH provider 的 `models[].input` 声明** + (S5 的负能力坑),触发条件仍是真会话 → 被 **G71** 挡。 +19. ~~键事件投递可靠性未定性~~ **已定论(Step 2,n=10 单变量)**:raw `keyevent KEYCODE_HOME` **0/10**、 + raw `keyevent 4`(BACK) **10/10**、HOME intent 每次都生效 ⇒ HOME 注入在这台 MuMu 上**确定性失效**,不是抖动。 + 根因不在设备也在契约:`adb_driver.py:372-383` 的 `press_key` 发完 shell **无条件 `return True`** + ⇒ py 按键面**没有效果语义**,按键类动作的判定只能靠 `compare_ui_frames`(S4/S11 词汇边界,已据此收窄)。 +20. **锁屏三态**(Step 2 定性):真机只到 `is_locked:false`,但 `true`/`null` 两个**判定分支两边单测都锁死了** + (py `tests/unit/core/test_diagnostics.py:83-104`、`tests/unit/mcp/test_device_listing.py:127`; + 插件 `tests/devicePool.spec.ts:267,272,282`)⇒ 诚实表述是「live 未触达、分支已锁」。 + **真洞在 unknown 的成因**:dumpsys 超时 / `returncode!=0` / 异常 三条通往 `None` 的路径**两侧都无测** + (只有 monkeypatch 级的上层假数据)—— 触发:造一次 adb 不可答的同屏复测。 +21. ~~层级面稀疏度差异极大~~ **已答(Step 2,属设计行为)**:`filter_ui_hierarchy` 默认 + `semantic_pruning="hoist"`(`artemis/utils/ui_filter.py:425-433`)丢语义空节点,而 UIAutomator2 的解析是 + **扁平无 `children`**(`third_party/mobile_use/clients/ui_automator_client.py:106-145`)⇒ 容器整条消失; + helper 可用时**走同一个 filter**(`artemis/clients/screen_client_factory.py:221-228` 只换来源不换过滤), + 所以稀疏**不是 helper 缺位造成**。口径早已在 `inventory/03-device-media-diagnostics.md:199`,本轮补真机确认 + (桌面 5→36 随屏内容变化,Settings 36)。对 `{index}` 的后果转记 **G74**(改吃 `parsed_bounds`)。 + +### Phase 5 偏差与落地(2026-10-14) + +rules 注入走 Phase 0 核实的真 API 全程可测: +1. `systemPrompt.contribute` 不存在 → 改 `section({name, order, text})`(order 取规格的 50)。 +2. 路径纠正:`/mcp_server/rules.md`(fork 实证,非仓库根)。 +3. inject 加 `systemPrompt`:可解性由 Tier-1 真 runtime 挂载实证(boot 真服务 + 本插件 + 真 inject 表,挂载即证明,G30 机理反用为锁);文本到达模型上下文仍需真会话。 +4. 失败策略:缺旗标/缺路径/缺文件/读失败/空内容一律 warn 跳过,永不抛,永不阻断挂载。 +5. 无消费者 Config 去留:`scrcpyEnabled` 接通(关则不建 manager);`defaultTimeoutSeconds` + 与 `traceRetentionDays` 保留(规格必填,无消费者,注释写明)。 +新文件:`src/prompt/rulesInjector.ts`;dsh-types 加 systemPrompt 镜像(含出处); +README 加段落与 Layout 行。 +错误路径补齐见 Review(本条 P5 前一节)。 + +### Verify + +每阶段按规格 §10 对应条目验收;外加文档双门禁 exit 0;插件 tsc exit 0、vitest 全绿; +R6 exit 0 且无 `unmatched`;mock 后端支撑全部单测,无需真机;真机项(20fps、触摸延迟、 +长任务轮询)留 live 验收,需用户授权。 + +### Done means + +- [x] Phase 0:十项核实结论回写本条,六条硬冲突逐条裁决(确认/降级/砍掉) +- [x] 插件 id 改名定案并同步(`dsh-autogamer`,用户定稿) +- [x] Phase 1–5 按验收逐阶段落地(离线面;live 见留白总账) +- [ ] 卸载无残留进程:scrcpy 那一半仍缺二进制(留白 6/7);**adb forward 那一半 Step 2 已实测证伪** + (server 退出后残留 `tcp:54254 → tcp:18888`)→ 转 **G78** +- [ ] README 完整化(Phase 5 已加段落与 Layout 行,双向锁在 `#61/#64`;仍待随 G71 的交付形态更新安装流一节) + +## 七、TS 全栈迁移流程(D19;规格见 [ts-fullstack-spec](migration/ts-fullstack-spec.md)) + +> 本节只做**流程与勾选**;终态定义、能力矩阵、分期细节在规格文件。**顺序不可跳**(R22:每期开工前先落自足条目,方案提交早于实现提交)。 +> 每期收尾门禁:插件 `tsc` + `vitest` 全绿;改过 patch 跑 **R6**(`--dump-config` rc 0 且无 `unmatched`);**R7** 开新会话;判「生效」只认 **R9**。 +> T1–T4 是分期名**不是编号**(见 registry §五);各期对应的差距编号已在括号里给出。 + +### 前置:契约实测(动 T2/T3 之前,任一未测不许开工对应期) + +- [ ] 前置-1 `adb shell uiautomator dump` 的真 wire 形态(设备 XML 与 py 扁平列表的对应关系) +- [ ] 前置-2 helper(accessibility APK)服务协议:端口 / 握手 / 请求响应格式 / `adb forward` 生命周期(与 **G78** 同源) +- [ ] 前置-3 `compare_ui_frames` 委托的 `check_ui_change` 算法与阈值 +- [ ] 前置-4 外部提供方接入边界:`artemisMcpCommand` / `artemisRepoPath` 在 T4 后只剩「可选外部 ARTEMIS」语义 + +### T1 · 引擎去消费者(**G82**) + +- [ ] 落自足条目(What / How / Verify / Done 四段)并按 R22 先提交方案 +- [ ] `autogamer_run_task` / `autogamer_manage_task` / `autogamer_inspect_trace` 改 TS 侧编排(S11 路由驱动 `run_device_action`) +- [ ] 跨回合任务状态载体定案(先查 DSH 原生 storage / session 投影,按 R20 取一手口径,不自建容器) +- [ ] 三条工具不再出现 `mobile_` 调用(grep + 契约锁) +- [ ] R7 真机跑一条完整任务,`report_task_status` 终态双轴正确 +- [ ] **Done:py 引擎在插件侧可达性为空** + +### T2 · 动作面 TS 化(**G83**) + +- [ ] 15 工具动作面 TS 原生实现(adb CLI + on-device 协议客户端) +- [ ] 与 py 面逐条对照:**同一输入同一输出**(fixture 复用现有 `tests/fixtures/py-action-tools.json`) +- [ ] 坐标 / 层级 / 截图三条金标准复测(沿用 Step 1 的独立测量法) +- [ ] **Done:`artemis mcp --type adb` 不再是任何路径的运行时依赖** + +### T3 · 图像与 OCR TS 化(**G84**) + +- [ ] `compare_ui_frames` 差分落 TS(算法与阈值从 py 侧读设计) +- [ ] OCR 改 `fetch` 直译 Google Vision +- [ ] overlay 标注改 sharp +- [ ] 对照表留档(同输入同输出,可复跑命令 + 实测输出) +- [ ] **Done:效果判定与 OCR 无 py 依赖** + +### T4 · 收尾删 py(**G85**)+ 溯源清理(**G86**) + +- [ ] 分批删 py 树,每批后跑插件全套 + R6 +- [ ] 清 CI 的 python job 与 py 依赖安装步骤 +- [ ] 文档口径同步(`AGENTS.md` 命令表、`migration/README.md` 导览、本文件 §零 / §六) +- [ ] `git ls-files '*.py'` 为空 +- [ ] **Done:规格 §一 五条全部成立** + +#### 溯源线(D20 / **G86**;约束贯穿 T1–T3,清理收尾于 T4) + +- [ ] **贯穿约束**:T1–T3 的实现只借鉴设计、不搬运上游文本(prompt / 注释 / 文档段落逐字照抄即违规)——每期收尾自检一次 +- [ ] copyright 头清零:`git grep -l 'Google LLC'` 为空或只剩 `LICENSE` 自身 +- [ ] `third_party/` 整树移除(含其自带的 LICENSE 与 NOTICE) +- [ ] 上游派生文档处置(`docs/research/*`、`docs/migration/inventory/*`、`artemis/agents/*.md`):删或标注为历史,且不得作为实现依据 +- [ ] 致谢 / 命名文案改写为「灵感来源」措辞(不写「基于」) +- [ ] `upstream` remote 处置(**待用户裁定**) +- [ ] git 历史处置(**待用户裁定**:保留 / 重写重建 / 新建仓库转源)——未裁定则记明「判据只覆盖工作树层」 diff --git a/docs/migration/README.md b/docs/migration/README.md new file mode 100644 index 000000000..8225b9c90 --- /dev/null +++ b/docs/migration/README.md @@ -0,0 +1,62 @@ +# 迁移文档导航(从这里进) + +> 这一页只解决一个问题:**你现在这件事该读哪份、读到哪、哪些别当真。** +> 体量数字是**脚本算的,不是手抄的**:`python -X utf8 scripts/check_doc_tables.py --refresh-sizes` 重生成,默认跑会核对每一行;算法见 § 三 表下「体量口径」。 +> **本页不复述编号范围与条数**:`G*`/`R*` 会随进度增长,一律查 `registry.md`。 + +## 一、按你要做的事找入口 + +| 你要做的事 | 先读 | 再读 | 大概 | +|---|---|---|---| +| 第一次进这个项目,想知道它要变成什么 | 本页 § 二 | `TODO.md` 第一、二节 | 5 分钟 | +| 改 `plugins/dsh-autogamer` 的任何代码 | `plugin-contract-rules.md`(R20-R22 是动手前置,R2 / R6-R15 是接线红线) | `TODO.md` 的 S 段对应条 | 10 分钟 | +| 改 `*.patch.yml` 或 preset 声明 | `plugin-contract-rules.md` R2-R8 | `registry.md` 查编号 | 5 分钟 | +| 判断「我这次改动到底生效没有」 | `plugin-contract-rules.md` R9 + R7 | `TODO.md` 顶部「当前状态」 | 3 分钟 | +| 想知道某个编号(`G30` / `R13` / `D7`)是什么 | `registry.md` | 定义所在文档 | 1 分钟 | +| 看不懂某个词(realm、parking、PTC、熔断) | `glossary.md` | — | 1 分钟 | +| 要动 py 侧(驱动、action server、诊断栈) | `ts-fullstack-spec.md`(D19 终态:py 归零) | `TODO.md` 第二节 D8/D18 + 第三节 S5 | 按需 | +| 想知道「py 到底要不要全删、怎么删」 | `ts-fullstack-spec.md` §一 终态与 §三 分期 | `TODO.md` §七 流程 | 8 分钟 | +| 做 UI(toolview、卡片、录屏展示) | `plugin-contract-rules.md` R18 | `inventory/05` | 10 分钟 | +| 怀疑某个能力要丢了 | `feature-inventory.md` | `inventory/0N` 明细 | 按需 | + +## 二、这个项目现在在做什么(三句话) + +1. 整个 ARTEMIS 正在收缩成**一个 DSH 插件** `dsh-autogamer` + 若干 skills:队列、调度、UI、审批、记忆全部交给宿主,插件只提供设备执行工具与设备资源治理(D1、D9、D10)。 +2. py 侧的动作面与驱动栈在**迁移期**原样复用;**终态由 D19 定义为 TS 全栈、py 归零**——上游 ARTEMIS 的能力逐步改由 TS 承载,外部 ARTEMIS 只保留为可选提供方(规格见 `ts-fullstack-spec.md`)。旧 py 智能栈(langgraph 多 agent)由「单层执行路由 S11」替代(D8、D14)。 +3. **P0 已收官**(2026-10-12):工具面挂载、指令投递、终态语义、真机质量 spike 全部 live 通过 ⇒ **D9「直通插件」成立**,交付形态也迁到了官方 `install_bundle`。**本页不复述当前阻塞点**:进度与差距一律看 `docs/TODO.md` 第「零、当前状态」节——为什么这么定,见 changelogs **#30**。 + +## 三、文档清单与可信度 + +| 文档 | 体量(实测) | 定位 | 可信度口径 | +|---|---|---|---| +| `docs/TODO.md` | 833 行 / 94.5 KB | **方案本体与进度**(活文档,完成即删,历史查 git);顶部第一节就是「当前状态」 | 现行权威 | +| `docs/CHANGELOGS.md` | 3613 行 / 296.5 KB | **已结案变更的历史**:`### #N` 三段条目 + 「已关闭差距史」表(结案 `G*` 的正文去处) | 现行权威;**编号一经发布不得改写**,写入规则见该文件顶部 | +| `docs/migration/registry.md` | 205 行 / 24.6 KB | **编号唯一权威**:全部 D / S / G / R 编号 + 已关闭差距 + 冲突登记 | 现行权威,`scripts/check_doc_registry.py` 守着 | +| `docs/migration/glossary.md` | 87 行 / 8.5 KB | 术语翻译表(含 `inject` 的两个意思) | 现行权威 | +| `docs/migration/plugin-contract-rules.md` | 304 行 / 35.9 KB | **守则全部条款**:每条带出处与判据 | 现行权威;与 todo 冲突时以守则为准并回改 todo | +| `docs/migration/ts-fullstack-spec.md` | 127 行 / 14.5 KB | **D19 终态规格**:TS 全栈、py 归零——能力迁移矩阵、四期分期、必须先实测的契约边界 | 现行;方案已立项、尚未开工 | +| `docs/migration/upstream-plugin-forms.md` | 230 行 / 27.8 KB | 上游插件形态第一轮调研(结论已提炼进守则)+第二轮(八:插件管理面的安装 / 卸载契约,含一处**已收回**的 CLI 结论;九:设备身份口径查证;十:Android / adb / scrcpy 一手口径) | 现行,含**已撤回**的归因(别引用它们);八、九、十的「未证」清单别当已证 | +| `docs/migration/dsh-verification.md` | 217 行 / 37.0 KB | rc.2 的 48 项假设核实报告 | **混合口径**:README / CLI 类证据可用;37 处包内代码行引用属 asar 旧口径,沿用前须按声明面重验(D15) | +| `docs/migration/feature-inventory.md` | 107 行 / 13.8 KB | 防丢失盘点索引 | 现行;设备与智能栈部分是上游 canonical 契约 | +| `inventory/01`…`05` | 1406 行 / 215.6 KB | 盘点明细,几乎全是表格 | **内容可用,编号不可引用**:其 `**Gn · …**` 与现行 G 编号撞车(冲突表见 registry,警示行已加进每份文件顶部);其中 `TODO.md:行号` 形式的引用属活文档旧快照,同样不可照用 | +| `docs/migration/upstream-rethink.md` | 50 行 / 5.4 KB | 为什么基线选 google/artemis 上游 | 现行 | +| `plugins/dsh-autogamer/README.md` | 124 行 / 10.2 KB | 插件包自己的说明:入口、三层引入流、契约要点、债务表 | 现行(2026-10-10 重写,旧版那条指向 `headless-spike.patch.yml` 的安装说明已废) | +| `AGENTS.md` | 208 行 / 16.7 KB | agent 作业说明:文档地图、命令、门禁 | 现行 | + +> 体量口径与写法:**第一列一律按仓库根写路径**(`docs/migration/registry.md`,不写 `../registry.md`),因为 `scripts/check_doc_tables.py` 就按仓库根寻址并核对第二列——寻址不到即 FAIL。 +> **行数**按 `\n` 计数,**KB** 按文件字节数(**先剥掉 CRLF 里的 CR**,即按 LF 规范化后的字节)除以 1024 保留一位小数——不这么剥,同一条 commit 在 CRLF 检出与 LF 检出下会量出两个数,表就永远只能在一种机器上成立(changelogs **#45**)。中文在 UTF-8 下占 3 字节,所以「字符数」与「字节数」差得很远——本页早期版本混过这两个口径。`inventory/01`…`05` 那一行按五份求和。 + +## 四、明确「别当真」的四处 + +1. **`artemis/agents/**.md`**(flash_runner、outputter、video_analyzer、step_capsule 等 prompt 文档):属 D8「langgraph 智能栈不迁移」的一侧。它们是现行 py 代码的说明,读到别当成插件轨道的目标形态。 +2. **`docs/research/vue3-arco-migration-research.md`**(297 行):旧控制台(Vue3 + Arco)迁移研究。旧控制台按 D9 是**原地只读保留**,这份研究不再是路线。 +3. **`apps/showcase_ui_v2/README.md`**(400 行):控制台自身的开发说明,有效,但只管"只读保留"的那部分。 +4. **`playground/backend_manager` 的 docker 材料**:不是本工程依赖(历史裁定「先不动」),别再调查或提清理。 + +## 五、改文档的规则(防止债务复发) + +- 新增编号(D/S/G/R)**先登记进 `registry.md`**,否则 `scripts/check_doc_registry.py` 直接报悬空。 +- 新增 markdown 表格文档,要把路径加进 `scripts/check_doc_tables.py` 的 `DEFAULTS`,并跑 `--selftest` 证明尺子仍能报警。 +- 活文档只留现行结论:已完成条目删掉,证据进 git;不要把已关闭项压成一句括号(那正是 14 个悬空引用的成因)。 +- **编号范围与「当前阻塞点」各只在一处写**:范围只写 `registry.md`,进度与阻塞只写 `TODO.md` 的「零、当前状态」。别处(含本页)要引用就写「见 X」,**不要抄一份**——抄出去的这份没人负责回改,本页就栽过一次(changelogs **#30**)。 +- 上游一手资料的取用配方与位置记在 `AGENTS.md` 的插件轨道一节;不要在本目录复制一份。 diff --git a/docs/migration/dsh-verification.md b/docs/migration/dsh-verification.md new file mode 100644 index 000000000..78c3bce99 --- /dev/null +++ b/docs/migration/dsh-verification.md @@ -0,0 +1,217 @@ +# DSH 本地源码核实报告 + +> 日期:2026-10-09;核实版本:**DSH desktop 0.2.0-rc.2**(全部 279 个 @deepseek-ai/dsh* 包同版本)。 +> 核实对象:本机 Electron 桌面安装(`D:\DeepSeek Harness\`)内嵌 runtime;源码提取:`resources/app.asar → D:\tmp\dsh-ref\dsh\`(临时解包,不入 repo);真实运行数据参照:`C:\Users\78557\.dsh\`(web profile 活配置、`sessions/--D-DEV-autoGamer--/` 真实会话 v3/v4 并存)。 +> 方法:4 路并行子代理对 48 项方案假设做源码级核实(jobs / session+Inbox / 插件缝+审批 / SDK+CLI)。 +> 维护规则:DSH 升级(尤其 rc→正式)必须重验本表全部「⚠️修正」项。 +> **来源口径变更(2026-10-10,用户裁定,见 TODO.md D15)**:此后不得再解包 `app.asar` 取证据,核实面收窄到声明面——`dsh --dump-config-schema` / `--help` / 随包 README / 已安装 npm 包产物。本文件是裁定之前写成的:其中 37 处 `lib/index.js:NN` 形式的包内代码行引用属**历史口径**,结论本身未被推翻,但重新引用时要按声明面复验一遍;README 与 CLI 旗标类证据(28 处)不受影响。已由声明面重验过的:插件 `Config` 必须是原生 Schemastery schema、preset 子项挂载形态(`dsh --dump-config` 合成产物可证)、py 动作面 13 工具(live 抓取入 fixture)、DSH peer 精确 pin。 + +## 结论速览(触发方案修改的 12 项) + +| # | 原假设 | 核实结果 | 方案修改 | +|---|---|---|---| +| 1 | 初版用自带 bash job kind 跑 py worker | ⚠️ bash-local **POSIX-only**("Windows is unsupported");kind 是开放字符串,官方注册了 bash/pwsh/subagent/workflow | Windows 直接自写 producer 插件,自定义 kind `artemis-worker`(照抄 tool-bash/tool-pwsh 样板) | +| 2 | 退出码 0→completed / 非0→failed | ⚠️ **registry 不做该映射**,outcome 全由 producer 的 `done` 给出;官方 shell job **非 0 退出=completed**(detail 带 exit code),failed 仅 spawn 失败/producer 抛错 | 与 G8(DB 终态权威)天然一致:producer 的 done 判定由迁移层自定 | +| 3 | kill→killed(Windows) | ✅ killed 成立,但 Windows = `TerminateJobObject` **整棵进程树立即硬杀**(无宽限无钩子;POSIX 有 SIGTERM→3s→SIGKILL);宿主进程死时 Job Object kill-on-close 连带杀子树 | G10 cancel-marker 优雅取消通道更加必要;孤儿对账在 Windows 有 OS 兜底,仍需 worker 状态文件对账 | +| 4 | 重启后 Inbox pending 原生续派(零自建) | ⚠️ pending **原生恢复**(durable projection 落 session log),但 resume 后 driver 处于 idle,**不会自动开跑——需一次显式唤醒**(新 followup 或宿主 kick) | autogamer-queue 启动对账时补一个「唤醒所有有 pending 的会话」动作 | +| 5 | workspace/session-stop 杀该会话全部 job | ✅ 存在,但它是 `agent.cancel` **不带 keepInbox——会丢弃该会话排队消息** | ARTEMIS「停单个任务/保留队列」语义用 `cancel(cause,{keepInbox:true})`;只有清空会话才用 session-stop | +| 6 | G20 晚订阅 catch-up 需验证 | ✅ **原生支持**:`readSessionState` 全量快照 / `page` 分页 / `projections(asOfSeq)` + live `session/event` | G20 关闭,无需自建补偿 | +| 7 | 通知可经 DSH webhook 出站 | ⚠️ **DSH 无出站 webhook**(webhook 只有入站,fire-and-forget 202、无重试/去重/完成回报;唯一带认证的入站适配器是 GitHub HMAC) | G21 通知适配全部插件侧自建:订阅 `ctx.jobs.events` settled → 按 ARTEMIS webhook schema POST | +| 8 | 媒体路由用 `ctx.connection.fetch` | ⚠️ 真实 API 是 **`ctx.webServer.register()`**(exact/prefix 路由 + handler(req,res) + registerUpgrade/registerFallback;无自带鉴权);`ctx.connection` 是浏览器侧 RPC | autogamer-media 改用 webServer;鉴权自担(根约束+扩展白名单语义照搬) | +| 9 | toolview 由插件注册(dsh-data-agent 模式) | ⚠️ 确认可注册,但缝是 **client 侧插件**:`ctx.slots.inject('tool.call.toolview',…)` 按 wire 工具名注册组件(`dsh-agent-tool-presentation` 是模型面 native/ptc 配置,不是 UI) | G2 交付物按 client plugin 形态写;工具名用全名(含 `mcp__` 前缀若走 MCP) | +| 10 | 配置热重载(patchReload: "live") | ⚠️ `patchReload` 字段在 rc.2 代码中**不存在**(惰性字段);真实机制 = `dsh-hmr` 监听 cordis.patch.yml/package.json 免重启 reconcile **配置层**;插件模块代码(node_modules)不监听 | P0 开发流改为「**配置层热重载、代码层冷重启**」 | +| 11 | DSH 官方 SDK(TS + Python);webhookRuntime 外部接入 | ✅ TS=`@deepseek-ai/dsh-sdk-client`(npm 0.0.1-rc.1),Python=`deepseek-harness`(PyPI 0.3.1,**版本线高于本地 rc.2**);⚠️ SDK/ACP 全是 **stdio 本地子进程**,无跨机 HTTP 入口 | 外部 HTTP 调用方需「HTTP→SDK 子进程」薄桥;接入主选 SDK,webhook 只做单向触发,ACP 备选 | +| 12 | 二期设备工具=autogamer-device TS 重写 | ➕ 新选项:**`dsh-mcp-client` 可直连 ARTEMIS 现有 mcp_server**(stdio/streamable-http,一条 patch entry,工具注册为 `mcp____`;注意无 MCP prompt templates、toolCallTimeoutMs 默认 60s) | 二期「ARTEMIS mcp_server 直接作为 DSH 工具源」与「TS 全量重写」两条路线并行评估 | + +## 一、jobs 子系统(12 项) + +| # | 结论 | 证据与关键 API | +|---|---|---| +| 1 | ⚠️ kind 开放字符串,无内置注册表;官方注册 bash/pwsh/subagent/workflow;**bash-local POSIX-only**,Windows 对应物 `dsh-pwsh-local`(pwsh7→PATH→PS5.1) | dsh-bash-local/README.md:142;dsh-tool-bash/lib/index.js:396;dsh-tool-pwsh/lib/index.js:367 | +| 2 | ✅ `JobSpec.owner?: SessionId` = 发起 agent 的 session id;围栏真实(非本人 list/get/read/kill 全拒);但 command/env/cwd 在 ShellExecRequest(shell 层)不在 JobSpec | dsh-jobs-local/lib/index.js:527-568;api-catalog.js:5464,6924 | +| 3 | ✅ env 完全可控:合成序 = scrubbedParentEnv → ENV_OVERRIDES{NO_COLOR,TERM,PAGER,GIT_PAGER} → `request.env` → dshEnv;**scrub 剥除键名匹配 /KEY\|PASSWORD\|SECRET\|TOKEN/i 与 DSH_***;显式 env 在 scrub 后合并可复活任意键 | dsh-subprocess/lib/index.js:32,50-62;dsh-subprocess-local/lib/runner-launch-*.js:694-712 | +| 4 | ✅ `start()` 内同步调 `spec.run(handle)`,run 必须同步返回 `{cancel(reason?), done: Promise}`;done reject → 强制 failed | dsh-jobs-local/lib/index.js:412-475 | +| 5 | ✅ ring 按 UTF-8 字节计:live 256KiB → settled 16KiB,泵 150ms;spillPath **运行中即可读**;官方 spill = `%TMP%\dsh-subprocess-*\*.log`,`maxSpillBytes` 默认 **64MiB(超限删除)**;dsh-spill 包是另一回事(工具结果存储) | dsh-jobs-local/lib/index.js:119-232;dsh-subprocess-local/lib/output.js:137-176 | +| 6 | ⚠️ kill = 协作式:调 hooks.cancel → status=stopping → 返回 `'requested'`;**Windows=TerminateJobObject 整树立即硬杀**(无宽限),fallback `taskkill /T /F`;POSIX=SIGTERM→3s→SIGKILL;kill-on-close 兜底宿主崩溃 | dsh-jobs-local/lib/index.js:611-621;dsh-subprocess-local/lib/runner-launch-*.js:724-735 | +| 7 | ⚠️ **非 0 退出 = completed**(detail="exit code: N",官方注释 "reported, not failed");failed 仅 spawn 失败/producer 抛错;Windows 强杀由 executor 依 abort signal 判 killed | dsh-tool-bash/lib/index.js:38-60,118-124 | +| 8 | ✅ `ctx.jobs.events.subscribe(filter, listener)`:settled 事件 `{type:'settled', job, cause:'producer'|'kill'|'teardown', awaited}`;dsh-tool-jobs 把 settled 转会话 notice(空闲 owner→followup 唤醒开新轮;忙碌→inject) | dsh-jobs-local/lib/index.js:736-765;dsh-tool-jobs/lib/index.js:256-296 | +| 9 | ✅ `workspace/session-stop` 原生杀该 Session 名下全部 owned job(reason 'session archived') | dsh-jobs/lib/types/archive-admission.js:18-38 | +| 10 | ✅ jobs-local 纯进程内 Map 无落盘;重启无任何记录(无孤儿对账——ARTEMIS 自建);Windows 宿主死→Job Object 连带杀子树 | dsh-jobs-local/README.md:32,138 | +| 11 | ✅ `JobHandle.updateProgress(line)`;**无 queued 状态**——排队以 progress 文本表达(run 同步执行,注册即 running) | api-catalog.js:5404;dsh-jobs-local/lib/index.js:430-435 | +| 12 | ✅ JobStatus = running/stopping/completed/killed/failed | api-catalog.js:5468 | + +附加:owner session 在 start 时**必须存活**(resolveOwner 否则抛错);owned job 随 owner disposal 被 cancel("owner disposed");**每 owner 并发 job 上限默认 10**(`maxConcurrentJobsPerOwner`)。 + +## 二、session / Inbox(12 项) + +| # | 结论 | 证据与关键 API | +|---|---|---| +| 1 | ✅ Inbox = **两条**有序列表(`next-turn`/`next-step`)的 durable projection,每次变更落 `agent/inbox/spliced` 事件进 session log;**resume 后不自动开跑**(driver idle,需一次唤醒) | dsh-agent-loop/lib/index.js:26-62,204,1559 | +| 2 | ✅ `followup(input)`(next-turn,FIFO,每 turn 消费 1 条)/ `steer(input)`(next-step)/ `inject`;API 侧还有 `updateQueue` 改/删排队项(对应 ARTEMIS 队列 chip 单独移除) | dsh-agent-loop/lib/index.js:103-119,806-814;dsh-api-session-controller/lib/index.js:882-937 | +| 3 | ✅ **create 接受外部 SessionId**:brandString 任意字符串;API `session.create({sessionId})` 幂等 create-or-adopt(`ensureSession`);id 经转义成为磁盘目录名 | dsh-agent-loop/lib/index.js:1851-1856;dsh-api-session-controller/lib/index.js:688,253 | +| 4 | ✅ owner-fencing 成立但**仅进程内**(跨进程/durable 后端需自塑身份——单宿主一期无碍) | dsh-jobs/README.md:38-127;dsh-agent/lib/types/index.js:241-243 | +| 5 | ✅ session log v4:JSONL+zstd、一事件一行 `{type,seq,time,data}`、61 种已知事件类型、append-only+fsync+torn-tail 恢复;v3→v4 惰性迁移且 v3 原文件永久保留(真实数据可证) | dsh-session-format-v3-to-v4/lib/index.js:1053-1099;dsh-session/lib/types/known-event-types.js:21-81 | +| 6 | ✅ `resume({resumeSessionId})` 冷读重建;崩溃 turn 由 interruptedTurnClosers 合成 `turn/end{reason:{kind:'interrupted'}}` 收尾,**不重入不重跑**;`sessions.fork()` 原生 fork | dsh-agent-loop/lib/index.js:1921-1978;dsh-session/lib/types/index.js:1064-1077 | +| 7 | ✅ **晚订阅 catch-up 原生**:`readSessionState` 全量 / `Remote("page")` 分页 / `projections` 快照(asOfSeq) / `sessionQuery.read*` 冷读 + live `session/event` | dsh-api-session-controller/lib/index.js:1022-1034,2596-2598 | +| 8 | ⚠️ `dsh-session-query-sqlite` 仅是 FTS5 全文搜索派生索引(可选 openAt);精确查询走 provider 无关的 `ctx.sessionQuery` | dsh-session-query-sqlite/README.md | +| 9 | ✅ checkpoint-policy = 持久化 flush 屏障(模型请求前/顶层工具执行前/每 pre-step),fail-closed;**非断点续跑**——与 py 侧「无 resume」结论一致,durable 前缀+中断收尾 | dsh-session-checkpoint-policy/README.md | +| 10 | ✅ compaction 原生:阈值 `floor(min(W×0.8, W−O−65536))`、保留最新 16%、`/compact` 命令;被遮蔽内容留在 log(回放确定性) | dsh-compaction-basic/README.md | +| 11 | ✅ 单会话 turn **严格串行**(唯一 driver `while(await turn())`;running 期间 wake 锁存到 turn 结束)——会话 FIFO 队列语义成立 | dsh-agent-loop/lib/index.js:887-901,854-858 | +| 12 | ⚠️ agent status 仅 idle/running;「archived」属 workspace 层;`cancel(cause,{keepInbox})` 确切存在(keepInbox:true 只中止当前 turn 保留 pending);`workspace/session-stop` 的 cancel **不带 keepInbox(丢队列)** | dsh-agent-loop/lib/index.js:790-798,815-821;dsh-agent/lib/index.js:26-37 | + +## 三、插件 / 审批 / 沙箱(12 项) + +| # | 结论 | 证据与关键 API | +|---|---|---| +| 1 | ✅ `dsh plugin --profile ` = 转发 pnpm(add/remove/why;源=pnpm 语义,git 需 profile `pnpm-workspace.yaml` 的 `allowBuilds`);声明 bundle 的包自动追加进 `dsh.profile.bundles`;另有 `allow-version/revoke-version/version-exemptions` | dsh/lib/bin.js:115-127;dsh-plugin-manager/lib/index.js:237-262 | +| 2 | ✅ `package.json` 声明 `"dsh": {"bundle": {"patch": "./cordis.patch.yml"}}`(string 或数组);patch entry 字段 id/name/config/disabled/inject/group;`name` 即 loader import 的模块说明符(双锚解析:dsh 安装目录→profile node_modules) | dsh-app-boot/lib/index.js:468-517 | +| 3 | ✅ peer 兼容强制:`semver.satisfies(runtimeVersion, range, {includePrerelease:true})` 不满足→启动 skip/安装拒(`incompatible-version`);豁免=profile `compatibility.json` + `--accept-risk`;**官方包惯例=dsh-\* 精确版本号** | dsh-app-boot/lib/index.js:286-322,929-932;dsh-plugin-manager/lib/index.js:1490 | +| 4 | ⚠️ `patchReload:"live"` 字段 rc.2 代码 0 命中(惰性);真实热重载=`dsh-hmr` 监听 cordis.patch.yml/package.json 免重启 reconcile **配置**;模块代码不监听(root:[] 且 ignored node_modules)→ **配置热、代码冷** | dsh-hmr/lib/index.js:339-370,239-244 | +| 5 | ✅ `--dump-config`:按层合成(bundle 层→profile patch→home patch→--patch),输出带每层来源注释的 entry 树+unmatched-patch 警告;另有 --dump-default-config/--dump-config-schema | dsh/lib/dump-config-BEDI-dNY.js;dsh-app-boot/lib/index.js:3604 | +| 6 | ⚠️ toolview 缝 = **client 侧**:`ctx.slots.inject('tool.call.toolview', …)` + `register({name:'tool.call.toolview', key:''}, Component)`;owner props=ToolCallOwnerProps(callId/toolName/phase/openFile/inspect/loadImage);`dsh-agent-tool-presentation` 是模型面 native/ptc 配置非 UI | dsh-client-ui-tool/README;dsh-client-ui-cordis/lib/client.js:1446-1453 | +| 7 | ⚠️ HTTP 路由缝 = **`ctx.webServer.register()`**:`{kind:'exact'|'prefix', path, handler(req,res)}` 返回 disposer,另有 registerUpgrade/registerFallback;匹配 exact→最长前缀→fallback;host 仅 127.0.0.1/0.0.0.0,**无自带鉴权**;`ctx.connection` 是浏览器 RPC 非路由 | dsh-host-webserver/lib/index.js:158-208 | +| 8 | ✅ approval preset:`{sandbox: read-only|workspace-write|danger-full-access, approval: ask|never}` + defaultPreset;授权粒度**只有 allowed-once**(无 always/remember);fail-closed 默认(无 answerer→unavailable→拒绝);never 在 waterfall 前确定性拒绝 | dsh-permission-presets/README;dsh-user-approval/lib/index.js:31,74,175 | +| 9 | ✅ Windows 沙箱 = `dsh-sandbox-windows-acl` AclSandbox(restricted token+capability SID ACE+Low integrity),**无需 Docker**;模式 read-only/workspace-write/danger-full-access;SANDBOX_UNAVAILABLE fail-closed | dsh-sandbox-windows-acl/README;dsh-sandbox/README:58-60 | +| 10 | ✅ `ctx.webhookRuntime.register(rule)/dispatch(delivery)`;`WebhookSessionRequest{workspacePath,title,prompt,agentPreset,permissionPreset,model?}`→提交=Agent.followup()(source.kind:"webhook");**无内建认证——认证归 adapter**(github 适配器=HMAC 验签在 ctx.webServer 路由上) | dsh-webhook/README.md:28-41;dsh-webhook-github/README.md:35-57 | +| 11 | ✅ `AgentOptions{provider, model, reasoningEffort?, maxTokens?}`(per-agent);采样面仅 temperature/maxTokens/stop;dsh-llm-retry:normal 模式对 EMPTY_RESPONSE/RATE_LIMIT/SERVER/TIMEOUT/TRANSPORT 重试 5 次、500ms→10s 指数+10% jitter、尊重 Retry-After、重试前落 `llm/retry` 事件 | dsh-agent/README.md:37-43;dsh-llm-retry README | +| 12 | ✅ `dsh-mcp-client` = DSH 作为 MCP **client** 连外部 server:stdio{command,args,env,cwd}/streamable-http{url,headers};工具注册 `mcp____`;重连退避可配、toolCallTimeoutMs 默认 60s、server instructions 进 system prompt;**不支持 MCP prompt templates** | dsh-mcp-client/README | + +## 四、SDK / CLI / 调度 / 版本(12 项) + +| # | 结论 | 证据与关键 API | +|---|---|---| +| 1 | ✅ CLI:唯一真子命令 `dsh plugin --profile …`;`dsh `≡`--profile`;旗标 --profile/--patch/--dump-config*/-V;内置 profile:web/headless/sdk/sdk-minimal/acp(desktop 保留给 Electron) | dsh/lib/bin.js:105-136 | +| 2 | ✅ SDK = **独立进程 stdio JSON-RPC**(initialize/session/prompt/shutdown + session.event/session.status/subagent.\* 通知);TS=`@deepseek-ai/dsh-sdk-client`(0.0.1-rc.1);**Python=`deepseek-harness`(PyPI 0.3.1,版本线高于本地 rc.2,接入前先对齐协议形状)** | dsh-sdk-protocol/README.md:12,38-52;dsh/README.md:5 | +| 3 | ✅ ACP = automation-only agent 侧 server(`dsh --profile acp`,stdio JSON-RPC:session/new/prompt/cancel/request_permission…),面向可信控制器,无认证 | dsh-acp/README.md:12,60-76 | +| 4 | ⚠️ webhook **只有入站**(202 fire-and-forget,无队列/重试/去重/完成回报);**无出站 webhook**——事件只推给已连接 web 客户端 | dsh-webhook/README.md:71-75 | +| 5 | ✅ dsh-schedule:after/at/every_seconds(≥60s)/daily/weekly/cron(5 字段) 六种 selector、持久化跨重启;**默认关闭**(experimental-schedule-bundle 启用);**硬依赖 Web Host Session controller(headless/SDK-only 挂不上)** | dsh-schedule/README.md:26-37 | +| 6 | ✅ web profile:默认 **127.0.0.1:3080**(--host/--port/--no-open);认证=启动 URL 一次性 token→签名 cookie 覆盖 API+WS;三层热更新(dsh-hmr 配置层 / dsh-client-hmr 浏览器无刷新 / SSE `/plugins/events`) | dsh-web-app/cordis.patch.yml:173-174;dsh-web-app/lib/startup.js:22 | +| 7 | ✅ desktop-host=Electron 壳的 Node 宿主;headless=一次性运行(`--json` NDJSON:session/status/text/thinking/tool_call/tool_result/final;--session-id 续聊;exit 0/1) | dsh-headless/README.md:12,58 | +| 8 | ✅ token-meter:`ctx.tokenMeter.measure()` → tokenUsage/contextPressure/contextBreakdown;启发式≈4 字符/token,**CJK/JSON 明显低估** | dsh-token-meter/README.md:36-55 | +| 9 | ✅ subagent:`ctx.subagents` 具名 provider(spawn=干净子代理 / fork=父对话已完成 turns 快照);maxDepth 默认 1、maxActiveSubagents 默认 8 | dsh-subagent-fork-in-process/README.md:12-24 | +| 10 | ✅ 配置层级:bundle patches→profile cordis.patch.yml→home patch→--patch;settings.yaml.imported=旧版一次性导入残留 | dsh/README.md:41-48;dsh-settings/README.md | +| 11 | ⚠️ workflow/workflow-ptc = 模型提交 JS 编排脚本(agent()/parallel()/pipeline()),**阻塞父 turn、同步、无持久化**——不是任务队列,勿与 Inbox/schedule 混淆 | dsh-workflow/README.md:32-53 | +| 12 | ✅ 279 个 dsh* 包全部 0.2.0-rc.2;peerDeps 惯例:dsh-\* 精确版本、cordis ~4.0.4;自研插件 peerDependencies 建议精确 pin + 升级走 conformance | 全树 package.json | + +## 方案修改对照(第一轮,已回写 TODO.md) + +| 位置 | 修改 | + +| 位置 | 修改 | +|---|---| +| S1 | spike 降级为接入验证(外部 SessionId 已核实可行,幂等 adopt) | +| S2 | bash kind→自写 producer(kind `artemis-worker`);outcome 映射归 producer done;ring/spill 容量限制;session-stop 丢队列→用 cancel({keepInbox}) | +| S5 | 新增 env scrub 警告:凭据组(*KEY/*TOKEN 等)须显式经 request.env 传入 | +| S6 | headless 全 never 需显式 patch 配置(否则是 fail-closed 拒绝而非 never) | +| D5 | 「原生续派」改为「原生恢复待派 + 显式唤醒」;中断收尾=interrupted 事件投影 failed | +| D6/D7 | 补核实结论(peer 强制机制/官方精确 pin 惯例/安装流/dump-config) | +| G2 | toolview=client 侧 slot 插件(非 host 侧、非 tool-presentation) | +| G6 | ✅ 关闭:Windows ACL 沙箱无需 Docker;进程树杀=TerminateJobObject(印证 G10 必要性) | +| G9 | 增 env scrub 断言项 | +| G20 | ✅ 关闭:catch-up 原生支持 | +| G21 | 明确:DSH 无出站 webhook,通知适配全插件侧 | +| 映射表 | media 行 `ctx.connection.fetch`→`ctx.webServer.register()`;/api/run 行补 SDK 主选+webhook 单向;mcp_server 行增 mcp-client 直连选项(二期评估) | +| P0 | 开发流改「配置热重载、代码冷重启」;S2 spike 增 producer 样板验证 | +| P3 | 补 web profile 默认端口 3080 与一次性 token 认证 | + +## 五、第二轮核查:防重复自建(2026-10-09,19 项) + +> 动机:第一轮核实「计划依赖的假设」,本轮核查「计划里仍标注自建的东西,DSH 是否已有更优实现」。范围:UI 聚合层 / 并发原语 / 持久终端 / 凭据 / hooks / 超时 / 媒体展示 / 存储原语。 + +### 5.1 UI 聚合与并发(G19/S3 相关) + +| # | 结论 | 证据与关键 API | +|---|---|---| +| 1 | ✅ **排队消息 UI 原生存在(QueueDock)——ARTEMIS dock 队列的等价物**:排队计数「{n} 条排队消息」、每行 edit/remove/steer 三键(steer 仅运行中可用)、走 `conversation.updateQueue(itemId,{kind:"edit"/"remove"/"steer"})`;slot `conversation.input.dock`(id queue, order 20) | dsh-client-ui-conversation/lib/client.js:15369,15677-15696 | +| 2 | ⚠️ round 概念部分原生:goal_round driver 有 `attempt.phase queued/claimed`、`roundsStarted` 计数、`goal.phase active/paused/complete/blocked`;但 **UI 无轮次时间线组件**、排队 goal 轮在 dock 里与普通消息无视觉区分 | dsh-goal-round-driver/lib/index.js:135-160;dsh-client-ui-chat/lib/client.js:6626 | +| 3 | ✅ slots 全集约 **70 个缝**;关键的:`conversation.view/chat.node/chat.turnTail/input.dock/input.overlay/composer(.bar/.dock)/session.header.{actions,utilities,corner,lineage}/approval.detail/tool.call.toolview(keyed)/sidebar.right.pane.tab/settings.general.item/shell.overlay` 等 | dsh-client-ui-slots/lib/index.js:50;dsh-client-ui-layout/lib/client.js:604-627 | +| 4 | ✅ jobs UI 原生:会话头按钮 roster(`conversation.session.header.actions`),数据 `job.list`/`job.follow`/`job.kill`,两段式 stop,无 job 不渲染 | dsh-client-ui-jobs/lib/client.js:610 + README | +| 5 | ⚠️ **无库级 semaphore/mutex/p-queue**(全树 grep 0 命中):闸门地基=弱表 promise 链(官方样板 `dsh-tool-bash-persistent/lib/index.js:325-333`:WeakMap 尾链;tracked pending 模式 :179-237 支持取消)+ `dsh-deque` 环形队列 | dsh-deque/lib/index.js(86 行);dsh-chunked-list | +| 6 | ⚠️ persistent shell **工具**(bash/pwsh-persistent)不支持交互 stdin(读 stdin 的前台子进程挂到 timeout);per-agent 单 shell、命令串行 | dsh-tool-bash-persistent/README.md | +| 7 | ✅ **`ctx.terminals` 服务层才是持久终端正解**:`spawn/startSend(交互 stdin)/read(有界 scrollback)/signal/abortAndClose/close`,per-owner 多实例,backend=shell(POSIX bash/Windows pwsh),输出可编程读取——正是 ARTEMIS `run_adb_command` 持久终端+stdin 的形态。注意用户面右侧栏终端(remote.terminal)是另一套、不进 transcript | dsh-terminal/lib/index.js:91-199;dsh-api-terminal-controller/README.md | +| 8 | ✅ dsh-output-retention = 模型面工具输出保留库(ItemRetainer head 窗+omitted 计数 / TextRetainer head/tail 字节窗 UTF-8 安全切)——自定义工具输出窗口化直接用 | dsh-output-retention/lib/index.js | +| 9 | ✅ 一个 session 一个 goal(GOAL_ALREADY_EXISTS;多 goal 排队串行)——ARTEMIS 一次提交多 goal = 多条 followup | dsh-goal/lib/index.js:639,90-100 | + +### 5.2 平台工具件(凭据/hooks/超时/媒体/存储) + +| # | 结论 | 证据与关键 API | +|---|---|---| +| 1 | ✅ **凭据管理原生**:`ctx.credentials.resolve/describe(永不回值)/set/unset` + records 枚举(`credentialKey('/')`)+ `/.credentials.yaml`(precedence:启动 env > 存储文件 > 项目 .env > home .env,watch 热载,rotation 即时生效);配置用 `apiKeyEnv` 引用。**缺**:连通性测试(seam 不发网络请求,自建一小块);refs 不可枚举(用 records 存 provider 清单) | dsh-credentials/lib/index.js:7-104;dsh-credentials-local/README | +| 2 | ⚠️→✅ **原生工具拦截缝存在且强**:`tools/pre-execute` waterfall → Decision `{allow/deny(reason)/ask/cancel}`,ask 经 `approval.request→'allowed-once'|'rejected'|'cancelled'|'unavailable'`;`tools/post-execute` 可替换输出;轻量版 `ctx.tools.guard`(返回 string 即 deny);`ctx.tools.restrict({allow,deny})`。**dsh-hook-protocol/-hooks-claude-code/-hooks-codex 是存量 hooks 兼容层(其 updatedInput/continue:false 不生效)——原生插件勿走** | dsh-tools/lib/types/index.js:876-900,1097-1144,513-525 | +| 3 | ✅ **工具超时原生且语义正确**:`ToolDefinition.timeoutMs`(每工具声明,无全局默认)→ wrapper 以 `deadline(exec.signal, timeoutMs,'TOOL_TIMEOUT')` 融合信号,超时→模型收结构化 `TOOL_TIMEOUT` 错误。**契约:工具内部必须监听 exec.signal 主动杀进程**,否则超时静默失效(不崩会话但挂住调用方)——正是 ARTEMIS「超时必须工具内生效」教训的 DSH 版 | dsh-tools/lib/types/index.js:468-471,975;dsh-timeout/README | +| 4 | ⚠️ 媒体展示:**`present(files[])` 工具 → deliverables 卡片**是最优承载(持久、`deliverables/presented` 事件可重放、右 Sidebar 预览/系统播放器打开);attachment 管线只对**用户上传图片**做入库+模型直读;**mp4 无专门处理、聊天流内嵌播放器无现成物**(要内嵌需 client UI 扩展) | dsh-tool-present/lib/index.js:116;dsh-client-ui-deliverables | +| 5 | ✅ 插件状态持久化不手写:`ctx.storageDomain.defineDomain({name,version,tables(zod)})` → durable 写 + `domain/changed` 事件 + per-domain 写链(host-side only);裸文件用 `dsh-atomic-write` 的 `writeFileAtomic` + `withFileLock`(跨进程锁;无 fsync) | dsh-storage-domain/README;dsh-atomic-write/README | +| 6 | ⚠️ dsh-http-proxy 仅出站 HTTP 代理(env 一次性解析,覆盖 LLM/web/MCP 流量,无 SOCKS/PAC/会话级)——与模型路由无关;本地 LM Studio/OpenAI 兼容端点直接配 dsh-llm(pi-ai) 适配器 + apiKeyEnv | dsh-http-proxy/README | +| 7 | ⚠️ dsh-fs-observation-policy 名字有误导:是 read-before-edit **写护栏**(fs/write-intent、FS_NOT_OBSERVED/FS_STALE_VERSION),不观察目录、不影响 bash 落盘(设备截图目录无涉) | dsh-fs-observation-policy/README | +| 8 | ✅ session-stats 原生:turns/steps/llmMs/toolMs/ttftMs/ttftSteps/decodeMs/decodeTokens 八指标全 log 折叠投影(对照 ARTEMIS usage 端点)+ token-meter 三投影 | dsh-session-stats/README | +| 9 | 一句话:auto-review=实验性 per-tool-call LLM 审查(三期审批参考实现);persona=preset 内 per-agent 人设行;`ctx.systemPrompt` registry=一期注入 autogamer 操作指引的挂点;invariants=运行时自检注册表(调试用) | 各包 README | +| 10 | ✅ 用户文件上传端到端原生(`ctx.fileUpload.upload` 进度/取消 + Host staged receipt + admission 落 attachments)——二期用户附图无需自建路由 | dsh-client-file-upload;dsh-attachment-local | + +### 5.3 对方案的最终影响(已回写 TODO.md) + +1. **G19 缩小 70%**:QueueDock(排队 chips/编辑/插队)+ jobs 头部 roster + timeline 按 turn/step 分组 + goal 轮 turn-trigger 卡全部原生;插件侧只剩「轮次时间线视图、排队 goal 轮视觉区分、轮状态徽标」,挂 `conversation.chat.node`/`chat.turnTail`,数据源 `goal` projection。 +2. **凭据管理删自建**(二期):dsh-credentials 承接存储/掩码/rotation/分层;自建仅连通性测试。 +3. **S3 闸门地基明确**:弱表 promise 链样板 + Deque + tracked pending;状态持久化用 ctx.storageDomain(不手写 JSON)。 +4. **二期模式 a 的闸门/审批前置有原生缝**:tools/pre-execute Decision(deny/ask),不必包一层工具;hook-protocol 兼容层勿用。 +5. **run_adb_command 持久终端建在 ctx.terminals**(非 persistent shell 工具)。 +6. **媒体展示面 = present 卡片优先**;内嵌播放器需 client UI 扩展(产品决策项)。 +7. **超时契约**:自定义工具必须声明 timeoutMs 并监听 exec.signal 主动杀——写入 autogamer-device 工具规范。 + +## 六、第三轮补充核实:轮次执行 shim(2026-10-09,主代理直查) + +> 盲区:方案定义了「用户消息 = followup 进 Inbox」,但没写**谁来把这条消息变成 job**——不处理的话每轮会跑一次普通 LLM 对话(无效实现)。六个发现: + +| # | 发现 | 证据 | +|---|---|---| +| 1 | ✅ **自定义 Agent 实现是一等缝**:agent-loop README 明言 "Choose a custom Agent implementation only when the standard 'call model, run tools, repeat' lifecycle is insufficient";`dsh-agent` 源码图有 **factory slot**(AgentRegistry factory provider——官方 ReactLoopAgent 即此挂法;provider unload 会停掉其创建的全部 handle) | dsh-agent-loop/README.md:28 Summary;dsh-agent/README.md:97,118 | +| 2 | ✅ **创建 agent 不强制 model**:"a model call additionally requires both provider and model"——只约束 dispatch;`agent/request` waterfall 可在派发前补/改 provider-model | dsh-agent-loop/README.md:32,51 | +| 3 | ✅ **`agent/pre-step` 拒绝 = 不开 step = 不调模型**("A rejected decision or empty first batch opens no step")——标准 loop 的最小拦截缝 | dsh-agent-loop/README.md:119 | +| 4 | ✅ inbox 生命周期事件 host 侧可订阅:`agent/inbox/inserted {message}` / `claimed {message, turn}` / `discarded`;Inbox API(append/prepend/replace/remove/clear/splice)= updateQueue 同源,remove/clear 是 durable 取消 | dsh-agent/README.md:91 | +| 5 | ⚠️ **`ctx.jobs.start()` 必须挂 `dsh-tool-jobs` 才被武装**("this plugin's controller is what arms producers' ctx.jobs.start()");`completionDelivery: quiet` 关闭模型通知、`maxConsecutiveWakes` 限自唤醒链;完成通知两形态:busy→注入下一步、idle→followup 唤醒新轮 | dsh-tool-jobs/README.md | +| 6 | ⚠️ 无 tool_choice 强制(前轮已确认采样面只有 temperature/maxTokens/stop)——tool-mediated 路线的「模型必调工具」只能靠 instructions 约束 | 前轮 §三.11 | + +**三条候选路线与推荐**: + +- **B1 插件直派(推荐)**:autogamer-queue 经 factory slot 注册自定义 driver——claim 到 next-turn 不调模型,直接闸门→spawn job→await settle→把结果写回会话(tool/call+result 对)。零 LLM 成本;turn/step/tool 事件由我们发,toolview/时间线天然有数据。风险:driver 细节工作量(事件正确性、prompt 装配最简化)。 +- **B2 备选**:标准 loop + preset 不配 model + `agent/pre-step` 一律拒绝。自定义代码最少,但 claim 后无 step 的消息归属(是否入 history、下轮可见性)需 spike 核实。 +- **A 兜底**:preset 极简 instructions + spawn 工具(每轮一次廉价 LLM 调用)。B1/B2 spike 失败才退到这里;无 tool_choice 是固有跑偏风险。 + +**任一路线通用**:composition 必须挂 dsh-tool-jobs(武装 job 启动);`maxConcurrentJobsPerOwner` 默认 10;autogamer preset 的 approval 对 spawn 工具应为 never(它就是产品动作本身)。 + +## 七、主流 agent harness 任务调用范式调研(2026-10-09,web 调研) + +> 动机:§六的 S10-B1(自定义 Agent factory driver)疑点=逆主流、维护面大。调研四个主流样本验证。 + +| 样本 | 任务调用方式 | 会话连续性 | 队列在哪 | turn 能否绕过模型 | +|---|---|---|---|---| +| Claude Code | `claude -p "goal" --output-format stream-json`(headless=任务单元) | `--resume ` / `--continue` | **harness 外**(CI/调用方) | ❌ 无此概念 | +| Codex CLI | `codex exec "goal"`("bounded agent workflow" 出结构化结果+确定性退出码) | session 参数 | harness 外 | ❌ | +| Gemini CLI | `gemini -p "goal"` | session flag | harness 外 | ❌ | +| ACP(编辑器↔agent) | client 启动 agent 子进程 → `session/new` → `session/prompt` 流式 | session 对象 | client 侧 | ❌(agent=LLM loop 是定义) | +| OpenClaw 网关 | Gateway(WS server)收消息(聊天平台/webhook/cron)→ 路由给 Agent runtime | 会话由 runtime 管 | **Gateway 层**(网关自带队列/路由) | ❌ | +| DSH | `dsh --profile headless "task"`(NDJSON 事件、exit 0/1)/ SDK stdio / web Inbox | `--session-id` resume | Inbox(harness 内,异类但可用) | ⚠️ 仅经 factory slot 自定义 driver(内部缝) | + +**铁律**:主流世界从不改造 harness 的 turn 模型——把「一次 harness 调用」当任务,队列/调度/重试全部在调用方或网关层。`dsh headless` 与 `claude -p`/`codex exec` 完全同构,且 DSH 已自带。 + +**对 S10 的影响(路线重排)**: +1. **甲·headless-per-task(主流对齐首选)**:autogamer-queue 收提交 → 自建 durable 队列(storageDomain)+ 设备闸门 → spawn `dsh --profile headless --session-id "goal"` 为 job → NDJSON 进 output ring → 退出码判终态。会话记忆原生延续;**运行中信息面 = job roster+progress+output ring,与一期 py worker 黑盒完全同构**(G2 降级已是接受项);QueueDock/Inbox 不承担排队(排队在插件队列)。 +2. **乙·tool-mediated(UI 体验完整选)**:即 §六路线 A——Inbox+QueueDock 原生排队 UI、turn/时间线全原生,代价每轮一次廉价本地 LLM 调用。 +3. **丙·B1 自定义 driver 降级为最后手段**(零 LLM 成本但逆主流、贴内部缝、rc.2 升级脆弱)。 +4. **二期换壳洞察**:甲方案下 **py worker ⇆ dsh headless 是对等可换壳物**——同一 job 命令从 py worker 换成 dsh headless agent(带 autogamer-device MCP 工具)即模式 a 的最平滑渐进入口,先换壳再拆逻辑。 + +来源:[Codex as a platform](https://developers.openai.com/blog/codex-as-a-platform)、[Unlocking the Codex harness](https://openai.com/index/unlocking-the-codex-harness)、[Harness Engineering: Headless mode](https://www.zyte.com/blog/harness-engineering-3-headless-mode-the-minimal-agent-harness)、[ACP Architecture](https://agentclientprotocol.com/get-started/architecture)、[OpenClaw Architecture](https://ppaolo.substack.com/p/openclaw-system-architecture-overview)、[Claude Code headless](https://mcpmarket.com/tools/skills/headless-mode-for-claude-code) + +## 八、业界「普通 agent + 外部机制约束」模式调研(2026-10-09,web 调研) + +> 动机:验证 S11「单层 agent + 确定性路由」是否业界正确形态。结论:**是,且是收敛方向**——研究趋势明确反对「反思式自检」(模型审查自己不可靠),支持 verifier 驱动 + 逻辑化守卫。 + +| # | 机制(按约束力排序) | 业界出处 | S11 对应 | +|---|---|---|---| +| 1 | 确定性代码包住模型(workflows:代码编排 LLM 调用,模型只填参数) | Anthropic「Building Effective Agents」五模式(routing/parallelization/evaluator-optimizer…);「workflows 给可控性、agents 给开放性」 | S11 路由器 = 动作级 workflow | +| 2 | **逻辑驱动的动作验证**(明确批评 reflection 式自检,提出逻辑化守卫) | arXiv 2503.18492「Safeguarding Mobile GUI Agent via Logic-based Action Verification」 | S11 前置校验/失败阈值门=逻辑守卫;validator subagent 只做语义层 | +| 3 | Verifier 驱动范式(验证者一等公民 + 离散化动作空间) | arXiv 2503.15937「Verifier-Driven Mobile GUI Agents」+ evaluator-optimizer 模式 | S11 checker subagent + run_outcome 记账;ARTEMIS 结构化动作集即离散动作空间 | +| 4 | **动作效果验证**(post-action effect check:屏幕真的变了吗) | ACL 2026「Action-Effect Verification and Self-Refinement」 | **S11 采纳升级**:路由器执行后以效果信号(屏幕差分/dHash,py utils 已有 image_diff/image_hash)确认生效,未生效=失败路径检测信号 | +| 5 | 分层反思(长程失败复盘) | MobileUse hierarchical reflection / GUI-Reflection | S11 checker 里程碑审计为粗粒度对应;不做模型自反思(与 #2 立场一致) | +| 6 | 代码模式/PTC(模型写代码确定性调用工具:98% token 节省+确定性控制流) | Anthropic PTC / code-execution-with-MCP;**DSH 原生对应=dsh-workflow + dsh-ptc-runtime(已解包核实)** | 一期不需要(py worker click_sequence/burst 已覆盖确定性动作串);三期把动作 burst 迁 PTC 的现成机制 | + +来源:[Building Effective Agents](https://www.anthropic.com/engineering/building-effective-agents)、[Logic-based Action Verification](https://arxiv.org/html/2503.18492v1)、[Verifier-Driven Mobile GUI Agents](https://arxiv.org/html/2503.15937v4)、[Action-Effect Verification (ACL 2026)](https://aclanthology.org/2026.acl-long.1335.pdf)、[MobileUse hierarchical reflection](https://openreview.net/pdf?id=KR6tnkb6h4)、[Code execution with MCP](https://www.anthropic.com/engineering/code-execution-with-mcp)、[Programmatic Tool Calling](https://platform.claude.com/docs/en/agents-and-tools/tool-use/programmatic-tool-calling) diff --git a/docs/migration/feature-inventory.md b/docs/migration/feature-inventory.md new file mode 100644 index 000000000..f1c10256c --- /dev/null +++ b/docs/migration/feature-inventory.md @@ -0,0 +1,107 @@ +# 迁移前功能盘点(feature inventory) + +> 目的:DSH 迁移启动(P0)之前,把「若无人记录就会无声丢失」的行为、不变量与契约全部成文,防止迁移中途功能漂移丢失。 +> 基线:cc6b20c;日期:2026-10-09。 +> 方法:五路并行子代理审计(逐文件精读 + 全仓 grep + 测试基线扫描,合计约 2.4 万 tokens/sec·334 次工具调用),主代理对全部「硬矛盾」结论逐条抽查核实(退出码 130 / `ARTEMIS_TASK_WORKER` 分支 / `_resolve_terminal_status` / 18 个 ENVIRONMENT_ERROR_MARKERS / `is_mock` 死检查 / 全部行数)。 +> 维护规则:条目证据一律 `file:line`,以代码为准;代码变了回改本目录。 + +## 文件导航 + +| 文件 | 范围 | 亮点 | +|---|---|---| +| [inventory/01-console-queue.md](inventory/01-console-queue.md) | admin_console 队列/控制台后端 + 单测基线 | /api/run 准入管线(幂等/去抖/409)、终态权威顺序、熔断 hold 解除矩阵、三种「暂停」辨析、SSE 服务端契约、45s 优雅停止、数值常量总表 | +| [inventory/02-runtime-persistence.md](inventory/02-runtime-persistence.md) | runtime + data_engine + CLI + sdk | device_lock 语义全景(parking/PID 复用/ticket mtime)、DB 全 schema 与幂等迁移、ARTEMIS_* env 全集、cancel marker 协议、notes 跨任务记忆、worker 端到端时序 | +| [inventory/03-device-media-diagnostics.md](inventory/03-device-media-diagnostics.md) | drivers/媒体管线/诊断/mcp 动作层 | 录像 manifest v2 与首帧锚定、0-1000 归一坐标契约、helper/u2 互斥、mock driver 复刻基线、doctor 检查项全集、Windows quirk 汇总表 | +| [inventory/04-agent-stack-config.md](inventory/04-agent-stack-config.md) | worker 启动退出契约/智能栈/LLM/配置 | 退出码真相(S2 矛盾)、task_plan.md 写入暗规则、工具敏感分级表(S6 输入)、LLM 可靠性/暂停协议、env 全集按语义分组 | +| [inventory/05-frontend-integration.md](inventory/05-frontend-integration.md) | showcase_ui_v2 + mcp_server + artemis-client + 脚本 | SSE 事件×前端消费矩阵(含死信道)、4-step merge 不变量、跨线程竞态五件套、通知通道矩阵、SDK 公开面、dev/构建链 | + +> 注:各 inventory 文件 §4(修订建议)写于统一编号之前,其 G 编号是草案编号;**权威编号以本文 G8–G29 为准**,其建议已由主代理汇总落进 `docs/TODO.md`(2026-10-09)。 + +## 与 TODO.md 的硬矛盾(P0 前必须消化,已回改 TODO.md) + +1. **S2 退出码映射与 py 真相相反**:verifier 判 blocked / flash 自报 failed 的任务退出码为 **0**(CLI 不检查任务结果,run.py:463-473 只对异常与取消设非零);协作取消退出 **130**。按 S2 原文「0→completed / 非0→failed」会把失败任务标成 completed。py 真相:**DataEngine DB 终态权威,退出码仅兜底**(task_queue_service.py:310-333)。 +2. **cancel_requests 被映射表标「✅ 删除」,但它是 Windows 下唯一优雅取消通道**:worker 以 CREATE_NO_WINDOW 创建,`JobRegistry.kill` 硬杀时收不到任何 Python 信号;录屏 remux / trace 编译 / 锁释放 / cancelled 终态全靠 cancel marker 文件协议 + 45s 宽限(默认 `ARTEMIS_CANCEL_GRACE_SECONDS`)。 +3. **S5 env 清单是子集**:漏 `ARTEMIS_TASK_WORKER=1`——不设(也无 ticket)时 `artemis run` 会 ensure_daemon + POST /api/run **反向自提交旧 daemon**(run.py:304-311);漏 `ARTEMIS_SUBMITTED_AT` 则聊天轮次排序回归;另有 TASK_INGRESS / DEVICE_QUEUE_TICKET / ADB_ENDPOINT_ID / IPC_PORT / driver 组 / 凭证组共 20+ 个。 +4. **「queue pause/resume → agent.cancel + inbox splice」归类错误**:实际是调度器闸门(保留 pending、不碰任何运行中任务);且现行为手动 resume 会同时清熔断挂起,与半开自动恢复的新设计需写明差异。 +5. **「graph checkpoint 由 session log 承接」易误读**:py 无 langgraph checkpointer、**无断点续跑**(进程死=任务死,恰与 D5 一致);checkpoint 是计划校验账本(check_ledger.jsonl),不是执行位置。 +6. **「旧线 [vlm] 路由已验证」不存在**:py 无 [vlm] 路由键,多模态按节点配置(video_analyzer/object_detector/explorer + is_multimodal)。 + +## 统一差距清单(G8–G29,已同步进 docs/TODO.md 差距表) + +### A. 一期对接语义修正(阻塞 P0 spike) + +| # | 差距 | 详情与证据 | +|---|---|---| +| G8 | 退出码/终态判定契约(S2 矛盾) | py 退出码不编码任务结果;DB 终态权威(success→completed 归一、raw 值保留对账);`exit 130`→cancelled;DSH kill 必须先写 cancel marker、宽限后再硬杀。inventory/01 §1.5、02 §1.5、04 §1.4–1.5 | +| G9 | worker env/CLI 全量契约(S5 是子集) | `ARTEMIS_TASK_WORKER=1` 必设(缺失→自提交循环);`ARTEMIS_SUBMITTED_AT`/`TASK_INGRESS`/`DEVICE_QUEUE_TICKET`/`ADB_ENDPOINT_ID`/`IPC_PORT`/driver 组(`ADB_*`/`ARTEMIS_MOCK_DRIVER` 等)/凭证组;stderr→stdout 合并;Windows creationflags。inventory/02 §2.1 全表、04 §1.3 | +| G10 | 优雅取消通道 | cancel marker(PID+process_created_at 防 PID 复用)→ 45s 宽限(env 可调,0=立即杀)→ 到期硬杀进程树;取消路径 worker 收尾:录屏 remux、trace 编译改名、锁释放。inventory/01 §1.5、02 §1.5、04 §1.5 | + +### B. 一期 job 侧能力缺口 + +| # | 差距 | 详情与证据 | +|---|---|---| +| G11 | 终态/环境级判定信号来源 | 零步骤失败一票判环境级(需查 DB `has_steps`)+ 18 个 ENVIRONMENT_ERROR_MARKERS(宽泛词会误判,照搬前须有意识);fail-open(步骤探测失败→不挂)vs fail-safe(设备枚举不确定→继续挂)不对称;auto 失败挂端点默认队列键;**allowed_fails 默认值需决策**(建议 1=保持现行为)。inventory/01 §1.2、04 §6 | +| G12 | LLM 暂停协议的 DSH 呈现 | 重试耗尽写 `.artemis_paused` → 任务静默挂 900s(`LLM_PAUSE_TIMEOUT_SECONDS`)→ LLMExhaustedError;DSH 侧表现为假死 job。删文件=resume。inventory/04 §2.3.3、01 §1.3 | +| G13 | 幂等/去抖提交语义 | session 级短路(重试跳过就绪探测)、1s 同 goal 防抖、409 设备锁定/400 未知端点/rejected/`unknown`(MCP 防重复提交)——webhookRuntime/SDK 接入的验收项。inventory/01 §1.1、05 §3.1 | +| G14 | 状态词汇表映射 | 现网 pending/running/completed/failed/cancelled(success→completed 归一)↔ DSH JobStatus(running/stopping/completed/killed/failed);manual stop=cancelled↔killed 等映射需显式定义。inventory/01 §4 | + +### C. 双跑/共存期契约 + +| # | 差距 | 详情与证据 | +|---|---|---| +| G15 | 双跑共享 temp 目录协议 | `/device-locks/` 是 py 线与 TS 闸门间的真实 IPC;换目录名/格式=互不相认→同设备双跑并发。inventory/02 §2.2 | +| G16 | 会话延续 notes 记忆 | 同 conversation 下一提交读 `traces//notes/*.md`(≤8000 chars)注入 planner——py 侧「多轮对话」本体;G7 旧数据不迁移→切换瞬间进行中线程失忆。inventory/02 §1.2、04 §2.6.4 | +| G17 | 共存期鉴权面 | SameOrigin(Host 校验防 DNS rebinding、Origin==Host、**无 Origin 头直通**)、lifecycle token、loopback-only restart/shutdown、/api no-store;vite 代理 Origin 剥离同样作用于 SSE。inventory/01 §1.7、05 §3.5 | + +### D. UI/事件契约 + +| # | 差距 | 详情与证据 | +|---|---|---| +| G18 | SSE 事件契约 + 死信道 | 前端订阅面与广播面有差集:`queue_held/paused/resumed` 服务端广播但前端**零订阅**——队列 UI 信源实为 2s 轮询 `/api/status` 的 `queue_paused/queue_holds`;`recording_ready` 双形状分叉(engine vs 队列兜底);llm_stream 合批(80/500ms)+泳道关闭语义。inventory/05 §1.2、01 §1.4 | +| G19 | 线程聚合视图不变量 | submitted_at 排序锚、幽灵防护(60s TTL+dismissedNoRowSessions)、自动跟随绝不跨线程(mayAutoFollowSession)、草稿豁免、排队轮不渲染/不提升——四源合并「问题消失」仅对数据源成立,聚合展示在 DSH roster/toolview 原样重现。inventory/05 §2.1/2.2/2.4 | +| G20 | 晚订阅 catch-up | SSE 订阅即回放(合成 session_started + startup_progress + 已落库步骤逐条 step_recorded)——晚开页面不空窗;DSH session/event 等价物待验证。inventory/01 §1.4、05 §1.1 | + +### E. 外部承诺 + +| # | 差距 | 详情与证据 | +|---|---|---| +| G21 | 对外通知契约 | 仅 `conversation_id 存在或 ingress=mcp` 才通知 + 两类判死触发点;webhook POST schema(5s 超时、UA Artemis-MCP/3.0)是外部网关既有契约;agentapi 通道 env 恢复黑科技。inventory/05 §3.3 | +| G22 | 外部 SDK/MCP 基线 | artemis-client 公开面(task_id 幂等重试、get_task 队列兜底、错误分层)此前无成文处;`/api/v1/capabilities` 是假端点(恒 404→客户端回退编造集);MCP 工具 docstring 即协议(1 分钟兜底轮询、release_loop 唯一优雅停止信号、对账「不确定=存活」)。inventory/05 §3.2/3.4 | + +### F. 二期重写基线(防重写时行为漂移) + +| # | 差距 | 详情与证据 | +|---|---|---| +| G23 | 录像产物与 trace 媒体契约 | manifest v2 字段、`recording{,_NNN}.(mkv|mp4)` 命名、首帧锚定(session.start_time=首帧)、`_PASS/_FAIL/_TESTFAIL` 终态改名、`image://{sha256}` 截图引用协议、媒体 HTTP=根约束+扩展白名单(无 token)。inventory/03 §1.4–1.6、02 §1.4 | +| G24 | 设备工具坐标与结果契约 | 0-1000 归一坐标域(换体系=历史 trace 回放与 prompt 全失配)、元素索引 client 端解析(wire 只见坐标)、target_description 记录不上 wire、ActionResult(设备拒绝=isError=False+ok=False,非异常)、动作名归一表。inventory/03 §1.8 | +| G25 | 层级后端互斥与降级 | helper(设备端口 18888+X-Artemis-Token)与 u2 的 UiAutomation 单例互斥;auto 模式 30s 降级窗;u2 用后必须 stop_server;离线快判。不复刻 helper=显式放弃 Mobly/Appium 共存。inventory/03 §1.9 | +| G26 | 跨进程 PID-liveness 协议 | `(pid, process_created_at)`±1s、不确定=默认存活——锁/取消/trace 锁/supervisor 五处共用;S7 孤儿对账 kill 必须用此协议(PID 复用前科)。inventory/02 §1.1 | +| G27 | 智能栈暗规则基线 | plan 写入机器规则六条([Loop:continuous] 保护/手滑重写检测/check 行合并回/finding 投影/checker- 前缀保留/ratchet 基线)、checker 三不变量、run_outcome 双轴(assert 失败≠任务失败)、memory 梯级与 xml_scrub_depth=1 的 stale-index 安全理由、transcript 回滚开关。inventory/04 §2.4–2.6 | +| G28 | parking + 全局并发上限 N | 单个未认领 pending/any ticket park 到恰好一台空闲设备(历史 bug 修复的不变量);`ARTEMIS_MAX_CONCURRENT_TASKS>1` 全局上限是 D3 未涵盖的第三种语义(承接或显式声明不支持)。inventory/02 §1.1 | +| G29 | 任务中途指导注入通道 | `/injected_instruction.json` `{instruction, release_loop}` 读后即删;release_loop 是 [Loop:continuous] 唯一合法结束信号——已立 S9,DSH 对应物待 spike。inventory/04 §2.5 | + +## 跨领域漂移风险 Top 10(五路审计汇总) + +| # | 风险 | 一句话 | +|---|---|---| +| 1 | 退出码/终态契约(G8) | 照 S2 原文实现会把失败任务标 completed——一期兼容的最大单点 | +| 2 | 优雅取消通道(G10) | cancel marker 被标删除,丢了它 Windows 下「停止」降级为硬杀、录屏与 trace 靠启动对账兜底 | +| 3 | worker env 契约(G9) | 漏 `ARTEMIS_TASK_WORKER=1` 自提交循环、漏 `SUBMITTED_AT` 轮次重排——「py 零改动接入」的前提 | +| 4 | 录像产物契约(G23) | 格式猜错=时间线/回放/step-seek 全断,且 py worker 一期继续按 py 格式写,双端漂移概率最高 | +| 5 | 线程聚合不变量(G19) | 五个刚修完的竞态 bug 的测试全部住在即将删除的 Vue spec 里,没有接棒者 | +| 6 | 队列状态信源(G18) | DSH 只做事件推送会丢掉现 UI 实际依赖的轮询字段——dock「继续队列」入口失明 | +| 7 | PID-liveness 协议(G26) | 孤儿对账裸看 pid 正撞已修过的 PID 复用前科——误杀/死锁回归 | +| 8 | 双跑共享协议(G15/G16) | device-locks 目录与 notes 记忆是 py↔TS 无形契约,破坏即「同设备并发」与「会话失忆」 | +| 9 | 坐标/互斥契约(G24/G25) | 换坐标体系=历史全失配;占用 UiAutomation=「偶发全设备层级失败」类事故 | +| 10 | 外部承诺(G13/G21/G22) | 幂等重试、通知矩阵、MCP docstring 协议都是外部调用方踩着的隐性契约 | + +## 待决策清单(2026-10-09 用户讨论后状态) + +1. ~~allowed_fails 默认值~~ → **工程默认 = 1**(保持现状「单次环境级失败即挂」),P1 落地时若要改再提。 +2. ~~job 侧终态判定方式~~ → **推荐采纳:读同一 SQLite**(S10 spike 时顺带验证插件侧只读连接可行性)。 +3. ~~队列状态信源(G18)~~ → ✅ 已随 S10 裁定关闭:乙路线(插件只提供内容)=QueueDock/时间线原生 + job roster 看设备级进度。 +4. ~~二期是否复刻 helper~~ → ✅ **用户决策:复刻 helper**(保留 Mobly/Appium 共存差异化能力)。 +5. LLM 暂停的 DSH 呈现(G12)→ 运维手册成文(删文件=resume),一期不建 UI;低优先级。 +6. ~~run_adb_command 审批方式~~ → ✅ **用户决策:整工具 ask**(allowed-once)起步,只读白名单二期再评估。 +7. ~~capabilities 假端点处置~~ → ✅ **随 D10 直接消失**(无外部调用方,artemis-client 直接删除)。 +8. quality_ratchet 与 locale 锁测试 → **TS 侧显式不做**(记录为有意撤除,非遗漏)。 diff --git a/docs/migration/glossary.md b/docs/migration/glossary.md new file mode 100644 index 000000000..41afcd498 --- /dev/null +++ b/docs/migration/glossary.md @@ -0,0 +1,87 @@ +# 术语表(glossary) + +> 为什么要有:方案文档里同时混着 **DSH 宿主术语**、**上游 Cordis 框架术语** 和 **本项目自造词**(闸门、熔断、parking、直通插件……)。同一个词还可能有两个意思(`inject`)。这张表只干一件事:**把黑话换成人话,并给出该去查哪个权威编号**。 +> 用法:读 `TODO.md` 或守则时卡住就回来查;新增自造词必须在此登记,否则视为不允许发明术语。 +> 口径标注:**宿主** = DSH/Cordis 的概念(依据见 `plugin-contract-rules.md` 的出处);**本项目** = 我们的叫法,权威定义在 `TODO.md` 的 D/S/G 编号里。 + +## 一、易混淆的第一名:`inject` 有两个意思 + +| 写法 | 意思 | 归属 | +|---|---|---| +| 插件导出 `inject = ['tools']` | 声明**服务依赖**,被注入的服务就绪后插件才激活;放进 `ctx.inject([...])` 即「可选」,缺服务时不激活而不是抛错 | 宿主 | +| `agent.inject({content, source})` | 往某个 agent 的收件箱**追加持久上下文**,**不会唤醒**它;下一次准入的 step 才看到 | 宿主(守则 R17) | + +写方案或代码时不要只说「inject」,要说清是哪一个。 + +## 二、DSH 与 Cordis 侧术语 + +| 术语 | 人话 | 备注 / 权威 | +|---|---|---| +| profile | 一套可启动的插件组合配置,目录在 `~/.dsh/profiles/`;本机 spike 用 `web` | 每个 profile 的 `node_modules` 只放 profile 级安装的 bundle | +| bundle | 一个包,`package.json` 里声明 `dsh.bundle.patch`,其 YAML 补丁插入插件行 | 守则 R2、R19 | +| patch layer / overlay | 补丁按层叠加,后层覆盖前层;`--patch ` 是加在 profile 层之后的官方 overlay | 覆盖是**整体替换** config(R5) | +| Cordis | DSH 内嵌的插件框架:插件 = 实现 Service 的对象,上下文 = 服务容器 | 「Everything is a Plugin」 | +| Loader | 读补丁、决定每行是否挂载的组件;`!!js` 表达式由它在挂载时求值 | | +| agent preset(预设) | 一份「这个 agent 挂哪些子插件」的声明,即 `@deepseek-ai/dsh-agent-preset` 的 config | 只能由 bundle 补丁承载(R3) | +| preset registry | 预设目录与默认值服务;声明在启动时加载 | 不扫目录、不接受路径 | +| scope | 注册的可见性与所有权:子作用域继承祖先贡献,近者优先 | 通过 `agent.ctx` 注册的工具只对该 agent 可见 | +| realm / `isolate` | 服务实例的隔离域。preset 里提供服务的插件必须与消费方同 realm,否则挂载被拒 | 守则 R8、D16;`cordis:group` + `isolate:` 是官方写法 | +| fiber | 插件运行时的一次执行体;dispose 它的 fiber 就注销其中的注册 | | +| effect / disposer | 注册是可逆副作用:`ctx.effect()` / `ctx.on()` 返回清理函数,卸载时撤销 | 一处注册两个所有者时要各自留 disposer | +| waterfall | 环绕式中间件事件(`(...args, next)`);不调 `next()` 即短路 | 不拥有决策权的监听器必须 `return next()` | +| guard | `ctx.tools.guard()`:单调、同步的最终拒绝,后续监听器翻不回来 | 我们的熔断出口该用它(R13) | +| restrict | `ctx.tools.restrict(filter)`:只能**缩小**某个 agent 的可见工具集 | 只能删不能加 | +| Inbox / QueueDock | 宿主原生的会话消息队列与排队界面 | D2/D4:不自建队列 | +| followup | 进 Inbox 的用户消息;会唤醒空闲 agent | | +| steer | 运行中注入指导 | S9 | +| turn / step | 一次 agent 回合 / 回合内的一步 | | +| session log | 会话事件追加日志,**唯一真相来源**;fork、resume、replay 都从它派生 | 插件内存只是派生缓存(R16) | +| durable 事件 | 已落盘的 `turn/end`、`assistant/message`、`tool/result` | 等这些,不要 poll `agent/status` | +| roster | 预设与插件的清单界面,激活失败会带诊断留在上面 | 判生效的入口之一(R9) | +| `enabled` / `fiberPhase` / `overridden` / `failed` / `restart-required` | 行的状态字段:`failed` 要诊断,`overridden` 表示有更高优先层赢了,`restart-required` 表示改动还没生效 | 全部按 R9 作数,别用日志推断 | +| PTC mode | 把工具暴露成 `await tools.(args)` 的程序化调用模式 | 呈现方式按 agent 不按工具(S11 记过) | +| Native mode | 普通 function calling | | +| `defineTool` | 类型化 DSL:参数在执行前校验、声明输出与展示 | 裸 schema 注册也合法,但要自己校验(G34) | +| `output.schema` / `render` | 规范 JSON 返回值 + 模型可见文本 | UI 卡片是另一件事(R18) | +| `presentationMeta` | 从规范值派生可回放的 UI 事实,持久化在 `tool/result.meta` | 我们 checker 落盘的正道(R16) | +| `install_bundle` | `plugin_manager` 的安装动作,会自己跑包安装与选择 | 不许用 shell 复现这些步骤(R2) | +| HMR | 改配置层可免重启热重载;换包代码需要重启才有新的 JS 模块代 | D7 | +| `!!js` | 补丁里的表达式标量(**不是** `!js`),在挂载/激活时求值 | `disabled` 与 `config` 里合法 | +| Schemastery | DSH 用的 schema 校验库;`Config` 必须是它的原生对象 | 用 zod 会被拒(R10) | +| `Config.listConfigs` | 查已安装插件的真实 config schema 与 `packageDir` | 写 config 前先查它并跟 `$defs`(R12) | +| danger-full-access | 会话的文件策略档位(无沙箱限制),审批提示也被关掉 | 我们的 preset 目前跑在这一档 | + +## 三、本项目自造术语 + +| 术语 | 人话 | 权威编号 | +|---|---|---| +| R2' / R2-prime | 现行方案:项目整体收缩为一个 DSH 插件 + 若干 skills | D1、D9 | +| 直通插件 | 插件直接提供执行工具,不经过 py worker/job 中间层 | D9 | +| 三段式双跑 | 直通质量不达标时的回退形态(新旧两条路径并行一段时间) | D9、S2 | +| 闸门 | 同一设备的动作串行化,后来的排队而不是失败 | S3、G3 | +| 熔断 | 设备连续环境级失败后暂时拒绝该设备的所有动作 | S4、G4 | +| closed / open / half-open | 熔断三态;half-open 用下一条真实动作当探测 | S4 | +| 不对称 fail 策略 | 判定不确定时**继续挂**(fail-safe),探测失败时**不挂**(fail-open) | S4 | +| parking | 没有指定设备时,让一条消息先占住一台空闲设备 | G28 | +| auto 端点 / 显式 serial | 设备寻址的两种形态;auto 走空闲池,显式走 serial 队列 | G3 | +| 0-1000 归一坐标 | 工具面对模型说千分比坐标,TS 侧换算成设备像素 | G24 | +| 层级后端 | UI 层级抓取的实现选择(u2 UiAutomation 与设备 helper 互斥) | G25 | +| helper | 装在设备上的辅助 APK,提供层级与输入等能力 | D13 | +| flash / pro | 旧 py 智能栈的两档执行策略:快档直接执行,慢档带复核 | D14、G27 | +| 单层执行路由(S11) | 一个 agent 循环 + 代码强制的路由器 + 两个只读 subagent | S11 | +| validator / checker | 执行失败后的自愈梯 / 出口与里程碑审计,都只读 | S11、G27 | +| 严格度梯级 | off / final / checkpoints / strict 四档审计强度 | S11 | +| run_outcome 双轴 | 任务终态分「目标达成」与「测试失败数」两条轴 | S2、G14 | +| 质量 spike | 用少量真实任务比较新旧执行质量,作为直通/回退的判据 | D9 | +| 盘点 | 怕丢能力而做的现状清单,`docs/migration/inventory/0N-*.md` | 引用它写「`inventory/0N` 的 X 段」,别写它的 G 编号 | +| 声明面 | 允许取证的范围:CLI `--help`、`--dump-config*`、随包 README、已安装包产物、`~/.dsh` 运行数据、上游公开仓库 | D15、R1 | +| 活门禁 | 能自己报警的检查:测试、尺子、`--dump-config` 自检、官方检查器 | R9 | +| 尺子 | 机械校验脚本的统称(表格几何、编号引用、质量棘轮) | `scripts/check_doc_tables.py`、`scripts/check_doc_registry.py` | +| P0-P4 | 里程碑阶段,**不是**差距编号 | 旧 `TODO.md` 第五节(2026-10-14 已清空,见 `../CHANGELOGS.md` #71) | + +## 四、写作约定(给文档,也给代码注释) + +- 差距与决策一律用编号引用,编号先登记进 `registry.md` 再使用。 +- 引用上游结论时标口径:**文档口径 / 源码口径 / 实测口径**;只读文档得出的结论不许写成"已验证"。 +- 「已生效」必须指名是哪个生产者状态字段或检查器回答的(R9),日志与截图不算。 +- 描述缺陷时给形态不给猜测:先说"连 `tools` 键都不存在",再说"所以像整体拒挂"。 diff --git a/docs/migration/inventory/01-console-queue.md b/docs/migration/inventory/01-console-queue.md new file mode 100644 index 000000000..1a67da85f --- /dev/null +++ b/docs/migration/inventory/01-console-queue.md @@ -0,0 +1,274 @@ +# 迁移盘点 · 队列与控制台后端 + +> **编号警示**:本文里 `**Gn · …**` 形式的「新增差距」段落属于早期编号方案,与 `docs/TODO.md` 现行 G 编号**不是同一套**(G8/G9/G10/G11/G12 已撞车,冲突表见 [registry.md](../registry.md))。引用内容时写「`inventory/0N` 的 X 段」,不要写 `Gn`。 + +> 范围: `apps/admin_console/**`(routers / services / core / database / schemas / server.py / replay_manager.py)+ `tests/unit/admin_console/**`;基线: cc6b20c;日期: 2026-10-09 +> 目的:记录 docs/TODO.md(D1–D8 / G1–G7 / 全模块映射表 / P0–P4)**未写明、迁移中若无人记录即会丢失**的行为、不变量与契约。TODO.md 已覆盖的条目只标注出处、不展开。 + +--- + +## 1. 行为与不变量 + +### 1.1 队列完整生命周期 + +**提交入口 `/api/run`(routers/tasks.py:69-198)的准入管线,顺序固定:** +1. goal/goals 归一(两者都缺 → 400,tasks.py:77-81)。 +2. `model_endpoint` 若提供,必须能在 endpoint library 中解析,否则 400 并回显全部已存端点名(tasks.py:87-96;测试 `test_run_rejects_unknown_model_endpoint_before_enqueue`)。pin 在**提交时刻校验**,运行期不再跟随全局默认。 +3. **幂等短路(先于一切昂贵探测)**:带 `session_id` 且单 goal 时,若该 session 已在 queue_items、已落 DB、或属于 active 状态,直接返回既有表示(status=running/queued),`enqueued_count=0`,不重复入队、不重跑设备就绪探测(tasks.py:102-130;测试 `test_idempotent_retry_skips_device_probe_for_active_session`)。理由:admission 响应丢包重试时设备可能正被原任务持有,再探测会误伤。 +4. 显式 serial 先经 `validate_explicit_serial_async` 拒绝——**仅当枚举确定**才拒绝(adb 抖动/空总线=不确定 → 放行,让任务排队后在下游以清晰的 no-device 错误失败,fail-open)(tasks.py:138-150;测试 `test_explicit_device_proceeds_when_enumeration_is_indeterminate`)。 +5. 就绪探测 `run_device_submission_probe`:`Device Locked`/`Lock State Unknown` → **409**(tasks.py:160-174);探测解析出活动设备且用户未显式指定 serial 时,把探测到的 serial 绑定为 target(显式 serial 永不被静默替换,tasks.py:176-182)。 +6. 进入 `enqueue_tasks`。 + +**`enqueue_tasks`(services/task_queue_service.py:1288-1399):** +- `conversation_id` 规则:调用方传入则 strip 后沿用;为空则 `uuid4()` **在 enqueue 时刻分配、绝不留 NULL**(task_queue_service.py:1328-1330)。一次提交的多个 goal 共享同一 conversation_id。 +- 去重 1:session_id 已 active/在队 → 返回既有项(仅单 goal 时生效,task_queue_service.py:1157-1181)。 +- 去重 2(防抖):同 goal + 同 serial + 同 endpoint.identity + **1.0s 内**的重复 pending 提交短路返回(task_queue_service.py:1184-1205;测试 `test_enqueue_tasks_debounces_rapid_identical_submissions`)。 +- 无显式 serial 时 `select_device_async` 解析设备(异常 → None = auto,task_queue_service.py:1343-1350)。 +- **新提交解除该 lock_key 的熔断挂起**(task_queue_service.py:1352-1359)。 +- 队列项字段(`_create_queue_item`,task_queue_service.py:1232-1286):session_id(复用或 uuid4)、goal、profile(默认 flash)、expected_output、enable_outputter、verification_level、explorer_mode、model_endpoint、locked_app_package、app_path、device_serial、adb_endpoint(**快照**,测试 `test_enqueued_task_keeps_its_adb_endpoint_snapshot`)、ingress、conversation_id、status=pending、queue_ticket(入队即 `DeviceExecutionLock.reserve`)、created_at/start_time=`now + index*0.001`(同批多 goal 错峰排序)。 +- 同步登记 `state.remember_submission(session_id, conversation_id, created_at, goal)`(task_queue_service.py:1380-1385)——registry 值此后恒优先于锁票据视图(AGENTS.md 已载;todo 未载,见 §4)。 +- 响应:`{"status": "started"|"queued", "tasks": [...], "enqueued_count": N, "total_queued": M}`(started vs queued 取决于 `state.is_running`,task_queue_service.py:1395)。 + +**worker 子进程 spawn(`_execute_task_item` → `_build_worker_invocation`,task_queue_service.py:578-663, 996-1067):** +- 命令:`{sys.executable} -m artemis.main --profile

          --test-name web__ [--session-id] [--output-description] [--enable-outputter|--disable-outputter] [--verification-level] [--explorer-pro-mode] [--locked-app] [--app-path] [--device-serial]`,cwd=WORKSPACE_ROOT。 +- env 契约(**全量清单,todo S5 只列了子集**):`PYTHONPATH`(workspace + apps/admin_console + apps/cloud_service + 继承值重组)、`PYTHONUTF8=1`、`PYTHONUNBUFFERED=1`、`ARTEMIS_IPC_PORT`、`ARTEMIS_SESSION_ID`、`ARTEMIS_TASK_INGRESS`、`ARTEMIS_TASK_WORKER=1`、`ARTEMIS_CONVERSATION_ID`、`ARTEMIS_SUBMITTED_AT`(入队墙钟,轮次排序锚,task_queue_service.py:618-623)、`ARTEMIS_MODEL_ENDPOINT`(仅 pinned 时)、adb endpoint env(`target.endpoint.apply_to_environment`)、`ARTEMIS_ADB_ENDPOINT_ID`(lock scope)、`ARTEMIS_DEVICE_QUEUE_TICKET`、`ADB_DEVICE_SERIAL`(显式时)。 +- spawn 选项:stdout=PIPE、stderr=STDOUT(**合并**);Windows 加 `CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW`(worker_process_io.py:32-51;测试 `test_windows_worker_has_no_inherited_console`)。 +- run_key = session_id(无则 uuid4.hex);launch 期取消竞争:spawn 后检查 cancelled_session_ids/manually_stopped_run_ids,命中则立即终止(task_queue_service.py:734-743)。 +- spawn 后:登记 active_runs、trace_store.update_trace_pid 写 status.json、`transfer_reservation` 把队列票据转移给 worker pid(description=`"{ingress} task: {goal[:120]}"`——**原始 goal 必须来自 registry/queue item**,锁描述是格式化截断的,task_queue_service.py:698-714)、启动输出转发器(tee 到 traces//stdout.log,**该路径是 MCP API 对外宣称的日志路径**,task_queue_service.py:717-731)。 + +**调度(`queue_worker` + `_dispatch_pending_tasks`,task_queue_service.py:376-540):** +- 常驻循环:探测挂起队列 → 派发 → `wake_event.wait(0.3s)`;初始 `cleanup_stale_locks`;取消时终止全部活 worker。 +- 并发语义:`resolve_env_concurrency()` 0=每设备一个任务、N≥1=全局 N(`ARTEMIS_CONCURRENCY_MODE` global/serial/1→1、per_device/device/parallel/0→0、`ARTEMIS_MAX_CONCURRENT_TASKS` 默认 0;device_lock.py:601-613)。limit==1 且 is_running 时不派发。 +- 容量计数把「已派发未 spawn」的 running 队列项也计入(active_runs 滞后于 subprocess 启动延迟,task_queue_service.py:479-493)。 +- `prune_finished_runs`:进程退出或 **PID 已不存在**的 run 从 active_runs 剔除,防止死协程永久占位(state.py:168-182)。 +- auto 设备(无 serial)任务只在完全空闲的调度器上起(limit==0 时 active_runs/in_flight/dispatched_any 任一非空即跳过),因为它的 lockKey 未定(task_queue_service.py:517-521)。 +- cancelled_session_ids 中的 pending 项直接移除。 + +**settle/kill 与终态(task_queue_service.py:310-333, 745-828, 959-1067):** +- 终态解析 `_resolve_terminal_status` 优先级:手动停止 → `cancelled`;worker 写入的 DB status 是**权威**(`success`→`completed` 归一;completed/failed/cancelled 原样保留且不再写库);DB 无终态时退出码兜底(0→completed,非 0→failed)。测试 `test_resolve_terminal_status_preserves_authoritative_result`。 +- DB 行不存在且结果为 failed → **补插最小 failed 行**(`create_failed_session`:goal/created_at 作 start_time/conversation_id/submitted_at/stdout 尾行或默认 "Task failed before executing any step.";INSERT OR IGNORE,task_queue_service.py:770-791 + session_repository.py:529-569;测试 `test_worker_death_before_session_row_persists_failed_round`、`test_manual_stop_without_row_never_inserts_failed_row`)。 +- 终态同步写回 trace status.json(canonical:completed/success→completed)供 MCP 轮询方对账,OSError 重试 3 次(0.5s/1.0s 退避)(task_queue_service.py:803-827)。 +- 结束广播 `session_ended {session_id, status, was_stopped_manually}`;**仅当有 conversation_id 或 ingress=="mcp" 才外发通知**(mcp_server.notifiers.notify,payload 带 trace_id/session_id/status/goal)(task_queue_service.py:944-962)。 +- 失败且非手动停止 → 进入熔断判定(§1.2)。 +- 清理 `_release_run_slot`:移除队列项、丢弃取消标记、清 cancel marker 文件、弹 active_runs;最后一个 run 结束时清 manually_stopped_run_ids 防泄漏(task_queue_service.py:964-993)。 + +**/api/stop(task_queue_service.py:1488-1937):** +- `all=true`:取消所有未 running 项的票据 → 清队列 → **清 held_queues 与手动暂停** → 终止全部设备锁 owner(逐个优雅停止 + DB/trace 标 cancelled + session_ended 广播)→ 终止本地 runs(逐 run 标记,测试 `test_manual_stop_of_one_run_does_not_pollute_concurrent_run`)→ 清 pause 文件。 +- 定向(session_id/device_id):owner 解析顺序——按 session 匹配 owner → 按 device 匹配 owner → 作用域回退 owner(**不同 session 的陈旧 owner 永不收养**,测试 `test_stop_tasks_session_target_never_adopts_mismatched_fallback_owner`)→ 本地 run(PID 相等才认 is_local_owner)→ 队列项票据取消 → DB 行 running 且 PID 活着则优雅停止。owner 记录存在但不可解析且未指定 session 时**拒绝停止**(task_queue_service.py:1674-1677)。无目标(遗留单设备手势)仅在恰有一个活 run 时才歧义消除。 +- 停止目标解析出 session 后:cancelled_session_ids 登记由该 run 自己的 finalizer 收尾(并发安全)、DB/trace 标 cancelled、session_ended 广播、队列项移除、清 pause 文件。 + +**覆盖状态**:生命周期大方向已覆盖(todo S1–S3、S7、映射表 task_queue_service 行);但 env/CLI **全量**契约(S5 只列 5 个)、幂等/去抖、admission 探测管线、终态权威顺序、票据转移失败兜底("worker will queue a fresh ticket itself",task_queue_service.py:710-714)均为**缺失**。 +**处置建议**:env/CLI 表与幂等语义 → 新增差距(见 §4 G8/G10);终态权威顺序与 never-started 补插 → 保留为 autogamer-queue 的领域规则(自建 TS,不得丢);admission 探测 → P0/P1 一期保留 py doctor(映射表 E 行已定)。 + +### 1.2 失败分类体系与熔断挂起(hold) + +- `ENVIRONMENT_ERROR_MARKERS` **具体内容**(小写子串匹配,task_queue_service.py:77-96):`device, adb, helper, llm error, llm request, model, unavailable, rate limit, quota, api key, unauthorized, forbidden, timeout, timed out, connection, refused, 503, 502`(18 个;todo S4/G4 只点名机制未列内容)。注意宽泛词(model/connection/timeout)可能把任务级失败误判为环境级——这是现状语义,照搬前应有意识。 +- `_classify_failure`(task_queue_service.py:1070-1086):**零步骤失败 → environment(不看错误文案)**;有步骤且命中 marker → environment;否则 task。测试 `test_failure_classification_separates_environment_from_task`。 +- 判定输入:`step_repo.has_steps`(探测 schema 失败 → **不挂起、继续派发**,fail-open,task_queue_service.py:1104-1111;测试 `test_step_probe_failure_keeps_queue_dispatching`)+ DB 行 error_message。 +- hold 结构(`state.held_queues[lock_key]`):reason/failure_class/session_id/device_serial(显式 serial 或 "auto")/last_probe。**auto 设备失败挂的是端点默认队列键**——该端点上所有 auto 任务都被挡(task_queue_service.py:1123-1131;测试 `test_auto_device_failure_holds_default_queue`)。hold 按设备隔离,其他设备照常派发(测试 `test_hold_is_per_device_other_device_keeps_dispatching`)。 +- hold 期间后续消息**保留 pending、可见**(SSE `queue_held` 事件含 reason/failure_class)。 +- 解除路径(共 4 条):① 设备重新上线——`_probe_held_queues` 每 **5s**/设备节流,用真实枚举判定;**枚举不确定(adb 不可达)→ 继续挂**(fail-safe),恢复后广播 `queue_resumed`(task_queue_service.py:418-462;测试 `test_held_queue_auto_resumes_when_device_comes_back`);② 对该 lock_key 的新提交(①-③ 见 §1.1);③ `resume_queue`(手动,清全部);④ stop 全部。 +- **当前是单次环境级失败即挂**(无失败计数);todo S4 的新设计是「连续 ≥ allowed_fails → open」——这是有意的语义变化,但 **allowed_fails 默认值未决**。 + +**覆盖状态**:机制与半开恢复已覆盖(todo S4、D3.c、G4);marker 内容、零步骤规则、auto→端点默认键、枚举不确定继续挂(fail-safe)vs 步骤探测失败不挂(fail-open)的不对称、单次即挂 vs allowed_fails 计数的差异为**缺失/需显式决策**。 +**处置建议**:挂起解除矩阵与不对称策略写进 autogamer-queue 规格ENVIRONMENT_ERROR_MARKERS 原样沿用(G4 已定);allowed_fails 默认值需决策(建议 1,保持现状语义)。 + +### 1.3 pause/resume 语义(共三个不同的「暂停」,不可混淆) + +| 机制 | 范围 | 语义 | 证据 | +|---|---|---|---| +| 手动队列暂停 `POST /api/queue/pause`(`state.queue_manually_paused`) | 全局调度器 | pending 全部保留不派发;**运行中不受影响**;resume 解除 | task_queue_service.py:1883-1911;state.py:65-67 | +| 熔断挂起 `state.held_queues` | 设备(lock_key)维度 | 见 §1.2;`POST /api/queue/resume` **同时**解除手动暂停与全部挂起 | task_queue_service.py:1896-1911;测试 `test_manual_pause_gates_dispatch_and_resume_clears_holds` | +| 任务级暂停 `PAUSE_FILE`(`POST /api/resume` 清除) | 单 worker 内部 | worker LLM 重试层在不可恢复 LLM 失败时写 pause 文件,engine 轮询暂停;`/api/status` 以 `status:"paused"+paused_error` 暴露(剥离 `"LLM Error: "` 前缀,默认文案 "AI model request failed. The task is paused.");stop 请求顺手清文件 | state.py:232-247;artemis/services/llm.py:307,866-870;task_queue_service.py:1401-1410,1939-1944 | + +**覆盖状态**:queue pause/resume 已覆盖(映射表「/api/stop、queue pause/resume」行);但该行把它映射到 `agent.cancel + inbox splice`,与实际语义(**调度器闸门,不碰任何运行中任务**)不符——轻微**矛盾**;PAUSE_FILE 任务级暂停 + paused_error 契约完全**缺失**(worker 内 py 保留件,但其 HTTP 面与状态字段是控制台契约)。 +**处置建议**:映射表该行修正为「queue pause/resume → autogamer-queue 调度器闸门(自建,百行)」;PAUSE_FILE 暂停随 worker 一期黑盒保留,DSH 侧无对应(LLM seam 重试原生),前端删除后仅 /api 消费方可见。 + +### 1.4 /api/status 四源合并 与 /api/stream SSE + +**/api/status(routers/tasks.py:281-480)四源**: +1. `state`(queue_items/active_runs/active_connections/current_*); +2. `DeviceExecutionLock`(global owner + active_owners + 排队票据); +3. `session_repo`(latest session、running session id、DB 行 profile/LLM traces 反推); +4. `state.submission_meta`(registry 回填)。 +关键规则(AGENTS.md 已载规则此处只列名):registry 值恒优先于票据视图(created_at 是锁文件 mtime=取号时刻非提交时刻;goal 是 "frontend task: " 格式化值);队列项优先、registry 兜 worker 已取走的任务;active_tasks 字段 = device_id/session_id/raw goal/pid/ingress/acquired_at/conversation_id/created_at(tasks.py:320-339);pinned endpoint 时 model_info 报 pinned 记录而非全局当前值(tasks.py:379-384);`status` ∈ running/paused/idle,running 优先,其次 latest-session 有 IPC 连接,否则 idle(idle 也带 queue/active_tasks/model_info/ipc_port);`queue_paused`+`queue_holds[]` 每个响应都带(tasks.py:418-429)。 + +**/api/stream(SSE,routers/tasks.py:483-614)**: +- 事件名全集:`info`(订阅确认)、`session_started`、`session_ended`、`startup_progress`、`step_recorded`、`step_updated`、`trace_recorded`、`background_tasks_updated`、`recording_ready`、`recording_failed`、`queue_held`、`queue_resumed`、`queue_paused`、`server_shutdown`、`keep-alive`(data `{}`)。 +- **订阅即回放**:`all/active` 订阅时合成一条 session_started + 补发该会话全部 startup_progress + **已落库步骤逐条 step_recorded**——晚接入的客户端不空窗(tasks.py:522-561;测试 `test_stream_events_active_session_replay`)。指定 session 订阅时也补发其 startup_progress。 +- 过滤规则:session_started/session_ended/background_tasks_updated 恒全局送达;其余事件按 session_id 过滤(无 session 的事件以 active_session_id 判定)(tasks.py:492-512)。 +- 空闲 5s 发 keep-alive;shutdown_event 触发即断流;payload 经 `ipc_service.sanitize_event_data` 清洗(剥截图字节、trace payload 归一,ipc_service.py:69-134)。 + +**覆盖状态**:两endpoint的去留已覆盖(映射表「/api/status 四源合并 ✅ 删除」「ipc_service + /api/stream ✅ 删除自建」);事件名全集、订阅即回放语义、paused/idle 响应形状为**缺失**。G2 只覆盖了「步骤时间线降级」的 UI 面。 +**处置建议**:DSH `session/event` 流需验证等价物:晚订阅 catch-up(或前端接受空窗)+ 队列级生命周期事件(queue_held/paused/resumed)在 DSH 事件模型中的承载——建议作为 P1/P3 验证点写入 checklist;事件清单保留为契约测试素材(G5)。 + +### 1.5 worker 死亡检测、stdout 尾行、孤儿处理 + +- 等待退出:`wait_for_worker_process` 1.0s 轮询 + psutil 看门狗——PID 被外部 reap/zombie 时以 `-15` 兜底返回(worker_process_io.py:126-145;测试 `test_wait_for_worker_process_watchdog_handles_reaped_process`)。 +- 输出转发:增量 UTF-8 解码(防多字节截断,测试 `test_forward_worker_output_preserves_split_utf8`)、4096B 块、tee 到 stdout.log、排空 2.0s 超时防句柄拖死队列(worker_process_io.py:54-123)。 +- stdout 错误尾行 `_worker_error_tail`(task_queue_service.py:830-857):读 stdout.log **末 4096 字节**、取最后一条非空行、截 300 字符、剥 traceback 首行模块路径前缀(`pkg.module ExcType: msg` → `ExcType: msg`,仅当点分段全是标识符)。测试 `test_worker_error_tail_reads_last_stdout_line`。 +- 录屏兜底 `_recover_or_fail_recording`(task_queue_service.py:859-922):worker 死前未 finalize 的原始 scrcpy 文件 → remux 发布 → 广播 `recording_ready`;无文件可救 → `mark_recording_failed_if_pending` + `recording_failed`。启动时另有全量扫描(`recover_orphaned_recordings_on_launch`,跳过仍 running 会话的行,session_repository.py:157-189;测试 `test_startup_sweep_publishes_orphaned_recordings`)。 +- 孤儿 session 对账:启动时 `cleanup_orphans_on_startup`(status=running 且 **PID 确认死亡**才标 failed;liveness 不确定=存活,绝不误收杀,session_repository.py:424-473;测试 `test_startup_cleanup_keeps_live_cross_process_session`);`/api/sessions` 列表时对 running 行再校验(非 active 且 worker 死 → failed + auto-harvest + trace 标 failed,routers/sessions.py:111-181)。repo 模块加载时把 reconcile 注册进 server_lifecycle(**停止跨进程 worker 的钩子**,session_repository.py:600-606)。 +- 优雅停止:不发信号,写 **cancel marker 文件**(带 PID + process_created_at 防 PID 复用误伤,psutil 取创建时刻),worker 轮询后走正常取消路径(停录屏 remux、编译 trace、释放租约);宽限 `ARTEMIS_CANCEL_GRACE_SECONDS`(默认 **45s**,0=立即杀);宽限期到由 deadline enforcer 硬杀(0.5s 轮询),杀后清 marker(task_queue_service.py:73-242;测试 `test_stop_requests_graceful_cancel_and_skips_kill_when_worker_exits`、`test_stop_kills_worker_that_ignores_the_cancel_request`、`test_stop_tasks_does_not_kill_stale_reused_pid`、`test_zero_grace_keeps_the_immediate_kill`)。 + +**覆盖状态**:孤儿对账已覆盖(S7、D5、映射表 cancel_requests/trace_store 行);「PID 复用前科」已提及(todo 背景段)。**缺失**:cancel-marker 优雅停止整套语义(宽限、created_at 防 PID 复用、取消路径收尾录屏/trace/租约)、stdout 尾行提取规则、`-15` 看门狗、/api/sessions 列表期 auto-harvest。 +**处置建议**:优雅停止是 DSH `JobRegistry.kill`(即时杀)**没有**的语义——若一期直接换 kill,取消中的任务将丢录屏 remux 与 trace 编译(只能靠启动对账兜底)。需决策:① DSH kill 前置钩子/先发 cancel marker 再延时 kill(autogamer-queue 内自建,推荐);② 或明确接受硬杀语义并写进 G6。尾行提取与 auto-harvest 规则随 never-started 补插一起保留在 queue 侧。 + +### 1.6 replay_manager 能力清单(2276 行,删除前资产清点) + +todo 映射表已定「回放 → DSH trajectory replay(session log fork/replay)原生承接大头;录屏产物经 spillPath → ✅ 一期后删除」。此处只补 todo 未写的**能力边界**,供二期评估 DSH trajectory replay 是否真覆盖: + +- **回放对象**:单步骤沙箱重执行,仅两个注册工具 `ask_explorer` / `ask_image_processor`(REPLAY_TOOLS_CONFIG,replay_manager.py:46-79)——拦截 Gemini 调用(wrapped_generate_content)捕获思考/原始输出,物理设备实时交互(adbutils + UIAutomator)。 +- **状态重建**:把 session traces 切块到 `traces/replay/data/_chunked/step_NN/`(`.chunked` 哨兵文件标记成功;DB 步数与块数不一致自动重切,replay_manager.py:1251-1290);沙箱临时 SQLite(setup_temporary_database)+ 步前状态预填充 + 主库截图/图片引用。 +- **产物**:沙箱输出在 `traces/replay/outputs/_step_/temp_traces/data_engine.db`(`/api/traces/{id}` 会优先查这个沙箱库,routers/steps.py:56-66);回放 trace 树经 `/api/sessions/{sid}/steps/{n}/replay_traces` 读取。 +- **与录屏的关系**:`resolve_master_video_path` 从 DB recorded path/文件索引定位主录像;`calculate_virtual_start_time` + `_preemptive_clip_video` 预切该步对应的视频片段(回放 UI 与原步骤视频对齐)。 +- **启动钩子**:每次启动把残留 replay outputs 归档到 `traces/data/older/_`(`archive_older_replays_on_launch`);对 **DB 中全部 session** 校验/补切块(`verify_chunks_exist_on_launch`——历史大会拖慢启动)。 +- 测试基线:test_replay_state.py(沙箱状态构建)。 + +**处置建议**:删除按映射表执行;但「replay outputs 归档/切块校验启动钩子」与「沙箱 DB 优先的 trace 查询」随控制台删除,无需迁移。若二期要保步骤级工具重放(explorer/image_processor),能力清单此节是唯一记录,建议移入项目记忆。 + +### 1.7 端口、静态资源、鉴权与进程生命周期 + +- 绑定:默认 `127.0.0.1:8000`;env `ARTEMIS_SERVER_HOST` / `ANTIGRAVITY_SIDECAR_WEB_PORT`(server.py:478-496)。远端访问约定走隧道 + `ARTEMIS_ALLOWED_HOSTS`。 +- **SameOriginBoundaryMiddleware**(core/security.py:100-178):无账号模型——能连上 TCP 端口即操作者;浏览器向量两关闭:Host 校验防 DNS rebinding(IP 字面量恒可信、localhost + ARTEMIS_ALLOWED_HOSTS 白名单)、Origin==Host(或白名单)防 CSRF;**无 Origin 头的非浏览器客户端直通**;`ARTEMIS_DISABLE_ORIGIN_GUARD=1` 关闭;附加安全头(nosniff/no-referrer/DENY frame/COOP)+ `/api/*` no-store;websocket 同样过检(纯 ASGI)。403 JSON 拒绝。 +- **管理面鉴权**(routers/system.py):`/api/system/restart` 仅 loopback(延迟 0.6s,POSIX execv / Windows Popen+`os._exit`);`/api/system/shutdown` loopback + `x-artemis-lifecycle-token` 头(启动时 `secrets.token_urlsafe(32)`,写进 server info 文件供本地 CLI 读取,server.py:112-113);ADB 端点变更 loopback(`ARTEMIS_ALLOW_REMOTE_ADB_CONFIGURATION=1` 可放开)+ Origin==Host。测试 `test_restart_is_loopback_only`、`test_adb_endpoint_mutation_*`。 +- 生命周期:`write_server_info`(pid/port/lifecycle_token)→ adb 预热(防首秒提交撞 adb 冷启动,server.py:137-147)→ 清陈旧锁 → 后台:归档 replay、校验切块、孤儿对账、孤儿录屏恢复 → IPC server(127.0.0.1:0 临时端口 + 端口文件)→ queue worker。关闭顺序(server.py:164-216):置 shutting_down + 广播 server_shutdown → 取 worker task(5s)→ 取消 run coroutines 让 finalizer 跑完(5s)→ 杀残余进程 → 取消队列票据 → 清锁 → **仅对 UI 拥有的 running 队列项标 cancelled**(跨进程 worker 不动,测试 `test_shutdown_marks_only_ui_owned_running_sessions`)→ 停 IPC → 停 awake 服务 → 清 server info。 +- **Windows SIGINT 保护**:任务活跃时忽略 Ctrl+C(防误杀),Ctrl+Break 强制(ArtemisUvicornServer.handle_exit,server.py:421-444;测试 `test_windows_sigint_is_ignored_while_task_is_active`)。 +- uvicorn `timeout_graceful_shutdown=5`。 +- SPA 服务(server.py:242-382):dist 查找顺序 `showcase_ui_v2/dist/browser` → `dist/` → wheel 内捆绑(测试 test_showcase_dist);路径解析防穿越(`_resolve_static_file`);拦截排除前缀 `api/ images/ videos/ local_file docs openapi.json redoc`;`/admin`、`/debug` 走旧控制台 index。 +- 媒体端点安全(routers/media.py + media_service):`/images/*` 仅 IMAGES_DIR 内 .jpg;`/videos/*` 限 WORKSPACE_ROOT/TRACES_PATH 根 + mp4/webm/mkv 后缀(resolve(strict) 后复查 is_relative_to);`/local_file` 仅媒体后缀白名单(jpg/jpeg/png/gif/webp/mp4/webm/mkv);session video 契约:recording/finalizing → `{status:"processing", retry_after_ms:750}`;ready → URL 带 `?v=` 版本参数 + 分段列表;failed 且无可救文件 → `{status:"failed", message}`(routers/media.py:110-176;测试 test_media_recording_lifecycle)。 + +**覆盖状态**:鉴权/设备流/查询 API 的去留已覆盖(映射表「core/security.py → DSH 认证原生 ✅ 删除/收缩」);SameOrigin 具体行为面、管理面 token/loopback 规则、SIGINT 保护、关闭对账范围、媒体 URL 版本契约、启动 adb 预热为**缺失**。 +**处置建议**:过渡期旧控制台只读共存(G7/P4)意味着这套 HTTP 面仍活着——建议 P4 把「同源边界 + lifecycle token + no-store」列为共存期不可回退项;DSH web profile 的认证模型与此不同(账号/凭据),切换时外部 SDK/MCP 调用方清单纳入 P4(G7 已含外部接入方,补充「无 Origin 直通」假设的迁移验证)。 + +### 1.8 数值参数总表(迁移时需显式决定去向的硬编码常量) + +| 常量 | 值 | 位置 | 说明 | +|---|---|---|---| +| 取消宽限 | 45.0s(env `ARTEMIS_CANCEL_GRACE_SECONDS`,0=立即杀) | task_queue_service.py:73,98-110 | 优雅停止 | +| 强杀期限轮询 | 0.5s | task_queue_service.py:216,226 | deadline enforcer | +| 调度器 tick | 0.3s(wake_event 超时) | task_queue_service.py:406 | 派发循环 | +| 挂起队列探测节流 | 5.0s/设备 | task_queue_service.py:437 | adb 枚举 | +| 提交防抖窗口 | 1.0s | task_queue_service.py:1195 | 同 goal+设备 | +| 同批 created_at 错峰 | index×1ms | task_queue_service.py:1284-1285 | 排序稳定 | +| submission_meta 容量 | 500 条 | state.py:95 | FIFO 淘汰最旧 | +| startup_progress 保留 | 16 条/会话 | state.py:275 | 按 stage upsert | +| stdout 尾行 | 末 4096B / 300 字符 | task_queue_service.py:831,851 | 错误横幅 | +| 终态 status.json 重试 | 3 次,退避 0.5/1.0s | task_queue_service.py:806-827 | MCP 对账 | +| 输出转发 | 4096B 块 / 排空 2.0s | worker_process_io.py:87,111 | | +| 退出等待看门狗 | 1.0s | worker_process_io.py:130 | psutil 兜底 | +| IPC | 127.0.0.1:0 临时端口;行上限 100MB | ipc_service.py:217 | worker→daemon | +| SSE keep-alive | 5.0s | tasks.py:577 | | +| uvicorn 优雅关闭 | 5s(worker/run 各再等 5s) | server.py:461,468,179,191 | | +| restart 延迟 / shutdown flush | 0.6s / 0.05s | system.py:1226,1269 | | +| MJPEG 采集 | 目标 0.08s(下限 0.03s)、帧>1000B、发送 0.04s | device_stream_service.py:82-88,132 | screencap 轮询 | +| 批量删除上限 | 500 | sessions.py:262 | 400 拒绝 | +| model_info 缓存 TTL | 10s | model_service.py:29 | 配置改动生效窗 | +| 视频处理轮询提示 | retry_after_ms=750 | media.py:132 | | +| ffmpeg 转换 | ≤45s,失败结果也缓存 | media_service.py:47-49 | 防重复转换 | +| 默认绑定 | 127.0.0.1:8000(`ARTEMIS_SERVER_HOST`/`ANTIGRAVITY_SIDECAR_WEB_PORT`) | server.py:483,492 | | +| 并发解析 | mode global/serial/1→1;per_device/device/parallel/0→0;`ARTEMIS_MAX_CONCURRENT_TASKS` 默认 0(=每设备) | device_lock.py:601-613 | | +| profile 反推 LLM traces | limit 3 | session_repository.py:231 | | +| 注册重放步骤 | 每次 /api/sessions 轮询全量重算 | sessions.py:71-183 | to_thread 防阻塞 | + +**覆盖状态**:全部**缺失**(todo 无任何参数表)。 +**处置建议**:随行为归属(autogamer-queue / autogamer-media / DSH job 配置)逐项分配;其中 45s 宽限、5s 探测、1s 防抖、并发解析是行为敏感项,建议成为 conformance 测试断言(G5)。 + +--- + +## 2. 外部契约清单 + +### 2.1 HTTP API(请求/响应字段;消费方:showcase_ui_v2、artemis-client SDK、mcp_server、CLI) + +| 端点 | 方法 | 请求要点 | 响应/错误契约 | 证据 | +|---|---|---|---|---| +| /api/run | POST | goal 或 goals[];profile/expected_output/enable_outputter/verification_level/explorer_mode/locked_app_package/app_path/device_serial/model_endpoint/ingress/session_id/conversation_id(task_schema.py:18-38) | `{status: started\|queued\|rejected, tasks[], enqueued_count, total_queued}`;rejected 附 error、tasks=[];400(无 goal / 未知 model_endpoint);409(设备锁定/锁状态未知) | tasks.py:69-198 | +| /api/run/defaults | GET | — | `{verification_level, explorer_mode}`(来自 artemis.jsonc) | tasks.py:201-214 | +| /api/devices | GET | — | `{devices:[…]}`;**tasks.py 与 replay.py 重复注册,先注册者(tasks)生效** | tasks.py:217-221; replay.py:33-36 | +| /api/stop | POST | query 或 JSON body:all/session_id/device_id | `{status:"stopped", session_id}` 或 `{status:"no_running_task"}` | tasks.py:224-256 | +| /api/resume | POST | — | `{status:"resumed"}` / `{status:"not_paused"}`(清 PAUSE_FILE) | tasks.py:259-264 | +| /api/queue/pause | POST | — | `{queue_paused:true}` | tasks.py:267-271 | +| /api/queue/resume | POST | — | `{queue_paused:false}`(同时清熔断挂起) | tasks.py:274-278 | +| /api/status | GET | — | running: status/paused_error/goal/pid/session_id/background_tasks/queue[]/model_info/active_tasks[]/queue_paused/queue_holds[];idle 另带 ipc_port | tasks.py:281-480 | +| /api/stream[/{sid}] | GET(SSE) | client 可选 | 事件清单见 §1.4 | tasks.py:483-614 | +| /api/stream/device-live,-state | GET | — | MJPEG multipart(boundary=frame)/ {connected,serial,live_stream_url} | stream.py:31-56 | +| /api/tasks/presets,/api/tasks/catalog | GET | category/packages/limit | 推荐任务 / 全量目录+应用注册表 | tasks.py:45-66 | +| /api/sessions 系列 | GET/POST | — | 列表(含 device_id/recording_status/video_url/model_info,轮询内 auto-harvest)、详情、usage、tree、startup_progress、checks、plan、notes、video;/api/cleanup 清空全部;单删/批删(≤500,部分失败 degraded per-item) | sessions.py 全文; media.py:110-199 | +| /api/sessions/{sid}/steps, /api/steps/{id}/traces, /api/traces/{id}[/download] | GET | session_id+step_number 可选沙箱库回退 | 步骤列表/trace 树/trace payload(unwrap 媒体引用) | steps.py 全文 | +| /api/system/* | GET/POST/DELETE | readiness、devices/select、adb restart/connect/heal-keys/server*、emulator launch/status/stop/dismiss、credentials(/test/entries)、model-config(GET/POST/DELETE)、endpoints/use、endpoints/{name}(DELETE)、server-status、restart、shutdown | 管理面鉴权见 §1.7;凭证仅回 presence/masked 预览,原文不出进程 | system.py 全文 | +| /api/replay/*, /api/sessions/{sid}/replay_steps, …/replay, …/replay_traces | GET/POST | ReplayRequest{device_id,user_submits,tool_name,replay_id} | 沙箱重放触发与 trace 树 | replay.py 全文 | + +### 2.2 worker 进程契约(env + CLI + IPC + 文件) + +| 类别 | 契约 | 证据 | +|---|---|---| +| env | `ARTEMIS_SESSION_ID`、`ARTEMIS_CONVERSATION_ID`、`ARTEMIS_SUBMITTED_AT`、`ARTEMIS_TASK_INGRESS`、`ARTEMIS_TASK_WORKER=1`、`ARTEMIS_IPC_PORT`、`ARTEMIS_MODEL_ENDPOINT`(仅 pinned)、`ARTEMIS_ADB_ENDPOINT_ID`、`ARTEMIS_DEVICE_QUEUE_TICKET`、`ADB_DEVICE_SERIAL`、endpoint env、`PYTHONPATH` 三段重组、`PYTHONUTF8=1`、`PYTHONUNBUFFERED=1` | task_queue_service.py:597-663 | +| CLI | `-m artemis.main --profile --test-name web__ --session-id --output-description --enable/-disable-outputter --verification-level --explorer-pro-mode --locked-app --app-path --device-serial`;cwd=workspace | task_queue_service.py:635-663 | +| spawn | stdout=PIPE、stderr→stdout 合并;win32 `CREATE_NEW_PROCESS_GROUP\|CREATE_NO_WINDOW` | worker_process_io.py:32-51 | +| IPC(worker→daemon,127.0.0.1:port) | 每行 JSON `{event_type, data}`;`session_started{session_id,pid,initial_goal,device_info{profile}}` 登记连接/取消会话抑制;`session_ended` 清理;`startup_progress` 落盘;其余全部转播订阅者 | ipc_service.py:136-227 | +| 文件 | `traces/data_engine.db`(sessions/steps/traces/background_tasks/video_recordings);`traces//status.json`(+lock/corrupt 隔离);`traces//stdout.log`(MCP 宣称路径);`traces/images/`;`traces/replay/data\|outputs`;`traces/data/older`;pause 文件(app_dir 或 ROOT);IPC 端口文件;server info 文件(pid/port/lifecycle_token) | paths.py:224-266; trace_store.py:196-305 | + +### 2.3 SSE 事件与 payload(要点) + +| 事件 | payload 要点 | 来源 | +|---|---|---| +| session_started | session_id/initial_goal/profile/device_serial | task_queue_service.py:568-576 | +| session_ended | session_id/status/was_stopped_manually | task_queue_service.py:935-942 | +| startup_progress | session_id/stage(queued→launching→process_ready)/message/timestamp | task_queue_service.py:271-282 | +| queue_held | device_serial/lock_key/session_id/reason/failure_class | task_queue_service.py:1136-1145 | +| queue_resumed | reason:"manual" 或 device_serial/lock_key | task_queue_service.py:458-461,1908 | +| queue_paused | reason:"manual" | task_queue_service.py:1891 | +| recording_ready/failed | session_id/video_url / error | task_queue_service.py:910-922 | +| step_recorded/step_updated | 步骤字典(剥 screenshot 字节;payload 清洗;normalize_step_actions) | ipc_service.py:94-133 | +| server_shutdown | {status:"stopping"} | server.py:168 | + +**覆盖状态**:§2 全表的「承载物去留」已由映射表覆盖;字段级契约为**缺失**。S2 只定义了 job output/exit-code 映射,未列 env 全集。 +**处置建议**:§2.2/2.3 原样作为 S2/S5 契约测试素材;§2.1 中被删除端点的外部消费方(mcp_server 读 status.json/stdout.log 对账、artemis-client 调 /api/run //api/status)迁移清单归入 P4/G7。 + +--- + +## 3. 漂移风险 Top 10(最易无声丢失,按风险降序) + +1. **worker env/CLI 全量契约**(S5 只列 5 个):漏 `ARTEMIS_SUBMITTED_AT` → 轮次排序回退到设备启动时刻、队列洗牌(AGENTS.md 教训重演);漏 `ARTEMIS_TASK_INGRESS` → mcp 通知路由失效;漏 ticket/scope env → 设备锁跨进程语义断裂。 +2. **优雅停止整套语义**(cancel marker + 45s 宽限 + created_at 防 PID 复用 + 取消路径收尾 remux/trace/租约):DSH kill 即 killed,直接替换会把「停止」降级为硬杀,录屏与 trace 靠启动对账兜底。 +3. **终态权威顺序**(worker 写的 DB status 权威 > 退出码 > cancelled 优先;success→completed 归一但 get_session_status 特意保持 raw 供对账):S2 的「退出码 0→completed / 非 0→failed」只是兜底层,若当主规则会覆盖 worker 的真实结果。 +4. **never-started failed 轮次补插**(含 stdout 尾行提取规则):丢了它,启动前死掉的轮次从时间线消失、队列与可见轮次不一致(这套行为的修复有测试锁定)。 +5. **幂等/去抖提交面**(session 短路 + 1s 防抖 + 重试跳过就绪探测 + 409/400 错误码):SDK 重试与 UI 双击的防线;webhookRuntime 接入时若不保留,会重复操作设备。 +6. **订阅即回放(catch-up)**:DSH session/event 若无历史回放,晚打开页面看不到进行中会话的任何步骤(G2 只覆盖展示降级,没覆盖 catch-up)。 +7. **熔断解除矩阵与不对称 fail 策略**(枚举不确定→继续挂 / 步骤探测失败→不挂 / auto 挂端点默认键 / 单次即挂 vs allowed_fails):G4 只定义了探测体;这些边界丢了会出现「adb 抖一下清空整队列」或「永挂」。 +8. **同源边界与管理面鉴权**(Host/Origin、ARTEMIS_ALLOWED_HOSTS、lifecycle token、loopback-only restart/shutdown、/api no-store):共存期与切换期都是攻击面;DSH 认证模型不同,等价性需验证。 +9. **关闭/启动对账范围**(只取消 UI 拥有的 running 项;孤儿判定 PID 不确定=存活;跨进程 worker 永不误收杀 + repo 注册进 server_lifecycle 的钩子):误杀别的 daemon 的 worker 是实测过的 bug 类。 +10. **MCP 对账双通道**(DB 行 + trace status.json 双写、3 次重试、stdout.log 宣称路径):mcp_server 轮询方靠它收敛 stale 状态;删 trace_store(映射表 ✅)时必须同步改 mcp_server,否则外部轮询永远看到 running。 + +--- + +## 4. 对 docs/TODO.md 的修订建议 + +**新增差距(G8+):** +- **G8 · worker 进程 env/CLI 完整契约表**:S5 的清单是子集,补 §2.2 全表(尤其 ARTEMIS_SUBMITTED_AT/TASK_INGRESS/TASK_WORKER/IPC_PORT/DEVICE_QUEUE_TICKET/ADB_ENDPOINT_ID/ADB_DEVICE_SERIAL/endpoint env/PYTHONPATH 重组/stderr 合并/Windows creationflags),并声明「py 侧零新增」的前提是这套 env 原样保留。 +- **G9 · 优雅停止宽限语义**:cancel marker(PID+created_at 防复用)→ 宽限(默认 45s,env 可调)→ 到期硬杀 → marker 清理;取消路径上 worker 收尾(录屏 remux、trace 编译、租约释放)。DSH `JobRegistry.kill` 需 pre-kill 钩子或 autogamer-queue 先发 marker 再延时 kill;否则明确接受硬杀并写明录屏兜底路径。 +- **G10 · 幂等/去抖提交语义**:session 级短路、1s 同 goal 防抖、重试跳过就绪探测、409 设备锁定 / 400 未知端点——webhookRuntime/SDK 入口需对齐,否则 SDK 重试重复操作设备。 +- **G11 · 状态词汇表映射**:现网 vocabulary(pending/running/completed/failed/cancelled + legacy success→completed 归一、get_session_status 保持 raw)↔ DSH JobStatus(running/stopping/completed/killed/failed):manual stop=cancelled ↔ killed;历史数据显示映射需显式定义。 +- **G12 · 晚订阅 catch-up**:DSH session/event 流是否支持新订阅者回放历史事件(startup_progress/已落库步骤);不支持则需 toolview/roster 侧补偿,作为 P3 验证点。 + +**映射表修正:** +- `routers/system.py` 现挂在 replay 行下(「replay_manager.py + routers/replay.py + routers/system.py(1071)」),但该文件实为 readiness/ADB 端点管理/emulator/credentials/model-config/lifecycle 五类,与回放无关——建议拆行:readiness+emulator 归 E 行(一期保留 py)、credentials/model-config/endpoints 归 F 行(DSH profiles+provider adapters)、server-status/restart/shutdown 归 launcher 行。 +- 行数修正:task_queue_service 1880→2028;system.py 1071→1280;device_stream_service 120→137(小项,避免盘点失真)。 +- 「/api/stop、queue pause/resume → agent.cancel + JobRegistry.kill + inbox splice ✅ 原生」一行:queue pause/resume 是**调度器闸门**(保留 pending、不碰运行中任务),与 agent.cancel/kill 不同物——建议改标「autogamer-queue 闸门(自建)」,且新设计里手动 resume 不再清熔断(半开自动恢复取代),需写明与现行为的差异。 + +**Checklist 增补:** +- P1 增补:熔断解除矩阵四路径 + fail-open/fail-safe 不对称 + auto 端点默认键;allowed_fails 默认值决策(建议=1 保持现行为)。 +- P1 增补:never-started failed 轮次补插 + stdout 尾行提取 + 终态双写(DB+status.json 等价物)规则随 queue 插件落地。 +- P4 增补:`/api/status`+trace_store 是 mcp_server 的对账回退路径——下线控制台前先改 mcp_server;无 Origin 直通假设(SDK/curl/CLI)在 DSH 认证下的等价性验证;共存期保留 SameOrigin+lifecycle token 行为。 +- P0 增补:S2 spike 增加「kill 时 worker 能否收到通知并收尾」验证(对应 G9)。 + +--- + +### 附:本审计依据的测试基线(tests/unit/admin_console,迁移后由 conformance 测试接棒的对应关系) + +- 队列生命周期/停止/熔断/幂等:test_task_queue_service.py(48 个用例,名称即行为基线) +- 优雅停止/录屏兜底:test_graceful_stop_and_recovery.py、test_media_recording_recovery.py、test_media_recording_lifecycle.py +- 服务边界/生命周期:test_security_boundary.py、test_server_lifecycle.py、test_server_status_endpoint.py、test_showcase_dist.py +- 就绪/设备准入:test_task_device_readiness.py、test_system_emulator_endpoints.py +- 模型 pin/配置:test_model_endpoint_pinning.py、test_model_config_endpoint.py、test_credential_entries.py、test_model_service.py +- 会话数据面:test_session_*.py、test_step_repository.py、test_session_usage_endpoint.py、test_startup_progress.py、test_replay_state.py diff --git a/docs/migration/inventory/02-runtime-persistence.md b/docs/migration/inventory/02-runtime-persistence.md new file mode 100644 index 000000000..ef239585a --- /dev/null +++ b/docs/migration/inventory/02-runtime-persistence.md @@ -0,0 +1,267 @@ +# 迁移盘点 · 核心运行时与持久化 + +> **编号警示**:本文里 `**Gn · …**` 形式的「新增差距」段落属于早期编号方案,与 `docs/TODO.md` 现行 G 编号**不是同一套**(G8/G9/G10/G11/G12 已撞车,冲突表见 [registry.md](../registry.md))。引用内容时写「`inventory/0N` 的 X 段」,不要写 `Gn`。 + +> 范围: `artemis/runtime/**`、`artemis/data_engine/**`、`artemis/interfaces/cli/**`、`artemis/sdk/**`、worker 进程 `ARTEMIS_*` env 契约、`tests/unit/data_engine` 基线; 基线: cc6b20c; 日期: 2026-10-09 +> +> 方法: 逐文件精读 + 全局 grep。每条含行为语义、证据 (file:line)、TODO.md 覆盖状态、处置建议。TODO.md 已覆盖的只标号不展开。 + +--- + +## 1. 行为与不变量 + +### 1.1 device_lock.py —— 跨进程设备互斥与 FIFO 队列 + +TODO.md 定位: 映射表「device_lock.py 946 行 → 跨进程场景消失, 收缩为进程内闸门 + 熔断 (D2/D3)」——**方向已覆盖**, 但以下语义细节是自建盘点未记录的迁移输入 (尤其双跑期与孤儿对账): + +**锁文件格式** (`DeviceLockOwner`): JSON `{pid, process_created_at, token(uuid4), device_id, description, acquired_at(ISO-UTC), session_id?, ingress?, lock_scope?}`, 写在 `/device-locks/artemis-device-.lock`, 创建用 `O_CREAT|O_EXCL` 原子占位 (device_lock.py:45-69, 141, 551-581)。 + +**FIFO 队列 ticket**: 全局共享目录 `/device-locks/artemis-global-device.queue/`, ticket 文件名 `{time_ns:020d}-{token}.wait`, **文件名前缀即排队序**。Windows 粗时钟 (~1ms) 会给连续预约相同前缀 → 进程内 `_reserve_lock` 串行化 + 前缀 bump 循环 (最多 64 次) 保证前缀唯一且严格递增; 同进程跨线程靠类级 threading.Lock (device_lock.py:88-95, 409-427)。**DSH 侧 Promise 链 FIFO (S3) 取代此机制, 但「Windows 时钟精度导致排序退化」是已修过的历史 bug, Promise 链天然免疫, 无需搬运**。 + +**pending/any parking 语义** (G3 相关, 但 parking 本身 todo 没写): `reserve()` 默认 `device_id="pending"` (提交占位); `_build_device_queue` 把未认领的 pending/any ticket **park 到恰好一个空闲设备** (按 lock-id 排序取候选), 而不是计入每台设备的队列——历史版本单个 pending 队头会卡住所有设备, parking 是修这个 bug 的不变量 (device_lock.py:296-343, 368-385)。worker 领到具体 serial 后 `transfer_reservation` 把 ticket 原地改写 (保留原始 mtime → 时间序不丢) 并带上 worker 的 pid/process_created_at (device_lock.py:445-486)。 + +**PID 复用防护 (核心不变量, 全仓库 5 处共用同一协议)**: 存活判定 = `(pid, process_created_at)` 二元组, `psutil.create_time()` 差 ≥1s 即判定 PID 已被回收 → 死; 无 created_at 的 legacy 记录降级为「进程名/cmdline 含 python/artemis/pytest 才信」; AccessDenied/无法判定 → **默认存活** (宁可漏清理不可错杀) (process_probe.py:15-98; device_lock.py:203-222)。同一协议还出现在 trace_store._lock_is_stale (trace_store.py:121-142)、awake_lease (awake_lease.py:79-86)、cancel_requests PID 标记 (cancel_requests.py:126-166)、ProcessSupervisor.terminate_tree_verified (supervisor.py:68-92, 销毁前校验 create_time 防 PID 复用误杀)。**S7 孤儿对账要 kill 孤儿 worker, 必须用此协议而非裸 pid**。 + +**锁 scope (多 ADB server)**: `ARTEMIS_ADB_ENDPOINT_ID` env = `tcp::` 小写 identity (adb_endpoint.py:36, 82-85, 104-113); 有 scope 时 lock id = `__` (device_lock.py:108-116)。同一台手机接在两个 ADB server 上是两把独立锁; ticket 匹配要求 scope 兼容 (device_lock.py:359-363)。S3「lockKey = endpoint+serial」已覆盖结论, 但 **env 注入链没写**: 队列 worker spawn 时 `target.endpoint.apply_to_environment(env)` + `env[LOCK_SCOPE_ENV] = target.lock_scope` (task_queue_service.py:629-630)——DSH bash job 跑 py worker 时若不注入, py worker 的锁会落到无 scope 的默认域, 与过渡期 py 线互不相认。 + +**ticket view 字段语义 (AGENTS.md 已写结论, 这里补证据与出处)**: `get_queued_tasks()` 返回的 `created_at`/`start_time` = **ticket 文件 mtime** (即 reserve 时刻/认领时刻, 不是提交时刻) (device_lock.py:869-889); `description` 由队列在 reserve/transfer 时写成 `f"{ingress} task: {goal[:120]}"` (task_queue_service.py:704, 1258)——「frontend task: 」前缀由此而来。真正的提交时刻在 `state.submission_meta` 注册表 + `ARTEMIS_SUBMITTED_AT` env (见 1.2/2.1)。**S3 的 `updateProgress("queued: waiting for device X")` 排队展示若要等价, 时间戳须来自 DSH Inbox/JobSpec, 不得复刻 mtime**。 + +**可重入**: 同 PID 且同 session_id (或同 token) 视为同一逻辑持有者——可越过 FIFO 直接再拿锁、可代替 release (device_lock.py:562-575, 671-684, 725-741)。**同 session 但不同 PID 不算重入** (pid 必须相等)。 + +**陈旧回收**: 损坏/不可读锁文件有 5s 宽限 (`_MALFORMED_LOCK_GRACE_SECONDS`); `cleanup_stale_locks()` 清死主锁 + 死主 ticket, stop_server 与队列 worker 启动时都调用 (device_lock.py:84, 232-244, 528-549, 981-1028; server_lifecycle.py:346, 375, 421; task_queue_service.py:391)。 + +**并发模式**: `ARTEMIS_CONCURRENCY_MODE` (global/serial/1→1; per_device/device/parallel/0→0) 优先于 `ARTEMIS_MAX_CONCURRENT_TASKS` (int; 0=按设备, 1=全局严格串行, >1=全局并发上限), mode=1 时重入父进程豁免 FIFO (device_lock.py:583-615)。队列服务共享同一解析 (task_queue_service.py:374)。**D3-c 熔断自建, 但「全局并发上限 N」这个第 3 种模式 todo 没提——DSH 侧若不承接, 配了该 env 的用户语义丢失 (需决策)**。 + +** acquire 循环**: 默认 poll 0.1s, 支持 `cancel_event` 中断等待 (抛 DeviceBusyError), 非阻塞模式抛带当前 owner 描述的 DeviceBusyError (device_lock.py:617-723)。S3 的 `gate.acquire(lockKey, signal)` + kill 中断已对应。 + +**覆盖状态小结**: 已覆盖 = 收缩方向 (D2/D3/映射表)。缺失 = parking 不变量、PID 复用协议、scope env 注入链、ticket mtime 语义佐证、全局并发上限 N 模式、可重入精确条件。 + +### 1.2 data_engine —— SQLite 持久化与会话/步骤生命周期 + +TODO.md 定位: 映射表「data_engine + repositories + SQLite → session log, ✅ 删除自建」+ S5「终态留痕: DB/trace 仍由 DataEngine 写入 (一期)」——**去向已覆盖**, 但以下契约是一期黑盒 worker 继续依赖、且旧控制台只读共存期 (G7) 仍然生效的: + +**DB 完整 schema** (SQLite, WAL, 连接 timeout 30s, 进程内 RLock 串行写; storage.py:70, 87-99, 103-104): + +| 表 | 列 (语义) | 证据 | +|---|---|---| +| sessions | session_id PK; initial_goal; start_time(引擎拿到设备轮的时刻); end_time; status(**canonical: completed/failed/cancelled; "success" 是 legacy 别名, end_session 强制归一为 completed**, engine.py:675-677); device_info(JSON); video_filepath; pid(worker 进程); error_message; model_endpoint(提交时经 `ARTEMIS_MODEL_ENDPOINT` 固定); conversation_id(聊天线程); **submitted_at(入队墙钟, 由 env 注入, 聊天轮次排序锚)** | storage.py:105-135, 293-315; models.py:27-45; engine.py:617-634 | +| images | image_name PK = **截图 SHA-256**; timestamp; ocr_result JSON; ui_tree JSON; extra_metadata JSON; 内容寻址, 文件在 `traces/images/.jpg`, INSERT OR IGNORE + COALESCE 补写 OCR/UI tree | storage.py:137-145, 496-544; engine.py:837-877 | +| steps | step_id PK; session_id FK; step_number(**1-based 单调, 恢复会话时从已有 steps 重建计数** engine.py:645-660, 897-908); timestamp; pre/post_image_name; summary; action_taken JSON; operator_raw/native_thinking; last_execution_result JSON; extra_metadata JSON (含 foreground_app、pre/post_image_dhash、width/height、summary_status/version/source/model) | storage.py:147-165, 611-638; engine.py:879-978 | +| traces | trace_id PK; session_id; step_id; parent_trace_id(树); type("agent"/"tool"/"log"/"llm_call"); name; timestamp(**同 tick 冲突用 +N×1e-7 微移保证顺序** engine.py:1052-1054); duration; status(success/failed/running); payload JSON | storage.py:186-202; engine.py:1035-1085 | +| failed_outputs | 自增 id; session_id; trace_id; model_name; prompt; raw_output; error_message; timestamp —— 「crime scene」 LLM 坏输出留痕 | storage.py:204-217, 1054-1074 | +| background_tasks | task_id PK; session_id; summary; status(running/completed/failed); start/end_time; trace_id; logs(终态时从内存累积日志落库) | storage.py:219-231, 1076-1145; engine.py:1591-1632 | +| history_chunks | chunk_id PK; session_id; start/end_step_id; start/end_step_number(**身份 = step 区间**); source_step_ids JSON; subgoal_hash; version(**append-only, 同区间新版本号更大, 读侧 latest-wins**); status(pending/ready); band1 JSON/band2/band3/rendered_text | storage.py:232-251, 736-827; models.py:73-97 | +| video_recordings | video_id PK; session_id; device_id; start/end_time; local_video_path; status(**默认 recording, 迁移时对已有行按 end_time 回填 ready**); error | storage.py:252-277, 342-395 | +| (外部表) video_analysis_observations/segments | 不由 storage.py 创建 (video memory 侧建), 但 clear/delete 按 board_key=`session:`/`video:` 清理 —— **删自建时别忘了这两张表的存在** | storage.py:1179-1192, 1255-1268 | + +**列自动迁移机制**: `_init_db()` 在**每次非只读 StorageManager 构造**时执行——`CREATE TABLE IF NOT EXISTS` + 一串 try/`ALTER TABLE ADD COLUMN`(吞 OperationalError); video_recordings 用 PRAGMA table_info 检查列再补 (storage.py:101-291)。**没有版本表、没有 down migration; schema 演进 = 启动时幂等补列**。G7「旧 DB 不迁移」成立, 但一期 worker 每次启动仍会对旧 DB 跑这套幂等迁移——**双跑期同一 DB 文件被 py 线持续演进, TS 侧任何「建表/校验 schema」的启动逻辑都必须容忍多出的列**。 + +**submitted_at vs start_time**: start_time = DataEngine.start_session 时刻 = 拿到设备轮的时刻; submitted_at = 入队墙钟, 由队列 worker 经 `ARTEMIS_SUBMITTED_AT` 注入, `_parse_submitted_at` 只接受 >0 的 float (engine.py:60-68, 629-633)。聊天轮次排序用 submitted_at (models.py:41-45 注释; AGENTS.md)。**会话延续取「最近完成轮」时却按 start_time DESC** (storage.py:455-471)——两个锚并存, 语义不同, 迁移 session log 时要各归其位。 + +**session 状态机**: create (INSERT OR REPLACE, status=running) → running → end_session(status∈completed/failed/cancelled, end_time=now); **end_session 幂等**: 已有 end_time 且 status∉(running,paused) 时跳过重复发布 (engine.py:687-692); **update_session 只更新 end_time/status/device_info/video_filepath/error_message** —— conversation_id/submitted_at/pid/model_endpoint 是 create 期一次性写, 之后不可变 (storage.py:322-340)。失败落库路径 = `_finalize_tracing` 把 task.status 映射为 session_status (failed/cancelled/completed) 写 end_session (third_party/mobile_use/sdk/agent.py:886-896); **worker 死亡(无机会 end_session)由孤儿对账补**: status=running 且 pid 已死 → 标 failed + end_time, 同步 status.json (session_repository.py:424-469; server_lifecycle.py:166-183 反转依赖注册)。 + +**step summary 版本化写** (并发收敛不变量): summary_status/version/source/model 写进 extra_metadata; 显式 version 低于存量 → 丢弃; version=None 自增; **status 降级保护**: pending 不覆盖 ready/failed, failed 不覆盖 ready (除非显式更新版本); summary 列只被 ready 写触碰 (storage.py:656-734; 测试 test_step_summary_versioning.py)。 + +**trace upsert 不变量**: ON CONFLICT(trace_id) — **running 写永不覆盖已 terminal (success/failed) 的 status/payload; timestamp 取 MIN(新旧); duration COALESCE** (storage.py:1017-1052) —— 乱序/重复上报收敛。 + +**截图去重**: pre==post 同 hash → post_image_name 置 None (engine.py:924-925; storage.py:869-876)。 + +**会话延续上下文 (跨提交记忆, todo 未记录)**: 同 conversation_id 的下一提交, worker 启动时读 `traces/<最近完成session_id>/notes/*.md` (文件名排序, 总量 ≤8000 chars) 拼成 header+sections 注入 planner 初始 State (storage.py:410-453; sdk/agent.py:357-374)。**这是「一个会话 = 多轮连续对话」的 py 侧实现本体; DSH 侧 agent 会话原生带记忆, 但一期黑盒 worker 仍靠它——G7 旧数据不迁移会导致切换瞬间会话记忆断档 (需在 P4 双跑清单里点名)**。 + +**写路径异步性**: 所有 SQLite/文件写经 `_run_in_background` (有 loop 走 asyncio.to_thread, 无 loop 起守护线程), `shutdown()` 等待全部落盘后才退出 (engine.py:1110-1141, 1238-1260)。**DSH job 的退出码 0 语义 = 「DataEngine 已落盘」, 依赖 py worker 正常走完 shutdown**。 + +**IPC 事件桥 (worker→server)**: DataEngine 把事件 (session_started/step_recorded/trace_recorded/step_updated/llm_stream/recording_ready/...) 以 NDJSON 发到 `127.0.0.1:`; 端口双源 = 继承 env + 磁盘 port file (server 重启后 env 永久过期, file 是新鲜源); 连接失败指数退避 (0.5s→30s 封顶), 0.2s 连接超时防遥测拖死 worker; socket 按锁串行防 Windows 帧交错 (engine.py:445-524)。**映射表「ipc_service+/api/stream SSE ✅ 删除自建」指的是 server 侧; worker→server 这一跳的 env/port-file 协议属于一期黑盒保留面, todo 未点名 (缺失)**。 + +**只读离线打开**: `StorageManager(read_only=True)` 用 URI mode=ro, **绝不建目录/建表/WAL 文件** (MCP trace inspector 用) (storage.py:73-78, 86-99)。DB 是外部消费面的一部分 (见 §2.3)。 + +### 1.3 daemon / server lifecycle + +TODO.md 定位: 映射表「runtime daemon/server lifecycle → DSH launcher/profiles, ✅ 大部分删除」——**去向已覆盖**, 保留细节: + +- **server_info 元数据文件**: `{pid, port, host, started_at, cwd, sys_executable, cmdline, lifecycle_token}`, 路径 = app_dir 或 ROOT_DIR 的 `server-info.json` (server_lifecycle.py:55-80; config/paths.py:162-166)。clear 支持按 port/token 定向删, 防误删别的实例 (server_lifecycle.py:100-124)。 +- **优雅停机 = 应用层请求, 不是 OS 信号**: POST `/api/system/shutdown` + `X-Artemis-Lifecycle-Token` 头 (token 来自 server_info; 无 token/端口不符 → 回退杀进程)。原因注释: Windows 上 taskkill /F 与 os.kill(SIGTERM) 都绕过 FastAPI shutdown hook, 而 hook 要负责取消任务 worker、落终态、释放锁/IPC (server_lifecycle.py:127-156, 357-366)。**S7/`JobRegistry.kill` 语义对照: DSH kill 是硬杀, 等价于 force 路径 → 优雅清理依赖 py worker 自己的 cancel marker 路径 (1.5) 而非信号**。 +- **PID 发现 4 层**: server_info → lsof → fuser → netstat(win, errors="replace" 防 GBK 解码炸) → psutil net_connections → 进程表 cmdline 扫 `apps.admin_console.server`/`artemis ui` (server_lifecycle.py:191-300)。stop 后 `cleanup_stale_locks()` + 孤儿会话对账 + 定向 clear info (server_lifecycle.py:420-433)。 +- **`artemis ui --restart`**: 先 stop_server(12s) 再起; **端口被占且非 --restart**: TTY 时给交互三选 (打开浏览器/重启/取消), 非 TTY 提示用 restart 并退出——**「server 永不自动开浏览器」(--open 默认 False, 端点 launcher 显式传) AGENTS.md 已写, 交互分支 todo 没有** (interfaces/cli/commands/ui.py:316-354)。 +- **daemon 拉起**: `spawn_daemon` 一律 `sys.executable -m apps.admin_console.server`, **绝不走 console-script shim**——Windows 上常驻 `artemis.exe` shim 锁自身文件, 阻塞 `uv sync` (os error 32) (daemon_client.py:98-165; ui.py:37-61)。日志落 `get_app_dir()/logs/daemon-.log` (daemon_client.py:91-95)。`artemis restart` 默认 daemon 模式, 等就绪最多 60s, 父进程退出再给 5s 宽限 (commands/server_lifecycle.py:98-192)。`--reload` 强制前台。 +- **`ARTEMIS_STANDALONE=1` / `--standalone`**: 跳过 daemon 自动拉起, 进程内直跑 (daemon_client.py:39-41; run.py:291-308)。 +- **worker 识别**: `ARTEMIS_TASK_WORKER=1` 或存在 `ARTEMIS_DEVICE_QUEUE_TICKET` → `artemis run` 不再转投 daemon, 直接本地执行 (run.py:304-311)。**DSH bash job spawn py worker 若两个都不设, worker 会反向 POST /api/run 造成自提交循环 (缺失, 必须写入 S5)**。 +- **`artemis run` 非 worker 默认行为**: ensure_daemon_running(自动 spawn) → POST /api/run → 轮询 wait_for_daemon_task(1800s) → 终态; 入队失败降级本地执行 (run.py:311-401)。**这是双跑灰度开关 (G7) 的现有挂点: 全走 DSH 后此路径整体退役**。 +- **`ARTEMIS_MOCK_DRIVER=1` 作用点**: `drivers/factory.py:52` 与 `controllers/unified_controller.py:100,408,528` —— worker 进程内驱动工厂层, **不是 server 层**; DSH bash job 环境里要透传才能跑离线 mock。 + +### 1.4 trace_store —— status.json 与 trace 目录 + +TODO.md 定位: 映射表 C「trace_store ✅ 删除, session log 原生」——**去向已覆盖**, 但删除前的消费耦合没记录: + +- **status.json 字段**: `{trace_id, task_desc, model, conversation_id, status(running→completed/failed/cancelled; success 归一为 completed), device_serial, start_time, end_time, error, result, pid?, hierarchy_backend?...}` (trace_store.py:221-246, 285-326, 346-357)。 +- **原子写**: temp + fsync + `os.replace`(带 5 次重试抗 Windows sharing violation); 读三态: 缺文件→静默 None; 坏 JSON→WARNING 并隔离为 `status.json.corrupt`; 其它 IO→WARNING None (trace_store.py:74-118, 249-277)。 +- **跨进程读-改-写锁**: `.lock` O_EXCL + 内容 `{pid, process_created_at}`; **年轻锁 (<0.5s) 不探测** (Windows 上打开读句柄会阻塞 owner 的 unlink — 无 FILE_SHARE_DELETE); 5s 超时后**放行无互斥继续写** (last-writer-wins 优于死锁) (trace_store.py:50-62, 121-193)。此为独立的第二套锁协议, 与 device_lock 语义不同 (timeout 后降级 vs DeviceBusyError)。 +- **消费方**: 队列服务 (终态回写防 MCP poller 永远看到 running; task_queue_service.py:803-812, 1438-1441, 1776-1778)、孤儿对账 (session_repository.py:448-454)、/api/sessions 路由 (routers/sessions.py:170-171)、SDK 层 `_announce_hierarchy_backend` 也写 (sdk/agent.py:498-505)、mcp_server 轮询。 +- **目录**: `traces//status.json|stdout.log|stderr.log|notes/` (trace_store.py:196-218)。stdout.log 由队列服务为 MCP API 兜底创建 (task_queue_service.py:722-726)。 +- **trace 目录成品命名**: 临时目录 `traces/` (worker 由队列命名为 `web__`, task_queue_service.py:596), 结束时编译 (GIF/steps.json/去截图尘) 后 **rename 为 `<_PASS|_FAIL|_TESTFAIL>_`** 到输出目录; 断言失败必须是 _TESTFAIL 与 _FAIL 区分 (third_party/mobile_use/sdk/agent.py:905-942; sdk/run_outcome.py:51-58)。**回放/外部脚本按此 glob; session log 迁移后此文件产物契约消失, P4 下线旧控制台前需确认无外部消费**。 +- `clear_all_data` 额外删 `web_*` 目录、`traces/images/*`、`mcp_server.log` (storage.py:1198-1219)。 + +### 1.5 cancel_requests —— 跨进程协作取消 + +TODO.md 定位: 映射表 C「✅ 删除, JobRegistry.kill/agent.cancel 原生」——**去向已覆盖, 但一条生命周期映射矛盾必须记录**: + +- **机制**: daemon 在 `/cancel-requests/` 写 `session-.cancel` 和/或 `pid-.cancel` (JSON: requested_at/reason/session_id/pid/process_created_at); worker 侧 `watch_for_cancel_request` 后台协程按 0.5s (`ARTEMIS_CANCEL_POLL_SECONDS`) 轮询, 命中后**先删标记再**调 `on_cancel` → Agent.stop_current_task() → 与 Ctrl+C 同一条收尾路径 (录像 remux、trace 编译改名、设备锁释放) (cancel_requests.py 全文; sdk/agent.py:183-204)。 +- **PID 标记防复用**: pid 标记仅在记录的 process_created_at 与本进程一致 (±1s) 时才生效 (cancel_requests.py:145-165)。 +- **TTL 与清理**: 标记 1h 过期; 每次 request_cancel 顺带 purge; watcher 消费即删; clear_cancel_request 由消费方调 (cancel_requests.py:40, 74-86, 169-199)。 +- **软取消→硬杀宽限**: 队列服务写标记后等待 `ARTEMIS_CANCEL_GRACE_SECONDS` (0=立即杀, 恢复 legacy) 再 terminate_tree (task_queue_service.py:100-104, 165)。 +- **退出码**: 取消路径 = KeyboardInterrupt/CancelledError → `SystemExit(130)` (run.py:405-473)。**⚠ 与 S2 矛盾**: S2 写「退出码 0→completed; 非 0→failed; kill→killed (reason 透传)」。py worker 被协作取消时**自己**退出 130; DSH bash job 视角这是「非 0 退出」→ 会标 **failed**, 而语义应为 killed/cancelled; 反之 DSH kill 打断 bash job 时 py worker 收不到信号 (CREATE_NO_WINDOW), 只能靠 cancel marker 自愿退出——**即 S2 的 kill→killed 实际上绕道 cancel marker 才能优雅, 需在 S2 补: (a) exit code 130 (或专门值) 映射 killed; (b) JobRegistry.kill 对 py worker 先写 cancel marker、宽限后再杀** (缺失+矛盾)。 + +### 1.6 awake_service / awake_lease —— 屏幕常亮 + +TODO.md 定位: 映射表 C「二期 TS, autogamer-device 内」——**去向已覆盖**, 语义要点 (TS 重写时全部要继承): + +- **双策略**: 首选 USB-scoped `svc power stayon usb` + 验证 (`stay_on_while_plugged_in==2` 且 dumpsys `mStayOn=true`); 验证失败降级 **5s 主机心跳** `input keyevent KEYCODE_UNKNOWN` (awake_service.py:87-150)。策略归属 USB 电源而非进程/任务——拔线即恢复用户自身息屏行为。 +- **只碰 Artemis 认领的设备**: 无显式 target 时只对 `device_pool.get_claimed_serials()` (活跃锁+活 ticket) 中的设备施策, **不碰同一 ADB server 上无关用户设备**; 2s 监控线程自动注册新插入/停拔线的 (awake_service.py:153-186, 261-300)。`ARTEMIS_KEEP_DEVICE_AWAKE` 可关 (默认开); `ARTEMIS_CLOUD_MODE=1` 全禁。 +- **附赠消毒**: `sanitize_device_state` = pkill 孤儿 screenrecord + 广播 CLOSE_SYSTEM_DIALOGS 关 ANR 弹窗 (awake_service.py:69-84)。 +- **wake lock 租约** (awake_lease, legacy 路径的清理者): Android `cmd power set-wakelock` 是 per-display/type 单把引用计数锁, 多进程各自调用会泄漏引用; lease 文件 `/awake-leases/.leases/.lease` + `.mutex` 互斥 (10s 超时, 5s 损坏宽限); 无活租约时先 drain 旧引用 (上限 256 次防失控) 再 acquire; 最后一个退出者 drain (awake_lease.py:44-283)。 +- **Windows 细节**: mutex 释放前先读回 token 校验再删, 防 O_EXCL 竞态下删掉别人的锁 (awake_lease.py:139-147)。 + +### 1.7 helper_manager —— 无障碍 helper 生命周期 + +TODO.md 定位: 映射表 C「helper_manager.py 931 → 二期 autogamer-device; 一期保留」——**去向已覆盖**, 契约细节: + +- **四段生命周期**: Provision (持久设备态: 装 bundled APK + secure settings 启服务) / Attach (每进程×设备×会话: `adb forward --no-rebind tcp:0`→固定设备端口 18888, **tcp:0 由 adb 分配宿主端口**, 复用同 serial 已有 forward 使多进程共享隧道) / Serve (HTTP, 传输错误 reattach 一次) / Detach (只删自己 owns 的 forward; 拔线 adb 自动清, transport_id 变化 = 重建信号) (helper_manager.py:15-59, 813-829, 846-1001)。 +- **Token 安全模型**: 设备 loopback 端口对手机上所有 app 可达 → helper 除 /ping 外要求 `X-Artemis-Token`; token 是**每主机一个随机 secret**, 存 `/helper-token/session.token` (0o600, 48 hex), 同机所有 Artemis 进程共享; 经显式组件 `am broadcast` 下发 (仅 adb shell uid 能发, receiver 有 WRITE_SECURE_SETTINGS 守卫); 每次 attach 都推, helper 回 401 (重绑后丢 token) 再推 (helper_manager.py:39-47, 308-368)。 +- **协议版本握手**: ping 报 `protocol_version`; `MIN_PROTOCOL_VERSION=2`, 过旧 → 能装则 force 重装, 否则报手动命令 (helper_manager.py:93, 926-950)。 +- **UiAutomation 抑制 (关键排障知识)**: 无 FLAG_DONT_SUPPRESS 的 UiAutomation 存活期间 Android 解绑一切无障碍服务; uiautomator2 server (`com.wetest.uia2.Main`/`com.github.uiautomator`) 是自家 fallback **可以杀** (杀后 1s 内重绑), **Appium 的永不碰**只点名 (helper_manager.py:50-59, 108-113, 431-475, 888-920)。 +- **provision 互斥**: 跨进程文件 mutex `/helper-provision/.mutex`, 90s 超时/180s 陈旧回收; Windows 对删除中的锁文件可能报 PermissionError——non-nt 才 raise, nt 继续轮询 (helper_manager.py:546-588)。 +- **enable 不粘滞**: `settings put secure` 后 0.4s 复读重试 ×5 (AccessibilityManager 可能刚装完解析不到又剪掉); 彻底失败 → 自动打开无障碍设置页让人手点 (helper_manager.py:590-629, 768-787)。 +- **自动安装开关**: `ARTEMIS_HELPER_AUTO_INSTALL` (env→settings, 默认 true); false 时缺装报错并给 `artemis helper install --serial` 手动命令, 过装旧版照用 (helper_manager.py:138-162, 696-722)。 +- **revive**: 已 enabled 但哑掉 (被杀/ROM 清) → secure settings 里移除再加回促重绑; reattach 永远允许 revive, 首次 attach 仅 provision 路径允许 (helper_manager.py:631-664, 853-863)。 + +### 1.8 device_pool / adb_endpoint —— 设备发现与分配 + +- **快照缓存**: TTL 1s 合并突发枚举; **枚举失败时旧快照续命 10s** (真断连是成功列举里少了设备, 只有 server 级故障才走 stale); 冷启动 `adb devices` 可能要拉起 server, 预算 2s(热)/8s(冷) (device_pool.py:68-80)。 +- **失败≠空列表**: `try_list_devices` 用 None 区分「问不了 adb」与「没有设备」; **显式 serial 校验 fail-open**: 枚举失败/为空一律放行, 让任务下游报清晰的 no-device 错而不是误硬拒 (device_pool.py:354-398)。**admission 探测 (G7→TS) 重写时易丢这个 fail-open 语义**。 +- **auto 选择**: 显式 serial 永远原样返回 (adb 可后续连上); 否则第一台空闲 → 全忙则第一台 ready (去排队) → 退而任何 attached (device_pool.py:448-474)。对应 G3 的 auto 池语义。 +- **claimed serials**: 纯锁/ticket 元数据计算, 零 adb 流量; pending/any 占位不认领 (device_pool.py:408-430)。 +- **endpoint 不可变快照**: 用户偏好 (current_adb_endpoint) 与任务执行快照 (AdbTarget) 分离, 排队/运行中任务不随偏好漂移 (adb_endpoint.py:15-21)。 + +### 1.9 worker 端到端时序 (把上述串成一条线) + +enqueue: `reserve(description, device_id=pending, scope)` → spawn worker (env: SESSION_ID/CONVERSATION_ID/SUBMITTED_AT/TASK_INGRESS/TASK_WORKER/MODEL_ENDPOINT/IPC_PORT/LOCK_SCOPE/QUEUE_TICKET) → worker 构造 DeviceExecutionLock 时 **pop 走 ticket env (一次性)** (device_lock.py:144) → `transfer_reservation` 绑 worker pid → acquire (FIFO 等待) → 拿锁删 ticket → start_session (INSERT + annotate_active_owner 把 session_id 回写锁文件) → 跑 langgraph → end_session → `_finalize_tracing` (trace 目录 rename + video path 回写) → release 锁。取消: marker → watcher → Ctrl+C 同路径 → exit 130; 宽限后硬杀 terminate_tree。 + +--- + +## 2. 外部契约清单 + +### 2.1 ARTEMIS_* 环境变量 (worker 进程读取的全部; 全局 grep 基线) + +| 变量 | 写入方 | 读取方 | 语义 | todo 状态 | +|---|---|---|---|---| +| ARTEMIS_SESSION_ID | 队列 spawn (task_queue_service.py:610) | engine.start_session (engine.py:595-603)、DeviceLock (device_lock.py:146-148)、run.py:88、sdk/agent.py:95-98 | 会话 UUID, 贯穿 DB/锁/取消/trace | S5 已列 ✅ | +| ARTEMIS_CONVERSATION_ID | 队列 (task_queue_service.py:617) | engine.py:629、sdk/agent.py:363 | 聊天线程; 触发 notes 上下文继承 | S5 已列 ✅ | +| ARTEMIS_SUBMITTED_AT | 队列 (task_queue_service.py:623) | engine.py:60-68, 633 | 入队墙钟; **S5 没列 (缺失)**; AGENTS.md 有 | **S5 缺失** | +| ARTEMIS_TASK_INGRESS | 队列 (611, 默认 frontend) / run.py:92-93 (cli) | DeviceLock (device_lock.py:149)、engine.py:641 | 描述前缀 ` task:` 来源 | **S5 缺失** | +| ARTEMIS_TASK_WORKER | 队列 (=1, 612) | run.py:304-307 | 标记 worker 身份, 防止 `artemis run` 反向自提交 daemon | **S5 缺失 (自提交循环风险)** | +| ARTEMIS_DEVICE_QUEUE_TICKET | 队列 (633) | device_lock.py:86, 144 (**os.environ.pop, 一次性**), run.py:306 | FIFO 预约 token 交接 | S3/S5 隐含, env 名未列 | +| ARTEMIS_ADB_ENDPOINT_ID | target.endpoint.apply_to_environment (task_queue_service.py:630; adb_endpoint.py:112) | device_lock LOCK_SCOPE_ENV (device_lock.py:87) | 多 ADB server 锁 scope | S3 结论覆盖, 注入链缺失 | +| ARTEMIS_MODEL_ENDPOINT | 队列 (task_queue_service.py:626) | engine.py:626 (落库 sessions.model_endpoint) + config 层路由 | 提交时固定模型端点 | S5 已列 ✅ | +| ARTEMIS_IPC_PORT | server 进程 env → 队列透传 (task_queue_service.py:608) | engine.py:446-466 (双源: env + port file) | worker→server 事件桥 | **缺失** | +| ARTEMIS_MOCK_DRIVER | 用户/脚本 | drivers/factory.py:52; controllers/unified_controller.py:100,408,528 | 强制离线 mock 驱动 (worker 层) | AGENTS.md 有; todo 无 | +| ARTEMIS_CANCEL_POLL_SECONDS | 可选 | cancel_requests.py:230 | 取消标记轮询间隔 (默认 0.5s) | 缺失 (cancel 已整体删除, 仅双跑期相关) | +| ARTEMIS_CANCEL_GRACE_SECONDS | 可选 (task_queue_service.py:104) | 队列软取消→硬杀宽限; 0=立即杀 | | 缺失 | +| ARTEMIS_STANDALONE | 用户 | run.py:308; daemon_client.py:39 | 跳过 daemon 自动拉起 | 缺失 | +| ARTEMIS_DEVICE_ID / ADB_DEVICE_SERIAL / ADB_HOST / ADB_PORT / ADB_SERVER_SOCKET | endpoint.apply_to_environment (adb_endpoint.py:104-113) | awake_service.py:158-165, adb_command | ADB 定位三件套 + 认领设备 | S3 隐含 | +| ARTEMIS_HELPER_AUTO_INSTALL | 用户/settings | helper_manager.py:143 | helper 自动安装开关 | 缺失 (helper 整体二期) | +| ARTEMIS_KEEP_DEVICE_AWAKE | 用户 (默认 true) | awake_service.py:41 | 屏幕常亮总开关 | 缺失 (二期 TS 时要接) | +| ARTEMIS_TRACES_DIR / ARTEMIS_APP_DIR / ARTEMIS_USE_USER_DIR | 部署层 | config/paths.py:112, 70 | DB/trace/temp 根路径重定位 | 缺失 | +| 其余 (CLOUD_*, TENANT_*, FAKE_LLM, DEBUG, HIERARCHY_BACKEND, EXPLORER_*, DEFAULT_*, MCP_*, EDGE_PORT...) | 调试/云/MCP 层 | 各处 | 非队列主链路 | 随所属模块处置 | + +### 2.2 文件/目录契约 (全部在 `get_temp_dir()` 或 `traces/` 下) + +| 路径 | 格式 | 创建者→消费者 | todo | +|---|---|---|---| +| `/device-locks/artemis-device-.lock` | JSON owner payload | worker→队列/状态页/pool | 映射表覆盖方向, 格式缺失 | +| `/device-locks/artemis-global-device.queue/*.wait` | JSON ticket, 文件名=序 | 队列↔worker | S3 替代, parking 缺失 | +| `/device-locks/artemis-global-device.lock` | legacy 兼容读 | 只读兼容 | 可随删除 | +| `/cancel-requests/*.cancel` | JSON marker, TTL 1h | daemon→worker | ✅ 删除已覆盖 | +| `/awake-leases/.leases|mutex` | JSON lease | awake service | 二期 TS 覆盖, 细节缺失 | +| `/helper-provision/.mutex`、`/helper-token/session.token` | mutex / 48-hex token | helper manager | 二期 TS 覆盖, 细节缺失 | +| `/ipc.port` 等 port/address/server-info 文件 | 文本端口 / JSON | server↔worker/CLI | **缺失** (worker 双源回退依赖) | +| `traces/data_engine.db` (+WAL/SHM) | SQLite, 见 2.3 | worker 写→控制台/MCP 读 | G7 只读共存 ✅, 多列容忍缺失 | +| `traces//notes/*.md` | markdown | worker 写→下一提交读 (≤8000 chars) | **缺失 (会话记忆)** | +| `traces/images/.jpg` | JPEG | worker→回放/检索 | 随 data_engine | +| `traces/{_PASS|_FAIL|_TESTFAIL}_{ts}/` | 录像+GIF+steps.json | worker→回放/外部脚本 | **缺失 (成品命名契约)** | +| `traces//status.json(.lock/.corrupt)`、`stdout.log`、`stderr.log` | JSON/文本 | daemon+worker+MCP 多写多读 | ✅ 删除已覆盖, 消费方缺失 | +| `/logs/daemon-.log` | 文本 | spawn_daemon→排障 | 缺失 (小事) | + +### 2.3 SQLite DB 契约 + +- 全 schema 见 §1.2 表格; **迁移 = 启动幂等补列, 无版本表** (storage.py:101-291)。 +- 读侧: 旧控制台 4-step merge (G7 只读共存)、`OfflineHistoryReader`/`friendly_step`/`build_interleaved_events` 与在线引擎共用同一投影代码 (engine.py:183-401; history_reader.py) —— **「两套读路径必须渲染一致」是 tests/unit/data_engine 锁定的行为基线** (test_offline_reader_*)。 +- read_only 连接绝不产生 WAL (storage.py:73-78)。 + +### 2.4 CLI 命令面 (`artemis`, typer; interfaces/cli/main.py:41-69) + +| 命令 | 关键 flags | 说明 | todo | +|---|---|---|---| +| `ui` | `--host/-h`(127.0.0.1) `--port/-p`(8000) `--open/--no-open`(默认 **no-open**) `--reload` `--restart/-r` | 前台起 server; 端口占用交互三选; 自动重建 showcase (sources 比 dist 新时 npm install+vite build; wheel 安装永不触发 npm) | 部分缺失 (交互/构建逻辑) | +| `restart` | `--host/-H` `--port/-p` `--open`(默认 **true**) `--force/-f` `--reload` `--daemon/--foreground -d/-F`(默认 daemon) | stop→spawn_daemon→等就绪 60s; `--reload` 强制前台 | 映射表覆盖 | +| `stop` | `--port/-p` `--force/-f` | 优雅 API→进程树杀→清锁→孤儿对账 | 覆盖 | +| `status` | `--port/-p` | server_info+端口探测 | 覆盖 | +| `run` | `--profile/-p`(pro) `--locked-app/-a` `--test-name` `--traces-path` `--output-description` `--with-video-recording-tools` `--app-path` `--enable/disable-planner-validation` `--enable/disable-committee` `--enable/disable-checker` `--enable/disable-step-summarizer` `--enable/disable-outputter` `--force-output-synthesis` `--explorer-version` `--explorer-flash-mode` `--explorer-pro-mode` `--verification-level`(off/final/checkpoints/strict) `--device-serial/-s` `--session-id` `--standalone` | worker 入口 (S2 bash job 调的就是它; flag 集即 job 参数面) | S5「入口不变」✅, flag 面未列 | +| `batch` | `--file/-f` `--profile/-p` `--delay/-d`(5s) `--standalone` `--verification-level` `--explorer-pro-mode` + 位置 goals | 顺序批量, 走 daemon | 缺失 | +| `init` | (交互向导) | API key/设备配置 | 缺失 | +| `doctor` | (8 个 flag) | 就绪诊断 (core/diagnostics 前端) | E 表一期保留 ✅ | +| `mcp` | `--type/-t`(agent/adb/xml) `--transport`(stdio/sse) `--host` `--port/-p`(8001) `--install/-i` `--generate-config/-g` | MCP server + 客户端配置安装器 | ⚠️ 薄层 ✅ | +| `server web` | `--port/-p`(8080) 等 | Cloud Run 代理 | 缺失 (云端专用, 可标注弃置) | +| `trace list/view` | `--path/-p` `--limit/-l` | 离线查 trace 目录 | 缺失 | +| `helper status/install/uninstall/parity` | `--serial/-s` `--json` `--force` `--all` `--yes/-y` `--rounds` | helper 设备侧管理 | 二期 TS 覆盖 | +| 全局 | `--version/-v` | | | + +### 2.5 SDK 对外面 (`artemis.sdk`) + +- `Agent(config=|device_serial=, concurrency_mode=, max_concurrency=, session_id=)` — AgentConfig builder (builders/agent_config_builder.py 474 行); `__all__ = Agent + types + builders` (sdk/__init__.py:21-26)。 +- 会话 id 解析链: 参数 → `ARTEMIS_SESSION_ID` → `ARTEMIS_CLOUD_SESSION_ID` → task.id (sdk/agent.py:95-98, 326-338)。 +- 返回值契约: `attach_test_summary` (dict 注入 test_summary; str 包 `{result, test_summary}`; 仅当有 check 项), `resolve_trace_suffix` (_PASS/_FAIL/_TESTFAIL) (run_outcome.py:18-58)。 +- `interfaces/sdk/task.py` + `client.py` (72+19 行): 提交侧薄类型。todo 映射表「packages/artemis-client 实为 Python client → 删除」✅; **sdk/ (agent 侧) 与 interfaces/sdk 未出现在映射表——一期随 langgraph worker 黑盒保留 (D8), 二期随模式 a 消亡; 建议映射表补一行**。 + +--- + +## 3. 漂移风险 Top 10 (按风险排序) + +1. **取消语义映射矛盾 (S2)**: 协作取消 = py worker 自愿退出码 130; DSH bash job 会把它标 `failed` 而非 `killed`; 且 DSH `JobRegistry.kill` 硬杀下 py worker (CREATE_NO_WINDOW) 收不到信号, 优雅清理只能靠 cancel marker 自愿路径——kill 语义与清理路径互相依赖却互相矛盾。(§1.5, §1.9) +2. **worker 自识别 env 缺失 → 自提交循环**: DSH bash job spawn `artemis run` 不带 `ARTEMIS_TASK_WORKER=1`/ticket 时, worker 会 ensure_daemon_running + POST /api/run 反向投递到旧 server。(§1.3) +3. **submitted_at/CONVERSATION_ID 注入断档 → 聊天时间线回归**: 双跑期 DSH 侧 spawn 忘带 `ARTEMIS_SUBMITTED_AT` → DB 列 NULL → 旧控制台按 start_time 排序 → 轮次重排/ghost 会话 (AGENTS.md 记录过的 bug 类复发)。S5 env 清单不全。(§2.1) +4. **跨进程 PID-liveness 协议丢半 → 误杀/死锁回归**: pid+process_created_at(±1s)+默认存活三元组遍布锁/取消/trace 锁/supervisor; S7 孤儿对账 kill 若只看 pid, 正撞 device_lock 前科 (PID 复用)。(§1.1) +5. **ticket view 语义漂移**: 排队展示的 created_at=mtime、description=` task: `——若 DSH roster 直接用 job 入队时间戳+全量 goal, 与旧控制台/AGENTS.md 规则漂移; 反向 (submission_meta 赢) 规则要随 state.py 删除一起显式迁移。(§1.1) +6. **双跑期共享 temp 目录协议**: py 线与 TS 闸门若并存, `device-locks/` 目录是两者间的真实 IPC; TS 侧换目录名/换格式 = 互不相认 → 同设备双跑并发。(§2.2; 与 G7 灰度开关直接相关) +7. **会话延续记忆断档**: notes 继承是 py 侧「多轮对话」本体; G7 旧 DB 不迁移 + 新会话无 notes → 切换瞬间所有进行中聊天线程失忆。(§1.2) +8. **`_PASS/_FAIL/_TESTFAIL` 成品目录与回放契约**: 回放器/外部脚本/`artemis trace list` 按此命名消费; session log 迁移后若 P4 前未确认无外部消费, 静默破坏。(§1.4) +9. **auto-serial fail-open 与 claimed-only**: 设备校验 fail-open (枚举失败不硬拒) 和 awake 只碰 claimed 设备, 都是「看似可简化实则防事故」的语义; TS 重写最易被"clean up"。(§1.6, §1.8) +10. **DB 多列容忍 + 只读连接零副作用**: 双跑期 py worker 持续幂等补列; 旧 MCP 只读打开绝不建 WAL。TS 侧任何 schema 校验/连接方式不当即破坏只读共存。(§1.2) + +--- + +## 4. 对 docs/TODO.md 的修订建议 + +### 新增差距 + +| # | 差距 | 建议调整 | +|---|---|---| +| G8 | **worker 取消/退出码契约未定义** (S2 矛盾): 协作取消 exit 130 vs kill→killed; 硬杀下 py worker 无法优雅清理, 需 DSH kill 前先写 cancel marker (双跑期) 或接受强制路径 | S2 补: exit code 130→killed 映射; P1 spike 增加「kill py worker 时终态与 DB 留痕」验证 | +| G9 | **S5 env 契约清单不全**: 缺 ARTEMIS_SUBMITTED_AT/TASK_INGRESS/TASK_WORKER/DEVICE_QUEUE_TICKET/ADB_ENDPOINT_ID/IPC_PORT; 无 TASK_WORKER 时 py worker 反向自提交旧 daemon | S5 改为引用本文 §2.1 全表; P0 S2 spike 断言 env 透传集合 | +| G10 | **跨进程 PID-liveness 协议** (pid+created_at) 是孤儿对账/锁回收的共同前提, todo 只写了「启动时对账孤儿」 | S7 补: 对账 kill 用 terminate_tree_verified 等价语义; 保留 process_probe 语义为契约测试项 | +| G11 | **会话延续 notes 契约** (traces//notes, ≤8000 chars) 未记录; G7 切换期记忆断档 | P4 双跑清单补: 切换粒度按 conversation 边界 (进行中线程不切), 或 py worker 继续 写 notes | +| G12 | **双跑期共享 temp 目录协议**: device-locks/ 是 py↔TS 真实 IPC, 目录名/JSON 格式即契约 | G7 灰度语义补: 双跑期 TS 闸门与 py 锁共用目录与格式, 或以「每任务二选一线路」保证不同设备不相交 | +| G13 | **全局并发上限 N** (ARTEMIS_MAX_CONCURRENT_TASKS>1) 语义 D3 未涵盖 (只写了 per-device 与熔断) | D3 补一句: 全局上限 N 由 TS 闸门信号量承接或显式声明不支持 | +| G14 | **成品 trace 目录命名** (_PASS/_FAIL/_TESTFAIL) 与 notes 是旧控制台/回放/外部脚本的文件契约 | P4 下线清单补「外部消费确认」检查项 | + +### 映射表修正 + +- 「data_engine ✅ 删除自建」行补注: `StorageManager(read_only)` 离线读、会话 notes 延续、双跑期幂等补列在一期仍活跃。 +- 「runtime daemon/server lifecycle ✅ 大部分删除」行补注: `spawn 走 python -m 不经 console-script shim`、`优雅停机=带 token 的 /api/system/shutdown 而非信号` 两条 Windows 约束在 DSH launcher 上同样适用 (DSH job/进程管理视角)。 +- 「ipc_service ✅ 删除」行补注: worker→server 的 IPC 事件桥 (ARTEMIS_IPC_PORT + port file 双源) 属 py worker 黑盒内部, 一期不动。 +- 映射表补一行: `artemis/sdk/* + interfaces/sdk/*` (Agent/AgentConfig/run_outcome) —— 一期随 langgraph worker 黑盒保留, 二期随模式 a 退役。 +- 「cancel_requests ✅ 删除」行补注: 双跑期 py worker 的优雅取消仍依赖 cancel marker; TS 删除它之前需 G8 落地。 + +### 新增 checklist 项 + +- [ ] P1 补: pending/any **parking 回归测试** (单个未认领 ticket 不得阻塞多设备; 认领后保持原时间序) —— 即使进程内闸门, auto 池语义 (G3) 也要锁死该行为。 +- [ ] P1 补: **ticket/roster 时间戳语义**测试 (提交时刻≠拾取时刻≠设备轮时刻, 三者不得混用)。 +- [ ] P4 补: 「外部接入方迁移清单」具体化 = mcp_server (trace/status.json 轮询) + packages/artemis-client (py) + `artemis run` 非 worker 分支 (daemon 投递) + `artemis trace`/`batch` CLI。 diff --git a/docs/migration/inventory/03-device-media-diagnostics.md b/docs/migration/inventory/03-device-media-diagnostics.md new file mode 100644 index 000000000..93a8bf21c --- /dev/null +++ b/docs/migration/inventory/03-device-media-diagnostics.md @@ -0,0 +1,310 @@ +# 迁移盘点 · 设备驱动、媒体与诊断 + +> **编号警示**:本文里 `**Gn · …**` 形式的「新增差距」段落属于早期编号方案,与 `docs/TODO.md` 现行 G 编号**不是同一套**(G8/G9/G10/G11/G12 已撞车,冲突表见 [registry.md](../registry.md))。引用内容时写「`inventory/0N` 的 X 段」,不要写 `Gn`。 +> **行号警示**:本文里的 `TODO.md:NNN` 是 **2026-10-09 基线 `cc6b20c` 那一版**的行号;`TODO.md` 是活文档,行早已错位。按段名/编号定位,别照抄行号。 + +> 范围: `artemis/drivers/**`、`artemis/mcp/**`、`artemis/controllers/**`、`artemis/clients/**`、`artemis/core/diagnostics/**`、`artemis/utils/**`(概述)、`apps/admin_console/services/media_service.py` + `routers/media.py`(媒体对外面)、相关测试。基线: **cc6b20c**; 日期: **2026-10-09**。 +> 目的: 找出 docs/TODO.md 没写、但迁移中若无人记录就会丢失的行为、不变量与契约。已覆盖项只标注决策号/表格行, 不展开重复。 + +--- + +## 1. 行为与不变量 + +### 1.1 Mock driver 行为面(TS mock 插件的复刻基线) + +TODO.md 状态: 已覆盖「mock driver 转 TS 插件」(剩余自建清单 ⑤, TODO.md:153), **但无行为契约**。以下为二期复刻必须逐条对齐的最小行为面: + +| 行为 | 语义 | 证据 | +|---|---|---| +| 构造默认值 | `device_id="mock-device-001"`, `width=1080, height=2400`, `initial_package="com.android.settings"` | artemis/drivers/mock/mock_driver.py:29-35 | +| 截图 | **全尺寸**纯黑 PNG(注意: 源码注释写 "1x1 black image" 是错的, 实际 `Image.new("RGB", (width, height))`), 同时缓存 base64 | mock_driver.py:44-49 | +| 层级 XML | 固定字符串 `` | mock_driver.py:50-52 | +| 元素列表 | 单元素 `{"text": "Settings", "bounds": [0, 0, width, 100]}` | mock_driver.py:73 | +| 动作记录 | 每个动作(tap/long_press/swipe/swipe_direction/input_text/press_key/launch_app/stop_app/execute_shell)追加 dict 到 `action_history` 并恒返回 `True` —— 这是测试断言缝(tests/unit/test_drivers.py、tests/unit/mcp/test_actuators.py 依赖) | mock_driver.py:79-191 | +| execute_shell | 返回 `"mock_output: {command}"` | mock_driver.py:184-191 | +| 录屏 | start 置 `recording=True`; stop 恒返回 **`"/tmp/mock_recording.mp4"`**(POSIX 风格路径, Windows 上也是这个字面量) | mock_driver.py:193-198 | +| ScreenData.platform | `"mock"` | mock_driver.py:76; drivers/types.py:62 | +| 连接态 | `connected`/`recording` 两个裸 bool, connect/disconnect 仅翻转 | mock_driver.py:40-66 | +| **激活方式** | driver 自身不读环境变量; 由 factory 判定 `ARTEMIS_MOCK_DRIVER=1` **或** `ctx.device.mobile_platform == "mock"`(优先级在 cloud 之后) | artemis/drivers/factory.py:49-58 | +| **隐藏坑: `is_mock`** | `UnifiedMobileController` 三处用 `getattr(self._driver, "is_mock", False)` 判 mock, 但 `MockDeviceDriver` **从未定义 `is_mock`** —— 恒为 False, 实际全部走 env/platform 分支。TS 复刻时若"修复"此属性会改变行为; 若照抄会继承死代码 | artemis/controllers/unified_controller.py:98, 406, 526 vs mock_driver.py 全文 | +| MockActuator | 测试用 actuator **故意继承 AdbActuator**(同一坐标换算/控制器派发路径), 只把 driver 换成内存实现, 经 `ctx._active_driver` 缓存缝注入 | artemis/mcp/actuators/mock.py:32-58; factory.py:75-80 | + +处置建议: 契约表**写入 autogamer-device/mock TS 插件的设计文档**(二期); `is_mock` 死检查标注为"复刻时二选一: 修复并同步改 controller 判定, 或不实现"。 + +### 1.2 设备驱动能力清单(AndroidAdbDriver —— autogamer-device TS 重写的功能基线) + +TODO.md 状态: 模块级已覆盖(映射表 `drivers/` 行, TODO.md:116; P2, TODO.md:205), 能力细目**缺失**。 + +连接与构造: +- 构造参数: `device_id`、`adb_client`(adbutils `AdbClient(host, port)`)、`ui_adb_client`(可为 None)、`width=1080`、`height=2400`(artemis/drivers/android/adb_driver.py:75-88)。 +- `connect()` 仅校验 `get_state()`, 异常只 warning 不抛(adb_driver.py:106-112); `disconnect()` 停录屏 + ui client disconnect(114-119)。 + +观察: +- `get_screen_data(skip_settling)`: 非 skip 时固定 `asyncio.sleep(0.3)` 稳定窗(adb_driver.py:123-124); 截图三级降级: ① ui client `get_screen_data()`(base64+尺寸回写 `self._width/_height`) ② adbutils `device.screenshot`→PNG bytes ③ **1x1 PNG 占位 base64 硬编码常量**(adb_driver.py:174, 211)——截图全败仍返回"成功"的 ScreenData; 层级走 `get_hierarchy()`(XML→`parse_hierarchy_xml_to_elements`)或 `get_ui_elements()`; 最后经 `filter_ui_hierarchy` 清洗(adb_driver.py:203-207)。 +- 消费方(-smoke test)以 **`< 512 字节` 判定"截图是占位图"**(diagnostics/device_smoke.py:72-74)——占位契约被两处共享。 + +输入: +- `tap`: `duration_ms>=500` 降级为 `input swipe x y x y duration`(长按路径); `times>1` 用 `&&` 链 + `sleep delay_ms/1000`; 每条命令打 `[ADB] {cmd}` 日志(adb_driver.py:229-252)。 +- `long_press` = `tap(duration_ms)`(254-255)。 +- `swipe`: `input swipe x1 y1 x2 y2 duration_ms`(257-272)。 +- `swipe_direction`: 智能滚动向量 —— 横向锁 60% 宽(避边缘手势/字母快速滚动条), up=0.7→0.3 高、down 反向(800ms 防 fling, ~50-60% 重叠)、left/right=0.75→0.25 宽(274-305)。 +- `input_text`(四层, adb_driver.py:307-370): 清空 = `keyevent 123`(MOVE_END)+`--meta 1 122`(Shift+Home 全选)+`67`+**20 连发 `67`**(DEL); 追加 = 先 `123`; 文本先归一 `\r\n|\n|\r`→`\n`; Tier1 剪贴板注入(ui client `set_clipboard` + `keyevent 279` PASTE, 多行/全字符集零 IME 干扰); Tier2 检测默认 IME 含 "adbkeyboard" 则 `am broadcast -a ADB_INPUT_B64 --es msg '{b64}'`; Tier3 逐行 `input text {escaped}` + 行间 `keyevent 66`(Enter)。转义表 `_escape_for_adb_text`: 17 个字符 + 空格→`%s`(50-69)。 +- `press_key`: 8 键映射表 `ANDROID_KEYCODE_MAP` home=3/back=4/enter=66/delete=67/power=26/app_switch=187/volume_up=24/volume_down=25(38-47); 未知 key 原样透传(372-383)。 + +App 管理: +- `launch_app`: `monkey -p {pkg} -c android.intent.category.LAUNCHER 1`(385-392); 注意 actuator 层另有 `launch_app_with_retries`(智能轮询, third_party/mobile_use/utils/app_launch_utils.py, adb_server.py:330、actuators/adb.py:289-307 两条路径都用它)。 +- `stop_app`: `am force-stop {pkg}`(394-400)。 +- `get_current_package`: `device.current_app` → 失败降级 `dumpsys window displays | grep -E 'mCurrentFocus|mFocusedApp'` token 解析(402-427)。 +- `execute_shell`: 默认超时 **15s**, 失败返回 `"Error: {e}"` 字符串而非抛错(429-436)。 + +录屏(driver 侧独立路径, 与 controller 侧并存): +- `start_video_recording(output_dir)`: 默认 `get_temp_dir("recordings")`, 目标 `recording.mkv` + `recording.mp4`; `find_scrcpy()` + `build_scrcpy_record_command(..., lock_capture_orientation=False)`(**注意: controller 路径传 True, 见 §1.4**); spawn 后 `sleep 0.5`(438-460)。 +- `stop_video_recording`: `terminate()` + `wait(5s)`(Windows: **残留 scrcpy 会锁住 MKV 文件** —— 源码注释); ffmpeg `-c copy -movflags +faststart` remux, 成功即删 mkv; 全败回退返回 mkv(462-507)。 + +抽象基类契约(drivers/base.py): `ScreenData` 模型(bytes+base64+xml+elements+w/h+timestamp+platform, 52-66); **0-1000 归一化坐标** `tap_normalized`/`swipe_normalized`(clamp 到 width-1/height-1, 194-223); `find_element` 匹配语义 = resource_id 子串包含 OR text 大小写不敏感子串包含 + index, tap 点**优先从活 bounds 算中心**(注释: 预计算 center 可能落后一个布局过渡), bounds 支持三种形态(parsed_bounds dict/list/`[x,y][x,y]` 字符串正则)(225-279); `KeyCode` 枚举 8 键(41-49); 默认 `swipe duration_ms=800`、`tap duration_ms=100`、`long_press 1000`、`execute_shell 15s`(109-177)。 + +死代码(无任何消费方, 二期不迁移): `drivers/android/recorder.py` `ScreenRecorder`(adb screenrecord→/sdcard/artemis_record.mp4→pull 的旧路径)、`drivers/android/input_ime.py` `AndroidInputIME`(逻辑已被 adb_driver 吸收)、`drivers/cloud/cloud_driver.py` `CloudDeviceDriver`(全 stub, cloud 实际走 RemoteAdbClient/RemoteUIAutomatorClient, factory.py:39-47)。grep 全仓无引用。 + +处置建议: 本清单+§1.4 产物契约 = **autogamer-device 的验收基线**(二期); 死代码三个文件标「删除」。 + +### 1.3 Driver factory 与环境变量契约 + +- 优先级: cloud(`ARTEMIS_CLOUD_MODE=1` → RemoteAdbClient/RemoteUIAutomatorClient) > mock(`ARTEMIS_MOCK_DRIVER=1` 或 platform=="mock") > 默认 AndroidAdbDriver(artemis/drivers/factory.py:34-72)。 +- ADB endpoint: `settings.ADB_HOST/ADB_PORT`(默认 **127.0.0.1:5037**, artemis/config/constants.py:111-112)。 +- driver 缓存在 `ctx._active_driver`(factory.py:75-80)——也是测试注入缝。 +- 完整 driver 级 env 清单(TODO.md S5 只列了 worker 的 3 个, **这些没写**): `ARTEMIS_MOCK_DRIVER`、`ARTEMIS_CLOUD_MODE`、`ARTEMIS_HIERARCHY_BACKEND`(auto/helper/uiautomator)、`ARTEMIS_DEVICE_ID`、`ADB_DEVICE_SERIAL`、`ADB_HOST`/`ADB_PORT`、`ADB_SERVER_SOCKET`(tcp:host:port, 子进程与 py client 的真实寻址通道)、`ADB_VENDOR_KEYS`、`ARTEMIS_IPC_PORT`(mcp stdio 模式置空防 SQLite WAL 竞争, artemis/mcp/adb_server.py:84-86)、`ARTEMIS_DISABLE_ORIGIN_GUARD`(security.py:113)、`ARTEMIS_TRACES_DIR`(paths.py:112)。 + +处置建议: env 契约表并入 TODO.md S5(worker 契约)或 G7 外部接入迁移清单——**DSH 侧 spawn py worker 时必须透传**。 + +### 1.4 录屏管线(触发/分段/编解码/产物) + +TODO.md 状态: 已覆盖「录屏栈留 py(D8 例外); 录屏经 spillPath 交接; mp4 托管/路由转 autogamer-media」(映射表 media_service 行, TODO.md:117; S5, TODO.md:68)。**产物格式契约与时间线语义缺失**——没有它 autogamer-media/前端无法消费。 + +触发: worker 内 `third_party/mobile_use/sdk/agent.py:540-558`, 由 `video_recording_tools_enabled` 门控(= scrcpy 且 ffmpeg 同时存在, artemis/utils/video.py:567-569; 缺失时 prompt 不 advertise 视频工具, artemis/agents/operator/prompts.py:203-207, graph/graph.py:1013)。输出目录 = `{traces}/{trace_name}/`。 + +scrcpy 命令(artemis/utils/video.py:48-76): `--serial {id} --no-window --record {path} --record-format mkv --video-bit-rate 2M`(+锁定朝向时 `--capture-orientation=@`)。 + +首帧锚定(video.py:79-113): 等 stdout 出现 `"Recording started"` 标记(超时 **6s**), 回退 spawn+**0.75s**; `session.start_time` 被改写为首帧时刻——**录像 t=0 ≈ 首帧**, 是所有分段偏移/剪辑对齐的锚(controller 注释, unified_controller.py:461-464)。 + +分段看门狗(unified_controller.py:361-393): 每 0.5s 轮询 `dumpsys window displays`(rotation 变化, video.py:331-357)与段龄 ≥ **1800s**(`ANDROID_RECORDING_SEGMENT_SECONDS`)或进程崩溃 → 停当前段 → finalize(异步 remux)→ 起下一段 `recording_{NNN}.mkv`。**一个段永不包含多种编码尺寸**(旋转即切段, video.py:55-61)。 + +停止(unified_controller.py:519-646): 停 scrcpy(**先 `CTRL_BREAK_EVENT`(Windows)/`SIGINT` 让 recorder flush, 等 8s, 再 terminate 5s**, 69-80)→ finalize 当前段 → 等全部 remux 任务 → 应急兜底(无任何段 mp4 时整文件 remux, 输出名防覆盖守卫 `recording_converted.mp4`, 593-605)→ 写 manifest → `record_video_stop` 入 DataEngine。 + +产物命名(unified_controller.py:252-256): 第一段 `recording.mp4`, 后续 `recording_{index:03d}.mp4`; 原始 `recording.mkv`/`recording_{NNN}.mkv` remux 成功后删除。 + +**manifest v2 契约**(video.py:360-433): `recording.json`, 原子写(`recording.part.json`→replace), 字段: `{version:2, duration, session_offset_ms, session_end_ms, segments:[{file, start, duration, offset_ms, duration_ms, width, height}]}`。`start/duration` 是段间背靠背的 legacy 时间线; `offset_ms/duration_ms` 携带**会话相对时间轴**(DataEngine start anchor − 录像 anchor 的 shift, unified_controller.py:299-322)——UI 据此跨 scrcpy 重启间隙按步骤时间 seek(media_service.py:407-411 消费)。无已知 offset 的段假定无缝衔接。 + +剪辑/抽帧(video.py): `render_timeline_clip` 720x1280@15fps, 间隙补黑帧(lavfi color), **concat 后必须显式 `fps=`**(否则 ffmpeg 回落 25fps CFR 破坏 `start_time + frame_index/fps` 映射, 源码注释 516-518); remux 双级: `-c copy +genpts +faststart` 失败 → `libx264 ultrafast +discardcorrupt`; 全部经 `.part` 临时文件原子发布(211-289)。`extract_keyframes_from_video`(cv2, 1fps/最多45帧/最长边1080/JPEG q80)、`extract_frames_at_timestamps`(30帧/q85)。 + +编解码栈: ffmpeg 来自 `imageio_ffmpeg.get_ffmpeg_exe()`, 否则 PATH `ffmpeg`(video.py:124-131); ffprobe 取段元数据(134-136); cv2(opencv)用于抽帧——D8 例外确指 **imageio-ffmpeg + opencv-python** 两个包。 + +处置建议: 产物契约(manifest v2 + 文件命名 + `_PASS_/_FAIL_/_TESTFAIL_` 目录命名, 见 §1.5)+ 首帧锚定语义 → **写入 autogamer-media TS 插件 spec**; 录屏 py 栈保留(D8), 但 `write_recording_manifest`/`render_timeline_clip` 的语义文档必须随交接落地。 + +### 1.5 media_service(623 行)行为面 + +TODO.md 状态: 已覆盖拆分方向(TODO.md:117)。**逐项职责与安全契约未记录**: + +- **MKV→MP4 按需转换**(`ensure_browser_playable_video`, media_service.py:44-98): mp4 直通; mkv/webm 转 mp4(两级: 15s copy remux → 30s ultrafast 重编码, 100-149); **liveness 门**: mtime 距今 < **10s**(`LIVE_RECORDING_GRACE_SECONDS`)判定 scrcpy 仍在写, 不转(否则把几秒的残片冻结成 recording.mp4, 151-167); 陈旧 mp4(早于源 mtime)判为中跑扫描残片, 重转(65-78)。转换结果缓存 `_playable_cache`(含**失败缓存**), 防 `/api/sessions` 轮询每跳 45s ffmpeg(37-41 注释)。 +- **孤儿恢复**(`recover_orphaned_recording`, 205-258): 硬杀 worker 留下 `recording.mkv`(可播但未 remux)+ 可能的中跑残片 mp4; 恢复 = remux 覆盖残片→删 raw→**把临时 trace 目录改名成 worker 会用的终态名**(无状态标记时 `{name}_FAIL_{yyyy-mm-ddTHH-MM-S}`, 179-203)。调用方两处: ① 任务结束时 task_queue_service.py:883; ② **服务启动时** `recover_orphaned_recordings_on_launch`(task_queue_service.py:1947-1985, server.py:158 spawn)——输入是 DB `video_recordings` 中 status ∈ recording/finalizing 的行(session_repository.py:175-199), 成功后 `mark_recording_ready`。**liveness 门同样适用**(还活着就不碰, media_service.py:237-239)。 +- **视频索引与解析**(`build_video_index`/`resolve_video_url`/`resolve_video_segments`, 270-415): 扫描三个根: `TRACES_PATH`、`WORKSPACE_ROOT/artemis-traces`、`.benchmarks/diagnoser/outputs/artifacts`; 每目录找 `recording.{mp4,mkv,webm}`; 索引键含目录名 + 去状态后缀前缀(`strip_trace_status_suffix`, 标记 `_PASS_/_FAIL_/_TESTFAIL_`, 152, 174-177)。URL 解析五级链: `video_filepath` → DB rec map → 索引(目录名/前缀) → 索引(session_id) → 直扫 `TRACES/{sid}/recording.*` → goal 里 `"Task: "` 提示(312-378)。segments 解析带**路径逃逸守卫**(segment 必须与 manifest 同目录, 394-399)。 +- **trace payload 解包**(`unwrap_payload`, 417-479): 字符串疑似 JSON 则递归; `data:image/*;base64` 或 `iVBORw0KGgo`/`/9j/` 前缀 → 解码存 **`IMAGES_DIR/{sha256}.jpg`** → 返回引用 `"image://{hash}"`(= DataEngine 步骤截图的落盘协议); >2000 字符截断为首尾 100 字符预览; >20 元素且含 bounds/resource-id 的列表 → `` 占位。**这是 trace 库里图片引用格式的隐性契约**。 +- **本地文件安全**(`get_safe_local_file`, 492-527): resolve(strict) 后必须位于 `WORKSPACE_ROOT` 或 `TRACES_PATH` 之内 **且** 扩展名在媒体白名单(jpg/jpeg/png/gif/webp/mp4/webm/mkv)——注释明示"永不能递出 .env/数据库/源码"。 +- plan/notes/checks 读取(529-620): `task_plan.md`(会话级→全局回退)、notes 目录(md/txt/json/yaml)、`check_ledger.jsonl`+`check_streams.jsonl`+`run_outcome.json`。 + +处置建议: 「托管/路由转 autogamer-media」时**逐项拆**: 录屏转换/孤儿恢复留 py(强依赖 ffmpeg 栈与 worker 生命周期, D8); URL 解析/索引/unwrap 跟 DataEngine trace 语义绑定——一期随 worker 黑盒保留; `/videos`、`/images`、`/local_file` 的 HTTP 面二期由 autogamer-media 的 `ctx.connection.fetch` 承接(需实现 §2.1 白名单语义)。 + +### 1.6 媒体对外路由与鉴权 + +TODO.md 状态: 「mp4 托管/HTTP 服务无原生」已覆盖方向(TODO.md:117), **路由清单与鉴权模型未记录**。 + +| 路由 | 行为 | 证据 | +|---|---|---| +| `GET /images/{name}`、`/api/images/{name}` | 只服务 `IMAGES_DIR`(`{traces}/images`)下 `*.jpg`(自动补后缀), resolve 后校验仍在 images root 内 | apps/admin_console/routers/media.py:88-101 | +| `GET /videos/{path:path}` | mp4/webm/mkv, 路径必须 resolve(strict) 在 workspace/traces 根内+扩展白名单 | media.py:31-35, 49-71, 104-107 | +| `GET /api/sessions/{sid}/video` | 返回 `{status: processing|failed|ready|unavailable, video_url, video_segments}`; processing 携带 `retry_after_ms: 750`; ready 时 URL 追加 cache-bust `?v={end_time*1000}`; 失败但找到可播文件会回写 ready | media.py:110-176 | +| `GET /local_file?path=` | `get_safe_local_file` 守卫后的 FileResponse | media.py:179-182 | +| `GET /api/sessions/{sid}/plan|notes|checks` | plan/notes/checks 文本读取 | media.py:185-198 | +| `GET /admin`、`/debug` | legacy admin HTML | media.py:74-85 | +| SPA fallback 排除 | `api/ images/ videos/ local_file docs openapi.json redoc` 不被 SPA 捕获 | apps/admin_console/server.py:279-293 | + +鉴权: **无 token**, 全部走 `SameOriginBoundaryMiddleware`(纯 ASGI): Host 头必须在 localhost/IP/`ARTEMIS_ALLOWED_HOSTS` 白名单; 带 Origin 的请求必须同源; `ARTEMIS_DISABLE_ORIGIN_GUARD=1` 整体旁路(apps/admin_console/core/security.py:100-138)。mp4 的"鉴权"= **路径逃逸守卫 + 扩展白名单**, 不是身份验证——DSH 侧 autogamer-media 复刻时须保留同样的"只能拿到媒体文件"保证。 + +处置建议: 路由表+守卫语义并入 TODO.md media_service 行的交接说明; 二期 autogamer-media 验收含"恶意路径拿不到非媒体文件"用例。 + +### 1.7 诊断(diagnostics) + +TODO.md 状态: E 区已列模块并定向「一期保留 py doctor; 二期部分转 autogamer-queue admission 探测」(TODO.md:144-145, 180-181)。**检查项清单/阈值/机制细节缺失**。注: TODO.md:144 写 "~3000 行"、TODO.md:180 写 "~2400 行", 实测约 **4300 行**, 两处自相矛盾且均偏低(见 §4)。 + +**doctor 检查项全集**(ReadinessEngine 默认探针 + doctor 专属, artemis/core/diagnostics/engine.py:64-82, readiness.py:30-60): +1. `python_runtime`(阻断): Python 版本。 +2. `system_config`(阻断): 系统配置。 +3. `toolchain`(非阻断): adb/scrcpy/ffmpeg/emulator 解析(artemis/toolchain)。 +4. `gemini_api_key`(阻断): google/openai/anthropic/openrouter/xai 五家凭据占位符检测(credentials_probe.py:49-110)。 +5. `vision_ocr_key`: OCR 凭据。 +6. `android_adb`(阻断): 见下。 +7. `integration_host`(仅 doctor/`mobile_diagnose` 显式跑, 不进默认探针集): MCP 宿主进程环境——解释器是否项目 venv、.env 位置、traces 可写(**唯一阻断项**)、守护端口占用; 识别 9 种 MCP 客户端(claude/cursor/windsurf/vscode/cline/roo/openclaw/codex/antigravity, 按 env 前缀, host_probe.py:15-118)。 + +判定: `overall_ready` = 存在阻断项且全部 PASS(engine.py:210-212); verdict 三级 `blocked`(无阻断项或任一阻断非 PASS)/`degraded`(仅可选失败)/`ready`(readiness.py:63-75)。修复顺序 CHECK_ORDER: runtime→config→host→gemini→adb→toolchain→ocr(屏蔽语义: runtime 问题遮蔽其后, 凭据遮蔽设备, readiness.py:38-46)。 + +**android_adb 探针语义**(probes/adb_probe.py): +- 仪表盘路径: `adb devices -l` 解析 serial/state/model/product; `emulator-*`/`127.0.0.1`/`localhost` 判模拟器(514-518); 富化缓存 **60s**(`getprop ro.build.version.release`、`wm size`、`pm list packages`, 44, 534-580); AVD 清单扫 `~/.android/avd/*.ini`(104-116)。 +- **锁屏状态**(136-253): `dumpsys window policy`(`KeyguardServiceDelegate.showing` + 旧字段 mShowingLockscreen 等)⊕ `dumpsys trust`(current-user `deviceLocked`); 现代信号优先、任一阳性即锁、**阳性需二次采样确认**(防单次抖动翻 UI); 仪表盘允许 15s TTL 的旧值平滑(255-281), **提交门禁不允许**(注释明示 fail-closed, 260-265)。 +- **提交快路径**(`probe_submission_readiness`, 354-475): `adb devices -l` 限时 **1s** → 无 ready 设备 WARN(Unauthorized/No Device); 偏好 serial 不可用/锁定时**自动切换到其它已解锁 ready 设备**; 锁定检测限时 1s(确认双采样); 判定 PASS 仅当 `is_locked is False`, 未知= WARN。 +- adb 缺失 → 平台化安装动作(WinGet/Homebrew/apt + 下载链接, 594-653)。 + +**adb_keys 自愈**(adb_keys.py): 路径 = `ADB_VENDOR_KEYS`(os.pathsep 分隔取首个)否则 `~/.android/adbkey(.pub)`(56-65); 损坏判据: 私钥 0 字节 / <100 字节 / 无 PEM `BEGIN...PRIVATE KEY` 头 / 公钥 0 字节(88-150); **机制背景**(模块 docstring): 键损坏时设备静默停留 unauthorized 且**永不弹授权框**; heal = 备份 `adbkey.corrupted.{ts}` → 删键 → `kill-server/start-server/devices`(剥离 ADB_SERVER_SOCKET, 各 5s 超时)→ 复检(164-270)。doctor --fix 与 engine.restart_adb_server 共用; restart 仅限本地默认 endpoint(远程 endpoint 时 skip, engine.py:300-315)。 + +**emulator_manager**(emulator_manager.py): 管**本地 AVD 的后台启动生命周期**(非通用模拟器池)。状态机 idle→starting(15%)→waiting_for_adb(35%)→booting(65%, 渐进到 95)→ready(100%) / failed / stopped(38-63); 二进制定位 = toolchain → PATH → 8 个 SDK 候选目录(ANDROID_HOME/ANDROID_SDK_ROOT/~/Android/Sdk/~/Library/.../LOCALAPPDATA/.../usr/lib//opt/, 92-121); **总超时 180s**; 前 5s 早崩检测(日志含 lock/already running → "被另一实例锁定", panic → 原样带出, 257-281); 轮询 `adb devices -l` 抓 `emulator-*` serial → `getprop sys.boot_completed == 1` 即 ready 并把 readiness 焦点切到该 serial(404-413); 停止 = `adb -s {serial} emu kill`(5s)→ terminate(3s)→ kill(451-491); 日志环形缓冲 deque(100)。**Windows 进程隔离**: `CREATE_NEW_PROCESS_GROUP|CREATE_NO_WINDOW`(CtrlC 不殃及), POSIX `start_new_session`(77-90)。 + +**device_smoke**(device_smoke.py): 端到端"设备可见但 ARTEMIS 观察不到"烟雾测试——复刻 `mobile_get_device_state` 的观察路径(`adb_server._get_controller` → `controller.get_screen_data()`)。要点: ① 忙判定——设备被 `DeviceExecutionLock` 活 owner 持有时**跳过探测**(u2.connect 会重启对端 UIAutomator server, 打断在跑任务, docstring 40-43); ② **专属线程+独立事件循环**执行(同步 u2 跑在事件循环线程上, `asyncio.wait_for` 永远打不中, 33-39); ③ 截图 < **512 字节** = 占位图判定(72-74); ④ 修复建议映射 busy/unauthorized/offline/no device/timeout→uiautomator(强制 stop `com.github.uiautomator{,.test}`、purge+init)(67-96, 99-141); ⑤ 默认 20s; ⑥ uiautomator2 异常继承 BaseException 且常带空消息(152-158)。 + +**hierarchy_parity**(hierarchy_parity.py): helper 与 u2 真机对拍——有标签元素双向匹配(bounds IoU ≥ **0.5**), recall ≥ **0.9**、precision ≥ **0.8**; helper 不得产出负坐标/越界 bounds; **两个后端不能同时跑**(u2 的 UiAutomation 连接会让 helper 掉线), 顺序 = 先 helper 后 u2, 收尾 `stop_server=True` 还原(184-204); CLI `artemis helper parity`。 + +**adb_server_connection**(adb_server_connection.py): 进程级 ADB endpoint 管理——发现顺序: `ADB_SERVER_SOCKET`(tcp:host:port)无显式 HOST/PORT 时优先 → settings; activate = 写 settings + 进程 env; persist = `dotenv set_key` 写 **`{repo}/.env` + `{app_dir}/.env` 两处**(305-326); probe = 子进程 `adb devices -l` 8s 超时、**剥离 ADB_SERVER_SOCKET 的干净 env**(274-278); 设备解析 serial/state/model/product; 错误文案映射(cannot connect/version mismatch, 259-272); import 即 `synchronize_environment()`(330)。 + +处置建议: 一期整体保留 py(D8/一期黑盒); 二期 autogamer-queue admission(S4/G4)**必须继承**: ① 锁屏确认探测语义(fail-closed)与多设备自动切换; ② 环境级失败判定已有(ENVIRONMENT_ERROR_MARKERS, todo G4 已写); ③ adb_keys 自愈可留 doctor CLI。行为细节建议压缩成一页"admission 探测规格"附在 autogamer-queue。 + +### 1.8 mcp 动作执行层(action_server/executor/session/specs/manifest/actuators) + +TODO.md 状态: 「一期保留(worker 内), 二期 → autogamer-device 工具执行管线承接」已覆盖(TODO.md:141, 177-178)。**协议语义与教训未记录**: + +- **进程内 MCP**: `build_action_server(actuator)` 造 FastMCP("artemis_actions"), 只注册 actuator `capabilities()` 声明的工具——缺的工具对消费者不可见(artemis/mcp/action_server.py:69-201)。返回协议: `CallToolResult.structuredContent = ActionResult`; `isError=False` 保留给协议级失败, **设备拒绝动作 = isError=False + ok=False**(作为观察到达模型而非异常, 15-31)。 +- **ActionResult 语义**(action_types.py): ok/code/message/detail(`detail` 给诊断不进 LLM)+ normalized_coordinates/duration_ms; ActionCode 枚举 OK/INVALID_ARGS/TARGET_NOT_FOUND/DEVICE_ERROR/PACKAGE_NOT_FOUND/TIMEOUT/UNSUPPORTED(31-40)。`ObserveResult.hierarchy_ok=False` 时调用方**必须保留旧元素索引不得清空**(83-87)。 +- **动作全集**(action_manifest.py:68-135): REQUIRED = `click_sequence`(唯一——Flash prompt 结构性依赖); OPTIONAL = click/long_press/input_text/swipe/press_key/manage_app/wait_for_delay/wait_for_text/open_link/erase_one_char/focus_and_clear_text(11); INTERNAL(不进 LLM, 适配器专用) = observe_screen/take_screenshot/get_ui_hierarchy。另有 BACKEND_INDEPENDENT 15 个(笔记/子代理/诊断/终止哨兵: read_note...report_task_status)不随 actuator 变。扩展工具描述 ≥ **40 字符**(manifest 149-151), 与已知工具重名即拒。 +- **三方言单源**(action_specs.py docstring 15-56): `operator`(agent 方言, target = **元素索引 int 或 0-1000 归一 [x,y]**, 索引由**客户端执行器**对"Visible UI Elements"列表解析——wire 只见坐标; 坐标 target 必须带 `target_description`, 只记录不上 wire)+ `declaration`(JSON-only, 仅 click_sequence 独享)+ `wire`(actuator 一一对应)。`long_press` 双拼写 `duration`(agent)/`duration_ms`(wire), 执行器两者都收(402-409)。历史措辞锁定: EXCEPTION_PREFIXES("Error during click" 等, 696-706), 有专门测试钉死(tests/unit/mcp/test_action_specs.py, 14 用例)。 +- **动作名归一**(action_names.py): Operator 内部动词 tap/long_press_on/focus_and_input_text/launch_app/stop_app/back → canonical click/long_press/input_text/manage_app/press_key; `KEYCODE_` 前缀剥离, 已知裸词集 {home,back,enter,delete,tab,search,menu,app_switch}, 其余**原样透传**(任意 KEYCODE_*/数字)(148-155)。 +- **ActionSession 单 owner 任务串行**(action_session.py): in-memory MCP 传输的 anyio cancel-scope 必须同任务进出 → 全部传输调用经请求队列在 owner 任务内执行; **调用串行化 = 单设备策略的强制点**(阻止 Validator 与子代理交错操作设备, 44-47); **超时教训**(两次实况): 传输级 read_timeout 或调用方 wait_for → mcp-1.29 客户端迟响应崩溃、会话全灭 → **超时必须作为 `timeout_ms` 参数在服务端工具内部生效**(action_server.py:136-142); 传输死亡 → `started=False` → 下次自动重建; aclose 5s。 +- **AdbActuator 参考实现**(actuators/adb.py): 归一→像素 clamp(122-131); `ensure_focus_at_coords` = 找包含点的**最小面积可聚焦元素**(focusable/clickable/EditText), 已 focused 则跳过 tap, tap 后固定 **1.0s** 等键盘弹起(45-98); `manage_app` 显示名→包名解析(`find_package`) + `launch_app_with_retries`(286-332); `wait_for_text` 0.5s 轮询 UI 树, 默认 5000ms(343-372)。 +- **adb_server(stdio 外部 MCP, "Android_ADB_Controller")**(adb_server.py): 13 个 **legacy 像素坐标**工具(tap/long_press_on/swipe/back/launch_app/stop_app/open_link/focus_and_input_text/focus_and_clear_text/erase_one_char/press_key/take_screenshot/get_ui_hierarchy)——注释明示 stdio 服务器保留像素契约、元素/聚焦逻辑与进程内 actuator 单实现共享(206-211); `_get_controller` 按 serial 懒缓存; 串选择 = 显式参数 > `ARTEMIS_DEVICE_ID` > `ADB_DEVICE_SERIAL` > 首个设备; cloud 模式专用分支; stdio 模式 = 日志重定向 `traces/mcp_server.log` + `ARTEMIS_IPC_PORT=""` + awake 服务(482-490); 入口 `artemis mcp --server adb`(stdio/SSE)(interfaces/cli/commands/mcp.py:836-847); 工具描述做 **py3.12/3.13 docstring dedent 归一**(92-111); FastMCP/pydantic Settings model_rebuild 兼容 shim(35-41)。契约由 tests/unit/mcp/test_adb_server_contract.py + tests/fixtures/action_surfaces/adb_server_manifest.json 钉住。 +- **xml_search_server**("Android_XML_Fuzzy_Search"): `search_ui(image_hash, query, threshold=0.6)` 按 SHA-256 从 DataEngine 取 UI 数据做模糊搜索(artemis/mcp/xml_search_server.py:41, 95+)。同样经 `artemis mcp --server xml` 提供。 +- **McpActionExecutor**(action_executor.py): Flash 侧执行器——索引解析、target_description 记录、智能滑动(经 utils/coordinates)、后观察、trace 全在这层; AGENT_TOOL_NAMES(note/ask_*/video_analyzer/history)不进 action server(72-78)。 + +处置建议: 一期黑盒保留; 二期重写时**必带三件行李**: ① 0-1000 归一化坐标 + target_description 记录契约(agent 方言↔wire 的分界); ② "设备拒绝=观察非异常"的 ActionResult 语义; ③ 超时在工具内生效的教训(DSH 工具同样适用——DSH 侧超时若在调用方 abort 也会产生迟响应类问题, 需在 spike 验证)。 + +### 1.9 controllers / clients + +TODO.md 状态: 「二期进 autogamer-device(TS 重写或 CLI 间接调用); 一期保留」已覆盖(TODO.md:142, 183-184)。依赖关系未记录: + +- **UnifiedMobileController**(controllers/unified_controller.py, 649 行)抽象两件事: ① 驱动无关的输入/观察面(继承 third_party 基类, 真实动作都委派 driver); ② **完整录屏生命周期**(§1.4)——所以它的二期去向其实是两半: 控制面进 autogamer-device, 录屏半留 py。分段缓存按 `(video_id, generation, start, end)` 键控(57, 113-132)。 +- **screen_client_factory**(clients/screen_client_factory.py): 层级三后端 `ARTEMIS_HIERARCHY_BACKEND`= auto(默认)/helper/uiautomator; auto = helper 优先 + **逐调用** u2 兜底; 健康的 helper 永不触发 `u2.connect`(**u2.connect 会推送自家 APK 并卸载 Maestro**, docstring 20-23); helper 失败后 **30s 降级窗**内直接走 u2(`retry_after`, 133-160); 降级前先 `adb get-state` 快判(5s 超时)——**手机不在手时绝不浪费 u2 多次重连超时**(DeviceOfflineError, 60-75, 206-210); 回 helper 前**必须停 u2 server**(其 UiAutomation 连接让所有 a11y 服务解绑, 231-238); `disconnect(stop_server=True)` 同理(为下一个任务/进程还回 helper, 277-285)。后端切换史(backend_history+listener)进报告。 +- **AccessibilityClient**(clients/accessibility_client.py): 设备侧 **Artemis Accessibility Helper**(com.artemis.helper)的 loopback HTTP 客户端——**与 u2 互补的关键设计: 不占用 UiAutomation 单例, 与 Mobly/Appium/Espresso 共存**(docstring 15-19)。端口: 设备固定 **18888**, 宿主经 `adb forward tcp:0` 动态分配(跨进程共享 forward, helper_manager.py:27-35, 87-92); 鉴权 = 会话 token, 头 `X-Artemis-Token`(70-71); 请求超时 6s(/snapshot 8s); **自愈语义**: HTTP 401 → 重推 token 重试一次; **读**操作传输失败 → 重建隧道重试一次; **动作不重放**(可能已执行, 211-251); 接口: `/dump_xml`、`/dump?fields=xml,elements`、`/snapshot?fields=xml`(Android 11+ 原子带回截图)、`/action {cmd}`; 截图兜底 `adb exec-out screencap -p`(10s); connect 时 `ensure_device_awake`(runtime/awake_service)。元素形状与 u2 **逐字节一致**(同一 parser + normalize_helper_elements 把 bool flag 转 "true"/"false"——下游到处比较字符串 "true", 107-137)。 +- **uiautomator2 关系**(third_party/mobile_use/clients/ui_automator_client): `UIAutomatorClient` 是 u2 的包装; u2.connect 三次尝试且会在认为 server 不健康时**重启对端 server**——这正是 smoke test 忙判定与 factory 注释的根因。 + +处置建议: 二期 TS 若不复刻 helper(决策待定), **互斥不变量必须写进 autogamer-device spec**: 任何占用 UiAutomation 的层级方案会杀掉 helper/其它 a11y 工具; 30s 降级窗与"离线快判"是防超时雪崩的经验参数。 + +### 1.10 utils/CV/OCR 栈(职责概述) + +TODO.md 状态: 「D8 例外整区保留(opencv/scipy 强依赖)」已覆盖(TODO.md:174-175), 无需展开。职责速览(迁移时知道谁被谁调即可): +- `coordinates.py`(606): swipe 参数解析(direction/target/gesture 兼容)、智能滑动向量(40% 重叠锚)、0-1000 归一/反归一、action dict 归一——**actuator/executor 的坐标大脑**。 +- `ui_filter.py`(651): 层级清洗——越界/最小尺寸(短边 0.5% 动态下限, 385-389)、语义空节点、父子冗余剪枝、固定系统条(状态栏/导航栏)钳制; adb_driver 与 observe 共用。 +- `task_tree.py`(1174)、`plan_grammar.py`(513): 任务计划 md 的双通道语法(机器通道解析); `notes.py`(470): 笔记工具后端。 +- `visualization.py`(802)+`cv_canvas.py`: 元素索引渲染/截图标注(→ DataEngine 图像); `ocr_xml_fusion.py`(318): OCR×XML 融合; `ocr_api.py`: OCR API 客户端; `image_diff.py`(335): 屏幕变化判定; `image_hash.py`: dHash 相似度; `element_hit_test.py`: 像素点→元素命中(Explorer 去重); `video.py` 见 §1.4; `python_executor.py`/`cython_compat.py`: 沙盒执行/兼容。 +处置建议: 维持 D8; 仅提示 `coordinates.py`+`ui_filter.py` 的语义(智能滑动向量/清洗规则)是**模型行为质量的一部分**——二期若 agent 重写, 这两个文件的语义规格需要随行。 + +### 1.11 Windows/平台 quirk 汇总(注释中的边界经验) + +| quirk | 证据 | +|---|---| +| 残留 scrcpy 在 Windows 上锁住 MKV 文件 | adb_driver.py:468-471 | +| 停 scrcpy 先发 CTRL_BREAK_EVENT(Windows)/SIGINT 让 recorder flush, 再 terminate | unified_controller.py:69-80; spawn 加 CREATE_NEW_PROCESS_GROUP(60-67) | +| 模拟器进程隔离: Windows CREATE_NEW_PROCESS_GROUP\|CREATE_NO_WINDOW, POSIX start_new_session | emulator_manager.py:77-90 | +| Windows monotonic 时钟 ~16ms 粒度 → 缓存合并判断必须严格大于 | engine.py:156-162 | +| Windows 并发提交下超时后 kill 可能 ProcessLookupError, 需吞掉 | adb_probe.py:342-348 | +| mcp-1.29 客户端: 迟响应进已关闭 response stream → 客户端收环崩溃、会话全灭 | action_server.py:136-142; action_session.py:28-47 | +| py3.12 vs 3.13 docstring dedent 差异 → 工具描述归一 | adb_server.py:92-111 | +| FastMCP/pydantic 版本漂移 → Settings model_rebuild shim | adb_server.py:35-41 | +| uiautomator2 异常继承 BaseException、常带空消息 | device_smoke.py:152-158 | +| 同步 u2 在事件循环线程上 asyncio.wait_for 打不中 → 专属线程+独立 loop | device_smoke.py:33-39, 192-233 | +| ADB restart/heal 必须剥离 ADB_SERVER_SOCKET, 否则打到远程 endpoint | engine.py:327-335; adb_keys.py:213-228; adb_server_connection.py:274-278 | + +处置建议: 这些是**跨语言有效的经验**, 应在二期 spike(尤其 G6 Windows 落地)时作为检查单; 建议把此表并入 TODO.md G6。 + +--- + +## 2. 外部契约清单 + +### 2.1 文件格式与目录 + +| 契约 | 规格 | 证据 | todo 状态 | +|---|---|---|---| +| 录像段文件 | `{trace_dir}/recording.mkv`→`recording.mp4`; 第 N 段 `recording_{NNN}.mkv`→`recording_{NNN}.mp4`(N 从 1 起 3 位零填充; 第一段 mp4 固定名 `recording.mp4`) | unified_controller.py:252-256, 340 | **缺失**(todo 仅"录屏栈留py/spillPath") | +| 录像清单 | `recording.json` v2: `{version, duration, session_offset_ms, session_end_ms, segments:[{file,start,duration,offset_ms,duration_ms,width,height}]}`; 原子写 | video.py:360-433 | **缺失** | +| trace 目录终态名 | 未完成目录被孤儿恢复改名为 `{原名}_FAIL_{yyyy-mm-ddTHH-MM-S}`; 状态标记 `_PASS_/_FAIL_/_TESTFAIL_` | media_service.py:152, 179-203 | **缺失**(前端/测试依赖) | +| 步骤截图引用 | trace payload 内图片落盘 `{traces}/images/{sha256}.jpg`, 引用串 `image://{hash}` | media_service.py:446-456; paths.py:257-262 | **缺失** | +| ADB 键 | `~/.android/adbkey(.pub)` / `ADB_VENDOR_KEYS`; 损坏备份 `adbkey.corrupted.{ts}` | adb_keys.py:56-65, 187 | 缺失(机制级) | +| ADB endpoint 持久化 | `{repo}/.env` + `{app_dir}/.env` 双写 ADB_HOST/ADB_PORT | adb_server_connection.py:305-326 | **缺失** | +| 剪辑产物 | `get_temp_dir("trimmed_videos")/video_trimmed_*/segment.mp4` | unified_controller.py:187-191 | 缺失 | +| mcp stdio 日志 | `{traces}/mcp_server.log` | adb_server.py:60-79 | 缺失(可弃) | + +### 2.2 HTTP 路由(§1.6 表)——todo「mp4 托管转 autogamer-media」已覆盖方向, 逐路由清单+安全语义缺失。 + +### 2.3 协议与端口 + +| 契约 | 值 | 证据 | todo 状态 | +|---|---|---|---| +| ADB server | 127.0.0.1:5037(默认), env ADB_HOST/ADB_PORT/ADB_SERVER_SOCKET 可覆盖 | constants.py:111-112 | 缺失 | +| Helper 设备端口 | 固定 18888(设备 loopback HTTP, X-Artemis-Token); 宿主端口 = adb forward tcp:0 动态 | helper_manager.py:87-92 | **缺失** | +| 无线 adb | adb connect {host}:5555(默认), 8s 超时 | engine.py:400-437 | 缺失 | +| MCP stdio | `artemis mcp --server adb|xml|agent`(stdio/SSE) | cli/commands/mcp.py:828-860 | 缺失(G7 只提 mcp_server 通知面) | +| 归一化坐标 | 全设备动作 0-1000(输入与动作记录), 内部换算 clamp | base.py:194-223; actuators/adb.py:122-131 | **缺失**(TS 重写的核心契约) | +| MCP 动作返回 | CallToolResult.structuredContent=ActionResult, isError=False 恒定 | action_server.py:55-66 | 缺失 | +| mock 录屏路径 | 字面量 `/tmp/mock_recording.mp4` | mock_driver.py:198 | 缺失 | + +### 2.4 关键数值常量 + +| 常量 | 值 | 证据 | +|---|---|---| +| 截图稳定窗 | 0.3s(driver)/400ms 默认 observe settle | adb_driver.py:124; action_server.py:98 | +| 占位截图 | 1x1 PNG 常量 + smoke 判定阈值 512B | adb_driver.py:174; device_smoke.py:74 | +| input_text 清屏 backspace | 20 连发 keyevent 67 | adb_driver.py:315 | +| 长按判定阈值 | tap duration_ms ≥ 500 → swipe 实现 | adb_driver.py:238 | +| 智能滚动向量 | 横向 60% 宽; 纵向 0.7↔0.3 高; 横扫 0.75↔0.25 宽; 800ms | adb_driver.py:274-305 | +| execute_shell 默认超时 | 15s | adb_driver.py:429 | +| scrcpy 码率/分段 | 2M / 1800s | video.py:52; unified_controller 常量 video.py:38 | +| scrcpy 首帧 | 标记 "Recording started"; 回退 +0.75s; 超时 6s | video.py:41-43 | +| scrcpy 停止 | flush 信号等 8s → terminate 5s(driver 路径: terminate 5s) | unified_controller.py:69-80; adb_driver.py:467 | +| 剪辑时间线 | 画布 720x1280@15fps; 间隙容差 0.05s | video.py:467-474, 45 | +| 抽帧 | keyframes 1fps/45帧/1080px/JPEG q80; 按时间戳 30帧/q85 | video.py:593-685 | +| ffmpeg 转换 | remux 15s / 重编码 30s(media_service); 录制活窗 10s | media_service.py:122, 146, 151 | +| unwrap 截断 | 字符串 >2000; XML 列表 >20 元素 | media_service.py:462-477 | +| 提交门禁 | devices 1s; 锁检测 1s×2 确认; (仪表盘 2s×2 + 15s 旧值 TTL) | adb_probe.py:425, 289, 200, 44-45 | +| 富化缓存 | 60s | adb_probe.py:44 | +| 探针总超时/报告缓存 | 30s / 2s | engine.py:53-60 | +| 模拟器 | boot 总超时 180s; emu kill 5s; terminate 3s; 日志环 100 | emulator_manager.py:255, 466-481, 72 | +| 无线连接 | 8s | adb_server_connection.py:172; engine.py:419 | +| smoke | 默认 20s; <512B 占位判定 | device_smoke.py:263, 74 | +| parity | recall≥0.9 / precision≥0.8 / IoU≥0.5 / 3 轮取中位 | hierarchy_parity.py:39-40, 109, 167 | +| helper | 请求 6s(/snapshot 8s); 设备端口 18888; u2 降级窗 30s | accessibility_client.py:151-156, 304; screen_client_factory.py:147 | +| focus 后等待 | 1.0s 键盘弹起 | actuators/adb.py:97 | +| wait_for_text | 0.5s 轮询 / 默认 5000ms | actuators/adb.py:346-360 | +| ActionSession | aclose 5s | action_session.py:208 | +| 扩展工具描述 | ≥40 字符 | action_manifest.py:151 | +| 录像上限 | DEFAULT_MAX_DURATION_SECONDS=900(15min, 基类默认参数; 实际由分段机制接管) | third_party/mobile_use/utils/video.py:42 | +| DataEngine 图片上限 | 500MB(Gemini File API 2GB 限) | third_party/mobile_use/utils/video.py:46-49 | + +--- + +## 3. 漂移风险 Top 10(按风险排序) + +1. **录像产物契约整体未成文**(manifest v2/分段命名/首帧锚定/trace 目录 FAIL 改名): autogamer-media 或任何 TS 侧接管托管时, 格式猜错 = 前端时间线/回放/step-seek 全断, 且 py worker(一期保留)继续按 py 格式写——双端漂移概率最高。→ §1.4/2.1 全表交接。 +2. **0-1000 归一化坐标 + target_description 契约未写**: 这是 agent 方言↔wire↔trace 记录三方的共同语言; 二期 autogamer-device 换坐标体系(如改像素)会让历史 trace 回放与 prompt 教学全部失配。→ §1.8。 +3. **helper/u2 互斥不变量**: 任何新层级实现若占用 UiAutomation 单例(或忘记 u2 用后 stop_server), 会静默杀死 helper 并表现为"偶发全设备层级失败"。→ §1.9。 +4. **mock 行为契约 + `is_mock` 死检查**: TS mock 插件若实现 is_mock 或改截图尺寸/路径字面量, 现有断言缝(action_history、/tmp/mock_recording.mp4)与下游判定(smoke 512B)一起漂。→ §1.1。 +5. **MCP 会话串行化=单设备策略 + 超时必须工具内生效**: 二期把动作层搬进 DSH 工具时, 若在调用方加超时/重试, 会复刻"迟响应崩会话"或引入设备动作重入(设备任务不重入是 D5 领域约束)。→ §1.8。 +6. **提交门禁的锁屏确认语义(fail-closed)与多设备自动切换**: todo G4 只定义了 env 级失败判定; admission 若只查 `adb devices` 不查 Keyguard, 会向锁屏设备派任务(现状明确视为不可提交)。→ §1.7。 +7. **媒体 HTTP 面的安全模型**: 现有保证=根约束+扩展白名单, 无鉴权 token; autogamer-media 用 `ctx.connection.fetch` 重写时若只做静态挂载, 会把 workspace 内任意文件(含 .env/db)暴露出去。→ §1.6。 +8. **环境变量契约清单不全**: S5 只列 3 个 worker env; `ARTEMIS_MOCK_DRIVER/HIERARCHY_BACKEND/DEVICE_ID/CLOUD_MODE`、`ADB_SERVER_SOCKET/VENDOR_KEYS` 等是双跑期 DSH 侧 spawn/观测 py worker 的必要透传集。→ §1.3。 +9. **`image://{hash}` 截图引用协议**: unwrap_payload 是 DataEngine trace 内图片的落盘协议; DSH 承接 trace/回放(映射表"session log 原生")后, 若无人实现该引用的解析, 历史步骤图全丢。→ §1.5。 +10. **platform quirk 检查单**(CTRL_BREAK flush/Windows 文件锁/16ms 时钟/stdio 日志静默): 都是注释级知识, 无人携带就会在 Windows 上以"偶现挂死/文件占用"复发。→ §1.11。 + +--- + +## 4. 对 docs/TODO.md 的修订建议 + +1. **新增差距 G8 · 录像产物与 trace 媒体契约**: manifest v2 字段、`recording{,_NNN}.(mkv|mp4)` 命名、首帧锚定(session.start_time=首帧)、trace 目录 `_PASS_/_FAIL_/_TESTFAIL_` 终态命名与孤儿改名、`image://{sha256}` 截图引用——绑定到「剩余自建清单 ③ autogamer-media」与 S5 spillPath 交接, 作为其验收规格。(来源 §1.4/1.5) +2. **新增差距 G9 · 设备工具坐标与结果契约**: 0-1000 归一化坐标域、元素索引 client 端解析、target_description 记录不上 wire、ActionResult(ok/code/message/detail, 设备拒绝≠异常)、动作名归一表——绑定 P2/autogamer-device。(来源 §1.8) +3. **新增差距 G10 · 层级后端互斥与降级语义**: helper(18888/X-Artemis-Token)与 u2 的 UiAutomation 单例互斥、auto 模式 30s 降级窗、u2 用后 stop_server、离线快判——绑定 P2; 若二期决定不复刻 helper, 需显式决策并记录对 Mobly/Appium 共存能力的放弃。 +4. **映射表修正**: ① `media_service 568 行` → 实为 **623 行**(media_service.py 实测); ② diagnostics 行数 TODO.md:144(~3000)与 TODO.md:180(~2400)自相矛盾, 实测约 **4300 行**(含 probes 1963/engine 441/smoke 364/emulator 501/keys 270/endpoint 330/parity 216); ③ E 区"提交前就绪检查"建议点名 **submission gate 的锁屏确认+多设备切换语义**为 admission 必须继承项(现 G4 只覆盖 env 级判定)。 +5. **S5 环境变量契约补全**: 在 S5 清单追加 driver/观察面 env: `ARTEMIS_MOCK_DRIVER`、`ARTEMIS_CLOUD_MODE`、`ARTEMIS_HIERARCHY_BACKEND`、`ARTEMIS_DEVICE_ID`、`ADB_DEVICE_SERIAL`、`ADB_HOST/PORT/SERVER_SOCKET/VENDOR_KEYS`、`ARTEMIS_DISABLE_ORIGIN_GUARD`(双跑期 DSH→py worker 透传集)。 +6. **checklist P2 补充验收基线引用**: autogamer-device/mock 的验收 = 本草稿 §1.1-1.2 能力清单 + §1.9 互斥不变量 + §2 契约表; `koffi/隐藏桌面` 之外补"Windows MKV 文件锁、CTRL_BREAK flush"两条已有经验(并入 G6 检查单)。 +7. **删除项确认**: `drivers/android/recorder.py`、`drivers/android/input_ime.py`、`drivers/cloud/cloud_driver.py` 全仓无消费方(§1.2), 可在映射表标注「死代码, 直接删除, 不迁移」。 diff --git a/docs/migration/inventory/04-agent-stack-config.md b/docs/migration/inventory/04-agent-stack-config.md new file mode 100644 index 000000000..39e25e4d6 --- /dev/null +++ b/docs/migration/inventory/04-agent-stack-config.md @@ -0,0 +1,308 @@ +# 迁移盘点 · 智能栈与配置 + +> **编号警示**:本文里 `**Gn · …**` 形式的「新增差距」段落属于早期编号方案,与 `docs/TODO.md` 现行 G 编号**不是同一套**(G8/G9/G10/G11/G12 已撞车,冲突表见 [registry.md](../registry.md))。引用内容时写「`inventory/0N` 的 X 段」,不要写 `Gn`。 + +> 范围: worker 进程入口/启动退出契约、`artemis/agents/**`、`artemis/graph/**`、`artemis/llm/**` + `services/llm.py` + `token_meter`、`artemis/memory/**`、`artemis/tools/**`、`artemis/config/**` + `admin_console/services/model_service.py` + `task_preset_catalog.py`。 +> 基线: cc6b20c;日期: 2026-10-09。 +> 覆盖标注约定:【已覆盖 D#/S#/G#】= TODO.md 已记录,此处不展开;【缺失】= TODO.md 未写、无人记录即丢;【矛盾】= 与 TODO.md 现有条目冲突,需修订;【部分】= 有条目但语义细节缺。 +> 处置: ①一期黑盒保留 py / ②二期 DSH 重写 / ③DSH 原生承接 / ④需决策。 + +--- + +## 1. worker 启动与退出契约(S5/S2 的输入,最优先) + +### 1.1 进程链与入口 + +- 队列 worker 的真实命令行: `python -m artemis.main --profile --test-name web__ [--session-id --output-description --enable/disable-outputter --verification-level --explorer-pro-mode --locked-app --app-path --device-serial]`。由 `task_queue_service._build_worker_invocation` 组装(apps/admin_console/services/task_queue_service.py:578-663)。 +- `artemis/main.py:40-54` 把无子命令的 `python -m artemis.main "goal"` 归一化为 `run "goal"`。 +- `run_command`(artemis/interfaces/cli/commands/run.py:304-311)以 `ARTEMIS_TASK_WORKER=1` 或 `ARTEMIS_DEVICE_QUEUE_TICKET` 存在判定"我是 worker",跳过 Daemon 派发分支直接本地执行。**DSH bash job 直接跑 `python -m artemis.main` 时若不设 `ARTEMIS_TASK_WORKER=1`,会走 Daemon 派发分支**(自动起 daemon、提交、轮询 1800s)——S5 说"入口不变",但没写这个开关是进入 worker 语义的钥匙。【缺失】 +- 执行链: `run_command` → `execute_task`(run.py:44-166,读 env 选 serial/装配 config builder)→ `run_automation`(third_party/mobile_use/main.py:90-130)→ `artemis.sdk.agent.Agent(config, session_id)` → `agent.init(ARTEMIS_HEALTH_RETRIES=5, ARTEMIS_HEALTH_DELAY=2)` → `run_task` → `_run_task`(third_party/mobile_use/sdk/agent.py:394-796)。【缺失】 + +### 1.2 启动顺序与 start_session 边界(时序不变量) + +`_run_task` 内部顺序(third_party/mobile_use/sdk/agent.py:471-560): +1. 解析 task_id/session(`self._session_id or ARTEMIS_SESSION_ID or ARTEMIS_CLOUD_SESSION_ID or uuid4`,agent.py:419-424;`artemis/sdk/agent.py:95-98` 同序)。 +2. **设备锁获取**:`DeviceExecutionLock.acquire`(除非 `already_held`——锁 active owner 的 pid==本进程 且 session/token 匹配,即队列票据已定;agent.py:471-513)。锁 `description=goal[:120]`,`ingress=ARTEMIS_TASK_INGRESS`。取消时经 `queue_cancel_event` 排空后重抛。 +3. **才** `_prepare_tracing` → `DataEngine(ctx)` + `data_engine.start_session(goal, device_info, session_id)`(artemis/sdk/agent.py:287-344)。注释明确:"Session creation must be inside the same boundary ... a queued task must not publish a new active session while the current task is still finishing"(agent.py:514-520)。 +4. 连接 screen client(helper 安装/升级事件在此发出)→ keyguard 检查(锁定则拒绝执行,绝不猜 PIN,agent.py:215-237)→ app 安装 → Chrome Web Accessibility 强制标志(写 /data/local/tmp chrome-command-line + `am force-stop com.android.chrome`,agent.py:239-285——**设备变更,无人审批**)→ 屏幕录制启动。 +5. profile 分叉:`flash` → FlashRunner;否则 langgraph `get_graph(context).astream(...)`,`recursion_limit=request.max_steps`(agent.py:602-625)。 + +【缺失】TODO.md 只有"DataEngine.start_session 持久化"一句话(AGENTS.md 有),**锁→start_session 的先后边界**是"排队任务不得提前发布 active session"这条不变量的载体,DSH 侧必须保住等价语义(job 只有在闸门放行后才算 session 开始)。 + +### 1.3 env 变量全集(worker 读到的) + +来源三处:队列注入(task_queue_service.py:597-662)、worker 自身读取(artemis/config/constants.py:24-83)、config 层(settings.py)。按语义分组: + +| 组 | 变量 | 语义 | 证据 | +|---|---|---|---| +| 身份 | `ARTEMIS_SESSION_ID`(回退 `ARTEMIS_CLOUD_SESSION_ID`) | 会话 UUID;trace 目录名 + DataEngine 主键 + cancel marker key | sdk/agent.py:95-98, 326-338 | +| 身份 | `ARTEMIS_CONVERSATION_ID` | 会话续聊线程;worker 据此查同线程上一完成任务的 notes 注入 planner(见 §2.6.4) | sdk/agent.py:362-374; task_queue_service.py:613-617 | +| 身份 | `ARTEMIS_SUBMITTED_AT` | 入队墙钟;仅 console 排序用,engine 侧再持久化 | task_queue_service.py:618-623 | +| 身份 | `ARTEMIS_TASK_INGRESS` | 提交渠道标记(cli/frontend/...),进锁 description 与 device_info | run.py:92-93; task_queue_service.py:611 | +| 模式 | `ARTEMIS_TASK_WORKER=1` | **worker 语义开关**(跳过 daemon 派发) | run.py:304-307 | +| 模式 | `ARTEMIS_DEVICE_QUEUE_TICKET` | 队列票据;worker 侧 `already_held` 判定输入之一 | run.py:306; sdk/agent.py:471-479 | +| 模式 | `ARTEMIS_STANDALONE=1` | 嵌入模式,不起 daemon | run.py:308 | +| 模型 | `ARTEMIS_MODEL_ENDPOINT` | 端点库记录名 pin(见 §2.2);未知名字 **RuntimeError 拒启** | config/llm.py:215-242 | +| 设备 | `ADB_DEVICE_SERIAL` / `ADB_HOST` / `ADB_PORT` / `ADB_SERVER_SOCKET` | 设备与 ADB 寻址 | constants.py:49-53; run.py:137-146 | +| 设备 | `ARTEMIS_HIERARCHY_BACKEND`(auto/helper/uiautomator) | UI 层级后端 | constants.py:54-55 | +| 设备 | `ARTEMIS_HELPER_AUTO_INSTALL`(默认 true) | 允许任务在持有的设备上安装/升级辅助 APK;false = 只附着 | constants.py:56-58 | +| 设备 | `ARTEMIS_KEEP_DEVICE_AWAKE`(默认 true) | 屏幕常亮服务开关 | runtime/awake_service.py:41 | +| 执行 | `ARTEMIS_DEFAULT_PROFILE`(默认 pro)/ `ARTEMIS_DEFAULT_MODEL` | CLI 未显式时的默认 | constants.py:75-76, 118-119 | +| 执行 | `ARTEMIS_EXPLORER_VERSION` | Explorer 层级 env 覆盖(优先级高于配置文件,低于显式参数) | config/agent.py:365-367 | +| 执行 | `ARTEMIS_CHECKER_ENABLED` / `ARTEMIS_COMMITTEE_ENABLED` / `ARTEMIS_PLANNER_VALIDATION_ENABLED` / `ARTEMIS_OUTPUTTER_ENABLED` / `ARTEMIS_VIDEO_RECORDING_ENABLED`(=`ARTEMIS_WITH_VIDEO_RECORDING_TOOLS`) / `ARTEMIS_VIDEO_LEDGER_ENABLED` | 运行时开关,**最高优先级**(压过 artemis.jsonc) | config/agent.py:977-1018 | +| 执行 | `ARTEMIS_EXPLORER_CACHING`(settings.EXPLORER_CACHING) | Explorer context caching env 覆盖 | settings.py:132-138 | +| 可靠性 | `LLM_PAUSE_TIMEOUT_SECONDS`(默认 900,≤0 永久等待) | LLM 重试耗尽后暂停死限(见 §2.3.3) | settings.py:141-148 | +| 健康检查 | `ARTEMIS_HEALTH_RETRIES`(5) / `ARTEMIS_HEALTH_DELAY`(2) | agent.init 的设备就绪重试 | third_party/mobile_use/main.py:105-108 | +| 测试 | `ARTEMIS_FAKE_LLM=1` / `ARTEMIS_FAKE_LLM_DELAY_S` | 全模型替换为 FakeChatModel;同时跳过连接预热 | llm/router.py:215-223; sdk/agent.py:131-136 | +| 调试 | `ARTEMIS_DEBUG` / `ARTEMIS_STRICT_STATE`(测试) / `KEEP_VIDEOS` / `EVENTS_OUTPUT_PATH` / `RESULTS_OUTPUT_PATH` | 调试与产物路径;`RESULTS_OUTPUT_PATH` 有值则结构化输出额外落盘 | constants.py:80-83; third_party/mobile_use/main.py:123-125 | +| 凭证 | `GOOGLE_API_KEY`/`GEMINI_API_KEY`/`GCP_API_KEY`/`OPENAI_API_KEY`/`OPENAI_BASE_URL`/`ANTHROPIC_API_KEY`/`OPEN_ROUTER_API_KEY`/`XAI_API_KEY`/`OCR_API_KEY`/`VISION_API_KEY` + `credential_bindings.json` 自定义名映射 | provider 凭证;placeholder 值清洗;Google/Gemini/GCP 三键互通 | settings.py:61-91, 157-216, 335-366 | +| 进程 | `ARTEMIS_IPC_PORT` | worker→daemon 事件总线端口(startup_progress/checker_event/llm_retry 等结构化事件走这里) | config/runtime.py:41; task_queue_service.py:607-608 | +| 进程 | `ARTEMIS_CANCEL_GRACE_SECONDS`(队列侧,默认 45) | 优雅取消宽限,超时硬杀进程树 | task_queue_service.py:73, 98-110 | + +【部分】S5 只列了 `ARTEMIS_CONVERSATION_ID`/`ARTEMIS_SESSION_ID`/goal/profile/model_endpoint 四个。上表全集(尤其 `ARTEMIS_TASK_WORKER`、`ARTEMIS_DEVICE_QUEUE_TICKET`、`ARTEMIS_IPC_PORT`、`LLM_PAUSE_TIMEOUT_SECONDS`、凭证组)是 DSH job 侧复制环境的最小集合,**漏掉 `ARTEMIS_TASK_WORKER=1` 会导致 worker 走错分支**。 + +### 1.4 退出码语义(S2 映射的直接输入)【矛盾,最重要发现】 + +**py 真相:退出码不编码任务结果,只编码进程结局。** + +| 结局 | 退出码 | stdout/stderr 表现 | DataEngine 会话状态 | +|---|---|---|---| +| graph/flash 正常跑完(含 verifier 判 blocked、flash 自报 failed) | **0** | 正常日志;blocked 时仅一行 `logger.warning("[..] Task ended blocked: ...")` | `failed`(end_session("failed"),third_party/mobile_use/sdk/agent.py:663-677, 588-599) | +| graph 正常跑完且通过 | 0 | `✅ Automation ... is success ✅` | `completed`(agent.py:686-689) | +| 运行中异常(设备/LLM/代码) | 1(traceback 入 stderr) | `Task execution failed: ...`(run.py:463-464);API key 类错误另有 Panel + SystemExit(1)(run.py:449-461) | `failed` | +| Ctrl+C / SIGTERM / cancel marker | **130**(run.py:442-446, 472-473) | 无 | `cancelled`(CancelledError 路径,agent.py:727-739) | +| 队列侧硬杀(宽限 45s 超时) | 非零(强杀) | — | 视 DB 既有状态 | + +队列侧的裁决函数 `_resolve_terminal_status`(task_queue_service.py:310-333):**DB 终态优先(success→completed 归一),退出码只是"会话从未到达终态"时的回退**(0→completed,非0→failed);手动停止一律 cancelled。 + +**与 S2 的矛盾**:S2 写"退出码 0 → completed;非 0 → failed(detail = stdout 错误尾行)"。按 py 行为,**verifier 判 blocked / flash 自报 failed / assert 有失败但任务完成的任务都退出 0**——DSH job 侧会把它们标成 `completed`,与 DataEngine 的 `failed` 漂移。一期兼容做法(按 S5"py 零改动"约束)二选一: +- DSH job outcome 判定改为:stdout 扫描 `Task ended blocked` / `Task execution failed` 等特征行 + 退出码联合判定(近似 `_classify_failure` 的文本扫描); +- 或承认"job outcome 只是 UI 层,任务终态以 DataEngine DB 行为准"(S5 已写"终态留痕由 DataEngine 写入"),DSH completed/failed 展示层标注"以 trace 为准"。【需决策,建议后者为主、前者为辅】 + +另有细节:`assert` 失败(strict 校验断言)在预算内不回流、任务仍 `completed`,只是 run_outcome.tests.failed>0(agent.py:679-687);只有 verify 未满足耗尽预算才 `blocked→failed`。 + +### 1.5 取消协议(cancel marker 文件)【矛盾】 + +- 队列无法可移植地给 worker 发信号 → 写 marker 文件(临时目录,session/pid 双键,TTL 3600s,**pid marker 记录进程创建时间防 PID 复用**);worker 内 `watch_for_cancel_request` 轮询,命中即走与 Ctrl+C 相同的优雅路径:停录屏并 remux、编译 trace 目录、释放设备锁(artemis/sdk/agent.py:183-204;artemis/runtime/cancel_requests.py:40-142;task_queue_service.py:124-159)。 +- Windows 上外部 TerminateProcess 不会触发 Python 的 SIGTERM 处理(run.py:407-415 的 SIGTERM→KeyboardInterrupt 钩子在 Windows 只对 `os.kill` 类信号有效);**优雅取消在 Windows 只能走 marker 文件**。 +- **矛盾**:TODO.md 模块映射 C 区把 `cancel_requests` 标为"✅ 删除(`JobRegistry.kill`/`agent.cancel` 原生承接)"。一期 py worker 黑盒运行时,若 DSH `JobRegistry.kill` 在 Windows 走进程树强杀,录屏 remux/trace 编译/锁释放全部跳过,且 worker 不会写 `cancelled` 终态。**取消通道在一期必须保留:DSH 侧 kill 前先写 cancel marker(或复用 `request_cancel`),宽限 `ARTEMIS_CANCEL_GRACE_SECONDS` 后再硬杀。**【需修订 TODO.md + 纳入 P1 spike】 + +### 1.6 stdout 面(job output ring 能看到什么) + +- 进程创建 `stderr→STDOUT` 合并,console 输出 tee 进 trace 的 stdout.log(apps/admin_console/services/worker_process_io.py:41-104)。 +- rich console 进度(排队/执行中/成功/失败)、loguru 日志、异常 traceback 是 ring 的全部内容。 +- **结构化事件不走 stdout**:thinking/toolcall/llm_retry/checker_event/startup_progress 走 `engine._publish` → IPC(SSE)(services/llm.py:184-195, 231-241;graph/checkpoints.py:70-72)。一期 G2 已接受降级;若要做"JSONL 结构化事件 + toolview"(G2 可选项),挂点在 `_publish`/`publish_startup_progress`,py 一期零新增则该选项不可用,需二期。【已覆盖 G2,补充挂点位置】 +- **机器可读最终结果只进 DB**:`_extract_output` 的返回值在 CLI 被丢弃(run.py 不消费 execute_task 返回值);结构化 output/outputter 报告由 DataEngine 会话行承载。DSH 侧要拿最终回答只能读 DB 或 stdout 特征行。【缺失,建议记入 S2】 + +--- + +## 2. 行为与不变量(按主题) + +### 2.1 profile 系统(config/agent.py) + +两个执行 profile + 一组组件开关,配置文件发现顺序 `artemis.jsonc → artemis.json → agent_config.json`(config/agent.py:1078-1088;config/llm.py:148-155)。工作区现配 config/artemis.jsonc(当前指向本地 LM Studio openai/qwen3.6)。 + +| 项 | flash | pro | +|---|---|---| +| 执行体 | FlashRunner 单 agent 反应环(observe-think-act),`max_turns=0` 默认无限 | langgraph 多 agent 图(§2.5) | +| 结束方式 | `report_task_status(status=completed|failed)` 工具唯一出口(validator/tool_declarations.py:276-289) | plan 全勾 + 收敛门 + exit settlement | +| 笔记工具 | **无写笔记工具**(读有),prompt 也不教(flash/runner.py:196-203) | scratchpad 全套 + plan 写入拦截 | +| Explorer 层级 | `flash.explorer_mode`(默认 flash) | `pro.explorer.mode`(默认 flash) | +| checker/committee | 无 | checker 梯级 + committee(默认关) | + +- 验证梯级 `off/final/checkpoints/strict`(config/agent.py:241-260):off=Operator 自报完成无审计;final=仅出口审计(**工厂默认**,midway off);checkpoints=每个 plan checkpoint+出口;strict=checkpoints + repair 预算加大(4/5/30)+ assert 失败 halt。`verification_level_for_checker` 反推梯级(agent.py:281-291),`run_tuning_for_profile` 把 pro 的梯级+explorer 模式写进会话 device_info(agent.py:294-311,model_service 反推的依据)。 +- [vlm] 说明:TODO.md 说"旧线 [vlm] 路由已验证"——py 侧**没有**名为 [vlm] 的路由键;多模态按节点配置(`video_analyzer`/`object_detector`/`explorer` 节点 + `is_multimodal` 默认 true,llm/router.py:100-102)。flash 层级要求 Gemini ER 模型做空间坐标(artemis.jsonc:39-49 注释),非 ER 模型检测会失败。【部分,防止二期照抄不存在的 [vlm] 键】 +- Explorer 层级解析优先级:显式参数 > `ARTEMIS_EXPLORER_VERSION` > per-agent `explorer_versions` > profile 模式 > default(config/agent.py:352-383)。层级=flash(1 turn)/pro(3 turn)/ultra(8 turn+zoom+OCR+image processor);caching 默认 ultra on、pro off(explorer/tiers.py:72-93)。 + +处置:①一期黑盒;②二期→DSH profiles + per-agent AgentOptions(TODO.md 已映射);**但 flash/pro 行为差异表和验证梯级语义是二期 skills/agent 定义的转化基线**,TODO.md 目前只有一行"逐 agent 渐进搬迁"。【部分】 + +### 2.2 model_service / 端点库(admin_console/services/model_service.py + artemis/config/endpoint_library.py + config/llm.py) + +- 端点库 = `endpoint_library.json` 保存的选择记录(name/api_format/api_base/model 四字段),**是保存的选择不是运行时输入**;运行时模型来自 artemis.jsonc `default` 块(endpoint_library.py:15-52)。 +- **pin 机制**:队列把选中记录名经 `ARTEMIS_MODEL_ENDPOINT` 传给 worker;config 层把它折算成 default 块覆盖(覆盖字段 = provider/model/provider_label + ENDPOINT_OWNED_KEYS(api_base/api_key/api_key_env/fallback),config/llm.py:215-256)。**未知名字直接 RuntimeError 拒启**(不静默回退——"静默跑在没人选的模型上是最要避免的失败");记录缺 api_format/model 同样拒绝。【缺失】TODO.md 只写"model_service 端点库/固定端点/profile 反推 → DSH 原生"。pin 的 fail-loud 语义与覆盖字段清单是 DSH per-agent model 选项要对齐的行为。 +- **profile 反推**(model_service.resolve_session_profile,model_service.py:101-165):device_info.profile → running 提交参数 → agent/trace 名单(planner/validator/...→pro,flashrunner→flash)→ llm trace payload,四级回退。纯展示层,DSH session log 原生后可删。【已覆盖映射表"✅ 原生"】 +- 节点继承:`default` 块展开进 12 个 agent 节点 + 4 个 utils 节点(outputter/hopper/video_analyzer/object_detector),节点覆写深合并(config/llm.py:158-212);`history_analyzer` 缺省继承 operator,`validator_pixel_safety_net`/`planner_validation` 共享轻量 judge 默认(default 端点 temperature 0),`output_analyzer` 继承 log_analyzer(config/llm.py:63-87, 132-145)。【缺失——节点清单与继承规则是 DSH AgentOptions 表的直接输入】 +- 无内置端点:default 未配置 → 一切模型解析 RuntimeError 带配置指引(services/llm.py:963-968)。 + +### 2.3 LLM 路由/可靠性/结构化输出 + +#### 2.3.1 故障分类与重试(llm/reliability.py) + +- 8 类 FailureCategory(rate_limit/provider_unavailable/timeout/connection/auth/bad_request/cancelled/unknown),由状态码+消息标记匹配(reliability.py:77-109)。每类 RetryPolicy:rate_limit 4 次(10s→60s 指数+抖动)、unavailable 4 次(5s→30s)、timeout/connection 3 次(2s→15s)、unknown 2 次;auth/bad_request/cancelled 1 次(不重试)(reliability.py:154-166)。**跨类累计上限 8 次**(services/llm.py:114, 448-454)。 +- fallback 判定:auth **可以** fallback(换凭证可能活)、bad_request **不** fallback(换弱模型只会藏 bug)(reliability.py:101-108;services/llm.py:1087-1094)。 + +#### 2.3.2 端点熔断(进程内) + +`_ENDPOINT_BREAKER`(threshold=3, cooldown=30s,key=`provider:model`):仅瞬态类计数;半开只放行一个试验调用;等待端有 120s 兜底防死锁(reliability.py:197-267;services/llm.py:107, 370-383)。**这是 LLM 维度熔断,与设备维度熔断(S4)不同层,别合并**。【缺失——S4 只讲设备熔断;DSH 的 LLM seam 若无等价物,行为会漂移为"打满重试才降级"】 + +#### 2.3.3 暂停-恢复协议(PAUSE_FILE)【缺失,DSH 一期必须知道】 + +重试耗尽且无 fallback → 写 `PAUSE_FILE`(内容=`LLM Error: ...`,路径=app_dir/.artemis_paused,config/paths.py:224-226)→ 任务**暂停等待文件被清除**,`LLM_PAUSE_TIMEOUT_SECONDS=900s` 到点 → `LLMExhaustedError`(services/llm.py:300-367, 487-507)。恢复信号 = 外部删文件(旧 UI 的"恢复"按钮做这件事)。事件:`task_paused`/`task_resumed` + `llm_pause` trace。**在 DSH 一期这表现为"job 挂着不输出 900s 然后失败"**——job 侧要么接受(按失败处理),要么删除 app_dir 下的 `.artemis_paused` 当作 resume 按钮,总之要在 DSH 侧成文,否则像死锁。 + +#### 2.3.4 流式与结构化 + +- `complete()` 是唯一调用形态:内部流式、增量转发 UI(`engine.stream_output`),**mid-stream 失败丢弃 partial 整体重试**(partial 永不进消息历史,防重复块);端点不支持流式→一次性探测并永久记忆 `_NON_STREAMING_ENDPOINTS`(services/llm.py:653-757)。`astream()` 是兼容 shim 只 yield 一条最终消息(旧版 chunk 级重试会重复投递——这是修过的 bug,别复原)。 +- `acomplete_structured`:parse 失败把错误回给模型自纠 1 轮(`correction_attempts=1`),仍失败抛 `StructuredOutputError`,**永不把 raw text 冒充解析结果**(services/llm.py:807-862)。 +- `parse_structured` 管线:剥 thinking 标签 → 围栏块优先/裸平衡跨度 → 宽容修复(注释/尾逗号)→ 可选 pydantic 校验(llm/structured.py:163-194)。 +- Google 特殊性:①四类 harm safety 全 `BLOCK_NONE`(router.py:255-260);②monkey-patch `ChatGoogleGenerativeAI._process_tool_config` 保 `include_server_side_tool_invocations`(内置工具+函数调用混用必需,router.py:139-198);③grounding:endpoint 开 `enable_grounding` 或显式声明时注入 `{"google_search": {}}`,非 Google 端点**过滤掉** google_search dict 工具(services/llm.py:553-586);④`thinking_level` 仅 Gemini≥3 支持(1.x/2.x 只认 thinking_budget,google/provider.py:136-144);⑤agentic 视频:Flash≥3.6 / Flash-Lite≥3.5 且仅 base 型号;**Flash-Lite auto 模式被刻意排除(实测误读时间戳),须显式 opt-in**(google/provider.py:147-185);⑥provider SDK 的 retry 日志被劫持成结构化 `llm_retry` 遥测(services/llm.py:244-297)。 +- Anthropic:reasoning_effort→thinking_budget 映射 low=2048/medium=8192/high=32768,开 thinking 时 temperature 强制 1.0(router.py:330-337)。 +- Gemini ER 模型名(不可解析版本)假定支持 thinking_level(google/provider.py:140-144)。 + +处置:①一期保留;②二期→DSH LLM seam。**重试策略表、fallback 判定(auth 可/bad_request 不可)、流式 partial 丢弃、暂停文件、Google safety/patch 这五点是 DSH provider adapter 的验收基线**,TODO.md 未拆。【部分→缺失】 + +### 2.4 多 agent 流水线(逐角色) + +| agent | 一句话职责 | 输入→输出 | 失败行为 | +|---|---|---|---| +| planner | 初始计划:读目标+首帧截图写 task_plan.md(LLM 工具环:save_note 等) | State.initial(goal, prior_conversation_context)+截图 → task_plan.md | 拿不到截图 raise ValueError→整个 run failed;LLM 经 with_fallback,再耗尽→pause 协议(planner.py:263-268, 298-329) | +| planner_validation | 里程碑文本变更的**咨询式**异步复审(轻量 judge) | last_validated_plan vs 新 plan → feedback | 异常一律视为通过(fail-open 不阻塞),flagged 也**不回滚**,仅以 operator_feedback 注入关注点(graph.py:189-228; planner.py:196-198) | +| operator | 屏幕感知+决策:产 structured_decisions(动作 JSON)或 plan 编辑 | 观察尾+反馈+transcript → decisions | 工具超限标记 `operator_tool_limit_exceeded`;fast-action burst 超 4 动作在执行前拒回(config/agent.py:797-824) | +| validator | 执行前安全网 + 动作执行:XML 匹配/坐标自愈/pixel VLM 双网,单动作(vetted)或 burst | decisions → ActionResult/incident | 执行 incident(blocked/failed terminal action)记入 `open_incident`,交 Operator 自行恢复(无独立修复 agent),成功后一次 CLOSED 通知(state.py:130-141; validator/validator.py docstring) | +| checker | **零副作用只读**判定 agent:midway checkpoint 审计(无实时屏幕,用证据锚)+ final 出口审计(有最终屏幕);只挂只读设备探针/读历史/读笔记,release 决策由调用方算(checker.py docstring; graph.py:31) | check_items+ledger → verdicts | 出错 fail-open 释放但 verdict=inconclusive 原样入账(graph.py:324-353);verify 失败回流 loop(预算 final_check_max_attempts),assert 失败永不回流只记 tests.failed | +| explorer | ask_explorer 视觉子 agent:元素定位/多候选搜索,三层级 | query+截图 → 定位/答案 | 层级见 §2.1;`denylisted_tools.explorer` 可裁工具 | +| diagnoser | ask_diagnoser 失败根因诊断(子 agent 带工具环,末轮强制 submit_answer) | 症状+日志 → 诊断 | submit_answer 唯一出口(diagnoser.py:63, 285) | +| committee(ask_committee) | 多席辩论(planner_avatar/history_analyzer_expert/diagnoser_expert × debate_rounds 轮),**不遵守 Operator 回合结束契约**(graph.py:987-988 注释) | 议题 → 建议摘录 | 默认关(committee.enabled=false) | +| summarizer(Pro) | 把步骤派发给共享视觉过渡 lens(不再自己调 LLM 产胶囊) | step_id → versioned summary | 未就绪回退 detailed 渲染,transition 安全(summarizer.py docstring) | +| flash step_summarizer | Flash 异步后台步骤视觉摘要(同 StepMemoryService) | pre/post 截图 → 摘要 | retry_limit=3 耗尽 → `summary_status=failed`,不无限重试(config/agent.py:401-409) | +| outputter | 出口报告合成 + 结构化 schema 提取(enabled 默认 true;force_synthesis 控制无 schema 时是否强做) | 全历史+notes → 报告/JSON | task.finalize content 的来源 | +| log_analyzer/output_analyzer | analyze_logs 错误日志诊断(log_analyzer 带 grounding);output_analyzer 继承其配置 | 日志 → 结论 | — | +| image_processor/object_detector | 通用多模型图像处理 / 视觉坐标检测(ER 模型要求见 §2.1) | 图像 → 文本/坐标 | 解析失败走 ParseFailure 类型化路径(object_detector.py:17) | +| history_analyzer | 会话历史问答(挂共享 history 工具) | 自然语言问题 → 检索回答 | — | +| video_analyzer | 屏幕录像时间线分析:native(Gemini Files API/agentic) + universal(多模型关键帧) 双引擎,自带熔断(3 次瞬态失败旁路 60s)与 bisection | 录像 → 动作账本/验证 | circuit_breaker_threshold=3/cooldown 60s;native 失败 1 次重试后切 universal(config/agent.py:68-136) | + +【已覆盖(映射表 A 区一行)→细节缺失】二期重写时本表是验收基线;尤其 checker 零副作用三不变量(append-only 历史 / 释放与判定分离 / check 任务内零副作用,graph/checkpoints.py:15-29)与 validator incident 移交语义。 + +### 2.5 graph / langgraph(Pro 编排) + +- 拓扑(graph.py:948-1055):`START → planner → convergence →(gate)→ perception → operator → execution_check →(有 decisions→validator→summarizer→convergence | 无→convergence) →(gate)→ ... → exit_settlement →(continue→perception | end→END)`。 +- **没有 langgraph checkpointer**:`graph_builder.compile()` 无 checkpointer 参数,无 interrupt/resume。**"断点续跑"在 py 线不存在**——进程死=任务死,与 D5"运行中任务死亡标 failed 不重入"完全一致。【矛盾(易误读)】TODO.md 写"graph/(langgraph 编排 + checkpoints)……checkpoint 语义由 session log 承接"——此 checkpoint 是**计划校验检查点**(check_ledger.jsonl),不是执行位置断点;二期别去 DSH session log 里找 resume 语义,直接按"无断点续跑"设计。 +- **checkpoints 真身**:plan 驱动的校验调度 + append-only verdict 账本,存 session trace 目录:`check_ledger.jsonl`(判定账)、`run_outcome.json`(机器可读终局)、`check_streams.jsonl`(checker 自身推理转写,单条 20k 截断)(graph/checkpoints.py:58-68)。三不变量:append-only(判定不改写过去)/释放与判定分离(fail-open 只影响释放,inconclusive 原样记录)/check 任务零副作用(副作用只在 harvest 点与 settlement)(checkpoints.py:15-29)。 +- run_outcome 语义:goal 轴 × test 轴;`task_status=blocked` 仅当 verify 未满足且预算耗尽(或 user_stop/assert_halt 门闩);assert 失败计 tests.failed 但任务可 completed(§1.4)。 +- **task_plan.md 写入通道的机器规则**(graph.py:515-806)——Pro 最重的暗规则集,TODO.md 零记录: + 1. 机器频道拒绝(带回滚):计划要结束时不得留未完成嵌套子目标;`[Loop:continuous]` 里程碑**只有用户显式 stop(user_stop_requested)才能删除/打勾**,意图绝不从措辞推断(graph.py:533-555)。 + 2. 手滑整文件重写检测:状态未变但文本漂移的顶层 milestone → 拒绝并回滚(graph.py:558-575);声明式 update_note(target/replacement)豁免。 + 3. check 行确定性保护:被删/改的声明 check 行纯文本合并回来(内容驱动、与开关无关);**用户指导豁免**:注入指导时在场的 check 行解除保护可被删改,之后新增的恢复保护(graph.py:645-691; perception.py:95-115)。 + 4. finding 行确定性投影:未决 verify findings 每次写计划时从 repair 状态重渲染(模型删了会再长出来,解决了自动退役,graph.py:698-698)。 + 5. `checker-`/`checker:` 前缀 note key 系统保留,模型侧写入被拒(graph.py:578-597)。 + 6. ratchet 基线:里程碑变更对照"上次验证过的 plan"而非上一次写入(多次小编辑不能把漂移摊薄到触发线下,graph.py:703-738)。 +- perception 节点:注入指令消费(`/injected_instruction.json`:`{instruction, release_loop}`,读后即删,perception.py:75-92)→ 用户指导解除 check 行保护 → 混合 settles 截图(上轮动作非 wait 时 0.4s 停顿)→ OCR(配置了才做,状态栏裁剪+坐标回映射)→ XML 融合 → 截图后台落盘(sha256 内容寻址)。【缺失——**injected_instruction.json 是"任务中途注入指导"的唯一通道**,见 §6 新 S9】 +- State 通道(state.py:30-163):全字段显式 `extra="forbid"`;`user_stop_requested` 用 sticky_or reducer(一旦 True 永不回落);`operator_feedback` append-only 注入([checker]/[planner]/[final check] 源标签)。visibility.py 是节点级读写字段清单(`ARTEMIS_STRICT_STATE=1` 测试期执法)——**这是每个 agent 能看/能写什么的权威声明**,二期权限模型可直接翻译。【缺失】 +- convergence_gate 终局路由(graph.py:872-945):assert_halt 门闩 → checker_success=False 回 operator → 计划文件缺失 continue → 无顶层子目标也进 settlement(final review 对象是原始 goal 不是 plan)→ 全勾但存在 [Loop:continuous] 且无用户停止 → 继续。 + +处置:①一期黑盒;②二期 DSH agent loop 替换时,上述 plan 写入规则 + run_outcome + Loop:continuous 语义是**必须搬进 skills/工具守卫的行为基线**,量级远超 TODO.md 目前的"DSH agent loop 原生替代"一句话。 + +### 2.6 memory + +#### 2.6.1 transcript 账本(memory/transcript.py) + +S/F/A/T 四区:S=字节级稳定系统前缀;F=冻结历史+压缩块;A=原始回合 append-only(**永不删除/重排,tool-call 配对永不拆散**);T=当前尾。`T+mm:ss` 会话起始偏移时间戳(冻结后字节稳定)。ephemeral blocks(提醒/提示/指导包装)标记在 `additional_kwargs.ephemeral_blocks`,scrub 边删除,**永不进冻结区或胶囊**(transcript.py:15-95)。**`memory.transcript.enabled=false` 是字节级回滚开关**(还原 legacy 2-message 路径,L2/L3 同乘此旗)。【缺失——回滚开关的存在本身是行为契约】 + +#### 2.6.2 scrub 边与压缩梯级(config/agent.py:444-596) + +- 截图边:深度 3(紧凑)/6(低于 start gate 的宽松档);`pending_grace_steps=3`;**深度变化永不重写已 scrub 的消息**。 +- 文本边 xml_scrub_depth=1:一有更新观察,旧观察的 UI 列表/plan 复述/ephemeral 块即删——**刻意浅:只有活观察带索引元素列表,stale [n] 索引永不成为可点目标**(安全理由,重写时必须保留)。 +- 压力梯级 start 0.35 / soft 0.7 / hard 0.9 × `context_budget_tokens=80000`(操作者自测 prompt 尺寸,**不是** token_meter 的 last_prompt_tokens——后台 lens 的小 prompt 不得冒充上下文基数,token_meter.py:99-118);`min_active_steps=5` 滑窗下限;ladder 校验 `0≤start≤soft≤hard≤1`(agent.py:581-596)。 +- 相似历史提示:dHash 64bit 汉明距离 ≤5 触发(460 步实测校准:同屏 ≤4、异屏 ≥7),近 3 步内静默(像素级同屏提示的地盘)。 + +#### 2.6.3 chunking(memory/chunking.py 1833 行) + +三带胶囊(①概要+效果 ②区间摘要 ③逐步动作账本)+ era 折叠 + recall-only 周期;边界=里程碑切换/12 步/2000 token;失败胶囊保留原文重试;hard 线强制换入含 pending、冻结区整体折叠 L3(不可逆,后续 L2 追加其后);`min_steps=3` 防一步一块(里程碑关闭豁免、hard 豁免);模型=部署 default(当前 qwen3.6)。注入指令是 band③ 永不逐出行(graph.py:156-159)。【已覆盖("chunking 策略专项评估")→数值与 L3 不可逆语义补充】 + +#### 2.6.4 跨任务会话记忆【缺失,S1 的盲区】 + +`ARTEMIS_CONVERSATION_ID` 存在时,worker 在 start_session **前**查同线程最近完成任务的 `notes/*.md`(上限 8000 字符)作为 `prior_conversation_context` 注入 planner prompt 顶部(sdk/agent.py:357-374; data_engine/storage.py:410-440)——"notes 是线程的持久记忆,跨提交旅行"。**S1 只映射了 ID,没映射这个上下文继承行为**:DSH 侧等价物 = agent 会话历史原生延续(若 conversation≡session 一对一成立则免费),但 py 的"只继承 notes 不继承对话原文"是有意的上下文预算决定,重写时要显式决定继承什么。 + +#### 2.6.5 step_memory(memory/step_memory.py) + +共享后台摘要运行时:零阻塞派发 / 有界重试(1+3 次,退避 0/0.5/1/2/3s,耗尽显式 failed)/ 有界 flush(30s) / step_id 规范键+别名(tool_call_id/legacy 序号)/ 信号量并发(2) / detached_trace(lens 的模型调用不挂到提交者 span)。Flash 与 Pro 共用。【缺失】 + +#### 2.6.6 context_policy(memory/context_policy.py) + +每 agent 编译历史视图策略表:operator=strict_milestone(窗口3)、operator_cold_start=全量、planner=strict(窗口5,last2)、outputter/history_analyzer=full+chunk full view、diagnoser/committee=whitelist;chunk 两视图 full(带③账本)/digest(③换成 search_history 指引行);`agent.memory.policies` 按 agent 覆写。旗关时输出字节不变。【缺失】 + +### 2.7 token_meter(services/token_meter.py 267 行) + +每会话计量器:真实 `usage_metadata` 逐调用记 `llm_usage` trace(prompt/completion/cached/context_base_tokens + 会话快照 + per-source cached_ratio);UI"上下文占用"分母 `OPERATOR_CONTEXT_WINDOW_TOKENS=1_000_000`;来源=endpoint key 或 lens 标签。**不做决策、永不 raise 进调用路径**;`last_prompt_tokens` 明确不供压缩阈值使用(§2.6.2)。会话结束 `log_session_summary` 一行 INFO。映射表 F 区"大部分删除"成立;但它是 transcript 压缩阈值的**数据来源说明**(阈值读 operator 自记值而非 meter),二期用 DSH KV-cache 统计时注意这个区别。【已覆盖(F 区)→补充】 + +### 2.8 task_preset_catalog(admin_console/services/task_preset_catalog.py 515 行) + +- 15 条预设(TaskPreset: id/title/description/goal/profile(flash|pro)/category(flash|pro|cross_app|monitor)/tag/apps/required_packages/match_mode(any|all)/priority 50-95)+ APP_REGISTRY 22 个常用包(含微信/小红书/美团/支付宝等中国区应用)。 +- 推荐打分:命中 required_packages +100(all 模式多包再 +30),未命中 -40,按 priority 基分排序取 limit=12;category 过滤(profile 严格等值)。 +- 转 DSH skills 的转化基线:goal 文本、profile→执行模式映射、包匹配→设备适用性过滤三件事要一起搬。【已覆盖(映射表"🔧 转化为 skills")→打分逻辑补充】 + +--- + +## 3. 工具敏感分级表(S6 approval 映射输入) + +**关键前提**:结构化动作集本身不含卸载/清数据;**破坏性面集中在 `run_adb_command`(任意 adb shell,无命令过滤)**。S6 的"卸载/清数据/支付类"在一期映射实操上= 对 run_adb_command 的参数做模式审查,或整工具 ask。逐工具: + +| 工具 | 一句话 | 敏感级(建议 S6 映射) | 证据 | +|---|---|---|---| +| click/click_sequence/long_press/swipe/press_key/input_text/erase_one_char/focus_and_clear_text/open_link | 屏幕级 UI 操作 | never(常规设备操作,S6 原则) | mcp/action_specs.py:685-693, action_specs 各 spec | +| manage_app(launch) | 启动 app | never | actuators/adb.py:301-319 | +| manage_app(stop) | 强停 app(terminate_app) | **low-risk ask/never(需决策)**:非数据破坏但可中断状态 | actuators/adb.py:320-327;仅支持 launch/stop 两值,无 uninstall/clear | +| wait_for_delay / wait_for_text / wait | 等待 | never | action_specs.py:614-653; tools/wait_tool.py:53 | +| **run_adb_command** | 任意 adb shell(host 子进程 `adb -s shell`),支持持久终端环境、超时转后台、stdin 交互 | **破坏性入口:`pm uninstall`/`pm clear`/支付类操作皆可达。建议:ask + 允许清单(logcat/dumpsys 等只读 never)或整工具 ask**;无任何内置过滤 | tools/command_tool.py:618-744(无 allowlist/denylist,grep 证实) | +| manage_task | 管理后台 adb 任务(查看/kill/send_input) | never(作用域限本会话启动的后台任务) | command_tool.py:902, 1148 | +| launch_app | 按名找包并启动 | never | tools/mobile/launch_app.py:47 | +| get_ui_hierarchy / ocr_recognition | 读屏幕 | never | mobile/read_hierarchy.py:52; mobile/ocr.py:139 | +| read_logs / search_logs / analyze_logs | 读/搜设备日志 | never | mobile/read_logs.py:76; mobile/search_logs.py:211; log_tool.py:54 | +| ask_explorer / ask_diagnoser / ask_committee / ask_image_processor / video_analyzer / object_detection | 感知/LLM 子 agent | never | explorer_tool.py:564; diagnostic_tool.py:72; committee_tool.py:85; image_processor_tool.py:63; video_tool.py:253; object_detection_tool.py:181 | +| save_note/read_note/list_notes/update_note/append_note | scratchpad 笔记(worker 本地文件) | never(写侧有 plan 拦截规则,§2.5) | scratchpad.py:104-790 | +| search_history / replay_steps / get_step_screenshot | 冷历史检索/回放/截图 | never(只读 DataEngine 产物) | tools/history/__init__.py:159-295 | +| report_task_status / submit_answer | 终局自报/诊断提交 | never(终态语义工具) | validator/tool_declarations.py:276; diagnoser_submit_answer_tool.py:57 | +| (隐式)helper APK 安装/升级 | 设备环境准备,**非模型调用、无审批点** | 设备变更:一期建议视为任务前置的隐式授权(`ARTEMIS_HELPER_AUTO_INSTALL=false` 可关) | constants.py:56-58; sdk/agent.py:384-415 | +| (隐式)Chrome accessibility 强制 | 写 /data/local/tmp + force-stop Chrome,任务启动时自动 | 同上,隐式设备变更,可经 AgentConfig.force_web_accessibility 关 | sdk/agent.py:239-285 | +| (隐式)keyguard | **拒绝**替用户解锁(不猜 PIN) | 正向安全不变量,二期必保 | sdk/agent.py:215-237 | + +【部分】S6 只有抽象分级原则;本表是逐工具落地输入。**"破坏性=run_adb_command 参数审查"这个事实 TODO.md 未记录。** + +--- + +## 4. 外部契约清单(一期 DSH job 侧必须复制/遵守的输入输出) + +| 契约 | 内容 | 破坏后果 | +|---|---|---| +| env 全集 | §1.3 表(尤其 `ARTEMIS_TASK_WORKER=1`、`ARTEMIS_IPC_PORT` 可省、`ARTEMIS_MODEL_ENDPOINT`、凭证组、`ARTEMIS_SESSION_ID`) | 缺 TASK_WORKER → 起错分支;缺 SESSION_ID → 会话无主 | +| CLI 形态 | `python -m artemis.main --profile --session-id ...`(§1.1) | — | +| 退出码 | §1.4:0≠completed;DB 终态权威 | 终态展示漂移 | +| 取消 | cancel marker 文件协议(§1.5)+ 45s 宽限硬杀 | Windows 下优雅取消失效 | +| 暂停 | `.artemis_paused` 文件(§2.3.3),删除=恢复 | 900s 假死 | +| stdout | 纯文本日志(§1.6);结构化事件在 IPC 不在 stdout | G2 降级已接受 | +| 终态留痕 | DataEngine SQLite(`DATA_ENGINE_DB_PATH` env 同步给子进程,settings.py:372-374);trace 目录 `traces//`(notes/*.md、check_ledger.jsonl、run_outcome.json、check_streams.jsonl、images/、stdout.log) | 会话丢失 | +| 配置文件 | `artemis.jsonc`(工作区根 config/)+ `endpoint_library.json` + `credential_bindings.json` + app_dir `.env`;env 开关压过配置文件 | 模型/开关漂移 | +| 注入指导 | `/injected_instruction.json` `{instruction, release_loop}`(§2.5 perception) | 会话中调整方向能力丢失 | +| 设备锁协作 | worker 内仍会自取 DeviceExecutionLock(除非票据判定 already_held,§1.2);`ARTEMIS_DEVICE_QUEUE_TICKET` + lock scope env 是协作键 | 双跑期双调度器互踩 | +| 结构化输出 | `--output-description` → Outputter → DB 会话行(+可选 `RESULTS_OUTPUT_PATH` 落盘) | 最终结果拿不到 | + +--- + +## 5. 漂移风险 Top 10(按风险排序) + +1. **S2 退出码映射与 py 真相相反**:blocked/flash-failed 任务退出 0;DSH 会把失败任务标 completed(§1.4)。需改 S2 判定或声明 DB 权威。 +2. **cancel marker 通道被标"✅ 删除"与一期黑盒矛盾**:Windows 下 JobRegistry.kill=强杀,优雅清理(录屏 remux/trace/锁释放/cancelled 终态)全部依赖 marker 文件(§1.5)。 +3. **PAUSE_FILE 暂停协议未记录**:LLM 重试耗尽后任务静默挂 900s,DSH 侧看起来像死锁(§2.3.3)。 +4. **plan 写入机器规则集零记录**:Loop:continuous 保护、手滑重写检测、check 行合并回、finding 投影、checker- 前缀保留、ratchet 基线——二期重写最易整包丢失的暗规则(§2.5)。 +5. **会话续聊上下文继承(notes 注入 planner)未入 S1**:conversation≡session 若一对一成立则原生免费,但"只继承 notes 不继承原文"的预算决定要显式化(§2.6.4)。 +6. **`ARTEMIS_TASK_WORKER=1` 缺席 env 清单**:不设则 worker 走 daemon 派发分支,S5"入口不变"悄悄失效(§1.3)。 +7. **LLM 端点熔断 + 暂停 + fallback 判定的可靠性行为未成文**:DSH LLM seam 若只做重试,auth 可 fallback / bad_request 不 fallback / 进程内 3 次 30s 熔断 / 8 次跨类上限都会漂移(§2.3)。 +8. **graph 无断点续跑**:TODO.md"checkpoint 语义由 session log 承接"易误读成有 resume 语义;py 侧 checkpoint=校验账本非执行断点(§2.5)。 +9. **`injected_instruction.json` 中途指导通道未入对接语义**:release_loop([Loop:continuous] 的唯一合法解锁)也走这里(§2.5, §6 新 S9)。 +10. **结构化最终结果只在 DB 不在 stdout**:一期 job output ring 拿不到机器可读结果,依赖 DataEngine SQLite 可达(§1.6)。 + +次级:manage_app 无 uninstall/clear(破坏性全在 run_adb_command,§3);Google safety BLOCK_NONE + langchain patch(§2.3.4);xml_scrub_depth=1 的 stale-index 安全理由(§2.6.2);Flash-Lite agentic 视频误读时间戳的显式排除(§2.3.4);ADB 后台任务随任务终止的 15s 清理边界(sdk/agent.py:752-760)。 + +--- + +## 6. 对 docs/TODO.md 的修订建议 + +1. **S2 修正**:补"py worker 退出码不编码任务结果(0 含 blocked/flash-failed);终态以 DataEngine DB 行为权威,job outcome 仅 UI 层;stdout 特征行(`Task ended blocked`/`Task execution failed`)可作一期辅助判定"。 +2. **S5 修正**:env 清单补全——`ARTEMIS_TASK_WORKER=1`(worker 分支开关,必设)、`ARTEMIS_DEVICE_QUEUE_TICKET`、`ADB_DEVICE_SERIAL`、凭证组、`LLM_PAUSE_TIMEOUT_SECONDS`;补"取消走 cancel marker 文件(`request_cancel`),kill 仅作 45s 宽限后的兜底"。 +3. **映射表 C 区修正**:`cancel_requests` 从"✅ 删除"改为"一期保留(Windows 优雅取消唯一通道),二期随 worker 退役"。 +4. **新增 S9(建议)· 任务中途指导注入**:`/injected_instruction.json` `{instruction, release_loop}` 读后即删;release_loop 是 `[Loop:continuous]` 里程碑唯一合法结束信号。DSH 侧对应物(followup 带 control 标志?)需 spike。 +5. **新增差距 G8**:一期 job 侧终态判定需访问 DataEngine SQLite(或 stdout 特征扫描),S4 环境级判定的 `has_steps`(零步骤)信号在 TS 侧无直接来源——需定"读同一 SQLite"还是"降级为 exit code+markers"。 +6. **新增差距 G9**:LLM 暂停文件(`.artemis_paused`)在 DSH 一期的呈现(挂起 job)与 resume 操作(删文件)需写入运维手册;或一期设 `LLM_PAUSE_TIMEOUT_SECONDS` 显式小值。 +7. **映射表 A/B 区补充**(防二期丢行为基线):指向本文件 §2.4/§2.5/§3 作为"逐 agent 渐进搬迁"的验收清单;点名 plan 写入机器规则、checker 三不变量、run_outcome 双轴语义。 +8. **G4 补充**:环境级判定除了 markers 扫描,py 原版以"零步骤失败"为一票通过(task_queue_service.py:1070-1086);一期 TS 侧若不读 DB,此信号缺失,误判率会升。 +9. **映射表 model_service 行补充**:pin fail-loud(未知端点名拒启)+ ENDPOINT_OWNED_KEYS 覆盖清单 + 节点继承规则(12 agent 节点+4 utils,history_analyzer←operator 等)是 DSH per-agent 模型选项的输入。 +10. **P0/P1 spike 清单补充**:S2 spike 增加"kill→cancel marker 联动验证(Windows)"与"worker 内 DeviceExecutionLock 自取与 TS 闸门叠加行为验证"。 diff --git a/docs/migration/inventory/05-frontend-integration.md b/docs/migration/inventory/05-frontend-integration.md new file mode 100644 index 000000000..cbdc6f00c --- /dev/null +++ b/docs/migration/inventory/05-frontend-integration.md @@ -0,0 +1,247 @@ +# 迁移盘点 · 前端控制台与外部集成面 + +> **编号警示**:本文里 `**Gn · …**` 形式的「新增差距」段落属于早期编号方案,与 `docs/TODO.md` 现行 G 编号**不是同一套**(G8/G9/G10/G11/G12 已撞车,冲突表见 [registry.md](../registry.md))。引用内容时写「`inventory/0N` 的 X 段」,不要写 `Gn`。 + +> 范围:`apps/showcase_ui_v2/src/**`(stores / utils / components / locales / co-located specs / vite.config)、`mcp_server/**`(工具面 + 7 通知通道)、`packages/artemis-client/**`(Python SDK)、SSE `/api/stream` 的服务端桥(`apps/admin_console/services/ipc_service.py` + `routers/tasks.py` 的 stream 端点,注意:仓库中**不存在** `artemis/ipc_service` 目录)、`tests/integration|e2e`、`scripts/`(dev.sh、quality_ratchet.py)、Makefile、start.sh/start.bat、pyproject/pre-commit 根配置;基线:cc6b20c;日期:2026-10-09 +> 目的:记录 docs/TODO.md **未写明、迁移中若无人记录即会丢失**的行为、不变量与契约。TODO.md / 草稿 01–03 已覆盖的只标注出处、不展开。本文是 P4(外部接入方迁移清单)/ G7(过渡边界)/ P0(契约测试)的输入。 +> 与既有草稿的分工:**01** 已覆盖后端 `/api/run` 准入管线、`/api/status` 四源、SSE 服务端事件名全集(含 `queue_held`/`queue_paused`/`queue_resumed`/`server_shutdown`)与订阅回放;**02** 已覆盖 worker env 全集;**03** 已覆盖媒体路由与诊断。本文从**前端消费方与外部调用方**视角补齐:每个事件前端怎么消费/忽略、控制台交互不变量、MCP 工具协议承诺、通知通道矩阵、SDK 公开面、开发/构建链。 + +--- + +## 1. SSE 事件契约(前端消费视角的对照基线) + +### 1.1 传输与连接语义 + +- **单通道** `/api/stream`(可选 `/api/stream/{session_id}`),`text/event-stream`;前端幂等 `start()`,`onerror` 只告警,**依赖浏览器 EventSource 原生自动重连,不手动重建**(stores/stream.ts:679-706;stream.spec.ts:587-604 锁定 URL 与单例)。 +- 服务端每连接一个 `asyncio.Queue` + 订阅回调,5s 无事件发 `keep-alive`,`state.shutdown_event` 触发即断流(routers/tasks.py:486-614)。订阅确认事件 `info`(tasks.py:520)。 +- **断线重连对账**:前端收到 `info` 即对当前会话触发 steps 快照回填 `backfillSessionSteps`(stream.ts:686-691;stream.spec.ts:523-530)。服务端补发:`all/active` 订阅合成一条 `session_started` + 该会话全部 `startup_progress` + 已落库步骤逐条 `step_recorded`(tasks.py:522-566;草稿 01 §1.4 已覆盖服务端面)。 +- **事件桥(worker→UI)**:worker 经 DataEngine 把 `{event_type, data}` JSON 行写到 127.0.0.1 TCP IPC(端口来自 env `ARTEMIS_IPC_PORT` + 端口文件**双候选**,env 因 UI 服务器重启永久陈旧而文件恒新,engine.py:445-524);UI 侧 `ipc_service` 接收、按事件清洗后广播给 SSE 订阅者(ipc_service.py:136-228)。`session_started` 对 `cancelled_session_ids` 里的会话**静默丢弃**(ipc_service.py:150-158);`startup_progress` 按 (session_id, stage) 幂等留档最近 16 条供晚订阅重放(state.py:257-278)。IPC 发送线程安全(锁内一次完整帧 + 单次重连,engine.py:499-524)。 +- payload 清洗(ipc_service.py:32-134):`trace_recorded` 的 tool/action payload 压成 `{args}`、llm_call 压成 `{error}`;`step_recorded/step_updated` **剥 `pre/post_screenshot_bytes`**(客户端经图片 URL 取图)并 `normalize_step_actions`;一切 "object at 0x…" / `` 合成键成「幽灵会话」,且连续提交拿不到线程 id 另起新线程(AGENTS.md 已载规则,其机制细节 todo 未载)。 +3. **Step 3 — active_tasks**:外部入口/多设备运行、且列表中尚无表示的任务**新建为 running**(conversation_id/submitted_at 依次取 active 行→tracking 桥接→null,session-merge.ts:183-215;spec:152-181);pending 表示被 active_tasks 升级为 running 时**只补缺不覆盖**(session-merge.ts:204-214)。activeList 为空但轮询报 running 的取走→持锁间隙:tracking 已有则复用其锚点复活(**绝不 now 现造遮蔽正确 submitted_at**),dismissed 的不现造(session-merge.ts:216-241;spec:183-199, 224-257)。 +4. **Step 4 — tracking 桥接**:跨轮询持久的非响应式 `Map`(session.ts:97)桥接「队列消费→DB 落库」的窗口防闪烁;载荷仍提及→照常桥接并重置宽限;载荷不再提及且无 DB 行→**60s 宽限(GHOST_TRACKING_TTL_MS)后清除**,防外部误报永久驻留 running 幽灵(session-merge.ts:70-84, 243-267;spec:200-222, 258-295)。 +- **dismissedNoRowSessions**:已停止且从未落库的会话 id 集合——停止后的补偿轮询旧载荷会短暂把它们写回 active/tracking,合并器按集合跳过;DB 行一旦出现即移出(session.ts:99-104, 370-376;session.spec.ts:489-517)。 +- **sessionChronoKey**:轮次排序/展示锚点 = `submitted_at ?? start_time`;`start_time` 随生命周期漂移(入队时刻→获得设备时刻),用它排序会让发射的轮「上顶」乱序(session-merge.ts:27-37;spec:400-407;AGENTS.md 已载一句话,机制未载)。 +- **conversationThreadKey vs submitConversationId**:合成 `round:` 键**只用于时间线/分组过滤**(session-merge.ts:375-380);提交参数走 `submitConversationId`,它**拒绝暴露合成键**(session.ts:140-146;session.spec.ts:277-297)。selectSession 的线程联动依次取 raw 行/pending/active 的 conversation_id,全无线索才退化合成键(session.ts:704-723;spec:519-561)。 +- **isDraftConversation**:「新建会话」已指派线程但无任何轮次落库的草稿态**豁免一切自动跟随**,否则 2s/6s 轮询把视图拽去别处、下一条消息误入他线程(session.ts:148-158;session.spec.ts:298-360;SessionListPanel.spec.ts:25-50)。 +- **轮询签名去重**:`statusSignature`(JSON.stringify)内容不变则跳过响应式赋值;乐观更新(stop/delete/clear)先 `invalidateStatusSignatures` 防旧状态回写(session.ts:209-221, 308-325;session-merge.ts:364-370;spec:598-670)。 +- **轮询节奏**:2s `/api/status` + 每 3 周期(6s)`/api/sessions`;`document.hidden` 暂停、visibilitychange 恢复即刷(session.ts:37-40, 258-271;spec:788-853)。localStorage 缓存 key `artemis.sessions.v1`(session.ts:37, 779-815)。 +- **conversationGroups**:轮按 conversationThreadKey 聚合;线程内按 sessionChronoKey 提交序;线程名 = 最早一轮 goal;线程态取 running>paused>pending 的最高秩;活跃线程置顶其余按最近活动倒序(session.ts:841-877)。 +- **runTask 回写**:以 `/api/run` 响应里的 `conversation_id` 回写当前线程(请求未带时后端新建),必须赶在下一轮 6s 轮询回填前,否则 selectSession 用合成键覆盖导致断线程(session.ts:465-471;spec:240-276)。`model_endpoint` pin 取提交时刻 system store 的端点名,未入库端点不编造(session.ts:426-432;spec:166-229)。 + +**处置建议**:映射表「问题本身消失」的判断对**数据源**成立,但对**展示聚合**不成立——DSH jobs roster / toolview 把 pending + running + 历史聚合成线程时,submitted_at 锚点、幽灵防护、草稿豁免、跨线程竞态这四类坑会原样重现。建议把本节作为 P3 UI 接入的验收清单输入。 + +### 2.2 CommandDock:队列 chip / 暂停 / 继续 / 停止多态(components/CommandDock.vue) + +- **排队 chips**:数据源 `threadPendingRounds`(**按当前线程过滤**,session.ts:164-168);纵向 FIFO 列表、最新在最下、可单独移除(移除 = 对该 session 走单任务 stop 路径,session.ts:495-497;CommandDock.vue:170-204;CommandDock.spec.ts:178-213)。 +- **scoped 到线程**:`threadHasQueueStake` = 本线程有排队消息或有运行/暂停轮;暂停/继续控件只在「在场」时出现——新会话/历史线程**不得**显示或操作他线程的队列状态(CommandDock.vue:43-59;spec:242-275;提交 138695c)。 +- **收敛动作**:手动暂停(`queue_paused`)与环境熔断挂起(`queue_holds`)收敛为同一个「继续队列」按钮(调 `/api/queue/resume` 双解除),原因只进悬浮提示,无说明文案(CommandDock.vue:52-59, 190-204;spec:214-241;对应 138695c「drop the state blurb」)。暂停入口只在有任务在场且无挂起时出现(只停新派发不影响运行中)。 +- **提交/停止多态**:`currentConversationRunningTaskId` 非空 → 按钮变「停止」,停的是**当前线程内**运行中的轮(session.ts:198-207;CommandDock.vue:33, 231-238;spec:128-152);停止后 400ms 防抖防按钮抖回(149-163)。 +- **输入语义**:Enter 提交(续 `submitConversationId` 线程)、Shift+Enter 换行、IME 组词中 Enter 不提交;Ctrl/Cmd+K 聚焦;profile(flash/pro)持久化 localStorage `artemis_selected_profile`;提交失败经 `ApiError.detail` 展示 5s 自动消失(CommandDock.vue:81-147;spec:63-166)。 + +**覆盖状态**:todo 完全未提(前端行为整体只在映射表一行)。最近 6 提交中 46e59b2(纵向堆叠+never-started 持久化)、138695c(scoped+去文案)锁定于本 spec。 +**处置建议**:队列 chips + scoped 交互是「Agent Inbox 可视化」的现成验收标准——P3 toolview 应对照本清单;删除 Vue 控制台时这些 spec 随之消失,需先把断言移植为 DSH 侧测试或本文件级清单。 + +### 2.3 AgentTimeline:时间线展示不变量(components/timeline/AgentTimeline.vue + spec) + +- **排队轮不进时间线**:pending 轮只留在 dock 队列列表,时间线不渲染;发射后按原轮序出现(AgentTimeline.vue:76, 108;spec:639-679;提交 3a1facc)。 +- **失败原因透出**:会话级 `error_message` 解释「轮次失败但无执行记录」的断点(AgentTimeline.vue:121, 901;spec:243-257);SessionTreeDrawer 同样把无 trace 失败轮的会话错误作为断点(SessionTreeDrawer.spec.ts:124-148)。对应 46e59b2 的 never-started failed 行(后端补插最小失败行含 stdout 尾行,草稿 01 §3 风险 4 已覆盖后端面)。 +- **回合级折叠**:历史会话(全部块完成)默认收起、头部显示步骤数;有未完成 live 块的回合自动展开、完毕后收起;轮级开合与动作卡手风琴语义(spec:422-638)。 +- **运行指示**:planning loader(运行中且无日志;有未完成 llm_stream 块即隐藏;session_ended 后隐藏)、retrying banner(retryInfo 组装)、stream reset notice、暂停卡 + resume 动作(spec:293-420)。 +- **媒体/回放入口**:replay 按钮与「重跑步骤」下拉 → ReplayDrawer(`GET /api/sessions/{id}/replay_steps`、确认模态后才 POST 回放、无设备禁用、回放中心双图/灯箱键盘操作,ReplayDrawer.spec.ts:145-292);录像/投屏 → FloatingPlayer + player store(running/paused→live 不轮询;历史无 video_url→processing 轮询、retry_after_ms 钳制 [500,3000]ms、120s 超时、generation 守卫、llm_stream 到达不触发帧重提取,player.spec.ts:67-377);设备实时投屏走 MJPEG `/api/stream/device-live`(routers/stream.py:31-56)。 +- **快照回填(timeline store)**:切会话清空重拉、世代守卫丢弃过期响应;steps 快照幂等(history_snapshot 恒在 live 前);checks 幂等且带 streams 时重建 Thought/Work 交错;notes 默认选中 task_plan.md;startup_progress 按 stage 幂等(timeline.spec.ts:30-244)。 + +**覆盖状态**:G2 只写了「一期接受输出环文本流、可选 JSONL+toolview」。**结构化卡片的信息骨架**(步骤/动作/截图配对/检查面板/笔记/失败断点)没有基线记录——二期把 ARTEMIS 执行逻辑搬入 DSH agent 时(G1 模式 a),这份 UI 语义就是 toolview 的渲染规格。 +**处置建议**:本节 + §1.2 的 llm_stream/checker_event 语义合并作为 G2「可选小件」的实现规格;`StepEvent`/`CheckerBlockData`/`StreamSegment` 等类型契约见 types/stream.model.ts(前端删除前是唯一成文处)。 + +### 2.4 自动跟随与跨线程竞态防护(stores/session.ts + stores/stream.ts) + +- **准入函数 `mayAutoFollowSession`**:目标会话属于当前查看线程(或当前无线程视图)才允许切换;目标线程暂不可解析(合并表示未到位)同样拒绝——SSE(stream.ts:417-424, 479-489)与状态轮询(session.ts:345-358)**共用同一准入**(session.ts:740-754)。理由:跨线程自动跟随会换走用户正在对话的线程,下一条消息借 `submitConversationId` 误入他线程队列(三个文件注释同文,提交 8a0108c)。 +- **pin 语义**:用户显式点选非运行会话即 pin;点当前运行中的任务取消 pin 恢复跟随(session.ts:683-696);runTask 提交成功即清 pin(session.ts:445)。已 pin 时 session_started/startup_progress/轮询都不抢视图。 +- **fetchSessions 兜底选中**:仅初次(无选中、未 pin、非草稿、非 running)触发,优先**当前线程**最新轮;线程尚有在场(排队/运行)表示时不抢——等发射时由轮询跟随(session.ts:378-406;spec:443-487)。 +- **session_ended 不提升排队轮**:全局 running 态只由剩余 activeTasks 推导(stream.ts:436-449;spec:461-489;提交 9739f32)。 +- **轮询抢跑 /api/run 响应**:状态轮询先观察到新 runner 时那仍是刚提交任务而非应保持的旧任务(session.ts:452-464)。 + +**覆盖状态**:todo 仅在背景段提「前端侧已修:9739f32 等 5 个提交」,未写不变量本体。测试锁定完整(stream.spec.ts:323-406、session.spec.ts:361-487)。 +**处置建议**:这是**队列×多会话 UI 的通用竞态经验**,与具体框架无关;autogamer-queue 的 toolview/roster 复刻线程视图时必须继承(见 §5 G11 建议)。 + +### 2.5 locale 键锁定机制(locales/locales.spec.ts) + +- **键集合对齐**:zh-CN 与 en-US 递归叶子键集合必须相等,唯一豁免前缀 `launcher.presets.items.`(推荐任务 id 键,**只登记 zh-CN**,英文以后端目录为准;且每个 id 必须同时有 title+goal 非空)(locales.spec.ts:18-52)。`DEFAULT_LOCALE === 'zh-CN'` 亦被锁定(:22-24)。 +- **机制性质**:这是纯前端测试锁,随 showcase_ui_v2 删除而消失;DSH Web UI 的文案无此约束。 +- **覆盖状态**:AGENTS.md 已载(「en-US locale keys are locked by tests」);todo 未提。 +- **处置建议**:删除(随控制台);若 DSH toolview 需要 i18n,preset id 键「单一事实源在 zh-CN/后端」的原则值得保留一句话。 + +--- + +## 3. 外部集成面(MCP / SDK / 脚本) + +### 3.1 REST API 消费方总表(前端 + artemis-client + mcp_server 共用) + +请求/响应字段级契约草稿 01 §2.1 已覆盖;此处补**消费方×端点**矩阵与前端侧语义: + +| 端点 | 消费方 | 前端侧语义要点 | +|---|---|---| +| `POST /api/run` | UI、SDK(ingress=python_sdk)、MCP(经 daemon,ingress=mcp)、webhook(未来) | 幂等短路/409/400/rejected 管线见草稿 01 §1.1;UI 依赖响应 `tasks[0].session_id` 选中 + `tasks[0].conversation_id` 回写线程(session.ts:449-471) | +| `GET /api/status` | UI 2s 轮询、SDK health/get_task 兜底、dev.sh 就绪探测(`curl /api/status` 200 即 ready,dev.sh:83,91) | `queue_paused`/`queue_holds` 是队列状态的**唯一**前端信源(SSE 死信道,见 §1.2) | +| `POST /api/stop`(all/session_id/device_id,body 或 query 双通道) | UI stopTask、SDK stop、MCP stop(经 daemon) | UI 乐观更新 + 签名失效(session.ts:499-592) | +| `POST /api/resume` | UI resumeTask | 后端 `not_paused` 时**不乐观改状态**(session.ts:594-612) | +| `POST /api/queue/pause|resume` | UI dock | resume 同时解除手动暂停与熔断挂起(tasks.py:267-278) | +| `GET /api/sessions`、`GET /api/sessions/{id}` | UI 6s 轮询、SDK get_task | 行携带 conversation_id/submitted_at(SQLite 列,session_repository.py:536-566);列表期对 running 行做存活校验、死→failed + 录屏 auto-harvest(草稿 01 §1.5 已覆盖) | +| `POST /api/sessions/{id}/delete`、`/api/sessions/delete-batch`、`/api/cleanup` | UI | delete-batch 一次请求删整线程(session.ts:879-923) | +| `GET /api/sessions/{id}/usage|tree|background_tasks|startup_progress` | UI(RunInfoPopover/SessionTreeDrawer/timeline store) | tree 是轨迹树数据源 | +| `GET /api/sessions/{id}/replay_steps` + replay POST | UI ReplayDrawer | 确认模态后才执行 | +| `GET /api/devices` | UI、SDK list_devices、e2e | `{devices:[d.to_dict()]}` | +| `GET /api/tasks/presets|catalog`、`GET /api/run/defaults` | UI 启动器/dock | defaults 回 verification_level+explorer_mode(tasks.py:201-214) | +| `GET /api/system/readiness`、`/api/system/*`(emulator/adb/model-config/credentials/endpoints 等 ~20 个) | UI system store + 诊断向导 | 草稿 03 §1.7 已覆盖后端;消费方在 system.ts:232-573 | +| `GET /api/stream(/{session_id})` | UI | §1 | +| `/images`、`/videos`、`/local_file` | UI(截图/录像/本地文件代理) | SameOrigin 鉴权语义见草稿 01 §1.7 | +| `GET /api/v1/capabilities` | **SDK capabilities()** | **服务端从未实现**——恒 404 → 客户端回退 legacy 假基线 `tasks.submit/get/stop, devices.list, system.readiness`(client.py:151-157, 62-70) | + +**覆盖状态**:去留已覆盖(映射表 L103-105);消费方矩阵与「capabilities 是假端点」缺失。 +**处置建议**:迁移清单(P4)按本表逐行给 webhookRuntime/SDK 找等价物;capabilities 端点要么在 DSH 侧落实要么从 SDK 删除,不留隐性假基线。 + +### 3.2 mcp_server 工具面(stdio/sse 双传输,server.py:93-102;启动附带 awake_service) + +| 工具 | 关键参数 | 外部承诺(docstring 即协议) | todo 覆盖 | +|---|---|---|---| +| `mobile_run_task`(tools/task_runner.py:204-549) | task_desc, conversation_id, model(Flash/Pro), locked_app_package, app_path, expected_output_desc, device_serial, verification_level(off/final/checkpoints/strict), explorer_mode(flash/pro/ultra) | 非阻塞返回 trace_id/stdout_log/stderr_log/notes_dir(Pro);完成唤醒**仅在给了 conversation_id 时**;否则调用方必须自设 **1 分钟兜底轮询**;显式 serial 未接→拒绝(防跑错设备);daemon 不可达时**禁止 standalone 兜底**防 runner 冲突;enqueue 无确认且队列不可查→返回 `unknown` 防重复提交(:361-414);spawn 看门狗 60s 无日志即杀+标 failed+通知(:44-129) | 映射表 L113 一行 ⚠️;工具协议承诺**缺失** | +| `mobile_manage_task`(tools/task_manager.py:218-627) | action=status/stop/inject_instruction; trace_id; instruction; release_loop | status 返回 device_serial/test_summary(run_outcome.json 机读)/progress(Flash=turn+thought+action,Pro=task_plan);**对账规则**:DB 终态>status.json;死亡判定需 pid 无 + 锁不可追踪 + 非宽限期(45s);「不确定=存活」;`success`→completed 归一;stop 先 daemon 后取消票据再杀进程树;**inject_instruction 是运行中引导通道,release_loop 是连续监控任务的唯一优雅停止信号**(自然语言「停下」不被解释为停止) | 缺失 | +| `mobile_get_device_state`(tools/device_state.py:34) | view_type, device_serial | 设备状态/层级快照 | 缺失 | +| `mobile_inspect_trace`(tools/inspect_trace.py:53) | trace 检索 + 截图标注 overlay | — | 缺失 | +| `mobile_diagnose`(tools/diagnose.py) | 系统就绪诊断(探测聚合 + 凭据步骤 + 任务忙态) | — | 缺失 | + +### 3.3 通知通道矩阵(mcp_server/notifiers/*;todo L151 只有一行 ⚠️) + +触发入口(谁调 `notify`):① 任务终态——`conversation_id` 存在 **或 ingress=mcp** 时由队列 worker 派发,payload `{trace_id, session_id, status, goal}`,event_type=终态(task_queue_service.py:943-962);② 存活对账判死(task_manager.py:191-215);③ spawn 看门狗判死(task_runner.py:112-128)。后两者 event_type=failed。 + +| 通道 | 可用条件(env) | 行为要点 | +|---|---|---| +| composite(默认聚合) | 任一子通道可用 | 顺序 File→AgentApi→Webhook→Script→Desktop(composite.py:27-34) | +| file | 恒可用 | 追加 `traces//notifications.jsonl`(file.py:54) | +| agentapi | `ANTIGRAVITY_LS_ADDRESS`+`ANTIGRAVITY_CSRF_TOKEN` | env 恢复三级:当前 env → 共享文件(`~/.gemini/jetski/.jetski_env`、`~/.artemis/.artemis_env`、父目录 `.jetski_env`)→ psutil 扫描其他进程环境(Linux 再扫 `/proc/*/environ`),命中即回写 env 与共享文件(agentapi.py:32-252) | +| webhook | `OPENCLAW_WEBHOOK_URL` / `MCP_NOTIFICATION_WEBHOOK` / `ARTEMIS_WEBHOOK_URL`(首个 http(s) 值) | POST JSON `{event, title, conversation_id, message, timestamp, payload}`,UA `Artemis-MCP/3.0`,5s 超时(webhook.py:81)——**这是外部网关的 payload 契约** | +| script | `ARTEMIS_NOTIFY_CMD` / `MCP_NOTIFY_COMMAND` | 命令模板占位符 `{title}{message}{conversation_id}{event_type}{trace_id}` 替换后执行(script.py:30-60) | +| desktop | `ARTEMIS_DESKTOP_NOTIFY` 真值;CI 环境默认关 | 系统通知(desktop.py:36-55) | + +**处置建议**:DSH `jobs settled` 事件承接终态通知时,webhook payload schema 与「ingress=mcp 无 conversation_id 也通知」的触发矩阵是必须保留的外部承诺;agentapi 的 env 恢复属客户端黑科技,可随通道一并删除或原样移植(建议:迁移清单中列为「外部用户实际在用哪些通道」调研项)。 + +### 3.4 packages/artemis-client(Python SDK 公开面 —— P4 迁移清单基线) + +- **入口** `artemis_client.__init__`:`ArtemisClient` + errors + models(`__all__`)。 +- **ArtemisClient**(client.py:55-401):`health()`→GET /api/status;`readiness()`→GET /api/system/readiness;`capabilities()`(见 §3.1 假端点);`list_devices()`;`submit(goal, *, profile, device_serial, expected_output, enable_outputter, locked_app_package, app_path, conversation_id, task_id, verification_level, explorer_mode, options)`——**task_id 客户端生成 UUID、作为 legacy `session_id` 发送,复用即幂等重试**(:208-214),ingress 固定 `python_sdk`(:221),rejected/空 tasks→`TaskRejectedError`;`get_task(id)`——GET /api/sessions/{id},404 则扫 /api/status 的 queue+active_tasks,仍无→合成 `status=launching`(:253-269);`wait_for_task(timeout=1800, poll_interval)`;`run()`=submit+wait;`run_task(task_obj)`(鸭子类型 goal/profile/device_serial);`stop(id)`→POST /api/stop,返回 `status=='stopped'`;`set_device`/`set_concurrency_mode`(后者仅留迁移兼容)。 +- **models**(models.py):`TaskHandle`(task_id/session_id 别名)、`TaskResult`(done=`TERMINAL_TASK_STATUSES`{completed,failed,cancelled};succeeded=`SUCCESS_TASK_STATUSES`{completed,success})、`Device`、`Capabilities`、常量 `TERMINAL_TASK_STATUSES`/`SUCCESS_TASK_STATUSES`/`VERIFICATION_LEVELS`/`EXPLORER_MODES`。 +- **errors**(errors.py):`ArtemisClientError` ← `NetworkError`/`ProtocolError`/`TaskRejectedError`/`TaskTimeoutError`/`ApiError`(← AuthenticationError/NotFoundError/ConflictError,带 status/body/detail)。 +- **transport**(transport.py):`JsonTransport`——同步 urllib、token header、30s 默认超时、HTTPError body 解析出 detail;client 经 `asyncio.to_thread` 包成 async(client.py:360-372)。 +- **base_url 解析**:参数 > `ARTEMIS_BASE_URL` > `ARTEMIS_DAEMON_HOST:ARTEMIS_DAEMON_PORT`(默认 127.0.0.1:8000)(:90-94)。 +- **测试基线**:`packages/artemis-client/tests/`(transport/client 单测)+ `tests/integration/test_thin_sdk_live.py`(opt-in,需 `ARTEMIS_TEST_DEVICE_SERIAL`)+ `tests/e2e/test_multi_port_end_to_end.py`(7 面:配置/daemon API/多设备/MCP 工具/SDK/CLI/锁隔离)。 + +**覆盖状态**:映射表 L195-196「DSH 官方 SDK 替代 ✅ 删除」已覆盖去留;**公开面清单与幂等重试承诺缺失**——P4 要求的「外部接入方迁移清单」没有可对照的输入。 +**处置建议**:本节原样进 P4 迁移清单;「task_id 幂等」与 get_task 的 queue/active 兜底语义在 DSH SDK 中需验证等价物。 + +### 3.5 dev / 启动脚本 · 构建产物链 + +| 项 | 行为 | 证据 | todo 覆盖 | +|---|---|---|---| +| `scripts/dev.sh` | 后端 :8000(可 `BACKEND_PORT` 覆盖)+ Vite :5180(`FRONTEND_PORT`);linked teardown(任一侧退出即全停,Ctrl+C 落在 wait -n);后端已运行则复用(curl /api/status 探测,**复用路径不登记 LISTEN pid,teardown 不会杀它**);后端启动 30s 超时 | dev.sh:22-133 | 缺失 | +| **msys/win32 PID quirk** | bash `$!` 是 msys PID 而 npm/vite/python 是原生 win32 进程,msys `ps` 看不见、plain kill 只杀 wrapper 留孤儿占端口;解法 = 起来后用 `netstat -ano` 记录真正 LISTEN 的 win32 pid,退出 `taskkill //PID //T //F`;POSIX 走 plain kill;`listen_pid` 恒返回成功防 `set -e` 误杀(grep 无监听者时非零) | dev.sh:38-71 注释 | 缺失(G6 只覆盖 Windows kill 验证,未记这个现成方案) | +| `make` 目标全集 | dev/start/ui/mock-ui/restart/stop/status/build-ui/release-ui/doctor/test/test-integration/test-device/test-all/install/install-deps/setup/lint/format/typecheck(pre-commit 走 pyright)/quality-ratchet/precommit-install/precommit/clean/help;`mock-ui`=`ARTEMIS_MOCK_DRIVER=1 … --no-open`;`release-ui`=build-ui+sync:resources | Makefile:16-137 | mock-ui/端口在 AGENTS.md;目标全集缺失 | +| quality_ratchet | 基线文件 `.quality-baseline.json`;三指标 `broad_exception_handlers`/`silent_broad_exception_handlers`/`type_ignore_comments` 按计数棘轮(超基线 fail,低于基线提示下调);源根 artemis/mcp_server/apps.admin_console/third_party | scripts/quality_ratchet.py:19-24 | AGENTS.md 一句话;机制缺失——**过渡期 py 侧冻结时它防劣化,DSH TS 侧无对应物** | +| pre-commit | 3 个 local hook:ruff-check / ruff-format --check / quality-ratchet(全 `uv run` system language) | .pre-commit-config.yaml | 缺失 | +| vite.config.ts | `build.outDir: dist/browser`;dev proxy `/api`(**changeOrigin + proxyReq.removeHeader('origin')**,否则 SameOriginBoundaryMiddleware 403——SSE EventSource 同源请求也带 Origin,必须同规则)、`/images` `/videos` `/local_file` → 127.0.0.1:8000;vitest jsdom 配置 | vite.config.ts:18-54 | AGENTS.md 已载 Origin 规则;SSE 也受影响这点缺失 | +| 构建产物链 | `npm run build`(vue-tsc+vite) → `dist/browser/` → FastAPI `_get_showcase_dist()` 候选托管(SPA fallback)→ `npm run sync:resources`(**先清空后复制**到 `artemis/resources/showcase_ui/` wheel 回退目录,以 `index.html` 存在为准)→ **手动执行不挂进 build**(漏跑则 wheel 态 UI 陈旧);`uv run artemis ui` 启动时源码有变自动重建 | sync-wheel-resources.mjs:1-30;server.py:245-247;Makefile:60-68 | AGENTS.md 已载两行;「先清空」「手动链易漏」缺失 | +| start.sh/start.bat | 终端用户启动器(PATH 补全/依赖检查/构建/`--open` 显式开浏览器);服务端**永不自动开浏览器** | start.sh:15-40;AGENTS.md | 已覆盖(AGENTS.md) | +| pyproject | `[project.scripts] artemis = artemis.interfaces.cli.main:cli`(worker 子进程入口即此 CLI,S5 依赖) | pyproject.toml:90-91 | S5 已隐含 | + +**处置建议**:DSH 侧开发流(P0「build + dsh plugin add(link 模式)」)重建时,dev.sh 的 linked-teardown + win32 PID 方案可直接参考或复用;quality_ratchet 在 py 冻结期继续生效,TS 侧建议对等物(lint 计数棘轮)或显式声明不做。 + +--- + +## 4. 漂移风险 Top 10(按风险降序) + +1. **SSE 事件的前端消费语义与死信道清单**(§1.2):合批/泳道/原位去重/`session_ended` 不提升排队轮——todo 只有一行「删除自建」;G5 契约测试没有这份对照表就无法断言 DSH `session/event` 等价。`recording_ready` 双形状(engine vs 队列兜底)是现成分叉。 +2. **队列状态前端靠轮询不靠 SSE**:`queue_held/paused/resumed` 广播无人订阅,`/api/status` 的 `queue_paused/queue_holds` 才是信源。DSH 侧若只做事件推送、丢掉轮询等价字段,dock 的「继续队列」入口会失明(§1.2 末两行)。 +3. **跨线程竞态五件套**(§2.4):自动跟随准入、草稿豁免、fetchSessions 兜底优先当前线程、submitted_at 排序锚、stop 后幽灵清除——五个提交刚修完、测试全在即将删除的 Vue spec 里;DSH roster/toolview 聚合线程视图时**必然重遇**,丢了就是「会话消息被移除/消息误入他线程」类 bug 重演。 +4. **4-step merge 的幽灵防护机制**(§2.1 step4 + dismissedNoRowSessions + 60s TTL):「为什么四源合并会闪烁」的全部经验只存在于代码注释与 spec;todo 写了「问题本身消失」,但 pending+running+历史的聚合展示在 DSH 侧仍是新问题。 +5. **/api/run 幂等 + 准入语义的对外承诺**:session_id 幂等重试、409 设备锁、400 端点校验、rejected 透传——SDK 重试与 webhookRuntime 接入都踩在这上面(草稿 01 已列后端规则、本文补消费方视角;两处合起来才是完整契约)。 +6. **artemis-client 公开面 + capabilities 假基线**(§3.4/§3.1):P4 要求迁移清单,但公开方法/payload/错误分层此前无成文处;`/api/v1/capabilities` 从未实现,features 集合是客户端编的。 +7. **MCP 工具 docstring 协议承诺**(§3.2):1 分钟兜底轮询、release_loop 唯一停止信号、unknown 防重复提交、对账「不确定=存活」——这些写给外部 LLM 调用方的行为承诺随工具面保留,删 mcp_server 前必须先确认 webhookRuntime 侧有等价语义。 +8. **通知触发矩阵与 webhook payload schema**(§3.3):`conversation_id 或 ingress=mcp` 才通知、失败双触发点、payload 字段——外部网关(OpenClaw/CI)的既有集成靠它。 +9. **vite 代理 Origin 剥离覆盖 SSE + wheel 资源手动链**(§3.5):双跑/共存期(G7)任何人改 dev 代理或漏跑 sync:resources,都是「页面能开但 SSE 403」「打包版是旧 UI」类无声故障。 +10. **quality_ratchet 与 locale 锁测试的消亡**(§3.5/§2.5):py 冻结期的防劣化棘轮与 en-US 键对齐测试都随删除消失;TS 侧无对等物时应在 todo 记一句「显式不做」,否则是静默的防线撤除。 + +--- + +## 5. 对 docs/TODO.md 的修订建议 + +### 新增差距 + +- **G8 · SSE/事件契约对照基线**:本文 §1.2 表格作为 P0 契约测试(G5)与 G2 toolview 的输入;须包含:前端订阅面 vs 服务端广播面的差集(`queue_held/paused/resumed`、`note_saved`、`llm_stream_downgrade` 为死信道)、`recording_ready` 双形状收敛决策、llm_stream 合批/泳道语义是否需要复刻。 +- **G9 · /api/run 准入语义在 webhookRuntime 的等价物**:session_id 幂等重试、409 设备锁定、400 端点校验、rejected 与 `unknown`(MCP 防重复提交)语义 → autogamer-queue admission 钩子的验收项(与草稿 01 §1.1 合读)。 +- **G10 · 对外通知契约**:`notify` 触发矩阵(终态 + 两类判死;`conversation_id 或 ingress=mcp`)+ webhook payload schema → jobs `settled` 适配薄层的承诺清单;迁移前调研外部用户实际启用的通道。 +- **G11 · 会话线程聚合视图的不变量**:submitted_at 排序锚、pending 不渲染/不提升、幽灵防护(TTL+dismissed)、自动跟随绝不跨线程、草稿豁免——DSH roster/toolview 聚合 Inbox+jobs+历史时的验收清单(§2.1/§2.4),作为 P3 的补充验收项。 +- **G12 · 外部 SDK/MCP 基线**:artemis-client 公开面清单(§3.4)+ `/api/v1/capabilities` 假端点处置 + MCP 工具 docstring 承诺(§3.2)纳入 P4 迁移清单;DSH SDK 需验证 task_id 幂等与任务查询兜底等价物。 + +### 映射表修正 + +- 「`ipc_service` + `/api/stream` ✅ 删除自建」行补注:事件消费语义与死信道清单见 drafts/05 §1.2(仓库无 `artemis/ipc_service` 目录,实际在 `apps/admin_console/services/ipc_service.py`)。 +- 「mcp_server 通知/外部接入 ⚠️ 薄层适配」行补注:触发矩阵与 payload schema 见 drafts/05 §3.3。 +- 「packages/artemis-client ✅ 删除」行补注:公开 API 基线见 drafts/05 §3.4,作为 P4 输入。 + +### 新增 checklist 项 + +- [ ] P0 补:SSE 事件名全集快照测试(以 drafts/05 §1.2 为基线,防 DSH 侧事件模型漂移时无人察觉)。 +- [ ] P3 补:队列 dock/roster 交互验收对照 drafts/05 §2.2(chips scoped、暂停/继续收敛、停止多态)。 +- [ ] P4 补:SameOriginBoundaryMiddleware 与 Vite 代理 Origin 剥离在**过渡共存期**的行为说明(SSE 受影响);`npm run sync:resources` 手动链在发布流程中的位置(漏跑 = wheel 态旧 UI);dev.sh linked-teardown + win32 PID 方案作为 DSH 开发流参考。 + +### 附:最近 6 提交(46e59b2..cc6b20c)的测试锁定核对 + +| 提交 | 行为 | 锁定测试 | +|---|---|---| +| 46e59b2 | never-started 失败轮补插最小 DB 行(含 stdout 尾行);排队消息纵向堆叠 | tests/unit/admin_console/test_task_queue_service.py(+161 行);CommandDock.spec.ts | +| 552a63c | `/api/status` 路由误接 pause handler 修复 + 路由注册守卫 | test_task_queue_service.py:1769 `test_queue_router_registers_expected_routes` | +| 138695c | dock 队列控件 scoped 到线程、去状态文案 | CommandDock.spec.ts:242-275 | +| 3a1facc | 排队轮不进时间线、发射后按原轮序入列 | AgentTimeline.spec.ts:639-679 | +| 8a0108c | 自动跟随绝不跨线程 + fetchSessions 兜底优先当前线程 | session.spec.ts:361-487;stream.spec.ts:350-406 | +| 9739f32 | session_ended 不把排队轮提升为运行态 | stream.spec.ts:461-489 | + +六条均有测试锁定,但**全部位于随迁移删除的仓库区**(Vue spec + py 队列单测);py 队列单测由 conformance 测试接棒(草稿 01 附表),**五条前端行为断言没有既定接棒者**——这是 §5 G11 的直接依据。 diff --git a/docs/migration/plugin-contract-rules.md b/docs/migration/plugin-contract-rules.md new file mode 100644 index 000000000..4f5ae3a73 --- /dev/null +++ b/docs/migration/plugin-contract-rules.md @@ -0,0 +1,304 @@ +# DSH 插件契约守则(编号红线) + +> 用途:`dsh-autogamer` 后续每一笔改动动手**之前**先对照本表;每条都给「判据」,要求能由命令或产物机器作答,不接受"看着没问题"。 +> 依据:上游 [deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) @ master 的官方 skill / 包 README / 源码,加上本机 0.2.0-rc.2 实测。逐条标 **文档口径**(官方文字)、**源码口径**(上游源码)、**实测口径**(本机产物)。 +> 覆盖范围:首轮成文(R1…R19)改写了 `TODO.md` 的 D7 / D15 / D16 / D17 与 S3 / S4 / S5 / S6 / S8 / S9 / S11 / P3,并派生差距条目 G30 / G31 / G32 / G33 / G34;R20…R28 是此后陆续新增的条款,回写位置记在各条自己的「判据」段与 `TODO.md` / `CHANGELOGS.md` 的引用里。**现行条款数与范围只看 `registry.md`**,本行只描述那几轮的回写去向,不是条款全集。两者冲突时以本守则的编号条款为准并回改 todo,不在两处各留一份说法。 + +--- + +## R1 取证只走声明面 + +不解包 `app.asar`。可用面:`dsh --help`、`dsh --profile

          --help`、`--dump-config`、`--dump-config-schema`、`--dump-default-config`、随包 README、已安装 npm 包产物、`~/.dsh` 下的真实运行数据、上游公开仓库源码。 + +- **出处**:用户裁定(D15)。官方同样写明 asar 内文件「只有 Host 进程自己的文件读取能打开」,`ls`/`cat`/`node`/pnpm/glob/ripgrep 一律失败(**文档口径**)。 +- **判据**:任何新写进文档的结论,其出处必须能在上面这些面里复现;写不出复现命令的结论降级为"猜测"。 + +## R2 分三层看「改配置」这件事,不许混用 + +| 层 | 定位 | 允许吗 | +|---|---|---| +| profile 目录内的 `package.json` / `cordis.patch.yml`,或在 profile 目录跑 pnpm | 常驻用户层 | **禁止**:`install_bundle` 才是执行者(**文档口径** `cordis-plugin-development/SKILL.md:10`) | +| `--patch `(可重复,应用在 profile 层之后) | 官方声明的 overlay 机制 | **允许,作为开发期主路径**(**文档口径** `dsh --help` 的 Options 段) | +| `plugin_manager` 工具 / Web 侧边栏「插件」面板 | 正规引入与启停 | **允许,交付形态走这条**(**文档口径** `dsh-plugin-manager` = 「shared by dsh CLI, Web and agent tools」;`dsh-client-ui-plugin-manager` = 「the sidebar Plugins panel **installs, enables, disables, retries, and composes**」) | + +- **判据**:实验用 patch 文件一律放 `plugins/dsh-autogamer/spike/`,路径里不得出现 `~/.dsh/profiles/**` 的写入。 +- **纠正记录**:上一轮我把 `--patch` 也归入"官方禁止的一侧",过度收敛——被禁的是手写 profile 常驻文件,`--patch` 是 `dsh --help` 里明列的选项。同时我把侧边栏面板误判成只读展示页,实际它具备安装/启停能力。 + +## R3 preset 只能由 bundle 补丁承载 + +「Nothing edits a declaration in place」;注册表不扫描目录,也不接受 preset 路径。 + +- **出处**:**文档口径** `editing-cordis-compositions/SKILL.md`、`agent-preset-registry/README.zh.md:46-48`。 +- **判据**:`--dump-config` 输出里必须存在 `- id: preset-` 行且其 `config.plugins` 非空;缺行 = 声明根本没进树。 + +## R4 preset 声明行的字段约束 + +`config.id` **必填**,只允许小写字母、数字、连字符;`config.plugins` **必填**;`name` / `description` / `order` 可选;Loader 行 id 约定为 `preset-`。**子插件行可以省略 id,由 Loader 分配。** + +- **出处**:**文档口径** `editing-cordis-compositions` Where declarations live 段 + `agent-preset/README.zh.md:48`。 +- **判据**:`--dump-config-schema` 里 `$defs` 对应节点的 `required` 数组;本机 rc.2 实测 `config97.required == [id, plugins]`。 +- **禁止**:把「子项必须自带 id」当约束使用——它没有官方依据,本机那条实测差异另有原因(见 R7)。 + +## R5 覆盖是整体替换,不是深合并 + +按 id 覆盖既有行时,给出的字段替换该行的字段,`config` **整体替换**,所以要把该行需要的字段**全部重述**;给了 truthy `name` 是**断言**既有插件名,不是改名。 + +- **出处**:**文档口径** `cordis-composition-reference/SKILL.md:15`。 +- **判据**:覆盖行提交前后各跑一次 `--dump-config`,逐字段 diff 被覆盖行的 `config`,确认没有字段因"没重述"而消失。 + +## R6 patch 里每一条非 insert 行都必须命中 + +非 insert 且 id 空,或目标匹配不到行的补丁,会被**警告并跳过**。 + +- **判据(每次改 patch 必跑)**: + 1. `dsh --profile web --patch --dump-config` 退出码为 0; + 2. stderr/stdout 里 grep `unmatched` 必须为空; + 3. 我们关心的行能在输出里按 id 找到(脚本判,不靠肉眼)。 + +## R7 改动只影响之后创建的 Agent + +「声明会提前加载,修改只影响之后创建的 Agent」;「existing sessions and their children keep the plugin revision they started with;validate changed behavior in **a new session**」。 + +- **出处**:**文档口径**,两处一致(`agent-preset/README.zh.md:12` + `editing-cordis-compositions` Verify 段)。 +- **判据**:复验前确认会话创建时刻**晚于**本次生效时刻(比对会话目录 mtime 与 patch/ dist 时间戳),否则这次复验不作数。 +- **血债**:本条是我 01:51 那次"单变量实验"的第二个变量,直接导致我写下过一条错误约束并撤回([upstream-plugin-forms](upstream-plugin-forms.md) 三)。 + +## R8 preset 里带服务的插件必须同 realm,否则整个 preset 不挂 + +preset 的服务提供方与其**全部**消费方要共处一个 `cordis:group` + `isolate` realm;条目局部隔离不沿 Agent 注册 scope 传播,组外的普通 Context 查找(含 `agent.ctx`)仍落到 Host realm。挂载时 `auditRows()` 汇总「从未启动 / 导入失败 / 激活失败」的行,`leakedServices()` 汇总发布进 root realm 的服务,任一非空即 `throw` ——**拒绝整个 preset**。 + +- **出处**:**源码口径** `agent-preset-registry/src/mount.ts:116,134,193,205,212-213`;**文档口径** `agent-preset-registry/README.zh.md:56-60`、`core/scope/README.zh.md:27,98`。 +- **后果形态**:不是"少一个工具",而是**Agent 完全没有工具面**(请求里连 `tools` 键都不存在)。这条形态特征用来区分「realm/挂载被拒」与「单个插件没装好」。 +- **判据**:`inspectCompositions()` 的 `leakedServices`;非空就按官方样板(shipped `minimal.patch.yml` 的 `persistent-shell: name: cordis:group` + `isolate`)把 provider 与消费方包进同一组。 + +## R9 判「生效」只认官方检查器 + +`install_bundle` 结果里的 `application` 与 `warnings`;`list_plugins` 的 `enabled` 与 `fiberPhase`;roster 上激活失败行的**诊断文本**;`cordis_inspect_query` 的 `Config.listConfigs`(挂载插件的 Config schema)与 `Tool`(**本 Agent 可调用哪些工具**);`inspectCompositions()`。 + +- **明确不认**:进程日志、进程列表、页面 boot payload、`dsh plugin list`(它只是 pnpm 的转发,只证明装了没证明生效)、以及从 LLM wire 反推。 +- **出处**:**文档口径** `cordis-plugin-development/SKILL.md:25`(原文点名「not logs, process lists, or the page's boot payload」「Confirm new rows with `cordis_inspect_query`, not `list_plugins`」)、`cordis-composition-reference/SKILL.md:25`。 +- **判据**:每个 live 结论都要留一条「哪条命令 / 哪个检查器、返回了什么」的记录;只有 wire/截图支撑的结论必须标注为间接证据。 + +## R10 插件必须声明原生 Schemastery 的 Config + +没有 Config 声明的插件行会被判 `config=absent`,preset 子挂载随之被静默跳过;外来 schema 对象(如 zod)被拒:`Config is not a native Schemastery schema`。 + +- **出处**:**实测口径**(本机 spike + `--dump-config` 产物)。 +- **判据**:`plugins/dsh-autogamer/tests/conformance/contracts.spec.ts` 的原生性断言 + `tests/apply.spec.ts` 的接线断言(两者都做过删除实验,回退 zod / 退回旧判据会变红)。 +- **附带坑(实测口径)**:Schemastery 把未配置的可选 tuple 归一化成**真值 `[undefined, undefined]`**,所以"是否配置过"必须判数值本身,不能判数组真假——已由 `configuredScreenSize()` 承接并锁死。 + +## R11 版本门禁 + +本机 spike 全程 `0.2.0-rc.2`;上游已发 `dsh-v0.2.1-alpha.1`(2026-10-03)与 `dsh-v0.2.1-alpha.2`(2026-10-09),alpha.1 主题即「让 Agent 给自己造插件」。 + +- **判据**:`gh api repos/deepseek-ai/deepseek-harness/releases` 与本机版本号比对;版本不一致时 conformance fixture 一律视为**过期**,升级要重录。 + +## R12 Host 插件的导出形态二选一,不许混用 + +`index.js` 只能是这两种之一:① `export function apply(ctx, config) {}` + 可选 `export const inject` 与 `export const Config`;② 一个 service class 作为 default export。**声明了 `Config` 的插件会在 activation 时校验该行的 `config`**,所以写 config 之前要先用 `Config.listConfigs` 查已安装插件的 schema,并**跟着返回文档里的 `$defs` 引用读**(顶层节点常是 `type: null` + `anyOf`,只看顶层会得出"没有字段"的错误结论)。所有资源注册都必须在 `apply` 内用 `ctx.effect` / `ctx.on` 完成并返回清理。 + +- **出处**:**文档口径** `references/host-plugin.md:59-64`;`$defs` 那条坑是**实测口径**(本机 `--dump-config-schema` 的 `config97` 就是这样)。 +- **判据**:`grep -c "^export function apply" dist/index.js` 与 `grep -c "export default" dist/index.js` 之和必须为 1。 +- 展示元信息与图标义务(原列在 R12 的待补条目)统一收在 **R19**,本条不重复。 + +--- + +## R13 扩展点按「够用即最弱」选,不许把策略内建进工具正文 + +官方机制强度(弱→强):`ctx.tools.restrict(filter)` 只能缩小单个 agent 的可见集合(掩码取交集,dispose 时解除);`ctx.tools.guard(guard)` 是**单调同步拒绝**,加在 `tools/pre-execute` waterfall 之后,后续监听器无法把拒绝变回允许;`tools/pre-execute` 决定允许/拒绝/**询问**;`tools/execute` 给分发加截止时间、重试、指标;`tools/post-execute` 可替换内容或值、阻止结果、附加有序上下文;`tools/result` 只观测冻结后的最终结果。原文规则:**「尽量不要把部署策略内建到工具中」**。 + +- **出处**:**文档口径** `docs/cookbook/adding-a-tool.zh.md:61`、`packages/core/tools/README.zh.md:85,89,141`。 +- **落到本项目**:S4 熔断的「冷却期拒绝」= `ctx.tools.guard()`(同步且单调,正合适);需要 await 的决策(问用户)只能走 `tools/pre-execute` 返回 `ask`;S3 闸门的串行化属于执行机制,可留在工具内,但**全局并发语义要先与宿主对齐**(见 R14),不许重复实现一套。 +- **判据**:写任何"拒绝/审批/限流"逻辑前,先答一句「这该是 guard、pre-execute,还是工具内部」;选工具内部必须写出为什么前两者不够用。 + +## R14 宿主已有并发约定,工具要声明安全属性 + +PTC 运行规则写明:**独立只读调用可以并发,变更类调用独占执行并按提交顺序**(「safe calls run concurrently; mutating calls run alone, in submission order」)。`defineTool` 的声明项里含「协作式超时」与「并行安全属性」两个字段。 + +- **出处**:**文档口径** `packages/core/tools/README.zh.md:12,197`。 +- **判据**:`cordis_inspect_query` 查 `Tool` / `Config.listConfigs` 拿到该属性的**真实字段名与取值**再写代码,不许按英文字面猜字段(本守则不给字段名,因为文档正文未列出)。 +- **落到本项目**:设备动作天然是变更类,宿主的"独占 + 提交序"已经覆盖我们排队需求的一半;per-device 闸门只该补"同一设备跨会话串行"这另一半,并把只读动作(`take_screenshot` / `get_ui_hierarchy`)标成可并发。 + +## R15 `timeoutMs` 是装饰,超时要么用包装层要么自己实现 + +**注册表绝不强制执行定义里的 `timeoutMs`**;要强制必须挂 `@deepseek-ai/dsh-tool-call-timeout-policy` 包装层。取消是协作式且等完全停稳:工具主体必须观测 `exec.signal`;主体前取消记 `ABORTED_BEFORE_DISPATCH`,主体后取消只能把成功替换为 `ABORTED`;超时报 `TOOL_TIMEOUT`;未知工具与抛异常工具都收敛成 `UNKNOWN_TOOL` 结构化错误,**调用失败但不结束轮次**。 + +- **出处**:**文档口径** `packages/core/tools/README.zh.md:126,235`。 +- **判据**:任何"超时已生效"的说法必须指出是谁执行的——包装层,还是我们 `ActionClient` 里的 `Promise.race`(现状是后者,`timeoutMs: 45_000` 只是给模型看的声明)。 + +## R16 不许自写会话事件类型;可回放状态走 `presentationMeta` 或投影 + +带新 `type` 的 append 会让 Session 拒开(只有 envelope 带 `ignorable: true` 才被接受,而 live `Session.append()` 设不了它);`appendPluginRecord()` 写 ignorable 记录但** reserved for DSH 实验包**(格式迁移只尽力保留)。可回放的结果事实走 `output.presentationMeta(args, value)`,它被持久化在 `tool/result` 的 `meta` 上并传给 `presentResult`。每会话状态用 `ctx.sessionProjections` 单元(`apply` 纯同步、`view()` 值不变就返回同一引用),**不要**订阅 `session/event` 再自己重扫;**不要 poll `agent/status`**;等 durable 事件 `turn/end` / `assistant/message` / `tool/result`。 + +- **出处**:**文档口径** `references/practices.md:21,26-28`、`docs/cookbook/adding-a-tool.zh.md:48`。 +- **落到本项目**:S11 的 checker「判定 append-only 落 session log」这条不变量**不能靠自写事件实现**,改走 `presentationMeta` 或 storage service;`whenIdle()` 不代表一个 followup 结束(多个输入可共享同一次运行区间),S3/S9 的等待判据要按此重写。 + +## R17 注入上下文有两种,唤醒语义不同 + +`exec.agent` 上 `agent.inject({content, source:{kind}})` **追加持久上下文但不唤醒**(空闲 agent 保持空闲),下一次准入的 step 才看到;定时器要驱动工作就调 `agent.followup()`(会唤醒)。`source.kind` 必须在插件里经 `MessageSourceMap` 声明——session format V4 在消息准入处**拒绝**已退役的 `{kind:'plugin', plugin:''}` 包装。对已 dispose 的 agent 要 try/catch。`agent/request` 监听者改不了请求消息;加提示词用 `ctx.systemPrompt.section()`,**不要**听 `system-prompt/assemble` 来增删工具或文本。 + +- **出处**:**文档口径** `docs/cookbook/adding-a-tool.zh.md:49`、`references/practices.md:17-18,28-29`。 +- **判据**:S9(中途指导)与 S4(冷却解除)里任何"唤醒"必须写明用的是 `followup` 还是 `inject`,两者后果不同。 + +## R18 UI 有两条硬路:slot 与拷贝,不许 iframe、不许 import 宿主 Client 包 + +页面/面板用 React 组件注册进 slot(`ctx.slots.inject(ownerKey, () => ctx.slots.register(...))`,owning 声明塌缩时自动卸载);**不要**从 Host 发 HTML 塞 iframe(拿不到主题 token、明暗切换与 `ctx.locale`)。**禁止** `require('@deepseek-ai/dsh-client-ui-primitives')` 或任何宿主 Client 包——要长得像就**拷贝**其 markup/CSS/行为并加自己的类名前缀,只保留 `--dsw-alias-*` token 引用;抛错的组件会 blank 掉你的 slot 条目(console: `slot entry crashed`)。Chat 行经 `ctx.uiConversation.events.register()` + `conversation.chat.node` slot(`kind` 即渲染键)。Factory 保持无副作用;样式、定时器、监听器在 `apply` 里经 `ctx.effect`/`ctx.on` 注册并返回清理。**Web Client 不消费 `presentCall`/`presentResult`**:专用卡片必须在 client 插件的 `tool.call.toolview` keyed slot 里按 wire 工具名注册,只定义 Host 展示方法不会增加 Web 卡片;也不许另建一套 Client presenter registry。 + +- **出处**:**文档口径** `references/practices.md:31-38`、`references/ui-plugin.md`、`docs/cookbook/adding-a-tool.zh.md:69-99`。 +- **落到本项目**:P3 的 toolview(录屏/截图展示)只能走 client 插件这条路,G2 早先结论成立且更细;展示器必须是纯函数(实时与回放都会跑),不得做 I/O、读会话状态或用时钟随机数,UI 格式不许混进模型可见规范值。 + +## R19 命名唯一 + 展示元信息是义务;授权类动作永远 user-only + +包与行都要**唯一命名**;官方模板的包名带 scope(`@local/`)。Host-only 或 configuration-only bundle **也有**可见 inventory 条目,「不是豁免」:`locale/en.json` 的 `meta.title` / `meta.description`(并按用户语言补 `locale/zh.json`,别留模板文案)、`exports` 出 `./locale/*.json` 与 `./icon`、`files` 覆盖 patch/locale/icon/每个运行时文件、图标为 SVG/PNG/JPEG/WebP 且 ≤256 KiB 不得越出包目录(绝对路径、URL、越界符号链接一律拒绝)。缺字段回退到 `package.json` 的 name/description,缺图用面板默认插画,元信息畸形会产生诊断。安装后要在面板逐 locale 核验标题/描述/图标;**无浏览器控制时只能核验到已安装资源,必须报告"渲染未验证",不许从安装成功推断视觉成功**。 +另一条授权红线:**授予或确认权限的动作(批准工具调用、回答 agent 的提问、放宽策略)保持 user-only,agent 不得执行也不得授权**;同一操作只实现一次(Host service 方法),UI 与 agent 工具共用它,不许两边各写一份逻辑。 + +- **出处**:**文档口径** `references/host-plugin.md:3,29-55`、`references/user-actions.md:11`。 +- **判据**:`package.json` 里 `exports`/`files` 是否含 locale 与 icon;面板出现默认插画或包名回退即不合格。本项目当前**两项都缺**,已登记为 G32。 + +--- + + +## R20 遇 DSH 行为问题先查官方一手资料取解法,不许先写猜测实现 + +任何"宿主到底怎么做的 / 该挂在哪一层"的问题,**动手前先查官方一手资料**:`docs/subsystems/*.zh.md`(生成的服务与事件参考,含每个事件的派发模式与 scope 规则)、`docs/cordis-primer.zh.md`、`docs/cookbook/*`、三个官方 skill(`cordis-plugin-development` / `editing-cordis-compositions` / `cordis-composition-reference`)、公开仓库 `packages/**` 源码与 shipped preset。**查不到才允许做实验,实验必须有可观测判据(R9)**。官方自己的措辞也支持这条纪律:`presets/standard.patch.yml` 的 planning 段写着 "Do not ask the user where code lives or how current behavior works when you can find out." + +反例(本轮实际付过的学费):`report_task_status` 的终态强制先在 preset 子插件里实现,`session/event` + `turn/end` 与 `agent/status` 双监听、61/61 单测全绿,live 一次都没触发。官方 `docs/subsystems/session.zh.md:1301` 早就写明 **`session/event` 是 scope-filtered dispatch:agent-scoped 监听器只收到"经该 agent 上下文进入的会话"的事件**——查这一段 30 秒就能定层,不必写一整套再回退。同一类:`goal-round-driver` 的 `inject = ['agents','goals','sessions']` 挂在 root 层,抄它即可。 + +- **出处**:**文档口径** `docs/subsystems/session.zh.md:1299-1317`(`session/event` 派发模式与 scope 过滤)、`packages/bundle/web-app/presets/standard.patch.yml`(planning 段原文)、`packages/goal/goal-round-driver/src/index.ts`。 +- **判据**:新增/改动任何与宿主交互的代码前,在 `docs/TODO.md` 对应条目里写明**查了哪份、原文怎么说**;只写"实测可用"或"看起来能跑"不算过。已有实现若被 live 否证,当场改正或回退,**不许留猜测代码**。 + + +## R21 手镜像的宿主契约必须标出处与版本,并区分"单测证明"与"live 证明" + +`src/dsh-types.ts` 里每一处手写镜像的宿主形状(服务方法、事件签名、结果字段)都必须带**出处 + 版本 + 是否 live 验证过**。宿主 npm 包在 rc.2 只随 app.asar 分发、npm 上的版本可能不同版(如 `@deepseek-ai/dsh-attachment` 是 `0.0.1-rc.1`),所以镜像有时不可避免——但它是**负债**,不是免费桩。凡"只有单测绿、没有 live 证据"的宿主交互,在 `docs/TODO.md` 里必须标 🔶 并写明缺哪条证据;单测里的假 context 必须按真实语义实现(教训见 changelogs #10:假 ctx 没有 `effect`,让自注销缺陷在 CI 里隐身)。 + +- **出处**:**文档口径** D15/R1(只用声明面)+ 本仓库 changelogs #10 的实测教训;`@deepseek-ai/dsh-llm` 的 `lib/types/*.d.ts` 引 `@deepseek-ai/dsh-attachment` 而本机未安装该版(tsc 因 skipLibCheck 静默放行,等于类型盲区)。 +- **判据**:审查表(`docs/TODO.md`「宿主交互实现审查」)里每一项都有 已证 / 仅单测 / 猜测 三态之一;出现"猜测"项必须在本轮内改正、加探针或回退,不许长期挂账。 + +## R22 功能实现三步序:查官方文档 → 写 todo 方案 → 才动代码 + +任何功能/修复的实现,顺序固定为三步,**不许跳步**: + +1. **查**:按 R20 查官方一手资料(`docs/subsystems/*.zh.md`、`cordis-primer`、cookbook、三个官方 skill、公开 `packages/**`),把"查了哪份、原文怎么说"摘出来; +2. **写**:把结论写进 `docs/TODO.md` 一条自足方案(What / How / Verify / Done means,含范围外与前置实验),**方案先提交**; +3. **做**:方案落地后在同一轮里勾验收项、记录偏差;被 live 否证的实现**当场回退**,不许留猜测代码(R21)。 + +- **出处**:**仓库口径** AGENTS.md「Change planning → todo ledger」+ 本文件 R20/R21;触发实例:G44 第一版先写了 preset 层监听(61/61 单测)才去查 `session/event` 的 scope 规则,结果整轮回退。 +- **判据**:`git log` 里**方案提交必须早于实现提交**(同一功能的两条 commit 顺序可机械核对;无独立方案提交即视为跳步);方案条目内必须出现"出处/原文"字样,只写"实测可用"不算过第 1 步。 + +## R23 不得在 `session/event` 的 emit 里 append;要注入上下文先推出发布边界 + +`agent.followup()` / `steer()` / `inject()` 与任何 `session.append()` 一样是**写日志**,而 `session/event` 是"提交后、发布中"的回调——在此同步 append 会被宿主拒绝:`session append cannot reenter while another append is being published`(官方口径 `docs/subsystems/session.zh.md:609`:「在验收/发布边界打开时重入的 append 会在日志变更前被拒绝」)。**判据**:任何从 `session/event` / `tool/result` 一类事件回调里发起的注入,必须先 `setTimeout(…, 0)`(或等价的边界外时机);官方 `goal-round-driver` 的 `case 'turn/end'` 分支**只更新状态、从不 append**,就是这一形态的样板。另外 `session/event` 按 scope 筛选派发,跨作用域观察须显式 `{ global: true }`(R20 的原文,`packages/core/scope/README.zh.md:75`)。 + +- **出处**:**文档口径** `docs/subsystems/session.zh.md:609,1301`、`packages/core/scope/README.zh.md:71,75`、cordis 4.0.4 `lib/types/events.d.ts:98-105`;**实测** 本仓库 CHANGELOGS #13(同步调用被拒的原文错误串)。 +- **判据**:插件里任何"事件回调 → 注入/append"的路径必须有边界外推迟,且单测要等一个宏任务再断言注入发生。 + +## R24 判「某事没发生」只认会话日志;wire 与插件 logger 都无否证力的 + +取证渠道的能力边界必须写死,否则会产出**看似严谨的假发现**(本仓库一天内两次:G40 的 UI 探针、G46 的 wire 推断): + +1. **wire 请求体看不到终结回合的那次工具调用**。`report_task_status` 若带 `concludesTurn`,它是本轮**最后一个** tool call, + 之后不再有模型请求,所以它**永远不会出现在任何请求体里**。拿 `proxy-dump.log` 数 `report_task_status` 得出「模型没汇报」, + 在成功路径上必然得到假结论。 +2. **插件 `ctx.logger` 不进 dsh 的 stdout**。实测:真机动作确实跑了,但 `web-server*.log` 里 `[route]` / `[turn]` 各 0 条。 + 所以「日志里没有」不能作为证据。 +3. **唯一可信的是会话日志**:`~/.dsh/sessions//session-/session.v4.jsonl.zstd`(zstd 帧流, + 须用 `ZstdDecompressor().stream_reader` 读,`decompress()` 只会拿到第一帧=1 条记录)。 + 它按序记 `turn/start`、`user/message`(含 `source.kind`/`source.reason`)、`tool/call`、`turn/end`(含 `reason.kind`)。 + 「追问是否发生」看有没有 `source.reason:"unreported-turn"` 的 user 消息 + 第二个 `turn/start`; + 「`concludeTurn` 是否生效」看 `report_task_status` 的 `tool/call` 之后是否**直接** `turn/end` 而无新的 `step/start`。 +- **出处**:**实测** 本仓库 CHANGELOGS #15(G46 当日撤回,根因就是拿 wire 当否证依据)。 +- **判据**:任何「没发生」的结论,正文里必须写明是**从会话日志的哪条记录**读出来的;只写 wire/日志 grep 结果的,一律当未定论。 + +## R25 定根因必须先读机制文档,再一次只测一个变量;禁止「改一处→重跑→再看」的连环试错 + +一条错误串**只能证明那次失败发生了,不能证明因果**。要把现象升级成根因结论,必须先做两件事之一: +读机制文档(pnpm/Windows/DSH 自己的配置发现链与语义),或拿到一条**否证性**的本地事实。 +然后设计**一次**只动一个变量的判别实验。理由:本仓库同一天内三次栽在同一种错误上—— + +| 次序 | 我看到的 | 我外推出的结论 | 真相 | 错在哪 | +|---|---|---|---|---| +| G40 | UI 探针找不到 `aria-label="发送消息"` | 运行中入口被禁 | 运行中主按钮改文案为「排队发送」 | 拿单个选择器失败当机制缺失 | +| G46 | wire 请求体里数不到 `report_task_status` | 汇报与追问都没发生 | 终结回合的那次调用**永不出现在后续请求体** | 拿无否证力的渠道当反证(→ R24) | +| #21 | `git+ssh://…` 与 `ERR_PNPM_EPERM` 两条报错 | ①公开仓库也会因 ssh 失败 ②墙是 Windows 跨卷符号链接权限 | ①pnpm 自己回退,公开仓库实测 `git+https` 抓取成功;②profile 里本就躺着 pnpm 建的跨卷链接,且 `nodeLinker: hoisted` 早是现状 | 把两条不相关的报错各外推一次;**没查本机现状就推荐了空操作方案** | + +- **判据**:写进台账的每条根因,必须附「读的是哪份机制文档 / 哪条本地否证事实」; + 只有错误原文的,一律写成「未定候选 + 待做的单次判别实验」,不许写成结论。 + 推荐方案前先核它是否**已经是现状**(#21 的 `node-linker` 就是这么露馅的)。 +- **出处**:**仓库口径**,本文件 R20/R21/R24 的延伸;触发实例三见 CHANGELOGS #21 的「更正」块。 + +## R26 `gh` 的写操作必须显式 `--repo`,禁止依赖默认仓库解析 + +本仓库同时挂着 `origin=giveUphope/autoGamer`(我们的公开 fork)与 `upstream=google/artemis`, +而 `gh repo view` **默认解析到的是 upstream**。于是任何不带 `--repo` 的 `gh` 写命令都会打到**别人的仓库**上, +失败信息还可能把人引向错误的归因——实测 `gh release create` 报 +「`workflow` scope may be required」,而 `gh auth status` 显示 token **本来就有 `workflow`**: +真实原因是请求发往了无权写入的上游,那句 scope 只是 gh 的通用猜测。 +- **判据**:`gh` 的**写**操作(`release create`/`issue create`/`pr create`/`api -X POST|PATCH|DELETE`)必须带 `--repo /`; + 失败后先 `gh repo view --json nameWithOwner` 核对到底打到了谁,再谈归因; + 误写风险动作执行前先跑一次只读核对(如 `gh api repos//releases --jq length`)确认没有留下东西。 +- **出处**:**实测** 本仓库 CHANGELOGS **#24**(上游核对 0 releases、0 相关 tag,未误写)。 + +## R27 判据必须被证明会失败;字节类口径要写明量的是 blob 还是工作树 + +一条测试或门禁**通过**不是证据——它可能压根没在观察任何东西。CHANGELOGS **#45** 在一次真实变异(把 +`measured()` 的规范化摘掉)下照出两种「虚锁」形态,两种都绿: + +| 形态 | 当时的写法 | 为什么咬不到 | 修法 | +|---|---|---|---| +| 样本太小 | 4 行正文比 CRLF / LF | KB 只保留一位小数,一步 = 102.4 字节,4 行差异 2 字节 → 两者同为 `0.0` | 样本加宽到 200 / 400 行,跨过口径步长 | +| 位置太后 | 不变量例排在 (a)–(h) 之后 | 后面的检查读真实文档,未规范化的测量让它们**先红先退出**,永远走不到这一例 | 移到 `selftest()` 开头 | + +同一条还照出历史面:体量表在它**被脚本生成的那次提交** `2ab17cb` 起就没在任何 LF 检出上匹配过 +(生成那台是 CRLF 工作树),而 `#32`–`#44` 里 6 条「文档门禁双 exit 0」都把它勾成已过—— +自证能长期假,是因为既没给复跑命令,也没给检出环境,而这两枚门禁**从未接进 CI**(**G60**)。 + +- **判据**: + 1. 新增或改动任何判据时须做一次**变异校验并写进验收**:故意把被测行为改坏,确认**就是这一条**变红; + 变不红的判据不得勾 `[x]`,勾了即为假验收。 + 2. 变异校验要选在**样本量**与**执行位置**上都咬得住:差异须跨过被测量纲的口径步长(此例 ≥ 103 字节), + 且排在任何可能提前退出的检查之前。 + 3. 凡依赖字节 / 尺寸 / 行尾的判据,必须写明量的是 **blob 还是工作树**,并把口径做成规范化无关 + (`#45` 采 LF 规范化)——否则同一条 commit 在两台机器上有两个答案,而工作树字节不是仓库里的事实(**G61**)。 + 4. 「门禁 exit 0」这类验收必须附**可复跑命令与检出环境**;未接进 CI 的门禁不许写成「由 CI 保证」。 + 5. **跑判据的命令本身也要有牙**:不许用 `cmd | tail`/`| grep` 的退出码当判据——那是过滤器/分页器的码, + 判据红了链子照样往下走(CHANGELOGS **#49** 就是这么把一张塌了的表提交进去的)。 + 正确形状:`cmd > /tmp/out 2>&1; echo $?`,或 `set -o pipefail`。 + 6. **变异本身要先被证明生效**,再读它跑出来的颜色。变异后「仍然全绿」有两种完全不同的原因—— + 锁是虚的,或者那条改动根本没进到被执行的路径。把后者读成前者会得出**相反的错误结论** + (以为要重写一条好锁)。所以落变异前先确认三件事:语法合法、改在真正被调用的分支上、 + 语义上确实反转了行为。两次实测各栽一种:`#59` 的变异留下悬空 `&&`,报的是收集期语法错而不是断言红; + `#60` 把「按位置回退」插进一个**看不见新鲜层级**的函数,等价于没改,却被脚本打成 + 「STILL GREEN - lock is vacuous!」——第三次把改动落到路由预检 + 解析回退两处才拿到 4 条断言名。 +- **出处**:**仓库口径**,由 CHANGELOGS **#45** 实测派生;是 R21(区分「单测证明」与「live 证明」)的同族延伸—— + R21 管的是证据**层级**,本条管的是证据**是否真的在观察**。 + +## R28 实测/调研的结论必须当轮回写台账;被推翻的旧表述不许静默保留 + +**做完不写=没做,写新的不删旧=埋下一次的错方向。** 台账(`docs/TODO.md` §零 与相关 `D/S/G/R` 行) +是下一个 agent 唯一的现状来源,它一旦滞后,后面每一步都在照着过期结论做。本会话一天之内撞到三种失效形态: + +| 形态 | 实际发生的事 | 后果 | +|---|---|---| +| 结论只留在对话里 | 我叙述过「方案甲已落地 / CHANGELOGS #50 / 新立 G63 / 插件 188 项测试」,`git log` 核对**全部不存在**(当时 HEAD 是 `8eecb39`、条目 49 条、`G63` 0 命中) | 若照这段叙述继续,会去"验收"一个不存在的东西 | +| 新事实推翻了旧行,旧行还在 | §零 写着「CI 用 `node scripts/spike-mcp.mjs` 做同一比对」,grep 工作流命中 **0 次**;又写「CI 侧全绿」,而唯一那次 run 整体 `failure` | 下一条 CI 工作(G60)差点建立在"已经有机器复核"的假前提上(**#53**) | +| 派生数没回抄 | 测试计数停在 `83 passed`,实跑 `148`;两处 `str.replace` 没校验命中而静默空转 | 台账自称的数字不可信(**#45/#56**) | + +- **判据**: + 1. 一轮里只要产生了实测或调研结论,**同一轮内**必须落地一次文档提交,且受影响的那一行被改; + 验收须附复跑命令,形状是 `git grep -n "<被推翻的旧说法>" docs/ ; test $? -eq 1`(判据本身按 R27 要能红)。 + 2. 新结论若**否定**旧表述:要么改掉它,要么就地标「已作废 + 出处编号」——不许留着让下一个人照旧做。 + 3. 写「已完成 X」时必须能指到 **commit hash 或命令输出**;指不到就写成「待做」,不写既成事实。 + 4. 派生的计数类事实(测试数、条目数、未推送数)只留**复测命令**,不写死数字(同 `AGENTS.md` 的单一权威条)。 +- **出处**:**仓库口径**。触发实例见 CHANGELOGS **#45**(判据绑机器)、**#49**(管道吞退出码)、 + **#51**(勾了没跑的判据)、**#53**(从未成立的 CI 表述)、**#56**(不校验命中的替换)、 + **#58**(未核实就叙述"已完成",以及本条成文)。是 R24(无否证力的渠道)与 R27(判据要有牙)的 + 同族:R24 管"从哪取证",R27 管"锁会不会红",**本条管"取到的证有没有落到别人会读的地方"**。 + +## 待补(诚实清单) + +1. **已逐字读**:三个官方 skill(`cordis-plugin-development` / `editing-cordis-compositions` / `cordis-composition-reference`)及其 `references/host-plugin.md`、`references/practices.md`、`references/ui-plugin.md`、`references/user-actions.md`、`references/verification.md`、`references/mcp-bundle.md`,加上 `agent-preset` 与 `agent-preset-registry` 的 README 与 `src/mount.ts`、`packages/core/scope` README、`docs/cordis-primer.zh.md`、`docs/cli-help.zh.md`、`docs/cookbook/adding-a-tool.zh.md` 与 `adding-a-package.zh.md`、`packages/core/tools/README.zh.md`、shipped `presets/minimal.patch.yml`。**R12-R19 全部来自这些一手文本。** +2. **已按 R20 补读**:`docs/subsystems/session.zh.md` 的 `session/event` 条目(1299-1317,scope-filtered dispatch 的原文,R20 的反例即出自此处)。 +3. **仍未读,不许凭印象引用**:`docs/capability-seams.md`(63 KB,已取回)、`docs/tool-catalog.md`(102 KB,已取回,官方口径就该按关键词 grep 而非整读)、`docs/dsh-tool-call-timeout-policy`(R15 提到的包装层,其包 README 未读)、`packages/jobs/jobs/README.zh.md`(S2 长任务与 owner 语义)、`docs/subsystems/scope.zh.md`。 +4. **一条已定论的悬案**:`install_bundle` 在 CLI 侧没有等价物——本机 `dsh --help` 只列 `dsh`(boot)与 `dsh plugin`(= pnpm 转发,`--help` 打出来是 pnpm 11.7.0 的帮助),上游 `docs/cli-help.zh.md:29,71` 同述。所以 R2 表里第三层的两条可执行路径是**Web 侧边栏「插件」面板**与**在会话里让 agent 调 `plugin_manager`**(D17);「shared by dsh CLI」那句里的 CLI 部分指的应是 `dsh plugin` 这层 pnpm 垫片,不是 install_bundle。G31 据此收口。 +5. **待查的真实字段名**:R14 的「并行安全属性」与 R15 的超时包装层入参,官方文本只给了名称描述没给字段名 —— 必须用 `cordis_inspect_query` 在装好的 Harness 上取真名(R1/R9 的口径),取到之前不许写代码。 diff --git a/docs/migration/registry.md b/docs/migration/registry.md new file mode 100644 index 000000000..d74c5e336 --- /dev/null +++ b/docs/migration/registry.md @@ -0,0 +1,205 @@ +# 编号登记表(registry)——D / S / G / R 的唯一权威 + +> 为什么要有这张表:本仓库的交叉引用全靠编号(`D7`、`S11`、`G30`、`R13`),而编号此前**散落在四份文档里且已经撞车**——机械对照查出 `G8` / `G10` / `G11` 在 `inventory/*` 与 `TODO.md` 里指的不是同一件事,`inventory` 内部同一个 `G10` 还被用了两次。审计还查出 14 个 `G*` 引用在任何表格里都找不到行(它们被压进了「已关闭」那一句的括号里)。 +> 用法:**任何新增引用前先在这里查号**;查不到就说明它还没登记,不许直接引用。改名要连引用一起改,或在此登记映射。 +> 机器校验:`python -X utf8 scripts/check_doc_registry.py`(悬空引用、编号语义冲突、本表与 todo 的集合一致性)。 + +## 一、D · 决策(现行有效) + +定义位置:`docs/TODO.md` 第二节决策表。 + +| 编号 | 一句话 | +|---|---| +| D1 | 项目以 DSH 插件形态重生,方案基线 = google/artemis 上游 | +| D2 | 会话队列 = DSH Inbox;禁自建队列容器与引 Redis;「autogamer-queue」概念已取消 | +| D3 | 设备模型 = 共享真机 + 完整闸门;每会话专属模拟器为二期演进项 | +| D4 | 积压策略:Inbox 原生无上限,零自建 | +| D5 | 重启恢复:Inbox pending 原生恢复待派 + 插件启动显式 kick;动作无断点续跑 | +| D6 | 版本管控:peerDependencies 声明 + runtime 强制校验 + conformance 契约测试 | +| D7 | 引入形态三层:禁手写 profile 文件 / `--patch` 为官方 overlay / 交付走 install_bundle;配置热重载、代码冷重启;声明只影响之后创建的 Agent | +| D8 | 技术栈:插件 TS,py 复用动作面与驱动栈,langgraph 智能栈不迁移 | +| D9 | 迁移路径 = R2' 直通插件,质量 spike 不过则回退三段式双跑;红线是旧控制台原地只读 | +| D10 | 产品形态:项目即 DSH 插件,无独立外部 HTTP API 面;删 artemis-client,webhook 按需 | +| D11 | 审批:常规动作 never,破坏性面集中在 run_adb_command 整工具 ask | +| D12 | 录屏后续补齐(一期无录像) | +| D13 | helper 二期 TS 复刻(与 Mobly / Appium 共存的差异化能力) | +| D14 | 智能层 = 单层执行路由(S11),不做反思式自检 | +| D15 | 契约核实只用声明面;禁止解包 app.asar | +| D16 | Host 提供共享服务,preset 贡献 scoped tools;provider 与消费方必须同 isolate realm;挂载 fail-fast | +| D17 | 自举引入:preset 挂 tool-plugin-manager,让 agent 自己执行 install_bundle | +| D18 | 长期技术栈以 TS 为主,py 仅在特定库成熟度高于 TS 库时保留;本次收缩不重写 py,只压到最小设备能力面作为后续 TS 迁移基线 | +| D19 | **终态 = TS 全栈、py 归零**:仓库不再承载任何 py 实现,上游 ARTEMIS 能力逐步改由 TS 承载,外部 ARTEMIS 只保留为可选提供方(收紧 D18;规格见 [ts-fullstack-spec.md](ts-fullstack-spec.md)) | +| D20 | **溯源终态 = 只留 LICENSE**:除 `LICENSE` 外不留存任何来自上游的信息(代码、prompt 文本、vendored 树、派生文档、copyright 头、指向上游的 git 元数据);本项目仅从上游获取设计灵感后独立重实现 ⇒ TS 侧重写**只借鉴设计、不搬运文本**(规格见 [ts-fullstack-spec.md](ts-fullstack-spec.md) §八;git 历史与 remote 的处置待用户裁定) | + +## 二、S · 对接语义(现行有效) + +定义位置:`docs/TODO.md` 第三节。 + +| 编号 | 一句话 | +|---|---| +| S1 | 会话映射:ARTEMIS `conversation_id` 等于 DSH `SessionId`,消息走 followup 进 Inbox | +| S2 | 任务 = 单个 turn 内的动作序列,不再 spawn py worker job | +| S3 | 设备闸门:工具内的 per-device 串行 + parking + 全局上限;只补宿主没覆盖的那半边 | +| S4 | 设备熔断:closed / open / half-open 与不对称 fail 策略;拒绝出口走 `ctx.tools.guard()` | +| S5 | 执行层契约:原样复用 py action server 的 13 工具面;超时自实现,不指望 `timeoutMs` | +| S6 | 审批映射:破坏性集中一工具;授权类动作永远 user-only | +| S7 | 重启对账:校验 action server + kick 有 pending 的会话 | +| S8 | 版本门禁:conformance 锁契约;本机落后上游两版,升级前 fixture 视为过期 | +| S9 | 中途指导:DSH `steer` / `followup` 原生;`inject` 不唤醒而 `followup` 唤醒 | +| S10 | turn 执行形态已裁定乙:用户消息 → Inbox → agent turn → 工具直调 | +| S11 | 单层执行路由:单 agent + 代码强制路由器 + validator / checker 只读 subagent | + +## 三、G · 差距清单 + +**现行在册**(2026-10-14 起 11 项;旧表 10 项随 TODO 清空全部结清,去向见 `../CHANGELOGS.md` **#71**): + +| 编号 | 一句话 | 定义位置 | +|---|---|---| +| G72 | 本机提供方形状未定:插件默认 `artemisMcpCommand` 指向 pre-contraction 的 `mcp_server` 5 工具面,本分支 `mcp_server/` 已无 `.py`,只有 `artemis mcp --type adb` 的 15 工具动作面 | `../TODO.md` 第四节(三条路线与排除证据在彼,§十 有 adb 侧约束) | +| G74 | 映射层按**已删的 5 工具面**锁形状:`take_screenshot` 实测返回裸 base64 JPEG 而非 `file://…` 文本;层级节点属性值是字符串但带 `parsed_bounds` 整数对象,且节点数被语义裁剪压低 | `../TODO.md` 第四节(测量在 §零 Step 1、Step 2) | +| G75 | **一台 MuMu 被 roster 报成 4 台**(4 条 adb 传输、`android_id` 全等、`ro.serialno` 全空)→ 闸门/并发上限/熔断 4 倍计数 | `../TODO.md` 第四节(测量在 §零 Step 1) | +| G76 | 坐标空间缺防护:主视口 `Rotation90` 使帧空间 1600x900 而 `wm size` 报 900x1600;截图取源路径已验正确,Config 手填 `screenSize` 无一致性校验 | `../TODO.md` 第四节(测量在 §零 Step 1) | +| G81 | `tsconfig.json` 的 `include` 只含 `src` ⇒ 插件 32 个 spec **从未被类型检查**;把 tests 纳入一次 `tsc` 实测 144 条错误(`apply.spec.ts` 独占 45)→「必填字段」这类守卫在测试里不咬人 | `../TODO.md` 第四节(#82 的附带发现) | +| G78 | `adb forward` 残留实测证实(server 退出后 `tcp:54254 → tcp:18888` 仍在),建 forward 的是 py 动作面 → Done means 的「无残留」被证伪而非待验 | `../TODO.md` 第四节(测量在 §零 Step 2) | +| G82 | py 引擎(langgraph 图 + langchain 绑定层)仍被 `autogamer_run_task` / `manage_task` / `inspect_trace` 消费 → 须由 TS 单层路由(S11)替代 | `../TODO.md` §四 + [ts-fullstack-spec](ts-fullstack-spec.md) §三 T1 | +| G83 | 15 工具动作面仍是 py-only(adb / uiautomator2 客户端)→ 须 TS 原生实现并与现面逐条对照 | 同上 T2 | +| G84 | 效果判定 `compare_ui_frames`、OCR、overlay 标注仍是 py-only → 须 TS 化 | 同上 T3 | +| G85 | py 归零收尾:删 py 树 / 清 CI 与依赖 / 更新文档口径 | 同上 T4 | +| G86 | **溯源清理未做**:仓库仍含上游派生内容——319 个文件的 `Google LLC` copyright 头、vendored `third_party/mobile_use`(自带其 LICENSE/NOTICE)、上游派生文档(`docs/research`、`inventory/*`、`agents/*.md`)、致谢文案,以及指向 `google/artemis` 的 `upstream` remote | `../TODO.md` §四 + [ts-fullstack-spec](ts-fullstack-spec.md) §八(git 历史处置待用户裁定) | + +**已关闭**(定义来源:`../CHANGELOGS.md`「已关闭差距史」逐行表;差距结案后从 `TODO.md` 第四节移除,正文进 CHANGELOGS): + +| 编号 | 原短语 | 关闭方式 | +|---|---|---| +| G1 | 模式矛盾 | 随 R2' 定稿消失 | +| G2 | UI 降级 | 随 DSH 原生 roster / QueueDock 与旧控制台只读消失;**注意它只覆盖展示降级面,没覆盖 catch-up**(后者是 G20) | +| G6 | Windows 沙箱 | 随 R2' 不 spawn job 消失 | +| G7 | 双跑 | 转为 D9 的回退件 | +| G9 | worker env | 随 R1 遗产消失 | +| G12 | LLM 暂停 | 随 py 智能栈不迁移消失 | +| G15 | 共享锁 | 由 D3 裁定吸收 | +| G16 | notes | 由 D2 与 S1 的 DSH 原生会话记忆承接 | +| G18 | SSE | 随 D10 收缩;事件名全集曾保留为 conformance fixture,P4 物理收缩时随旧控制台(admin_console / showcase_ui_v2)及其生成器一并删除 | +| G19 | 轮次视图 | 旧控制台只读,DSH 原生承接 | +| G20 | catch-up | 核实为 DSH 原生支持(`readSessionState` 等),无需自建 | +| G21 | 通知 | 改为按需再建(D10) | +| G22 | SDK | 随 D10 删除 artemis-client | +| G29 | 注入 | 由 S9 以 DSH 原生承接 | +| G30 | preset 工具可见性(曾是 P0 阻塞点) | 双根因修复:cordis `effect()` 语义反转导致自注销 + 裸 `register()` 未经 `defineTool()` 编译;证据见 ../CHANGELOGS.md #10 | +| G37 | live overlay 停在二分实验形态 | overlay 收回 `inner + tool-jobs` 交付形态,R6 exit 0 且 R7 新会话实测工具面恰为 5 个;证据见 ../CHANGELOGS.md #11 | +| G42 | `{index}` 元素定位整条不可用 | 按数组位置统一编号(模型看到的列表与解析器同源)+ 修掉 render 的 200 字符截断;7 条单测 + live 两轮命中 `#28`/`#17` | +| G39 | 效果判定归属层(真机回 JPEG 令 TS 侧 PNG 差分失效) | 裁定路线 A:py 动作面新增只读 `compare_ui_frames`,判定委托仓库内上游实现 `check_ui_change`;证据见 ../CHANGELOGS.md #12 | +| G41 | 终态不收敛:`report_task_status` 之后回合继续跑 | `concludeTurn()` live 实证:汇报的 tool/call 之后直接 `turn/end`、无后续 `step/start`(CHANGELOGS **#15**) | +| G44 | 模型可以不调 report_task_status 就结束回合 | `{global:true}` 监听 + 边界外 `followup()` 的兜底追问端到端可用,第二轮模型补出汇报(CHANGELOGS **#15**) | +| G40 | 运行中 UI 不接受第二条消息(**当日撤回的错误归因**) | 探针按固定 `aria-label="发送消息"` 找按钮,把运行中主按钮切成「排队发送」误读成入口消失;改按按钮位 class 枚举后实测 QueueDock 原生可用、回合结束按序执行 | +| G46 | 未汇报追问在交付形态下从不触发(**当日撤回的错误归因**) | 拿 wire 请求体数 `report_task_status` 推断「没汇报也没追问」;实际那次汇报是**终结回合的最后一次工具调用**,永远不会出现在后续请求体里。改读会话日志后:追问与汇报都正常发生,详见 CHANGELOGS **#15** 与守则 **R24** | +| G45 | 交付 preset 没有指令投递行:flash 契约从未进过模型,spike 与服从性观察的前提都不成立 | `persona` 行进交付形态与 overlay(文本内禁 `{{}}`);新会话 wire 断言 system 4052 字符、契约起于第 50 字符;详见 CHANGELOGS **#14** | +| G49 | GitHub 安装失败于 `ERR_PNPM_EPERM`(根因未定) | 单变量跑完:停服务报错不变→否证占用假说;清掉同名 junction 后 `added 17` exit 0→成立。机制=`importPackage` 顺着旧 `link:` 走进开发工作区重建符号链接被 Windows 拒;详见 CHANGELOGS **#22** | +| G50 | git 形态装出来的 bundle 缺 `dist/`,pnpm 成功但不可用 | tarball 路线端到端验通(清单含 dist+skills、安装 exit 0、服务加载后工具面与终态链路完整);对外交付形状定为 GitHub Release 资产 tarball URL;详见 CHANGELOGS **#23** | +| G51 | 本机 Node TLS 信任被本地中间代理接管,走 https 的依赖安装要 `--use-system-ca` | 源码核对 + 两次 DSH 侧实测确认前置有效;README 已写安装前置并禁用关掉校验;详见 CHANGELOGS **#25** | +| G47 | `install_bundle` 的两项未验:失败是否真回滚 profile 文件、`files` 打包清单是否生效 | 一次性包三轮实测:两例失败后 profile 两文件 SHA256 不变;tarball 安装落地树恰等于 `files` 白名单;详见 CHANGELOGS **#19** | +| G48 | 成功的 git 安装无证据;`files` 与 `path:` 子目录在 git 形态下是否生效未测 | `git+file://` 本地仓库两形态都 `application:"applied"`,落地树恰等于 `files` 白名单;详见 CHANGELOGS **#20** | +| G35 | 文档门禁脚本默认路径写死 | `DEFAULTS` 从脚本自身位置推导仓库根;自测锚点改为动态取第一条 G 行;详见 CHANGELOGS **#16** | +| G32 | bundle 展示元信息缺失(locale meta 与 icon) | 补 locale/{en,zh}.json 的 meta.title/description + 原创 icon.svg + exports/files;详见 CHANGELOGS **#17** | +| G31 | 引入流仍靠手工:交付形态要迁 install_bundle | 一次性空 bundle 端到端验通 install_bundle(applied/注册/撤除干净);随包 skill 路径改官方 !!js 解析;详见 CHANGELOGS **#18** | +| G5 | conformance 需要真实 DSH runtime(CI 起 DSH 跑契约测试) | 三层落地:真实 DSH 服务包 in-process 挂载读 `ctx.tools.schemas()`、三条 fixture 改生成物、live 取证脚本,并新增 CI `plugin-contracts` job(双 OS 实测 success);详见 CHANGELOGS **#29**(过程 #27/#28) | +| G52 | 追问路径仍只有 fake 事件总线证据 | 新增 Tier 1 `tests/conformance/followup.spec.ts`:真 `dsh-session` 提交 `turn/end`、真 `AgentRegistry` 解析句柄、真 `ToolRuntime.execute` 标记 acted,并在两个 realm 之间观察到 scope 筛选确实排除非 global 监听;顺带摘掉追问消息的手镜像(缺 `UserMessage.id`);详见 CHANGELOGS **#32** | +| G56 | mock 与 `ActionClient` 握手超时 | 手拼应答缺 `jsonrpc` 字段被 SDK 读侧拒收;补帧字段后真 SDK 握手通过;详见 CHANGELOGS **#35** | +| G57 | 插件挂进 `createScope` realm 后其工具对注册表视图不可见 | 裸 scope 注册本就生效(`register()` 打 scope 标签,读须传 scope;旧「双空」是误读);详见 CHANGELOGS **#34** | +| G33 | 策略内建在工具正文里 | 冷却拒绝迁 `guard()`,只读动作声明 `isConcurrencySafe`;顺带修掉 `starting` 失败缓存与组装期传输分类;详见 CHANGELOGS **#36** | +| G53 | 前端 lockfile 全镜像 URL 使门禁红 | 221 个 `resolved` 只换 host(零漂移),门禁绿;详见 CHANGELOGS **#37** | +| G55 | `check_dependency_sources.py` 不合 `ruff format` | 一条命令修好(`e1de835`);仓级另 9 处红另立 G58;详见 CHANGELOGS **#38** | +| G34 | 裸 schema 注册的输入自校验缺口 | 输入语义校验入工具(coordinate 存在即判、不静默回退,text 非空、duration 正数、key 可解、swipe 起止成对、package/url 非空),11 条单测锁死;详见 CHANGELOGS **#33** | +| G38 | 插件内部错误被归类成任务级失败 | 第三类 `internal`:无前缀异常与逃逸抛出走 internal,不记 breaker,抛 `internal:` 并带勿重试信号;传输层保持 environment(fail-safe);详见 CHANGELOGS **#33** | +| G4 | 熔断探测体(矩阵落定) | 键同源、四解除、不对称引用既有锁;新提交不旁路等显式偏差见 S4;详见 CHANGELOGS **#39** | +| G43 | spike 复位与判据不可靠(`settings put` 无效) | `scripts/spike_reset.py` 复位脚本 + dry-run 锁;live deferred,P1-live 接;详见 CHANGELOGS **#42** | +| G58 | 仓级另 9 处格式红(G55 文件已先修) | `ruff format .` 零语义 + 同文件 3 处 E731 改 `def`;279/1 回归绿;详见 CHANGELOGS **#43** | +| G8 | 终态权威(收缩) | 汇报双轴 + `concludeTurn`;exit-code 映射随 worker 消失;详见 CHANGELOGS **#44** | +| G10 | 动作中断语义 | `exec.signal` 穿透到 SDK 请求;闸门释放早已在 `finally`;详见 CHANGELOGS **#44** | +| G11 | 熔断判定信号 | 工具层错误分类;详见 CHANGELOGS **#44** | +| G13 | 幂等 / 去抖 | in-flight 去重(变更类专属);详见 CHANGELOGS **#44** | +| G14 | 状态词汇 | 汇报 `completed/blocked + tests.failed`;详见 CHANGELOGS **#44** | +| G24 | 坐标契约 | 归一域/端侧索引;详见 CHANGELOGS **#44** | +| G60 | 两枚文档门禁从未接进 CI | `ci.yml` 新增无 `if` 的 `Check documentation gates` step;**已在真 run 里执行并通过**(run 38296414103,见 **#67**);详见 CHANGELOGS **#46** | +| G54 | 本 fork 的 `on: push` 从不触发 workflow run | **前提被直接观察否证**(`4201e66` 的 push 立刻产出 run 38296414103);成因未查明;详见 CHANGELOGS **#67** | +| G61 | 仓库无 `.gitattributes`,工作树字节随 checkout 变 | 新增 `.gitattributes` + renormalize 实测零文本 blob 改写;本机 `ruff format --check` 假红转绿;详见 CHANGELOGS **#47** | +| G62 | py action 面无「无设备可跑」路径,离线契约只能吃插件的 JS 桩 | `artemis/mcp/mock_server.py` + CLI `--type mock`,用现成 `MockActuator` 预置两条缓存短路绕开 adb I/O;与 `--type adb` 工具清单逐字相同;详见 CHANGELOGS **#52** | +| G63 | py 动作面 fixture 从未在 CI 被重新比对 | `python-quality` job 新增无 `if` 的一步,用 `--type mock` 每次重导并比对;本地同命令 exit 0,变异校验(fixture 删一名)exit 1;详见 CHANGELOGS **#54** | +| G3 | auto-serial 闸门缺口:显式 serial 队列 + auto 空闲池 | A 片 `src/devicePool.ts`(roster 由 config 注入,默认空=现状不变);真枚举另立 G64;详见 CHANGELOGS **#55** | +| G28 | parking 与全局并发上限的内部语义 | 按 `device_lock.py:332` 口径实现并六条锁死;详见 CHANGELOGS **#55** | +| G64 | 设备 roster 的真来源 | py 只读 `list_devices` 工具 + 插件 `rosterSource`(`discoverDevices` 默认 false);详见 CHANGELOGS **#56** | +| G65 | 锁屏判定进不了插件 | `list_devices(include_lock_state)` 出三态,复用双采样确认探针;详见 CHANGELOGS **#59** | +| G67 | `{index}` 句柄按数组位置重解(违反 stale 句柄不可点的安全不变量) | 活观察快照 + 按节点身份解析;详见 CHANGELOGS **#60** | +| G68 | #60/#62 复审轮余下缺陷(文案被信封截断、快照声明半证、句柄无界远跟、不可解析自读仍放行) | 五条各自修法与锁;含对 #60 一处声明与 #40 远跟半句的更正;详见 CHANGELOGS **#63** | +| G17 | 共存鉴权 | 放弃,未实现;详见 CHANGELOGS **#71** | +| G23 | 录像产物契约 | 放弃,未实现;详见 CHANGELOGS **#71** | +| G25 | 层级后端互斥 | 放弃,未实现;详见 CHANGELOGS **#71** | +| G26 | PID-liveness 协议 | 放弃,未实现;详见 CHANGELOGS **#71** | +| G27 | 智能栈暗规则 | 放弃,不再作为验收口径;详见 CHANGELOGS **#71** | +| G36 | 截图媒体展示 | 模型侧已通(#11);UI 卡片侧放弃;详见 CHANGELOGS **#71** | +| G59 | 本机 `--dump-config-schema` 常驻失败 | 放弃,未实现;详见 CHANGELOGS **#71** | +| G66 | G27 保真差集余下项 | ③④已由 #62 关闭;余部放弃;详见 CHANGELOGS **#71** | +| G69 | pre-commit 钩子从未安装 | 放弃,未实现;详见 CHANGELOGS **#71** | +| G70 | dispatch-only py 测试步从未绿过 | 放弃,成因未定;详见 CHANGELOGS **#71** | +| G77 | 预检把「已安装」等同于 `uv tool list` 命中,`helper` 又假定 PATH 有 `artemis` | 两项都承认 project-venv 形态,只读探针 `uv run --project --no-sync`;详见 CHANGELOGS **#79** | +| G73 | `artemis doctor` 收尾提示已删命令 `artemis run` | 文案改指 `artemis mcp --type adb`,旧锁同批改,新增吃已注册命令集的防腐烂锁;详见 CHANGELOGS **#80** | +| G79 | 预检 `helper` 不传设备序列号,多传输同机被拒 | 必填 `defaultDeviceSerial`,两条形态都追加 `--serial`;详见 CHANGELOGS **#82** | +| G80 | rules 注入缺文件时静默 no-op,返回值被丢弃 | 结构化结果 + 经 `autogamer_diagnose` 暴露;详见 CHANGELOGS **#82** | +| G71 | web profile 组合包仍指旧名 `autogamer-device`(挂载失败) | CLI 重装(Step 5)+ headless 死链移除 + wire 抓包证 7 工具在 AutoGamer 预设新会话请求体;详见 CHANGELOGS **#83** | + +> 逐条证据:`TODO.md` 已关闭行的原话是「随 R2' 与裁定消失,逐条证据见 git 历史与三份研究文档」。本表只登记编号与短语,**不重述证据**;要考证就查 git 与研究文档,别在这里二次抄写(抄一遍就多一处会腐烂的地方)。 + +### 编号冲突(**不得作为引用目标**) + +机械对照(`scripts/check_doc_registry.py`)查出 `inventory/*` 沿用的 G 编号与现行编号撞车,且 inventory 内部自身也重复使用: + +| 编号 | `TODO.md` 现行含义 | `inventory/*` 里另一套含义 | 出处 | +|---|---|---|---| +| G8 | 终态权威 | worker 进程 env / CLI 完整契约表;SSE 与事件契约对照基线 | `inventory/01-console-queue.md`、`inventory/02-runtime-persistence.md` 等 | +| G9 | worker env(已关闭) | 优雅停止宽限语义 | `inventory/01-console-queue.md` | +| G10 | 动作中断语义 | 幂等 / 去抖提交语义;对外通知契约 | `inventory/01-console-queue.md`、`inventory/02-runtime-persistence.md` | +| G11 | 熔断判定信号 | 状态词汇表映射;会话轮次视图不变量 | `inventory/01`、`inventory/05` | +| G12 | LLM 暂停(已关闭) | 熔断解除矩阵与 fail 策略等(inventory 正文另有用法) | `inventory/01`、`feature-inventory.md` | + +处置:**以本表与 `TODO.md` 为唯一权威**;`inventory/*` 是盘点现场记录,其中 `**Gn · …**` 形式的「新增差距」段落属早期编号方案,**只作内容参考,不作编号引用**。需要指代 inventory 里那条时,写「`inventory/0N` 的 X 段」而不是 `Gn`。 + +## 四、R · 插件契约守则 + +定义位置:`docs/migration/plugin-contract-rules.md`(每条带出处口径与判据)。 + +| 编号 | 一句话 | +|---|---| +| R1 | 取证只走声明面,禁解包 asar | +| R2 | 改配置分三层看:禁手写 profile 文件、`--patch` 合法、交付走 install_bundle | +| R3 | preset 只能由 bundle 补丁承载;注册表不扫目录、不接受路径 | +| R4 | preset 声明行的字段约束(`id` 与 `plugins` 必填;子项可省略 id) | +| R5 | 覆盖是整体替换 config,truthy `name` 是断言不是改名 | +| R6 | 每条非 insert 补丁都必须命中:`--dump-config` exit 0 且无 unmatched | +| R7 | 声明只影响之后创建的 Agent;复验必须开新会话 | +| R8 | realm 约束与挂载 fail-fast(缺 `tools` 键 = 整体被拒的形态特征) | +| R9 | 判生效只认官方检查器,不认日志 / 进程 / `plugin list` / wire 反推 | +| R10 | 插件必须声明原生 Schemastery 的 `Config`;未配置可选 tuple 是真值数组 | +| R11 | 版本门禁:本机与上游的版本差要让 fixture 过期 | +| R12 | Host 插件导出形态二选一,不许混用;写 config 前先查 schema 并跟 `$defs` | +| R13 | 扩展点按「够用即最弱」选,策略不许内建进工具正文 | +| R14 | 宿主已有「只读并发 / 变更独占按提交序」,工具要声明安全属性 | +| R15 | `timeoutMs` 不执行;超时要么包装层要么自实现,取消必须观测 `exec.signal` | +| R16 | 不许自写会话事件类型;可回放状态走 `presentationMeta` 或投影 | +| R17 | `inject` 不唤醒、`followup` 唤醒;`source.kind` 要声明 | +| R18 | UI 只进 slot、拷贝不 import 宿主 Client 包;Web 不消费 Host 展示方法 | +| R19 | 命名唯一与展示元信息是义务;授权类动作永远 user-only | +| R20 | 遇 DSH 行为问题先查官方一手资料取解法,不许先写猜测实现 | +| R21 | 手镜像的宿主契约必须标出处与版本,并区分「单测证明」与「live 证明」 | +| R22 | 功能实现三步序:查官方文档 → 写 todo 方案 → 才动代码(方案提交须早于实现提交) | +| R23 | 不得在 `session/event` 的 emit 里 append;注入类调用先推出发布边界 | +| R24 | 判「没发生」只认会话日志;wire 与插件 logger 都无否证力 | +| R25 | 定根因先读机制文档,一次只测一个变量;禁止连环试错,推荐方案前先核是否已是现状 | +| R26 | `gh` 的写操作必须显式 `--repo`,失败先核对打到了哪个仓库 | +| R27 | 判据必须经变异校验证明会红;字节/行尾类口径要写明量 blob 还是工作树,未进 CI 的门禁不许当验收 | +| R28 | 实测/调研结论必须当轮回写台账,被推翻的旧表述不许静默保留 | + +## 五、还有一类编号:P 里程碑与 X 面 + +- `P0`-`P4`:旧 `TODO.md` 第五节 checklist 的里程碑(2026-10-14 随清空删除,见 CHANGELOGS **#71**),**不是差距编号**,与 `G*` 无对应关系。 +- `T1`-`T4`:[ts-fullstack-spec.md](ts-fullstack-spec.md) §三 的四个分期名(D19 迁移),**不是编号**;引用要写全「ts-fullstack-spec §三 T2」,每一期对应的差距编号见 registry 已在上表给出(G82-G85)。 +- 文档内引用「盘点 04」「`inventory/03`」等指 `docs/migration/inventory/0N-*.md`,按文件名引用。 diff --git a/docs/migration/ts-fullstack-spec.md b/docs/migration/ts-fullstack-spec.md new file mode 100644 index 000000000..cb9f71a5b --- /dev/null +++ b/docs/migration/ts-fullstack-spec.md @@ -0,0 +1,127 @@ +# TS 全栈终态规格:上游 ARTEMIS 能力收敛为 DSH 插件 + +> 本文件是决策 **D19**(TS 全栈、py 归零)与 **D20**(溯源归零、只留 LICENSE)的展开规格。编号查 [registry](registry.md);文档导览见 [导航](README.md)。 +> 状态:**2026-10-14 立项(方案,未开工)**。每期开工前按守则 **R22** 先在 `../TODO.md` §七 落自足条目,方案提交早于实现提交。 +> 本文件不写体量与行数(那是 `README.md` §三 表管的派生事实),也不复述进度与阻塞(那是 `../TODO.md` §零 的唯一权威)。 + +## 一、终态定义(Done 的判据) + +D19/D20 的终态是**五条同时成立**,缺一条不算收敛: + +1. **仓库 py 归零**:`artemis/`、`mcp_server/`、`third_party/`、`pyproject.toml`、`uv.lock` 与 py 测试树全部从本仓库删除;CI 不再有 python job 或 py 依赖安装步骤。可复跑的判据是 `git ls-files '*.py'` 为空。 +2. **TS 全栈**:`plugins/dsh-autogamer/` 是唯一实现面。设备执行、效果判定、图像/OCR、轨迹读取、诊断全部在 TS 侧完成,不再依赖任何常驻外部进程。 +3. **外部 ARTEMIS 降为可选提供方**:仓库**不内置、不 vendored、不为它开开发依赖**。仅保留 adapter 能显式指向一个**用户自行安装的外部 ARTEMIS**(MCP 后端,走现有 `artemisMcpCommand` + `artemisRepoPath` 路径);未配置即走 TS 原生。 +4. **能力不丢**:§二 矩阵每一行都必须有 TS 落点与判据;任何被判定不迁移的能力,必须在 `../CHANGELOGS.md` 留下明确的放弃记录,不允许静默消失。 +5. **溯源归零(D20)**:除 `LICENSE` 外,仓库不留存任何来自上游的信息——代码、prompt 文本、vendored 树、派生文档、copyright 头、指向上游的 git 元数据。判据见 §八。 + +> 解释口径:用户 2026-10-14 定稿「py 库可以引入、artemis 库可以引入,但不能作为主要技术栈,只能作为模块引入」,与「py 归零」合读 = **仓库内不再承载 py 实现,py 只可能以外部依赖形式被显式接入**。这是 §一.3 的来处。 +> 同日追加(**D20**):「最终不再留存除 license 以外的信息,包括代码,相当于本项目仅从该项目获取灵感后重新实现」——这是 §一.5 的来处,也是 §八 的全文。 + +## 二、能力迁移矩阵 + +上游 ARTEMIS 的能力按「现载体 → TS 落点 → 判据」逐行列清。状态列只写三态:**已具备**(TS 侧已在跑)/**部分**(TS 有骨架但契约未对齐)/**未开工**。 + +| 能力(上游面) | 现载体 | TS 落点 | 判据 | 状态 | +|---|---|---|---|---| +| 设备动作 15 工具(tap / swipe / input / launch / …) | py `adb_server.py` | adb CLI + on-device 协议客户端(自实现) | 与 `tests/fixtures/py-action-tools.json` 工具面逐字同名同参 | 部分(`ActionClient` 仅为 MCP 转发) | +| 截图 | py 动作面 | `adb exec-out screencap -p` | 同设备同帧的 PNG IHDR 尺寸等于帧空间(已验 1600x900) | 已具备(`coord.ts` 取源路径已验) | +| UI 层级 dump | py `uiautomator2` 客户端 | `adb shell uiautomator dump` + 自解析 | 节点集与 py 面一致、`parsed_bounds` 齐备、扁平语义保留 | 部分(`hierarchy.ts` 有骨架,契约未测) | +| 动作执行闸门 / 熔断 / 分类 | 插件 TS | — | 现有 spec 锁 | 已具备 | +| 效果判定 `compare_ui_frames` | py 委托上游 `check_ui_change` | TS 差分(dHash 类) | 同一 before/after 对判 red/green 与 py 一致 | 未开工 | +| OCR | py `httpx` → Google Vision | TS `fetch` | 同图同密钥返回同一文本集与坐标 | 未开工 | +| 画面标注 overlay | py `PIL` / `cv2` | sharp(libvips) | 标注元素集与 py 一致 | 未开工 | +| 任务编排 `run_task` / `manage_task` / `inspect_trace` | py 引擎(langgraph 图) | TS 单层路由(S11)+ DSH agent loop | 三条工具不再调 `mobile_*`;任务由 TS 侧循环驱动 `run_device_action` | 未开工 | +| 诊断 `diagnose` | py `core.diagnostics` | TS 探针 | 就绪七项同判 | 部分(`preflight.ts` 已有七项骨架) | +| 轨迹 / 步骤持久化 | py `data_engine`(SQLite) | DSH session log(首选)或 better-sqlite3 | 回放同一步集 | 未开工 | +| rules 注入 | `/mcp_server/rules.md` | 插件内固定文本或外部文件 | 注入内容非空且可达模型 | 部分(`rulesInjector.ts` 已在,供应链随 T4 改) | +| 录屏 / 视频分析 | py ffmpeg 系 | 不迁移(D12:一期无录像) | 放弃记录进 CHANGELOGS | 已砍 | +| notes / plan 语法 | py `utils` | TS 状态机(checker 用) | 计划与检查项语义等价 | 未开工 | + +## 三、分期(顺序不可跳) + +**T1 · 引擎去消费者**(对应 **G82**) +- What:把 `autogamer_run_task` / `autogamer_manage_task` / `autogamer_inspect_trace` 从「调 py `mobile_*`」改为 **TS 侧编排**:S11 路由循环驱动 `run_device_action`,任务状态落 DSH session,不再 spawn py 进程。 +- How:复用已有 TS 资产(`runDeviceAction.ts` 路由、`validatorLadder`、`incidents`、`turnLedger`、`observations`);补齐「跨回合任务状态」的 TS 载体(先查 DSH 原生 storage / session 投影,按 R20 取一手口径,不自建容器)。 +- Verify:三条工具不再出现 `mobile_` 调用(grep + 契约锁);新会话真机跑一条完整任务,`report_task_status` 终态双轴正确。 +- Done:py 引擎在插件侧**无任何消费者**(可达性检查为空)。 + +**T2 · 动作面 TS 化**(对应 **G83**) +- What:15 工具动作面 TS 原生实现(adb CLI + on-device 协议),替换 py `adb_server.py`。 +- How:先按 §四 实测 wire 契约;逐条对照 py 面行为(**同一输入同一输出**),fixture 复用现有 `py-action-tools.json` 作为逐字判据。 +- Verify:工具面 fixture 全等;真机逐工具一次成功判定(用现成 `scripts/spike-mcp.mjs` 的对照方式);坐标/层级/截图三条金标准复测。 +- Done:`artemis mcp --type adb` 不再是任何路径的运行时依赖;15 工具全在 TS 侧可跑。 + +**T3 · 图像与 OCR TS 化**(对应 **G84**) +- What:`compare_ui_frames` 差分、OCR 调用、overlay 标注落 TS。 +- How:差分算法从 py 侧读设计(阈值与算法即设计事实,属「无思路时从上游取设计」的典型);OCR 是 HTTP 调用,直译。 +- Verify:同 before/after 对、同图,TS 与 py 判定一致(对照表留档);`runDeviceAction` 的效果确认路径不再经 py。 +- Done:效果判定与 OCR 无 py 依赖。 + +**T4 · 收尾删 py 与溯源清理**(对应 **G85** 与 **G86**) +- What:删除 py 树、清 py 依赖与 CI、更新全部文档口径(`AGENTS.md` 命令表、`README.md` 导览、`../TODO.md` 基线节);同时执行 §八 的溯源清理(G86)。 +- How:按目录分批删,每批后跑插件全套 + R6;`docs/` 里的 py 引用要么改指向 TS,要么标注为历史;溯源清理的逐项处置见 §八 表。 +- Verify:`git ls-files '*.py'` 为空;CI 无 python job;插件 tsc + vitest 全绿;R6 rc 0;R7 新会话活体验证;§八 的判据命令(`git grep -l 'Google LLC'` 为空等)逐条通过。 +- Done:§一 **五条**全部成立。 + +## 四、必须先实测的契约边界(动 T2/T3 之前) + +按守则 **R25**(先读机制文档、一次只测一个变量)逐项取证,任一项未测不许开工对应期: + +1. **uiautomator dump 的真 wire 形态**:`adb shell uiautomator dump` 输出到设备的 XML 是什么形状,与 py 解析后的扁平列表如何对应(py 侧已知为扁平、无 `children`)。 +2. **helper(accessibility APK)服务协议**:端口、握手、请求/响应格式、`adb forward` 生命周期(与 **G78** 的残留问题同源)。 +3. **`compare_ui_frames` 的算法与阈值**:py 委托的 `check_ui_change` 判据是什么,阈值取多少。 +4. **外部提供方接入的边界**:`artemisMcpCommand` / `artemisRepoPath` 在 T1–T3 期间仍可用,T4 后它的语义只剩「可选外部 ARTEMIS」。 + +## 五、风险与不做项 + +- **回归面是主要风险,不是技术可行性**:py 引擎有数百条测试与真机实战记录,TS 侧重写要重新挣这些行为。已有的语义锁(动作效果判定、错误分类、`run_outcome` 双轴)是有用地基,但不是全覆盖。 +- **衍生作品风险(D20 相关)**:重实现时最容易被忽略的一条——「读代码照着写」会把上游文本带进来。§八 的「只借鉴设计、不搬运文本」是硬约束,T1–T3 全程适用,不能等到 T4 才想起来。 +- **真机强依赖**:T2/T3 的验收必须有设备在场(T1/T4 可离线推进)。 +- **中间态共存**:T1 完成前不得动 T2,否则 py 动作面与 TS 动作面同时被两条链消费,契约漂移无人看守。 +- **不做**:视频分析(D12)、旧控制台、把 py 以「可选运行时」名义反装回仓库、在迁移期并行维护两套任务编排。 + +## 六、与既有编号的关系(不另立新语义) + +- **D19 收紧 D18**:D18 的「py 仅在特定库成熟度高于 TS 同类库时保留」在终态下**不成立**——已实测该例外不适用:`langchain` 出现在 54 个 py 文件而 DSH 宿主本就提供 LLM 绑定;OCR 只是 HTTP 调用;`cv2`/`PIL` 有成熟 TS 同类;`uiautomator2` 是 on-device 协议的客户端。D18 保留为演进过程记录,D19 是终态。 +- **D8 / D13 / D14 的对应**:D8「langgraph 智能栈不迁移」由 T1 落实(不是新设计,是把已声明的做法做完);D13「helper 二期 TS 复刻」归入 T2/T3;D14/S11 是 T1 的设计依据。 +- **S5 的退化**:S5 原写「原样复用 py action server」。D19 下它退化为**迁移期过渡**,主体由 TS 原生执行面取代;外部 ARTEMIS 若被配置,它就是「可选外部提供方」。 +- **G72 / G74 / G78 的终局**:G72(提供方形状)在 T4 消解——不再需要仓内提供方;G74(映射层按 5 工具面锁形状)在 T2 重锁为 TS 原生契约;G78(`adb forward` 残留)由 §四.2 的生命周期设计一并处置。 +- **G81 并行**:spec 类型检查是 TS 侧独立债,与本期并行,不构成前置。 +- **D20 与 §一.5 / G86**:D20 是**独立于 D19 的一根轴**——D19 管「用什么语言实现」,D20 管「内容从哪来、留什么」。两者同期收尾(T4),但判据互不替代:py 删干净 ≠ 溯源清干净(copyright 头、vendored 第三方、派生文档、文案措辞、git 元数据都可能残留)。 +- **D1 的边界不变**:D1「方案基线 = google/artemis 上游」指的是**设计灵感来源**,与 D20 的「不留存上游内容」不冲突;D20 正是把这条说清楚——借鉴设计,不保留内容。 + +## 七、判据与门禁 + +- 每期收尾:插件 `tsc` + `vitest` 全绿;改过 patch 跑 **R6**(`--dump-config` rc 0 且无 `unmatched`);**R7** 开新会话验证;判「生效」只认 **R9** 的官方检查器。 +- T2/T3 的对照判据是**同一输入同一输出**,不是「都能跑」:对照结果必须留档(可复跑命令 + 实测输出)。 +- T4 的终态判据按 §一 **五条**逐条勾,且每条都要给出可复跑命令,不许用「CI 会保证」代替。 + +## 八、溯源与许可:终态只留 LICENSE(D20) + +**要求(用户 2026-10-14 定)**:本仓库虽从 `google/artemis` fork 而来,终态**除 `LICENSE` 外不留存任何来自上游的信息,包括代码**——即本项目仅从上游获取灵感后独立重实现。 + +**法律边界(实测,非推测)**: + +- 根 `LICENSE` 为 **Apache-2.0 全文**;仓库内另有一份**第三方**许可 `third_party/mobile_use/LICENSE` + `NOTICE`——vendored 树来自**另一个上游**(凭 33 处 `minitap` 标记与 29 处 `originally from` 判定),它的许可义务与 artemis 无关、独立成立。 +- Apache-2.0 的义务**只随「保留的派生作品」走**(保留许可与声明、标明改动);不保留,即不承继该作品的义务。因此「只留 LICENSE」在法律上可行——**前提是确实没有保留任何派生内容**。 +- **推论(本规格的硬约束)**:TS 重实现必须**只借鉴设计,不搬运文本**。设计(算法、阈值、状态机、契约形状)可自由研读并照语义重写;**prompt 正文、注释、文档段落不得逐字照抄**——逐字照抄等于保留派生内容,与 D20 冲突。这条约束**贯穿 T1–T3**,不是收尾才做的事。 + +**清理面(2026-10-14 实测现状)**: + +| 面 | 现状 | 处置 | +|---|---|---| +| py 代码 | 281 文件(`artemis` 218 / `third_party` 40 / `mcp_server` 23 等) | T4 删(**G85**) | +| copyright 头 | 319 个被跟踪文件带 `Google LLC` 字样(其中 `docs` 3、`scripts` 1、`tests` 30、`Makefile`/`pyproject` 各 1) | 随文件删或逐面清理(**G86**) | +| vendored 第三方 | `third_party/mobile_use/`(自带 `LICENSE` 与 `NOTICE`) | T4 删(**G86**) | +| 上游派生文档 | `docs/research/*`、`docs/migration/inventory/*`(旧代码盘点)、`artemis/agents/*.md`(prompt 正文) | 删或标注为历史;**不得作为本项目实现依据**(**G86**) | +| 致谢 / 命名文案 | `README`、插件 `README` 的「致谢 ARTEMIS」、包描述 | 按 D20 改写为「灵感来源」措辞,不写「基于」(**G86**) | +| git 元数据 | `upstream` remote 指向 `github.com/google/artemis`;历史含全部上游内容 | **待用户裁定**(见下) | + +**判据(可复跑)**:`git ls-files '*.py'` 为空;`git grep -l 'Google LLC'` 为空(或仅剩 `LICENSE` 自身);`third_party/` 目录不存在;`git remote -v` 不含 `upstream`;无逐字搬运的上游文本(人工复核 + prompt 文本自写)。 + +**两个必须由用户裁定、不代决的问题**: + +1. **git 历史怎么处置**:工作树可以清干净,但**历史仍含上游全部内容**。三条路——(a) 保留历史:仓库事实上的 fork 记录仍在,「不留存」只在工作树层成立;(b) 重写历史 / 重建仓库:不可逆,会断开 origin 上既有分支与 PR;(c) 新建仓库、本仓库降为来源。**未裁定前,D20 的判据只覆盖工作树层,规格如实这么写。** +2. **`upstream` remote 留不留**:删除不影响功能;保留等于留一条指向上游的元数据。 + +**保留物**:`LICENSE`(Apache-2.0 全文)。不额外要求 `NOTICE`——Apache-2.0 并不强制为「未保留派生内容的项目」出具 NOTICE,故本规格只要求留 `LICENSE`。 diff --git a/docs/migration/upstream-plugin-forms.md b/docs/migration/upstream-plugin-forms.md new file mode 100644 index 000000000..b2223050e --- /dev/null +++ b/docs/migration/upstream-plugin-forms.md @@ -0,0 +1,230 @@ +# 上游插件形态与工具形态调研(第一轮 2026-10-10 · 第二轮 2026-10-13) + +> 来源口径:`github.com/deepseek-ai/deepseek-harness` @ `master` 的公开文档与源码,符合 [TODO.md](../TODO.md) 的 D15(声明面 + 公开上游,不解包 app.asar)。每条标 **文档口径** / **源码口径**;本机尚未复验的写 **未实测**。 +> 取用方式:`gh api repos/deepseek-ai/deepseek-harness/...`(仓库确认为 public、default branch `master`,描述「DeepSeek Harness: Everything is a Plugin」)。原文副本落在 `plugins/dsh-autogamer/spike/sandbox/`(gitignored,只作取证现场,不作引用依据)。 + +## 一、为什么要查 + +S10 live 复验给出两个事实:AutoGamer 会话发给模型的请求里**连 `tools` 键都不存在**;而把 `preset-autogamer` 的 plugins 换成「standard 的全套顶层工具插件 + 我们的 inner」并在**新会话**里发,请求变成 `tools=16`,16 个全是内置工具,`run_device_action` / `report_task_status` 一个都不在。 + +上一轮我据此写下结论「内置 `@deepseek-ai/dsh-*` 名字作为 preset 子项能解析,而 profile 级 link 安装的外部插件名不被解析」。**这条归因不成立**,第四节逐条收回。 + +## 二、硬事实(会影响方案的九条) + +1. **preset 只能是 bundle 补丁**(文档口径,`editing-cordis-compositions/SKILL.md`):「Agent presets are ordinary `@deepseek-ai/dsh-agent-preset` declarations carried by bundle patches. Nothing edits a declaration in place」;注册表 README 第 48 行补充「新建 preset 或覆盖内置 preset 都是 bundle 补丁……再用 `plugin_manager` 安装到 profile」,并明确「**注册表不扫描目录,也不接受 preset 路径**」。 +2. **安装正道是 `install_bundle`,且明令禁止手写 profile 文件**(文档口径,`cordis-plugin-development/SKILL.md:8-10`):「Do not write the profile's `package.json` or `cordis.patch.yml`, create packages under `$DSH_HOME`, or run pnpm in the profile directory: `install_bundle` performs those steps」。→ 我们现在这套 `dsh plugin add file:` + 手写 patch 的做法,正落在被禁止的那一侧。 +3. **覆盖是整体替换 config**(文档口径,`cordis-composition-reference/SKILL.md:15`):「`config` is replaced wholesale, never deep-merged, so restate every field the row needs」;且「A truthy `name` **asserts** the existing plugin name rather than renaming it」。非 insert 且 id 匹配不到行的补丁会 **warn 并跳过**(同页 16 行)。 +4. **声明只影响之后创建的 Agent**(文档口径,两处一致):`agent-preset/README.zh.md:12`「声明会提前加载,**修改只影响之后创建的 Agent**」;`editing-cordis-compositions` Verify 段「Existing sessions and their children **keep the plugin revision they started with**; validate changed behavior in a new session」。 +5. **挂载会被整体拒绝**(源码口径,`packages/preset/agent-preset-registry/src/mount.ts`):`auditRows()`(:134)收集「Rows that never started or whose import or activation rejected」(:116);`mountRevision` 里 `:193` 注释「**Failed rows and root-realm service leaks reject the mount**」,`:212-213` 分别 `throw` 出失败行清单与 `Preset services require isolate realms: ...`。`leakedServices()`(:69)读的是「子树把实现发布进 **root realm**」的服务名。 +6. **isolate realm 是硬约束**(文档口径,注册表 README 第 58 行):「preset 的服务提供方及其消费方必须共享同一个 `cordis:group` 隔离 realm。**条目局部隔离不会沿 Agent 注册 scope 传播:组外的普通 Context 查找(包括 `agent.ctx`)仍选择 Host realm**」。`editing-cordis-compositions` 的 placement 段(:78-80)给出分层约定:Host 插件提供共享服务(含 tools registry、agent loop、sessions…),**preset 插件向这些 registry 贡献 scoped tools / persona / prompt 片段 / policy**。 +7. **官方给了现成的活门禁**(文档口径,三处):`list_plugins` 报告每行的 `enabled` 与 `fiberPhase`,`set_plugin` / `set_bundle` 在有上层胜出时回答 `overridden`(composition-reference:25);「A declaration whose activation fails **stays on the roster with its diagnostic** and cannot compose a session until the bundle is fixed and reinstalled」(editing skill Verify 段);`inspectCompositions()` 返回 preset ID、活动模块引用与解析基址、**以及泄漏的服务名**(注册表 README:62);`cordis_inspect_query` 能直接问「**the Tools this Agent can call**」(development SKILL 第 30 行的 Tool 项)。 +8. **我们的 inner 写法与官方 shipped 形态同构,不是格式问题**(源码口径):`packages/bundle/web-app/presets/minimal.patch.yml` / `standard.patch.yml` 里工具插件就是裸子项 `- id: tool-fs` / `- id: tool-jobs` + `name: '@deepseek-ai/dsh-…'`,与我们的 `- id: dsh-autogamer-inner / name: dsh-autogamer / config: {actionServerCommand: […]}` 同形。带服务的官方样板则包在 `name: cordis:group` + `group: true` + `isolate: {terminals: true}` 里(minimal 的 `persistent-shell`)。外部 bundle 的插件确实可出现在 patch 中(composition-reference:29「Plugins outside this list come from installed bundles」)。 +9. **版本差**:上游 releases 已有 `dsh-v0.2.1-alpha.2`(2026-10-09)与 `dsh-v0.2.1-alpha.1`(2026-10-03),本机 spike 跑的是 `0.2.0-rc.2`(2026-09-29)。alpha.1 的主题按公开报道是「让 Agent 给自己造插件」,即上面那两个 skill 的落地。 + +## 三、收回上一轮的两条结论 + +- **收回「外部插件名不能进 preset 子项」**:没有任何官方文档这样说;相反 composition-reference:29 承认 installed bundles 的插件。而 01:51 那次 `tools=16` **同时变了两个量**(plugins 集合 + 旧会话→新会话),按事实 4,光「换成新会话」这一项就足以解释「此前无 `tools` 键」。所以那次的正确读法是:**旧会话沿用其启动时的 preset revision**,与外部性无关。 +- **收回「裸 `- name:`(无 id)子项产不出工具」**:与 `agent-preset/README.zh.md:48` 直接冲突——「**子插件可省略行 ID,由 Loader 分配**」。这条实测差异的真实原因还没定性(可能同样是 revision 混淆),不许再当作约束写进方案。 + +两条都是我自己写进 `TODO.md` 并已提交的(`1f05039`),现在按本节修订。 + +## 四、对方案的冲击(改哪条、为什么) + +| 位置 | 冲击 | 处置 | +|---|---|---| +| D7 引入形态 | 「`dsh plugin add` + 手写 patch」是官方禁止面,且 preset 声明必须由 bundle 承载 | 改写为 `plugin_manager install_bundle` 正道,CLI `--patch` 降级为临时实验层 | +| 决策表 | 缺「Host 提供共享服务 / preset 贡献 scoped tools」这条分层约定与 isolate realm 约束 | 新增 D16 | +| S5 执行层契约 | 原契约只讲「工具注册成功」,没讲注册落在哪个 realm、Agent 看不看得到 | 补作用域可见性一条,并把判据从 wire 换成官方检查器 | +| S8 版本门禁 | conformance fixture 录自 0.2.0-rc.2,上游已两版在前 | 补版本差与重录触发条件 | +| 差距清单 | P0 真实阻塞点是「preset 挂载被整体拒绝」这一族失败,原先没有对应条目 | 新增 G30 | +| P0 状态 | 上一轮两条归因要作废 | 按本节改写,不删历史记录(走 git) | + +## 五、下一步判据(按成本排序,全部是官方给的) + +1. **roster 诊断**(零成本):Web「插件」页 / `list_plugins` 看 `preset-autogamer` 行的 `enabled`、`fiberPhase` 与诊断文本——官方明说激活失败会留在列表里带诊断。这一步能直接判「是 mount 被整体拒绝」还是「挂上了但工具不在 Agent 面上」。 +2. **`cordis_inspect_query` 的 `Tool` 项**:列出「本 Agent 可调用哪些工具」。这替代我们目前靠 wire 反推的做法。 +3. **`inspectCompositions()`**:若返回 `leakedServices`,即命中 mount.ts:213 那条 `throw`,修法就是按事实 6 把 provider 与消费方放进同一个 `cordis:group` + `isolate`。 +4. 以上三条都要求**改完在新会话里验**(事实 4)。 + +## 六、未读与待查(别当已证) + +- **状态更新(同日第二轮)**:上一版列在这里的「已取回未逐字读」清单——`references/practices.md`(官方列为「选择扩展点之前必读」)、`host-plugin.md`、`ui-plugin.md`、`user-actions.md`、`verification.md`、`docs/cookbook/adding-a-tool.zh.md`、`packages/core/tools/README.zh.md`、`packages/bundle/web-app/presets/minimal.patch.yml`——**已逐字读完**,成果写成守则 R13-R19 并据此改写了 S3/S4/S5/S6/S9/S11/P3,本文不再重述(避免两处平行口径)。**仍未读**:`docs/capability-seams.md`(63 KB)、`docs/tool-catalog.md`(102 KB,官方口径本就要求按关键词 grep 而非整读)、`docs/tool-execution-pipeline.md`、`packages/jobs/jobs/README.zh.md`、`dsh-tool-call-timeout-policy` 的包 README、`docs/subsystems/scope.zh.md`。 +- 第三方站(`deepseekdocs.com`、`dshplugin.store`、`awesome-deepseek-harness-plugins`、`dsh-plugin-radar`):**未读**,只在搜索结果里出现,不作为任何结论的依据。 +- 0.2.1-alpha.2 相对 rc.2 的 breaking change:未读 CHANGELOG,未实测。 + +## 七、出处 + +- 仓库与发布流:[deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness)(`gh api repos/deepseek-ai/deepseek-harness`、`.../releases`) +- [editing-cordis-compositions/SKILL.md](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/preset/agent-preset/skills/editing-cordis-compositions/SKILL.md) +- [cordis-plugin-development/SKILL.md](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/preset/agent-preset/skills/cordis-plugin-development/SKILL.md) 及其 [references/mcp-bundle.md](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/preset/agent-preset/skills/cordis-plugin-development/references/mcp-bundle.md)、[templates/decoration/cordis.patch.yml](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/preset/agent-preset/skills/cordis-plugin-development/templates/decoration/cordis.patch.yml) +- [cordis-composition-reference/SKILL.md](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/preset/agent-preset/skills/cordis-composition-reference/SKILL.md) +- [packages/preset/agent-preset/README.zh.md](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/preset/agent-preset/README.zh.md)、[packages/preset/agent-preset-registry/README.zh.md](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/preset/agent-preset-registry/README.zh.md)、[src/mount.ts](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/preset/agent-preset-registry/src/mount.ts) +- [packages/core/scope/README.zh.md](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/core/scope/README.zh.md) +- shipped preset 模板:[presets/minimal.patch.yml](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/bundle/web-app/presets/minimal.patch.yml)、[presets/standard.patch.yml](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/bundle/web-app/presets/standard.patch.yml) +- [docs/cordis-primer.zh.md](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cordis-primer.zh.md)、[docs/cookbook/adding-a-package.zh.md](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cookbook/adding-a-package.zh.md) +- 搜索结果里出现但未读的第三方站:[Plugin Ecosystem - deepseekdocs.com](https://deepseekdocs.com/en/ecosystem)、[awesome-deepseek-harness-plugins](https://github.com/walkinglabs/awesome-deepseek-harness-plugins)、[dshplugin.store](https://www.dshplugin.store/) + +## 八、第二轮调研(2026-10-13):插件管理面的安装 / 卸载契约 + +> 触发:插件改名 `autogamer-device` → `dsh-autogamer`(提交 `70cc7af`)后,本机 web profile 的组合包选择仍指旧名, +> `--dump-config-schema` 直接 rc=1。为定「按官方路径怎么卸载旧名插件」而查。 +> 版本:本机宿主 `dsh 0.2.0-rc.2`(`where.exe dsh` → `D:\DeepSeek Harness\resources\runtime\cli\bin\dsh.cmd`,动态解析、不写死盘符); +> 上游 `deepseek-ai/deepseek-harness` @ `master`,2026-10-13 经 `gh api repos/.../contents/...` 取。 +> 口径标注沿用本文头的约定;本节另有**本机实测**一类,按 R21 与「单测证明 / live 证明」分开记。 + +1. **`dsh plugin` 是 pnpm 转发,但转发之后宿主会 `reconcile()` —— 所以 CLI 卸载确实会清掉组合包选择** + (文档口径,`docs/cli-help.zh.md` 的 `dsh plugin` 段:「manage a profile's plugins by **forwarding the remaining + arguments to pnpm** in the profile directory」,参数示例 `add `, `remove `, `why `; + **源码口径**,`packages/boot/plugin-manager/src/operations.ts:88-97`): + ``` + const bundles = previous.filter((name) => { + if (!beforeDeps.has(name) && !dependencies.includes(name)) return true + return dependencies.includes(name) && bundleManifest(name, dir, anchor) !== undefined + }) + ``` + 即 `pnpm remove ` 成功后该名字既不在 `dependencies` 也不在 `beforeDeps` ⇒ 被从 + `dsh.profile.bundles` 剔除;`add` 方向同一段的 `for (const name of dependencies)` 循环会把新装且声明 + `dsh.bundle` 的包补进层。**且只在 `exitCode === 0 && !control.stalled` 时执行**(`operations.ts:464-465`)。 + + > **收回八节初稿的两句话**(2026-10-13 第二轮自查,R28):我先前写下「pnpm 不碰 `dsh.profile.bundles`, + > 所以 CLI 卸载只删依赖、bundle 选择会留着」和「**CLI 侧不存在卸载命令**」。**两条都不成立**——我只读了 + > `index.ts` 的 `removeBundle`,没读 `operations.ts` 的 `runProfilePnpm`→`reconcile` 收尾。 + > 后果不只是措辞:它让 **G71** 看起来必须走宿主 UI 审批,实际存在零审批的 CLI 路径(见 §八 末与 TODO 第四节)。 + > 教训记法与 R25 同源:断言「官方没有 X」之前要读完同一条调用链的收尾,别只读最显眼的那个方法。 +2. **`remove_bundle` 才是把两步一起做掉的那个**(源码口径,`packages/boot/plugin-manager/src/index.ts:645-669`): + `listBundles()` → 要求 `bundle.removable` → `if (bundle.enabled) await this.selectBundle(name, false)`(**这一步才清 + `dsh.profile.bundles`**,`README.zh.md` 原文「组合包开关修改 `package.json` 的有序 `dsh.profile.bundles` 列表」) + → `runPnpm(['remove', name])` → `refreshPackages()`。pnpm 那步非 0 或超时即抛。 +3. **可移除性判据**(源码口径 `index.ts:315`):`removable = owned || (enabled && !installed && !shipped)`,其中 + `owned = installed && !shipped`;而 `installed` **只表示 profile 是否声明该依赖**(文档口径 `README.zh.md`)。清单读不到的包 + (我们的死链正是)在 `index.ts:324-327` 仍会被列出并带 `error`,`removable` 照算;`README.zh.md` 另写明「插件页面显示错误并 + **允许取消选择**」。 +4. **三个会挡住卸载的失败码**(源码口径 `index.ts:649-665`):`not-removable`、`stop-profile`(`hmr` 未开且该包在 + `startedBundles` 里——本 profile 写了 `patchReload: "live"`)、`bundle-in-use`。 +5. **入口只有两个**(文档口径 `README.zh.md`「使用本包」段):Web 侧边栏**插件**页,或会话里的 `plugin_manager` 工具 + (`src/tools.ts:23` 的 action 枚举:`list_plugins` / `list_bundles` / `set_plugin` / `set_bundle` / `install_bundle` / + `remove_bundle` / `list_version_exemptions` / `set_version_exemption`)。**每个动作都要求 `danger-full-access` 或该次调用的批准**, + 批准不改变会话权限模式;改动影响该 profile 的全部会话。 +6. **`link:` 交付本身官方认可,违规的只是手改 profile 文件**(文档口径 `README.zh.md`:「开发目标保留普通 `link:` 依赖…… + 链接包使用该 checkout 的依赖与 peer;**代码改变后仍需重新构建并重启**」)。与本文**二.2**(`cordis-plugin-development/SKILL.md:8-10` + 禁手写 profile `package.json` / 禁在 profile 目录跑 pnpm)合读:本机该走的是 `install_bundle` 带 `link:<绝对目录>`, + 而不是当初手改 `package.json` 那条。 + +**本机实测(2026-10-13,live 证据)** + +- `dsh --profile web --patch <插件绝对路径> --dump-config`:**rc 0**,输出含 6 处 `dsh-autogamer`,其中有 + `# == D:\DEV\autoGamer\plugins\dsh-autogamer\cordis.patch.yml` 与 `- id: dsh-autogamer`。⇒ **`--patch` overlay 与组合包选择是两条 + 独立通道,旧名死链不阻塞 R6 自检**(这解释了为什么 R6 绿而 schema 红)。 +- `--dump-config-schema` 同参数:**rc 1**,错误原文 + `dsh: error: Selected profile bundle "autogamer-device" could not be loaded; repair or remove its bundle selection.` 与 + `dsh: error: [/183] Cannot find package 'dsh-autogamer' imported from C:\Users\78557\.dsh\profiles\web\cordis.yml`。 +- 死链现场:`~/.dsh/profiles/web/node_modules/autogamer-device -> /d/DEV/autoGamer/plugins/autogamer-device`; + `web/package.json` 的 `dsh.profile.bundles` 仍含 `"autogamer-device"`,`dependencies` 仍是 + `link:D:/DEV/autoGamer/plugins/autogamer-device`,`pnpm-lock.yaml` 同一 specifier。 +- **断链时点**:改名提交 `70cc7af` 把 `package.json` 从旧目录移走,检出该分支时旧目录只剩 `dist/ node_modules/ scripts/ spike/` + (无 `package.json`)——link 在**切分支那刻**已解析失败,本会话删除残骸只是清了现场,不是致因。 +- 自检没有改写宿主配置:`web/package.json` mtime 仍是 `2026-10-12 02:10`;只有生成的 `web/cordis.yml`(内容 `[]`, + 注释写明「Edit cordis.patch.yml, not this file」)在每次 `dsh` 调用时重写。 + +**未证(别当已证)** + +- ~~`G59` 的「常驻」二字未复现~~ ⇒ **2026-10-13 已定案(本机实测)**:G71 修好后同一条命令仍 rc=1, + 但两条 bundle 加载错已消失,剩下 5 条 `unrecognized Loader tree carrier` 与 `--dump-config` 的条目表逐一对位, + 落在 **`preset-standard`(179) / `preset-ptc`(180) / `preset-minimal`(181) / `preset-cordis`(182) / + `preset-autogamer`(184)** —— **前四个是宿主自带的 shipped preset,第五个才是我们的**,五者同错同形。 + 对照:`--profile headless` 与 `--profile desktop` 的 schema dump 同样 rc=1 但 carrier 错 **0 条**(不含这些行), + 而 `--dump-config` 全程 rc 0、`unmatched` 0。⇒ **「常驻失败」现在可以说成:web profile 必现、成因在宿主 + schema dumper 走 preset 子项的固有行为、与本包形状无关**。 + 仍未证的一条:为什么同一棵树在 `--dump-config` 与 `--dump-config-schema` 两条路径上宽严不同。 +- ~~`remove_bundle` 对带 `error` 的死链组合包能否一路走完,本机未执行~~ ⇒ **本机已执行**(G71): + `dsh plugin --profile web remove autogamer-device` rc 0,`dsh.profile.bundles` 里旧名**直接消失**、 + `dependencies` 与 lock 同步清掉 —— 走的是 `reconcile()` 而非 `removeBundle()` 那条路径, + 效果等价且**零审批**(见本节第 1 条的收回段)。 +- 0.2.1-alpha.\* 相对 rc.2 在这个面上的变化:未读 CHANGELOG,未实测。 + +**本节出处**(补入 §七 之外的增量) + +- [docs/cli-help.zh.md](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/cli-help.zh.md)(`dsh plugin` 段) +- [packages/boot/plugin-manager/README.zh.md](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/boot/plugin-manager/README.zh.md)、 + [src/index.ts](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/boot/plugin-manager/src/index.ts)、 + [src/tools.ts](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/boot/plugin-manager/src/tools.ts) +- 撤回一条来源:本轮曾对一个**非上游**的第三方代理 URL 取证,两次抓取返回互相矛盾的「官方口径」,已整条弃用; + 本节全部结论只来自公开仓库 `gh api` 与本机实测。 + +## 九、设备身份口径查证(R20,2026-10-13) + +> 起因:**G75** —— 一台 MuMu 被 `list_devices()` 报成 4 台。守则要求「遇行为问题先查官方一手资料,不许先写猜测实现」, +> 所以先问:**两个上游里到底有没有「同一物理设备的多条传输要合并」这个口径**。查证结果决定 G75 的解法边界。 + +1. **DSH 侧没有 Android 设备身份概念**(源码口径,`deepseek-ai/deepseek-harness` @ `master`,全树路径检索)。 + `device|roster|fleet` 命中 24 处但语义全部无关:DSH 的 **roster 指 preset / 工具行清单** + (`.agents/notes/archived/bug-fix/2026-08-09-broken-preset-roster-rows.md`、 + `apps/cli/tests/profiles/web/tests/runtime-roster*.ts`),`device` 命中是 + `apps/desktop/src/device-info.ts`(**桌面主机自身**信息)与 `device-local-shortcut-preferences`(本机快捷键)。 + ⇒ **在 DSH 里找设备去重口径是找错仓库**,这条归因先纠正再往下走。 +2. **artemis 上游(canonical 设备契约)同样没有去重 / 身份口径**(源码口径,`google/artemis`): + `artemis/runtime/device_pool.py` 里 `dedup|unique|identity|android_id` **零命中**,只有 + `serial.startswith("emulator-") or "127.0.0.1" in serial or "localhost" in serial`(:309,判 `is_emulator`) + 和 `DeviceExecutionLock._normalize_device_id`——它把 serial 洗成 filesystem-safe(非字母数字→`_`), + **不做跨传输合并**。上游 `tests/unit/runtime/test_device_pool.py` 的 12 条测试全是 snapshot / single-flight / + warm-up 一族,**没有一条测同设备多传输**。 +3. **本仓与上游逐字相同**:`diff artemis/runtime/device_pool.py <上游同路径>` = **0 行差异**(两边都 478 行) + ⇒ G75 **不是 fork 丢了东西**,是上游 canonical 行为;要按物理身份合并,只能改上游契约或在我们这一侧显式绕开枚举。 +4. **插件现状已有两层缓解,但都不解决身份问题**(源码口径 `plugins/dsh-autogamer/src/devicePool.ts`): + - 构造期 `this.devices = [...new Set(options.devices)]`,注释原话「A serial listed twice would be two pool + entries for one device」(:93-94)——**只去字面重复**。 + - `refreshRoster()`:`if (this.devices.length > 0) return;`(:166,注释「操作者钉了 roster 就整跳 RPC」), + 发现出来的 roster 也**只按 serial 字面**建 Map(:175-179)。 + ⇒ 敞口条件被代码限定得很干净:**Config 钉住 `devices` 时不引入误计数;不钉时才会**。 +5. **插件侧拿不到机器身份**(源码口径):本树 15 工具动作面**没有**任意 adb 执行入口(无 `run_adb_command`), + 所以插件无法自行读 `settings get secure android_id`。本轮实测的同机证据(四条传输 `android_id` 全等 + `c0021066f3fbaf47`)是在本机 adb 上量的,**不是插件走得通的路**。要让 `list_devices` 带身份,得在 py 面新增字段。 + +**由此 G75 只剩三条诚实路线**(不许写猜测实现): + +| 路线 | 代价 | 是否碰 canonical 契约 | +|---|---|---| +| ① 交付默认钉显式 roster(`Config.devices`),自动枚举降级为「操作者明确接受按传输计数」时才开 | 改动最小 | **不碰** | +| ② py 的 `list_devices` 增发机器身份字段 | 需与上游对齐 | **碰**(D6/D18 的稳定面) | +| ③ 只在文档与 UI 明示「计数单位是 adb 传输,不是设备」 | 零代码 | 不碰 | + +选 ②③ 之前不得自造指纹:`is_emulator` + model 在多实例场景不可区分(实测四条 model 全同)。 + +## 十、设备侧一手口径(Android / adb / scrcpy,2026-10-13) + +> 为什么写进本文:G75 / G76 / G78 与留白 19 的解法边界由 **adb 与平台的官方口径**决定,而不是由 DSH 或本仓决定。 +> 来源全部是 primary(`developer.android.com` 的 adb 文档、AOSP `platform_frameworks_base` 源码镜像、 +> Genymobile/scrcpy 源码与 FAQ、openatx/uiautomator2 README),并标注 文档口径 / 源码口径 / 实测 / 未证。 + +1. **adb 只建模「传输」,不建模「设备」**(文档口径 + 本机 `adb help` 逐字):emulator 由 adb 扫描 + 5555–5585 的奇数端口发现,「每个 emulator 用一对连续端口——偶数 console、奇数 adb」, + `emulator-5554` 的 serial 就是 adb 自己按端口造出来的字符串;**console↔adb 端口配对是唯一被文档化的身份信息**。 + `127.0.0.1:16384` / `:7555` 属 `connect HOST[:PORT]` 的 TCP 传输,**没有任何官方文档把它绑定到某个 emulator 实例**。 + 可用旗标:`-d`(USB) / `-e`(TCP) / `-s SERIAL` / `-t TRANSPORT_ID` / `get-serialno` / `get-devpath` / + `--one-device SERIAL|USB`(仅 start-server)。⇒ **`transport_id` 不可当身份**(宿主分配,重连或 `kill-server` 会变,实测确认)。 +2. **HOME 是框架消化、不下发给应用的键**(源码口径,AOSP `core/java/android/view/KeyEvent.java` 原文): + “Home key. **This key is handled by the framework and is never delivered to applications.**” + `KEYCODE_BACK` 的注释只有 “Back key.”。⇒ 与实测一致(本机 HOME 0/10、BACK 10/10)。 + shipped 的 Android 15 `input` 只接受 **`-d DISPLAY_ID`**(实测:`-d: specify the display ID. + (Default: INVALID_DISPLAY for key event, DEFAULT_DISPLAY for motion event if not specified)`)—— + **`--display` 与 `-p` 在这个 build 里不存在**。官方从未推荐用 `am start MAIN/HOME` 代替 keyevent, + 该替代路径是本机实测有效,属**推断**;HOME 被丢的具体环节(PhoneWindowManager 策略还是视口投递)**未证**(取不到源码)。 +3. **坐标空间的正解不是 `wm size`**(文档口径,AOSP `android/view/Display.java`):`getRotation()` + “will match the results of getMetrics”,而 `getRealSize()` 已 **@Deprecated**,官方指引是 + `WindowManager#getCurrentWindowMetrics()` / `WindowMetrics#getBounds()`。 + 本机实测:`screencap` 的 PNG IHDR、uiautomator `bounds`、`input tap` 三者同处 1600x900 空间, + `wm size` 的 “Physical size 900x1600” 是平衡朝向 ⇒ **「取当前帧空间尺寸」这条路有官方依据, + 手填 `wm size` 值没有**(G76 据此选校验而非注释)。`wm` 自身的 help 文本未取到,**未证**。 +4. **forward 属于 adb server,不属于建它的进程**(文档口径,`adb help`:「forward --list — list **all** + forward socket connections」,另有 `--remove LOCAL` / `--remove-all`):没有任何文档承诺进程退出会清理, + 与本机实测一致(server 已退,残留仍在)。**官方项目自己的做法可抄**:scrcpy `app/src/adb/adb.c` 的 + `sc_adb_forward_remove()` 走 `-s forward --remove ` —— + **记住自己的本地端口、按 serial+port 精确删**,而不是 `--remove-all`(那会误伤别处的转发)。⇒ G78 的形状有了。 +5. **层级稀疏不能归因于 UIAutomator2**(源码口径,openatx/uiautomator2 README):设备端是 + “an HTTP service based on UiAutomator”,可按 `transport_id` 选设备。第三方 launcher(Lawnchair) + 与 window filter 造成的稀疏度差异**无官方文档**,属**未证**——本仓那半边已由 + `filter_ui_hierarchy` 的语义裁剪解释清楚(§零 Step 2)。 + +**这五条对决策的直接影响**:G75 的「钉显式 serial」只剩 `emulator-5554` 有文档化身份含义,MuMu 的 TCP 端口 +可能随启动/实例变化 ⇒ 白名单会**静默丢设备**;G76 应加与截图 IHDR 的一致性校验而非只写注释; +G78 照 scrcpy 的 serial+port 精确删;留白 19 记为「HOME 由框架消化」的已知平台行为,不再当随机故障追。 diff --git a/docs/migration/upstream-rethink.md b/docs/migration/upstream-rethink.md new file mode 100644 index 000000000..98d6fcfc7 --- /dev/null +++ b/docs/migration/upstream-rethink.md @@ -0,0 +1,50 @@ +# 上游实现情况研究与方案重构(upstream rethink) + +> 日期:2026-10-09;上游:google/artemis(本仓库为其 fork,领先 92 提交);方法:`git fetch upstream` + 结构/关键文件精读,对照 5 份既有盘点。 +> 触发:用户裁定「当前迁移路线并非最优,需从未修改的上游项目获取实现情况重新思考」。 + +## 一、上游的三张调用面(google canonical) + +| 面 | 形态 | 关键证据 | +|---|---|---| +| **MCP 面(README 主打)** | artemis 定位=「Let AI assistants use real phones」——`mcp_server` 让 Claude Code/Antigravity/Windsurf 直接驱动机器+logcat+截图 | README Key Highlights「MCP-Native Server」徽标与 MCP-setup 节 | +| **playground 云端面** | **每会话一个完整容器**:backend_manager 收请求 → spawn `artemis-session-` 容器(内跑完整 admin_console server :8080)→ entrypoint 自动 `adb connect` **专属 Cuttlefish AVD**(6520+N)→ Nginx 按 `/session/:id/artemis/*` 反代;reaper 30s 心跳/TTL 销毁容器+AVD;BigQuery 登记映射 | playground/artemis_container/README 架构图;backend_manager/README 核心职责 1-6 | +| **本地控制台面** | admin_console + showcase_ui:单机多会话**共享设备**,共享队列 + device_lock + 熔断(我们 fork 强化的就是这张面) | 已有盘点 01/02 | + +**容器 env 契约(上游版)只有 4 项**:`SESSION_ID` / `ADB_DEVICE_SERIAL` / `PORT` / `PYTHONUNBUFFERED` + LLM 凭据转发(docker_service.py:96-104)。我们 fork 演化出的 20+ env(ticket/ingress/submitted_at/lock scope/ipc)**全部是「共享队列」形态的复杂度,上游不存在**。 + +## 二、fork 偏移量化(92 提交的构成) + +- admin_console:+1292/-44(队列增强:pause/resume、熔断、never-started 补插、幂等——全部服务于「共享设备多会话」形态) +- artemis/ 核心:41 文件 +868/-269(**智能栈基本未动**——上游 canonical 的 agents/graph/llm/memory 与我们审计基线一致) +- 新增 109 文件:showcase_ui_v2(~70)+ docs/migration(本文档系列)+ replay_manager + 少量测试 + +**结论:上游 = 纯净基线;我们审计的 5 份盘点对 artemis/* 核心全部有效,但「共享队列/锁/熔断」的复杂度属于 fork 场景(本地单机共享真机),不是上游核心。** + +## 三、对迁移方案的重构(R2'·上游对齐直通插件) + +上游定位(给 AI 助手的设备工具集)与「DSH 插件提供内容」完全同构。重构后的方案: + +1. **内容层(=插件提供物)**: + - 设备工具:上游 `mcp_server` 动作面(13 工具)为基线,经 TS 包装转发 py action server(闸门阻塞在自家工具内;不裸用 dsh-mcp-client patch 以免绕闸) + - flash 模式:DSH agent loop + skills(与 FlashRunner 反应环天然同构,README:3-5s/步) + - **flash/pro 合并为单层路由(S11,用户提议)**:单 agent + `run_device_action` 路由工具(代码强制按序判定:目标置信→短路径直执行 / 风险→validator 前置+ask / 失败→自愈梯→阈值→checker 审计 / 有 plan→里程碑审计)+ 严格度梯级(off/final/checkpoints/strict)降为路由器配置;validator 升级梯原样、checker 三不变量保留(详见 TODO.md S11) + - **pro 模式一期内实现(用户已决)**:checker/validator 以 DSH subagent 承接,**调用逻辑保真**——上游语义 = Operator 产出动作 → validator 执行前安全网 → blocked/failed 终态动作作为 `open_incident` 回 Operator 自行恢复 → checker 零副作用只读判定 → verify 失败回流(预算内)、assert 失败只记 tests.failed 不回流(盘点 04 §2.4/2.5 为保真清单) + - 诊断/模拟器:上游 `core/diagnostics`(emulator_manager 管本地 AVD 生命周期——playground 的 Cuttlefish 编排在单机版对应物)→ admission 工具 +2. **DSH 原生承接**:Inbox 队列、调度、web UI、jobs 监管、审批(run_adb_command 整工具 ask)、凭据、超时、沙箱、present +3. **设备模型分叉(✅ 已决策:共享真机+完整闸门)**: + - **共享真机**(一期选定):本地单机现状——run_device_task 内保留完整闸门+熔断+准入(盘点 01 §1.2 全部语义,工具内部承载) + - 每会话专属模拟器(上游 playground 模型;emulator_manager 原生管 AVD)→ 二期演进项,届时闸门退化为薄校验 +4. **录屏**:后续补齐(用户已决) +5. **旧控制台**:原地只读保留(一期红线不变);showcase_ui_v2 的近 6 提交行为断言不再需要接棒者(队列 UI 由 QueueDock 原生) + +## 四、对既有文档的影响 + +- 盘点 01(队列控制台)的大部分条目降级为「fork 场景遗产」:仅在共享真机形态下部分有效(ENVIRONMENT_ERROR_MARKERS/终态权威/stdout 尾行仍随 py worker 沿用) +- 盘点 03/04(设备/智能栈)全部有效且升格为「上游 canonical 契约」——S10/S2/S5 的 env 契约表按上游 4 项 + py worker 必需项重新裁剪 +- dsh-verification 全部有效;S10 架构(乙·tool-mediated)不变,run_device_task 工具内部按设备模型决策裁剪 + +## 来源 + +- google/artemis main@351ca84:README.md、playground/{backend_manager,artemis_container}/README.md、docker_service.py、server.py +- 本仓库 5 份盘点(docs/migration/inventory/01-05)+ dsh-verification.md diff --git a/docs/research/vue3-arco-migration-research.md b/docs/research/vue3-arco-migration-research.md new file mode 100644 index 000000000..76bd043f1 --- /dev/null +++ b/docs/research/vue3-arco-migration-research.md @@ -0,0 +1,296 @@ +# ARTEMIS Web 前端重构调研报告:Angular → Vue 3 + Arco Design Vue + +> 调研日期:2026-10-06 +> 调研范围:`apps/showcase_ui`(Angular 源码)、`artemis/resources/showcase_ui`(编译产物)、`apps/admin_console`(FastAPI 后端) +> 性质:探索性调研,本轮不修改任何现有代码。 + +--- + +## 0. 已确认决策(2026-10-06,用户拍板) + +| # | 决策项 | 结论 | 对方案的影响 | +|---|---|---|---| +| D1 | 视觉基调 | **接受 Arco 视觉重做**,逻辑层原样平移 | 视觉层按 Arco token 重做,不逐像素复刻手写 SCSS;节省约三分之一人日 | +| D2 | 共存策略 | **一次性替换**(修订报告原推荐的 /v2 并行) | 不做 /v2 子路径;开发期用 Vite dev server + 代理,切换日以新 dist 直接替换托管产物。回退手段 = 从 git 历史恢复旧 Angular dist 并重挂(需在切换文档中写明回退步骤);切换前 M0–M6 验收必须全部通过 | +| D3 | 迁移范围 | **对等迁移 + 中文界面** | 界面文案中文化(Arco 自带 `zh-CN` 语言包,成本低);`/tree`、`/tasks/presets`、replay 组等未消费端点的增强**不在本轮范围**,留待迁移后的独立迭代 | +| D4 | UI 库 | **坚持 Arco Design Vue,锁定 `~2.58.0`** | 接受其维护放缓风险:锁版本 + 必要时自行修补;package.json 用 `~` 前缀防止意外大版本漂移 | + +--- + +## 1. 现状盘点 + +### 1.1 目录与托管关系澄清 + +| 路径 | 性质 | 说明 | +| --- | --- | --- | +| `D:\DEV\autoGamer\apps\showcase_ui` | **Angular 源码(真正的源)** | 含 `src/`、`package.json`、`angular.json`、`node_modules`、`.angular` 缓存 | +| `D:\DEV\autoGamer\artemis\resources\showcase_ui` | **仅编译产物** | 6 个文件:`index.html`、`main-BGAFACUZ.js`、`polyfills-5CFQRCPP.js`、`styles-CDJM3XQS.css`、`logo.png/svg`、`favicon.ico`。由 `artemis/resources/__init__.py` 的 `get_bundled_showcase_dist()` 在 wheel 安装态回退使用 | +| `D:\DEV\autoGamer\apps\admin_console\index.html` | 独立的 Admin Debug Console | 3787 行单文件 vanilla HTML/JS(无构建),挂在 `/admin`、`/debug` 路径,**不在本次重构范围** | + +### 1.2 技术栈与依赖(`apps/showcase_ui/package.json`) + +- Angular **22.1.4**(`@angular/core` 等 `^22.1.4`),`@angular/cdk ^22.1.5`,`@angular/build ^22.1.6`(esbuild 应用构建器),TypeScript `~6.0.3`,`zone.js ~0.15.0`,`rxjs ~7.8.0` +- **没有引入任何第三方 UI 组件库、图表库或播放器库**:界面全部手写(Material Symbols 图标走 Google Fonts CDN),Angular CDK 只用到 `overlay-prebuilt.css`(见 `src/styles.scss`) +- 测试:Karma + Jasmine,6 个 spec 文件(`agent.service.spec.ts` 418 行、`stream-aggregator.util.spec.ts` 640 行等) +- 构建:`@angular/build:application`,输出 `dist/frontend/browser`,`outputHashing: all`,预算 initial 2MB 警告 / 4MB 报错 + +### 1.3 代码规模(非测试源码共 31,030 行) + +| 文件 | 行数 | 职责 | +| --- | --- | --- | +| `src/app/services/agent.service.ts` | 2012 | 全局状态中枢:sessions 合并、状态轮询、SSE 流、视频回放状态机 | +| `src/app/utils/tool-formatter.util.ts` | 1419 | 工具调用卡片格式化 | +| `src/app/utils/stream-aggregator.util.ts` | 1021 | 步骤/流事件聚合为时间线块 | +| `src/app/utils/action-formatter.util.ts` | 1015 | 动作解析/文案 | +| `src/app/utils/image-overlay.util.ts` | 662 | 截图 canvas 坐标叠加 | +| `src/app/components/agent-stream/` | TS 2234 + HTML 1436 + SCSS 3973 | 核心时间线视图:任务下拉、notes、usage、阶段折叠、LLM 流打字机、暂停/重试卡片、Checker 面板 | +| `src/app/pages/home/` | TS 1111 + HTML 2103 + SCSS 5168 | 首页:诊断向导(readiness 三步引导)+ 任务启动器(flash/pro、Pro 调优滑杆、outputter 抽屉、智能任务推荐) | +| `src/app/components/floating-video-player/` | TS 631 + HTML 433 + SCSS 987 | 浮动播放器:视频分段播放、step 帧回放(pre/post)、canvas 坐标叠加、MJPEG 实时投屏、双时间轴 seek | +| `src/app/components/chat-interface/` | TS 269 + HTML 380 + SCSS 1171 | 任务队列/历史列表 + 输入框 | +| `src/app/pages/workspace/` | TS 323 + HTML 201 + SCSS 885 | 工作台:左时间线/右队列分栏(可拖拽)、浮动命令条(Ctrl+K) | +| `src/app/pages/legacy-workspace/` + `legacy-agent-stream/` | ~600 行 | `/check` 路由的旧版流视图(淘汰候选) | +| `src/app/core/models/` | 5 文件 ~560 行 | session / stream / system / pro-tuning / markdown 类型契约 | + +### 1.4 路由与页面清单(`src/app/app.routes.ts`) + +| 路由 | 组件 | 功能 | +| --- | --- | --- | +| `/` | `HomeComponent` | 双 Tab:System Setup(readiness 三步诊断、ADB/emulator/凭据配置)与 Task Launcher(任务提交、Pro 调优、智能推荐) | +| `/workspace` | `WorkspaceComponent` | 主工作台:AgentStream 时间线 + ChatInterface 队列 + FloatingVideoPlayer 浮层 | +| `/check` | `LegacyWorkspaceComponent` | 旧版流视图(legacy,仅维护) | + +全局导航为 `NavSwitcherComponent` 浮动切换按钮(`app.component.ts` 仅 30 行)。 + +### 1.5 状态管理与通信方式 + +- **状态管理**:无 NgRx/其他状态库。`AgentService`(`providedIn: 'root'`)与 `SystemService` 直接使用 **Angular Signals + computed** 作为全局状态;`sessionLogs`、`rawSessions`、`pendingQueue`、`activeTasks` 等信号承载全部数据。 +- **通信方式共 4 类**(`proxy.conf.json` 代理 `/api`、`/images`、`/videos`、`/local_file` 到 `:8000`): + 1. **REST 轮询**:`/api/status` 每 2s、`/api/sessions` 每 6s(`pollCounter % 3`)、`/api/system/readiness` 每 3s、emulator 启动期每 1s;均带签名去重(payload JSON 相同则跳过 setState)与 `visibilitychange` 暂停/恢复。 + 2. **SSE**:`new EventSource('/api/stream')` 单通道持久连接,在 `NgZone` 外注册,消费 17 种事件类型(见 §2.3)。`llm_stream` chunk 按 `execution_id|stream_type` 键合批(可见时 80ms / 后台 500ms flush),非流事件前强制 flush 保证顺序。 + 3. **MJPEG 流**:`` 实时投屏(video player 的 live 模式)。 + 4. **静态媒体**:`/images/{name}.jpg`(截图)、`/videos/{path}`(录像分段)、`/local_file?path=`。 +- **本地缓存**:`localStorage` 键 `artemis.sessions.v1`(会话列表冷启动快照)与 `artemis_selected_profile`。 +- **性能细节**(迁移时必须保留的语义):SSE 合批与事件顺序保证、快照(`/steps`)与 live 事件的 append-only 合并(`history_snapshot` 标记)、checks 快照幂等回填(`checks_snapshot`)、乐观更新 + 签名失效(`invalidateStatusSignatures`)、`WeakMap` 模板缓存。 + +### 1.6 构建产物如何被 FastAPI 托管 + +`apps/admin_console/server.py`: + +- `_get_showcase_dist()`(L245-260):依次探测 `apps/showcase_ui/dist/{frontend/browser, browser, frontend}` 下是否存在 `index.html`;否则回退 `artemis/resources/showcase_ui`(wheel 内置)。 +- Catch-all 路由 `serve_showcase_spa`(L281-390):拦截所有非 `api/`、`images/`、`videos/`、`local_file`、`docs`、`openapi.json`、`redoc` 前缀的路径 —— 静态文件精确命中 → `public/` 图标回退 → SPA `index.html` 回退。**天然适配任何 SPA(含 Vite)的 history 路由**。 +- `SameOriginBoundaryMiddleware`(`core/security.py`):校验 `Host`(防 DNS rebinding)与 `Origin`(防 CSRF),**无任何 CORS 放行**。非浏览器客户端(无 Origin 头)直接放行。 +- 自动构建链路:`artemis ui` CLI(`artemis/interfaces/cli/commands/ui.py` 的 `ensure_showcase_built()`,L192-229)按源码 mtime 判断是否需要 `npm install && npm run build`;`start.sh`(L264-365)与 `Makefile`(L40)有同样逻辑(含 Node 22 自动安装)。 + +--- + +## 2. API 契约梳理(以路由代码为准) + +### 2.1 前端实际消费的端点 + +来源:`agent.service.ts`、`system.service.ts` 中的全部 `http.get/post` 与 `EventSource` 调用。 + +**任务与会话**(`routers/tasks.py`、`routers/sessions.py`): + +| 端点 | 方法 | 前端用法 | 响应/请求形状 | +| --- | --- | --- | --- | +| `/api/sessions` | GET | 每 6s 轮询 | `Session[]`:`{session_id, initial_goal, start_time, end_time?, status?, video_url?, recording_status?, model_info?, device_serial?, device_info?}`(后端动态补齐 `device_id/device_serial/model_info/recording_status/video_url`,见 `sessions.py` L53-167) | +| `/api/sessions/{id}` | GET | 未被前端消费 | 单会话行 | +| `/api/sessions/{id}/usage` | GET | RunInfo 面板 | `SessionUsage`:`{llm_calls, prompt_tokens, completion_tokens, total_tokens, cached_tokens, operator_context_tokens, operator_context_window_tokens, profile?, run_tuning?}` | +| `/api/sessions/{id}/steps` | GET | 选中会话时回填快照 | `StepItemData[]`:`{step_id, step_number, timestamp, operator_native_thinking?, operator_raw_thinking?, action_taken?, generic_tools?, last_execution_result?, pre_image_name?, post_image_name?, token_usage?, duration?}` | +| `/api/sessions/{id}/startup_progress` | GET | 选中/提交时 | `StartupProgressEvent[]`:`{session_id?, stage, message, timestamp}` | +| `/api/sessions/{id}/notes` | GET | Notes 面板 | `{notes: Record}`(键为 `task_plan.md` 等) | +| `/api/sessions/{id}/checks` | GET | Checker 面板回填 | `{records[], streams: PersistedCheckerStream[], run_outcome}` | +| `/api/sessions/{id}/video` | GET | 打开播放器,轮询直到 ready | `{status: 'processing'|'ready'|'failed'|'unavailable', has_video, video_url, video_segments: [{url, start, duration, width, height, offset_ms?, duration_ms?}], retry_after_ms?, message?}`(`media.py` L110-176) | +| `/api/sessions/{id}/tree`、`/background_tasks` | GET | **前端当前未消费** | 轨迹树 / 后台任务 | +| `/api/sessions/{id}/delete` | POST | 单条删除 | `{status, message}` | +| `/api/cleanup` | POST | 清空全部历史 | `{status, message}` | +| `/api/status` | GET | 每 2s 轮询 | `{status: 'idle'|'running'|'paused', session_id, goal, pid, paused_error?, queue: (TaskQueueItem|string)[], background_tasks[], active_tasks: [{device_id, session_id, goal, pid, ingress, acquired_at}], model_info, ipc_port}`(`tasks.py` L250-383) | +| `/api/run` | POST | 提交任务 | `RunRequest`:`{goal | goals[], profile='flash', expected_output?, enable_outputter?, verification_level?, explorer_mode?, locked_app_package?, app_path?, device_serial?, ingress='frontend', session_id?, conversation_id?}` → `{status, tasks[], enqueued_count, total_queued}` | +| `/api/run/defaults` | GET | 启动器滑杆默认值 | `{verification_level, explorer_mode}` | +| `/api/stop` | POST | 停止(支持 query + body 双通道) | `{all: bool, session_id?, device_id?}` → `{status: 'stopped'|'no_running_task'}` | +| `/api/resume` | POST | 恢复暂停 | → `{status: 'resumed'|'not_paused'}` | +| `/api/tasks/presets`、`/api/tasks/catalog`、`/api/devices` | GET | **前端当前未消费**(Home 用的是本地 `smart-tasks.data.ts` 静态推荐) | — | + +**系统诊断**(`routers/system.py`,prefix `/api/system`,`system.service.ts` 消费): + +- `GET /api/system/readiness?force=` → `SystemReadinessReport`(`probes: ProbeResult[]`、`active_device`、`os_type`、`timestamp`) +- `GET /api/system/adb/server`、`POST /adb/server/probe|connect|local`(ADB server 端点切换) +- `POST /devices/select`、`POST /adb/restart`、`POST /adb/connect`(Wi-Fi ADB) +- `GET /emulator/status`、`POST /emulator/launch|stop|dismiss`(`EmulatorLaunchState`:`{avd_name, status, stage_message, progress_percent, logs[], can_retry}`) +- `GET /credentials`、`POST /credentials/test`、`POST /credentials`(LLM/OCR key) +- `GET /model-config-env`(`ModelConfigEnvResponse`:config 内容、presets、env_vars) +- 未消费:`/server-status`、`/restart`、`/shutdown`、`/adb/heal-keys` + +**流与媒体**(`routers/stream.py`、`routers/media.py`): + +- `GET /api/stream`(SSE,见下)、`GET /api/stream/device-live`(MJPEG `multipart/x-mixed-replace`)、`GET /api/stream/device-state` +- `GET /images/{name}`(截图 jpg)、`GET /videos/{path}`(mp4/webm/mkv,白名单后缀 + 路径越界防护)、`GET /local_file?path=` +- `GET /api/sessions/{id}/plan`(未消费) + +### 2.2 前端未消费、但 Vue 版可考虑接入的端点 + +`/api/sessions/{id}/tree`(轨迹树)、`/api/sessions/{id}/background_tasks`、`/api/tasks/presets|catalog`(服务端智能推荐,替代前端硬编码的 `smart-tasks.data.ts` 334 行)、`/api/steps/{step_id}/traces`、`/api/traces/{trace_id}` 以及 replay 组(`/api/replay/tools|config`、`replay_steps`、`steps/{n}/replay`)—— replay 组目前只被 `/admin` vanilla 控制台使用。 + +### 2.3 SSE 事件契约(`tasks.py` `stream_events` L386-517 与 `agent.service.ts` L786-802) + +单通道 `GET /api/stream`(支持 `/api/stream/{session_id}` 过滤),事件类型: + +`info`、`keep-alive`(5s 心跳)、`session_started`、`session_ended`、`startup_progress`、`step_recorded`、`step_updated`、`trace_recorded`、`llm_stream`、`llm_stream_reset`、`llm_retrying`、`task_paused`、`task_resumed`、`background_tasks_updated`、`recording_ready`、`recording_failed`、`checker_event`。 + +服务端还会在订阅 `all/active` 时重放当前会话的 `session_started` + `startup_progress` + 已落库 `step_recorded`(幂等快照语义)。前端按 `session_id` 过滤事件、按 trace_id 去重 `trace_recorded`。 + +--- + +## 3. Vue 3 + Arco Design Vue 技术映射 + +### 3.1 推荐技术栈 + +| 层 | 选型 | 版本建议 | 说明 | +| --- | --- | --- | --- | +| 构建 | Vite | 7.x | esbuild/rollup,产物 hash 与 Angular 对齐(`outputHashing` 等价于 Vite 默认) | +| 框架 | Vue | ^3.5 | ` - - diff --git a/playground/backend_manager/app/static/style.css b/playground/backend_manager/app/static/style.css deleted file mode 100644 index c0b469e5e..000000000 --- a/playground/backend_manager/app/static/style.css +++ /dev/null @@ -1,305 +0,0 @@ -/* Artemis Aurora & Glassmorphism Design System */ -:root { - --bg-primary: #0b0f19; - --bg-card: rgba(23, 32, 54, 0.7); - --bg-card-border: rgba(255, 255, 255, 0.08); - --accent-cyan: #38bdf8; - --accent-purple: #a855f7; - --accent-green: #10b981; - --text-primary: #f8fafc; - --text-secondary: #94a3b8; - --text-muted: #64748b; - --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; -} - -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body { - font-family: var(--font-family); - background-color: var(--bg-primary); - color: var(--text-primary); - min-height: 100vh; - display: flex; - flex-direction: column; - overflow-x: hidden; -} - -/* Header */ -.app-header { - height: 64px; - padding: 0 24px; - display: flex; - align-items: center; - justify-content: space-between; - border-bottom: 1px solid var(--bg-card-border); - background: rgba(11, 15, 25, 0.85); - backdrop-filter: blur(12px); - position: sticky; - top: 0; - z-index: 100; -} - -.logo-group { - display: flex; - align-items: center; - gap: 12px; -} - -.logo-badge { - background: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple)); - color: #fff; - font-weight: 800; - font-size: 14px; - padding: 4px 10px; - border-radius: 8px; -} - -.title { - font-size: 18px; - font-weight: 700; - letter-spacing: -0.5px; -} - -.header-actions { - display: flex; - align-items: center; - gap: 16px; -} - -.user-tag { - font-size: 14px; - color: var(--text-secondary); - background: rgba(255, 255, 255, 0.05); - padding: 6px 12px; - border-radius: 20px; - border: 1px solid var(--bg-card-border); -} - -/* Auth Modal */ -.modal-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.75); - backdrop-filter: blur(8px); - display: flex; - align-items: center; - justify-content: center; - z-index: 200; -} - -.auth-card { - background: var(--bg-card); - border: 1px solid var(--bg-card-border); - border-radius: 20px; - padding: 36px; - width: 100%; - max-width: 440px; - box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5); - text-align: center; -} - -.auth-card h2 { - font-size: 22px; - margin-bottom: 8px; -} - -.auth-card p { - color: var(--text-secondary); - font-size: 14px; - margin-bottom: 24px; -} - -.input-group { - display: flex; - flex-direction: column; - gap: 14px; - margin-bottom: 20px; -} - -.input-field { - width: 100%; - padding: 12px 16px; - background: rgba(0, 0, 0, 0.3); - border: 1px solid var(--bg-card-border); - border-radius: 10px; - color: #fff; - font-size: 15px; - outline: none; - transition: border-color 0.2s; -} - -.input-field:focus { - border-color: var(--accent-cyan); -} - -.btn-primary { - width: 100%; - padding: 12px; - background: linear-gradient(135deg, #0284c7, #6366f1); - color: #fff; - font-size: 15px; - font-weight: 600; - border: none; - border-radius: 10px; - cursor: pointer; - transition: transform 0.1s, opacity 0.2s; -} - -.btn-primary:hover { - opacity: 0.9; -} - -.btn-primary:active { - transform: scale(0.98); -} - -/* Dual Pane Layout */ -.workspace-container { - flex: 1; - display: flex; - height: calc(100vh - 64px); - padding: 16px; - gap: 16px; -} - -.pane { - flex: 1; - background: var(--bg-card); - border: 1px solid var(--bg-card-border); - border-radius: 16px; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.pane-header { - height: 52px; - padding: 0 18px; - display: flex; - align-items: center; - justify-content: space-between; - border-bottom: 1px solid var(--bg-card-border); - background: rgba(0, 0, 0, 0.2); -} - -.pane-title { - font-size: 15px; - font-weight: 600; - display: flex; - align-items: center; - gap: 8px; -} - -.status-pill { - font-size: 12px; - padding: 2px 8px; - border-radius: 12px; - background: rgba(16, 185, 129, 0.15); - color: var(--accent-green); - font-weight: 500; -} - -/* Left Pane: Device Stream */ -.device-viewport { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - background: #000; - position: relative; -} - -.screen-placeholder { - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; - color: var(--text-muted); -} - -.device-frame { - max-height: 90%; - max-width: 90%; - aspect-ratio: 9/19.5; - background: #111; - border: 4px solid #334155; - border-radius: 28px; - overflow: hidden; - box-shadow: 0 15px 30px rgba(0,0,0,0.6); - position: relative; - display: flex; - align-items: center; - justify-content: center; -} - -.device-frame video, -.device-frame img, -.device-frame iframe, -.device-screen-live { - width: 100%; - height: 100%; - object-fit: contain; - background: #000; - border: none; -} - -/* Right Pane: Artemis Workspace */ -.agent-logs { - flex: 1; - padding: 16px; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 12px; -} - -.log-entry { - background: rgba(0, 0, 0, 0.25); - border: 1px solid var(--bg-card-border); - border-radius: 10px; - padding: 12px; - font-size: 14px; -} - -.log-entry.thought { - border-left: 3px solid var(--accent-cyan); -} - -.log-entry.action { - border-left: 3px solid var(--accent-purple); -} - -.log-entry .meta { - font-size: 11px; - color: var(--text-muted); - margin-bottom: 4px; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.chat-input-area { - padding: 16px; - border-top: 1px solid var(--bg-card-border); - background: rgba(0, 0, 0, 0.2); - display: flex; - gap: 10px; -} - -.chat-input { - flex: 1; - padding: 12px 16px; - background: rgba(0, 0, 0, 0.3); - border: 1px solid var(--bg-card-border); - border-radius: 10px; - color: #fff; - font-size: 14px; - outline: none; -} - -.hidden { - display: none !important; -} diff --git a/playground/backend_manager/docker-compose.yml b/playground/backend_manager/docker-compose.yml deleted file mode 100644 index 3941049d8..000000000 --- a/playground/backend_manager/docker-compose.yml +++ /dev/null @@ -1,51 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -version: '3.8' - -services: - backend-manager: - build: - context: . - dockerfile: Dockerfile - container_name: backend - restart: unless-stopped - ports: - - "8000:8000" - volumes: - # Mount host Docker socket for spawning ephemeral Artemis containers - - /var/run/docker.sock:/var/run/docker.sock - environment: - - APP_ENV=production - - PORT=8000 - - DOCKER_SOCKET_PATH=unix://var/run/docker.sock - - DOCKER_NETWORK=artemis-net - - ARTEMIS_IMAGE=artemis:latest - - CLOUD_ORCHESTRATOR_URL=http://cloud-orchestrator:2081 - - CUTTLEFISH_HOST_GATEWAY=cloud-orchestrator - - USE_LOCAL_FALLBACK_DB=true - networks: - - artemis-net - extra_hosts: - - "host.docker.internal:host-gateway" - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/healthz"] - interval: 15s - timeout: 3s - retries: 3 - -networks: - artemis-net: - name: artemis-net - driver: bridge diff --git a/playground/backend_manager/requirements.txt b/playground/backend_manager/requirements.txt deleted file mode 100644 index 6fcc6a327..000000000 --- a/playground/backend_manager/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -fastapi>=0.115.0 -uvicorn[standard]>=0.32.0 -pydantic>=2.9.0 -pydantic-settings>=2.5.0 -python-jose[cryptography]>=3.3.0 -passlib[bcrypt]>=1.7.4 -docker>=7.1.0 -google-cloud-bigquery>=3.26.0 -httpx>=0.27.2 -python-multipart>=0.0.12 -aiofiles>=24.1.0 diff --git a/playground/deploy_to_cos.sh b/playground/deploy_to_cos.sh deleted file mode 100755 index 642e5b87f..000000000 --- a/playground/deploy_to_cos.sh +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env bash -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -set -euo pipefail - -# Usage: ./playground/deploy_to_cos.sh [ZONE] [PROJECT] -if [ "$#" -lt 1 ]; then - echo "Usage: $0 [ZONE] [PROJECT]" - echo "" - echo "Example:" - echo " $0 my-cos-instance us-central1-a" - echo " $0 my-cos-instance us-central1-a my-gcp-project" - exit 1 -fi - -VM_NAME="$1" -ZONE="${2:-}" -PROJECT="${3:-}" - -SSH_OPTS=() -if [ -n "$ZONE" ]; then - SSH_OPTS+=(--zone="$ZONE") -fi -if [ -n "$PROJECT" ]; then - SSH_OPTS+=(--project="$PROJECT") -fi - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$ROOT_DIR" - -echo "=================================================================" -echo " Deploying Artemis to Container-Optimized OS (COS) VM" -echo " Target VM : ${VM_NAME}" -echo " Zone : ${ZONE:-'(default)'}" -echo " Project : ${PROJECT:-'(default)'}" -echo "=================================================================" - -# ---------------------------------------------------------------------- -# Step 1: Build Docker Images on local gLinux workstation (linux/amd64) -# ---------------------------------------------------------------------- -echo "" -echo "[Step 1/3] Building Docker images locally on gLinux (linux/amd64)..." - -echo " -> Building artemis:latest..." -docker build --platform linux/amd64 -f playground/artemis_container/Dockerfile -t artemis:latest . - -echo " -> Building artemis-backend-manager:latest..." -docker build --platform linux/amd64 -t artemis-backend-manager:latest playground/backend_manager - -echo " -> Building artemis-nginx-proxy:latest..." -docker build --platform linux/amd64 -t artemis-nginx-proxy:latest playground/nginx_container - -echo "[Step 1/3] All images successfully built locally." - -# ---------------------------------------------------------------------- -# Step 2: Stream & Load Docker Images into COS VM via SSH Pipe -# ---------------------------------------------------------------------- -echo "" -echo "[Step 2/3] Transferring and loading images into COS VM via compressed SSH stream..." - -stream_image() { - local img="$1" - echo " -> Transferring ${img}..." - docker save "$img" | gzip -1 | gcloud compute ssh "$VM_NAME" "${SSH_OPTS[@]}" -- "gunzip | docker load" -} - -stream_image "artemis:latest" -stream_image "artemis-backend-manager:latest" -stream_image "artemis-nginx-proxy:latest" - -echo "[Step 2/3] All images loaded into COS VM Docker daemon." - -# ---------------------------------------------------------------------- -# Step 3: Launch / Restart Artemis Services on the COS VM -# ---------------------------------------------------------------------- -echo "" -echo "[Step 3/3] Starting containers on COS VM..." - -gcloud compute ssh "$VM_NAME" "${SSH_OPTS[@]}" -- bash -s << 'EOF' -set -euo pipefail - -echo " [COS] Ensuring shared Docker network (artemis-net)..." -docker network create artemis-net 2>/dev/null || true - -echo " [COS] Stopping previous container instances if running..." -docker rm -f backend artemis-nginx-proxy 2>/dev/null || true - -echo " [COS] Starting Backend Manager container..." -docker run -d \ - --name backend \ - --restart unless-stopped \ - --network artemis-net \ - --add-host host.docker.internal:host-gateway \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -e APP_ENV=production \ - -e PORT=8000 \ - -e DOCKER_NETWORK=artemis-net \ - -e ARTEMIS_IMAGE=artemis:latest \ - -e CLOUD_ORCHESTRATOR_URL=http://cloud-orchestrator:2081 \ - -e CUTTLEFISH_HOST_GATEWAY=cloud-orchestrator \ - -e USE_LOCAL_FALLBACK_DB=true \ - artemis-backend-manager:latest - -echo " [COS] Starting Nginx Reverse Proxy container..." -docker run -d \ - --name artemis-nginx-proxy \ - --restart unless-stopped \ - --network artemis-net \ - --add-host host.docker.internal:host-gateway \ - -p 80:80 \ - -p 443:443 \ - artemis-nginx-proxy:latest - -echo " [COS] Verifying active containers..." -docker ps --filter "network=artemis-net" -EOF - -echo "" -echo "=================================================================" -echo " Deployment Complete! 🚀" -echo " Artemis Reverse Proxy is listening on ports 80 & 443 of ${VM_NAME}." -echo "=================================================================" diff --git a/playground/nginx_container/.dockerignore b/playground/nginx_container/.dockerignore deleted file mode 100644 index 8b3858aca..000000000 --- a/playground/nginx_container/.dockerignore +++ /dev/null @@ -1,5 +0,0 @@ -.git -.gitignore -certs/*.crt -certs/*.key -*.md diff --git a/playground/nginx_container/Dockerfile b/playground/nginx_container/Dockerfile deleted file mode 100644 index 930cc12b3..000000000 --- a/playground/nginx_container/Dockerfile +++ /dev/null @@ -1,49 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -FROM nginx:1.27-alpine - -# Install openssl for auto-generating self-signed dev certificates and curl for healthchecks -RUN apk add --no-cache openssl curl bash - -# Create directory structure -RUN mkdir -p /etc/nginx/conf.d \ - /etc/nginx/includes \ - /etc/nginx/certs \ - /usr/share/nginx/html - -# Copy main Nginx configuration -COPY conf/nginx.conf /etc/nginx/nginx.conf - -# Clear base image default configs and copy custom configs -RUN rm -rf /etc/nginx/conf.d/* -COPY conf/conf.d/ /etc/nginx/conf.d/ -COPY conf/includes/ /etc/nginx/includes/ - -# Copy custom error templates and fallback static files -COPY html/ /usr/share/nginx/html/ - -# Copy entrypoint script -COPY docker-entrypoint.sh /docker-entrypoint.sh -RUN chmod +x /docker-entrypoint.sh - -# Expose standard HTTP and HTTPS ports -EXPOSE 80 443 - -# Healthcheck to ensure Nginx is responding -HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \ - CMD curl -k -f https://localhost/healthz || curl -f http://localhost:80/healthz || exit 1 - -ENTRYPOINT ["/docker-entrypoint.sh"] -CMD ["nginx", "-g", "daemon off;"] diff --git a/playground/nginx_container/README.md b/playground/nginx_container/README.md deleted file mode 100644 index a21094cf3..000000000 --- a/playground/nginx_container/README.md +++ /dev/null @@ -1,127 +0,0 @@ -# 🌐 Artemis Ingress Reverse Proxy Container - -Production-ready, high-performance Nginx Reverse Proxy container designed for running Artemis on a **Container-Optimized OS (COS)** VM in Google Cloud. - ---- - -## 📌 Architecture & Routing Matrix - -The proxy acts as the single TLS/SSL termination gateway on standard ports (`80` $\rightarrow$ `443`), dynamically routing traffic to persistent platform services and on-demand ephemeral Artemis session containers without needing Nginx reload or restart: - -| Path Prefix | Destination Upstream | Protocol | Purpose | -| :--- | :--- | :--- | :--- | -| `GET /healthz` | Nginx Internal | HTTP/HTTPS | Ingress health check probe | -| `/` | `http://backend:8000/` | HTTP/1.1 | Static frontend Single Page Application (React/Vue/Angular) | -| `/api/*` | `http://backend:8000/api/*` | HTTP/1.1 / WS | Management API, OTP auth verification, session lifecycle | -| `/session/{id}/artemis/stream/*` | `http://artemis-session-{id}:8080/stream/*` | **SSE / WebSocket** | Real-time agent thought stream (zero buffering, 1h timeout) | -| `/session/{id}/artemis/*` | `http://artemis-session-{id}:8080/*` | HTTP/1.1 / WS | Artemis REST API and task execution endpoints | -| `/session/{id}/cuttlefish/*` | `http://host.docker.internal:8443/*` | **WebRTC / WS** | Cuttlefish WebRTC signaling & interactive device controls | - ---- - -## 📁 File Structure - -``` -playground/nginx_container/ -├── Dockerfile # Nginx alpine image with openssl and health checks -├── docker-entrypoint.sh # Auto-generates dev certificates & tests nginx syntax -├── docker-compose.yml # Compose specification with artemis-net bridge network -├── .dockerignore -├── README.md # Detailed operational documentation -├── certs/ -│ ├── generate_dev_certs.sh # Manual SSL certificate generation helper -│ └── .gitkeep -├── conf/ -│ ├── nginx.conf # Master Nginx configuration (JSON logging, WebSocket map) -│ ├── conf.d/ -│ │ ├── http_redirect.conf # Port 80 redirect to Port 443 + ACME challenge -│ │ └── artemis_ssl.conf # Port 443 SSL server block & dynamic Docker DNS routing -│ └── includes/ -│ ├── proxy_headers.conf # Standard forwarding headers (Host, X-Real-IP, X-Forwarded-*) -│ ├── websocket_params.conf# WebSocket upgrade headers & 3600s timeouts -│ └── sse_params.conf # SSE streaming parameters (proxy_buffering off) -└── html/ - ├── index.html # Fallback landing page - ├── 502.json # JSON error response for provisioning containers - └── 504.json # JSON error response for timeouts -``` - ---- - -## 🚀 Quick Start - -### 1. Build the Docker Image -```bash -docker build -t artemis-nginx-proxy:latest . -``` - -### 2. Run with Docker Compose -```bash -docker-compose up -d -``` - -### 3. Run Standalone on COS VM -```bash -# Create shared bridge network if not exists -docker network create artemis-net || true - -# Run Nginx Reverse Proxy Container -docker run -d \ - --name artemis-nginx-proxy \ - --restart unless-stopped \ - --network artemis-net \ - --add-host host.docker.internal:host-gateway \ - -p 80:80 \ - -p 443:443 \ - -v $(pwd)/certs:/etc/nginx/certs:ro \ - artemis-nginx-proxy:latest -``` - ---- - -## 🔑 Key Features & Technical Highlights - -### 1. Dynamic Zero-Reload Upstream Resolution -When a user launches a new session, the backend starts a new Docker container named `artemis-session-` on the `artemis-net` network. - -Using Docker's embedded DNS server (`127.0.0.11 valid=5s`) combined with variable-based `proxy_pass`: -```nginx -resolver 127.0.0.11 valid=5s ipv6=off; - -location ~ ^/session/(?[a-zA-Z0-9_-]+)/artemis/(?.*)$ { - set $artemis_host "artemis-session-$session_id"; - proxy_pass http://$artemis_host:8080/$artemis_path$is_args$args; -} -``` -Nginx resolves the container hostname at **request-time**, allowing session containers to be created and destroyed without touching or reloading Nginx configuration. - -### 2. Real-Time Streaming (SSE & WebSocket) -- **SSE Streams** (`/stream/*`): Uses `proxy_buffering off;`, `proxy_cache off;`, and `chunked_transfer_encoding on;` so the browser receives agent reasoning tokens with zero buffering delay. -- **WebRTC Signaling & WebSockets**: Employs `map $http_upgrade $connection_upgrade` with 1-hour idle timeouts (`proxy_read_timeout 3600s;`). - -### 3. Automated SSL/TLS Management -- **Development/Staging**: The container entrypoint automatically generates a self-signed certificate if no certificates exist in `/etc/nginx/certs/`. -- **Production**: Mount your Let's Encrypt or GCP managed certificates to `/etc/nginx/certs/tls.crt` and `/etc/nginx/certs/tls.key`. - -### 4. Resilient Error Handling -If a requested session container is still booting or unavailable, Nginx returns a clean JSON error response (`502.json` / `504.json`) instead of standard Nginx HTML error pages, enabling the frontend UI to gracefully poll or display a loading spinner. - ---- - -## 🧪 Verification & Testing - -1. **Verify Health Endpoint**: - ```bash - curl -k https://localhost/healthz - # Output: OK - ``` - -2. **Test Dynamic Routing**: - ```bash - # Launch a mock session container - docker run -d --name artemis-session-test123 --network artemis-net hashicorp/http-echo -text="Hello from Session test123" -listen=:8080 - - # Test via Nginx proxy - curl -k https://localhost/session/test123/artemis/ - # Output: Hello from Session test123 - ``` diff --git a/playground/nginx_container/certs/.gitkeep b/playground/nginx_container/certs/.gitkeep deleted file mode 100644 index b3eb48bf2..000000000 --- a/playground/nginx_container/certs/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# Keep certs directory in repository while ignoring .crt and .key files diff --git a/playground/nginx_container/certs/generate_dev_certs.sh b/playground/nginx_container/certs/generate_dev_certs.sh deleted file mode 100644 index ac261e6ea..000000000 --- a/playground/nginx_container/certs/generate_dev_certs.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/bin/bash -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -set -e - -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CERT_FILE="${DIR}/tls.crt" -KEY_FILE="${DIR}/tls.key" - -echo "Generating self-signed SSL certificates for development..." - -openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ - -keyout "${KEY_FILE}" \ - -out "${CERT_FILE}" \ - -subj "/CN=localhost/O=Artemis Development/C=US" \ - -addext "subjectAltName=DNS:localhost,DNS:*.localhost,IP:127.0.0.1" - -chmod 600 "${KEY_FILE}" -chmod 644 "${CERT_FILE}" - -echo "Development certificates generated:" -echo " Private Key: ${KEY_FILE}" -echo " Certificate: ${CERT_FILE}" diff --git a/playground/nginx_container/conf/conf.d/artemis_ssl.conf b/playground/nginx_container/conf/conf.d/artemis_ssl.conf deleted file mode 100644 index 4862b8f53..000000000 --- a/playground/nginx_container/conf/conf.d/artemis_ssl.conf +++ /dev/null @@ -1,118 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -server { - listen 443 ssl default_server; - listen [::]:443 ssl default_server; - http2 on; - server_name _; - - # TLS / SSL Configuration - ssl_certificate /etc/nginx/certs/tls.crt; - ssl_certificate_key /etc/nginx/certs/tls.key; - ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384'; - ssl_prefer_server_ciphers off; - ssl_session_timeout 1d; - ssl_session_cache shared:SSL:10m; - ssl_session_tickets off; - - # Security Headers - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; - - # Embedded Docker DNS Resolver for dynamic container discovery - # 127.0.0.11 is Docker's internal DNS server - resolver 127.0.0.11 valid=5s ipv6=off; - resolver_timeout 5s; - - # Health check endpoint - location = /healthz { - access_log off; - default_type text/plain; - return 200 "OK\n"; - } - - # -------------------------------------------------------------------------- - # 1. Backend Core Management API (Auth, OTP, Session Lifecycle) - # -------------------------------------------------------------------------- - location /api/ { - set $backend_upstream "backend:8000"; - proxy_pass http://$backend_upstream; - include /etc/nginx/includes/proxy_headers.conf; - include /etc/nginx/includes/websocket_params.conf; - } - - # -------------------------------------------------------------------------- - # 2. Dynamic Artemis Session Containers: Real-time Trace & Event Stream (SSE/WS) - # Target container: artemis-session-:8080 - # -------------------------------------------------------------------------- - location ~ ^/session/(?[a-zA-Z0-9_-]+)/artemis/stream/(?.*)$ { - set $artemis_host "artemis-session-$session_id"; - proxy_pass http://$artemis_host:8080/stream/$stream_path$is_args$args; - include /etc/nginx/includes/sse_params.conf; - include /etc/nginx/includes/websocket_params.conf; - } - - # -------------------------------------------------------------------------- - # 3. Dynamic Artemis Session Containers: REST APIs & Admin Console - # Target container: artemis-session-:8080 - # -------------------------------------------------------------------------- - location ~ ^/session/(?[a-zA-Z0-9_-]+)/artemis/(?.*)$ { - set $artemis_host "artemis-session-$session_id"; - proxy_pass http://$artemis_host:8080/$artemis_path$is_args$args; - include /etc/nginx/includes/proxy_headers.conf; - include /etc/nginx/includes/websocket_params.conf; - } - - # -------------------------------------------------------------------------- - # 4. Cuttlefish Emulator WebRTC Signaling & Stream Proxy - # Proxies signaling requests to the Cloud Orchestrator / CVD daemon - # -------------------------------------------------------------------------- - location ~ ^/session/(?[a-zA-Z0-9_-]+)/cuttlefish/(?.*)$ { - # Host gateway communication or cloud-orchestrator container - set $cuttlefish_upstream "host.docker.internal:8443"; - proxy_pass http://$cuttlefish_upstream/$cf_path$is_args$args; - include /etc/nginx/includes/proxy_headers.conf; - include /etc/nginx/includes/websocket_params.conf; - } - - # -------------------------------------------------------------------------- - # 5. Static Frontend Single Page Application (Served via Backend or Static Dist) - # -------------------------------------------------------------------------- - location / { - set $backend_upstream "backend:8000"; - proxy_pass http://$backend_upstream; - include /etc/nginx/includes/proxy_headers.conf; - } - - # -------------------------------------------------------------------------- - # 6. Custom JSON Error Pages for Ephemeral Container Boot & Teardown States - # -------------------------------------------------------------------------- - error_page 502 /502.json; - error_page 504 /504.json; - - location = /502.json { - root /usr/share/nginx/html; - internal; - default_type application/json; - } - - location = /504.json { - root /usr/share/nginx/html; - internal; - default_type application/json; - } -} diff --git a/playground/nginx_container/conf/conf.d/http_redirect.conf b/playground/nginx_container/conf/conf.d/http_redirect.conf deleted file mode 100644 index 3e5e8d145..000000000 --- a/playground/nginx_container/conf/conf.d/http_redirect.conf +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -server { - listen 80 default_server; - listen [::]:80 default_server; - server_name _; - - # Health check endpoint over plain HTTP - location = /healthz { - access_log off; - default_type text/plain; - return 200 "OK\n"; - } - - # ACME Challenge endpoint for automated Let's Encrypt SSL renewal - location /.well-known/acme-challenge/ { - root /var/www/certbot; - allow all; - } - - # Enforce HTTPS redirect for all incoming web traffic - location / { - return 301 https://$host$request_uri; - } -} diff --git a/playground/nginx_container/conf/includes/proxy_headers.conf b/playground/nginx_container/conf/includes/proxy_headers.conf deleted file mode 100644 index 5fdb35031..000000000 --- a/playground/nginx_container/conf/includes/proxy_headers.conf +++ /dev/null @@ -1,25 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -proxy_set_header Host $host; -proxy_set_header X-Real-IP $remote_addr; -proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; -proxy_set_header X-Forwarded-Proto $scheme; -proxy_set_header X-Forwarded-Host $host; -proxy_set_header X-Forwarded-Port $server_port; - -# Buffer tuning for standard API calls -proxy_buffers 8 64k; -proxy_buffer_size 128k; -proxy_busy_buffers_size 128k; diff --git a/playground/nginx_container/conf/includes/sse_params.conf b/playground/nginx_container/conf/includes/sse_params.conf deleted file mode 100644 index 7c9c18311..000000000 --- a/playground/nginx_container/conf/includes/sse_params.conf +++ /dev/null @@ -1,18 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# Critical directives for real-time Server-Sent Events (SSE) streaming -proxy_buffering off; -proxy_cache off; -chunked_transfer_encoding on; diff --git a/playground/nginx_container/conf/includes/websocket_params.conf b/playground/nginx_container/conf/includes/websocket_params.conf deleted file mode 100644 index edaf3d07d..000000000 --- a/playground/nginx_container/conf/includes/websocket_params.conf +++ /dev/null @@ -1,16 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -proxy_set_header Upgrade $http_upgrade; -proxy_set_header Connection $connection_upgrade; diff --git a/playground/nginx_container/conf/nginx.conf b/playground/nginx_container/conf/nginx.conf deleted file mode 100644 index de7c7eece..000000000 --- a/playground/nginx_container/conf/nginx.conf +++ /dev/null @@ -1,92 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -user nginx; -worker_processes auto; -worker_rlimit_nofile 65535; -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 4096; - multi_accept on; - use epoll; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - # High-performance event I/O settings - sendfile on; - tcp_nopush on; - tcp_nodelay on; - keepalive_timeout 65; - types_hash_max_size 2048; - server_tokens off; - - # Client payload size (allows large artifact, image, APK uploads) - client_max_body_size 100M; - client_body_buffer_size 128k; - - # Enhanced JSON-structured access logging for easy parsing & cloud monitoring - log_format artemis_json escape=json '{' - '"time_local":"$time_local",' - '"remote_addr":"$remote_addr",' - '"request_method":"$request_method",' - '"request_uri":"$request_uri",' - '"status":$status,' - '"body_bytes_sent":$body_bytes_sent,' - '"request_time":$request_time,' - '"upstream_response_time":"$upstream_response_time",' - '"upstream_addr":"$upstream_addr",' - '"upstream_status":"$upstream_status",' - '"http_referrer":"$http_referer",' - '"http_user_agent":"$http_user_agent"' - '}'; - - access_log /var/log/nginx/access.log artemis_json; - - # Global proxy defaults - proxy_http_version 1.1; - proxy_connect_timeout 10s; - proxy_send_timeout 3600s; - proxy_read_timeout 3600s; - - # Gzip compression for static assets and API payloads - gzip on; - gzip_vary on; - gzip_proxied any; - gzip_comp_level 6; - gzip_types - text/plain - text/css - text/xml - text/javascript - application/json - application/javascript - application/x-javascript - application/xml - application/xml+rss - image/svg+xml; - - # WebSocket Upgrade Header Mapping - map $http_upgrade $connection_upgrade { - default upgrade; - '' close; - } - - # Load modular server configurations - include /etc/nginx/conf.d/*.conf; -} diff --git a/playground/nginx_container/docker-compose.yml b/playground/nginx_container/docker-compose.yml deleted file mode 100644 index d771bcce0..000000000 --- a/playground/nginx_container/docker-compose.yml +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -version: '3.8' - -services: - nginx-proxy: - build: - context: . - dockerfile: Dockerfile - container_name: artemis-nginx-proxy - restart: unless-stopped - ports: - - "80:80" - - "443:443" - volumes: - # Mount certificates from host (or let entrypoint generate dev certs) - - ./certs:/etc/nginx/certs:ro - networks: - - artemis-net - extra_hosts: - # Allows reaching Cuttlefish / Cloud Orchestrator running on the COS host - - "host.docker.internal:host-gateway" - healthcheck: - test: ["CMD", "curl", "-k", "-f", "https://localhost/healthz"] - interval: 15s - timeout: 3s - retries: 3 - -networks: - artemis-net: - name: artemis-net - driver: bridge diff --git a/playground/nginx_container/docker-entrypoint.sh b/playground/nginx_container/docker-entrypoint.sh deleted file mode 100644 index 685868457..000000000 --- a/playground/nginx_container/docker-entrypoint.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/bash -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -set -e - -CERT_DIR="/etc/nginx/certs" -CERT_FILE="${CERT_DIR}/tls.crt" -KEY_FILE="${CERT_DIR}/tls.key" - -echo "[Nginx Entrypoint] Initializing Artemis Ingress Proxy..." - -# Check if SSL certificates exist; if not, auto-generate a self-signed fallback cert -if [ ! -f "$CERT_FILE" ] || [ ! -f "$KEY_FILE" ]; then - echo "[Nginx Entrypoint] No SSL certificate found at ${CERT_FILE}. Generating self-signed certificate for development/staging..." - mkdir -p "${CERT_DIR}" - openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ - -keyout "${KEY_FILE}" \ - -out "${CERT_FILE}" \ - -subj "/CN=localhost/O=Artemis/C=US" \ - -addext "subjectAltName=DNS:localhost,DNS:*.localhost,IP:127.0.0.1" \ - 2>/dev/null - chmod 600 "${KEY_FILE}" - chmod 644 "${CERT_FILE}" - echo "[Nginx Entrypoint] Self-signed certificate generated successfully." -else - echo "[Nginx Entrypoint] SSL certificate verified at ${CERT_FILE}." -fi - -# Test Nginx configuration validity before starting -echo "[Nginx Entrypoint] Verifying Nginx configuration syntax..." -nginx -t - -echo "[Nginx Entrypoint] Starting Nginx daemon..." -exec "$@" diff --git a/playground/nginx_container/html/502.json b/playground/nginx_container/html/502.json deleted file mode 100644 index 14ed3f8d4..000000000 --- a/playground/nginx_container/html/502.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "status": 502, - "error": "bad_gateway", - "message": "The requested Artemis session container or Cuttlefish device is provisioning, booting, or currently unreachable. Please retry shortly.", - "timestamp": "auto" -} diff --git a/playground/nginx_container/html/504.json b/playground/nginx_container/html/504.json deleted file mode 100644 index 046f8e58c..000000000 --- a/playground/nginx_container/html/504.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "status": 504, - "error": "gateway_timeout", - "message": "The upstream Artemis container or device service took too long to respond.", - "timestamp": "auto" -} diff --git a/playground/nginx_container/html/index.html b/playground/nginx_container/html/index.html deleted file mode 100644 index 575df8159..000000000 --- a/playground/nginx_container/html/index.html +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - Artemis Reverse Proxy - - - -

          -
          - Ingress Proxy Active -
          -

          Artemis Cloud Platform

          -

          The reverse proxy is operational and listening on ports 80 & 443. Connect your backend service to begin routing user sessions and Cuttlefish devices.

          -
          - - diff --git a/plugins/dsh-autogamer/.gitignore b/plugins/dsh-autogamer/.gitignore new file mode 100644 index 000000000..3549fbc3e --- /dev/null +++ b/plugins/dsh-autogamer/.gitignore @@ -0,0 +1,26 @@ +node_modules/ +dist/ + +# spike run artefacts and local captures (tokens appear in these) +spike/run*.ndjson +spike/run*.stderr +spike-run*.ndjson +spike/proxy.stdout +spike/proxy-dump.log +scripts/proxy-dump.log +spike/web-server*.log +spike/mock.stdout +spike/spike-token.txt +spike/cookies.txt +spike/auth-page.html +spike/apply-probe.txt +spike/sandbox/ + +# dsh --dump-config / --dump-config-schema captures +spike/dsh-dump*.json +spike/dsh-dump*.err +spike/dsh-schema*.json +spike/dsh-schema*.err +spike/dump-config*.out +spike/dump-config*.err +spike/mock-*.txt diff --git a/plugins/dsh-autogamer/.npmrc b/plugins/dsh-autogamer/.npmrc new file mode 100644 index 000000000..f301fedf9 --- /dev/null +++ b/plugins/dsh-autogamer/.npmrc @@ -0,0 +1 @@ +auto-install-peers=false diff --git a/plugins/dsh-autogamer/README.md b/plugins/dsh-autogamer/README.md new file mode 100644 index 000000000..b9fc6e30f --- /dev/null +++ b/plugins/dsh-autogamer/README.md @@ -0,0 +1,124 @@ +# dsh-autogamer + +AutoGamer 的设备执行插件(DSH 内容插件)。**只提供内容**:`run_device_action` 执行工具、 +`report_task_status` 终态汇报、五个 `autogamer_*` 任务工具(启动/管理/轨迹/设备状态/诊断, +外部 ARTEMIS 透传)、`autogamer` agent 预设,以及设备资源治理。队列、调度、UI、 +审批、会话记忆全部由宿主提供。 + +- 方案与进度:[`../../docs/TODO.md`](../../docs/TODO.md)(活文档) +- **动手前必读**:[`../../docs/migration/plugin-contract-rules.md`](../../docs/migration/plugin-contract-rules.md)(守则逐条;现行条款与范围看 [`registry.md`](../../docs/migration/registry.md)) +- 编号查表:[`registry.md`](../../docs/migration/registry.md)|术语查表:[`glossary.md`](../../docs/migration/glossary.md) +- 当前状态与阻塞点**不在本文件维护**:读 [`../../docs/TODO.md`](../../docs/TODO.md) 的「零、当前状态」。本文件曾自持一份状态副本,结案后没人回改(见 `docs/CHANGELOGS.md` **#30**) + +## Layout + +``` +src/index.ts 插件入口:Config(原生 Schemastery)+ apply() 注册七个工具 +src/tools/runDeviceAction.ts 执行工具:闸门 → MCP 转发 → 错误分类 → 路由日志 +src/tools/reportTaskStatus.ts 终态汇报(completed / blocked + tests_failed 双轴) +src/tools/getDeviceState.ts 只读设备状态(预检门禁 + 外部 ARTEMIS 透传,Phase 1) +src/tools/runTask.ts 启动任务(500ms 内返回 task_id;timeout/maxSteps 承认但不转交,Phase 2) +src/tools/manageTask.ts 任务状态/停止/注入(最近 5 步预算,Phase 2) +src/tools/inspectTrace.ts 轨迹查看(summary 取边加失败步,Phase 2) +src/tools/diagnose.ts 诊断(provider 报告合并本地预检修复,Phase 2) +src/tools/envGate.ts 五个新工具共用的预检门禁(同一 environment: 拒绝口径) +src/artemis/adapter.ts 后端选择(CLI 已砍,仅剩 MCP 工厂;Phase 2) +src/scrcpy/server.ts 镜像服务生命周期(resolve/start/stop;argv 逐条对 scrcpy 开发文档,Phase 4) +src/scrcpy/input.ts 触摸转 adb input 纯函数(降级控制路线,Phase 4) +src/prompt/rulesInjector.ts 提供方 rules.md 经 systemPrompt.section() 注入(每次挂载重读,Phase 5) +client.js 浏览器半(手写 ModuleLoader 形态):五个任务工具的 toolview 卡片(Phase 3) +src/artemis/types.ts 外部 ARTEMIS 后端接口(Phase 1:getDeviceState;其余 Phase 2) +src/artemis/mcpBackend.ts 外部 ARTEMIS 的 MCP stdio 后端(懒连接,传输失败=environment) +src/env/preflight.ts 环境预检 7 项(uv/Python/仓库/已安装/adb/设备/Helper),60 秒缓存 +src/gate.ts per-device FIFO 闸门(弱表 promise 链 + tracked pending) +src/devicePool.ts 闸门之上的调度:parking / 只认已认领 / 全局并发 N / roster 与准入 +src/breaker.ts 设备熔断(closed / open / half-open) +src/turnLedger.ts 回合账本:动过手却没汇报终态的回合(G44) +src/classify.ts 工具层错误分类(environment / task) +src/coord.ts 0-1000 归一坐标换算 + 截图取尺寸(含 configuredScreenSize) +src/actionClient.ts py action server 的 MCP stdio 客户端包装 +src/mcpText.ts / src/hierarchy.ts 文本解包 / 层级解析:`#N` 按**节点身份**在可达界内跟随到当前位置(G67/#63) +src/observations.ts 活观察快照:每个 (agent, device) 只存模型最后一次所见的那份列表 +src/dsh-types.ts 宿主契约的本地结构别名(详见下方「契约要点」,升级 DSH 必复核) +skills/autogamer-flash/ flash 执行策略 skill +scripts/mock-action-server.mjs 离线 mock action server(take_screenshot 回真 PNG 头) +scripts/spike-mcp.mjs 真 py 动作面取证 + fixture 生成(--write) +scripts/conformance-live.mjs live 取证:declared(dump-config)与 live(会话日志)两层 +spike/ live overlay 与取证现场(多数已 gitignore,含 token 的别提交) +tests/ vitest:gate / breaker / classify / coord / devicePool / liveObservation / + apply / conformance(contracts=生成式 fixture,runtimeBoot=真实 DSH runtime 挂载) +``` + +## 引入与验证(按守则 R2 的三层) + +```bash +# 开发期:代码改了必须重新构建,再冷重启宿主(配置层可热重载,代码层不行) +npm run build && npm run test + +# 把本包挂进 web profile(pnpm 垫片:只证明「装了」,不证明「生效」) +"D:/DeepSeek Harness/resources/runtime/cli/bin/dsh.cmd" plugin --profile web \ + add file:/D/DEV/autoGamer/plugins/dsh-autogamer + +# 每次改 patch 后的强制自检(R6):exit 0 且 grep 不到 unmatched +dsh --profile web --patch /spike/web-live.patch.yml --dump-config +``` + +- **禁止**手写 profile 目录内的 `package.json` / `cordis.patch.yml`,也不要在那目录跑 pnpm + (官方口径:`install_bundle performs those steps` / `do not reproduce those steps with shell commands`)。 +- **交付形态**:走 `plugin_manager` 的 `install_bundle`,或 Web 侧边栏「插件」面板。已发布的资产是 + `https://github.com/giveUphope/autoGamer/releases/download/autogamer-device-v0.1.0/autogamer-device-0.1.0.tgz` + (prerelease;`npm pack` 的产物,因此带 `dist/` 与 `skills/`——**git 依赖只含已提交文件,装 git 地址会缺 `dist/`**)。 + D17 已落地:本包 preset 里挂着 `tool-plugin-manager`(`disabled` 跟随服务条件,见 CHANGELOGS **#26**),会话里的 agent 能自装。 +- ⚠️ **本机 TLS 前置(实测)**:如果这台机器的 HTTPS 被本地拦截型工具接管(`openssl s_client` 会看到 + 它的根 CA 签发者),那么宿主与 pnpm 取 Release 资产会报 `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, + 而 `git`/`gh` 照常工作(它们信任 Windows 证书库)。解法是**启动宿主时带上 Node 官方开关**: + `NODE_OPTIONS=--use-system-ca dsh --profile web …`(`scrubbedParentEnv` 会把它传给 pnpm 子进程)。 + **不要**用 `NODE_TLS_REJECT_UNAUTHORIZED=0`——那是关掉校验,不是修问题。 +- 判「生效」只认官方检查器(R9):`list_plugins` 的 `enabled` / `fiberPhase`、roster 诊断、 + `cordis_inspect_query`(`Tool` = 本 agent 能调用哪些工具、`Config.listConfigs`)、 + `inspectCompositions()`。 +- **声明只影响之后创建的 agent**(R7):验证一律开新会话,旧会话保留其启动时的 revision。 +- **提供方 rules 注入系统提示**(Phase 5):`artemisRepoPath` 下 `mcp_server/rules.md` + 在每次挂载时重读,经官方 `systemPrompt.section()` 贡献(`injectRules` 开关,默认开; + 缺路径/缺文件/读失败一律 warn 跳过,不阻断挂载)。挂载可解性由 Tier-1 真 runtime + 挂载实证(`systemPrompt` 在 inject 表里),文本到达模型上下文仍需真会话。 +- **设备画面要手动装 scrcpy**(Phase 4):插件不下载二进制。只从官方源装 + (Genymobile/scrcpy,按其签名验证页验签),把 `scrcpy` 放进 PATH( release 包里的 + scrcpy-server jar 会被自动找到),或把 jar 路径配进 `scrcpyServerPath`。版本必须 + 与 jar 严格一致(server 自检,不兼容即拒跑)。画面推送本身尚无 DSH 通道,当前只有 + Host 侧启停与截图轮询卡。 + +## 契约要点(踩过坑的) + +- **fixture 是生成物,不是手抄本**(G5)。升级 DSH 后按这三条重取,别再照文档手写: + ```bash + ARTEMIS_MOCK_DRIVER=1 node scripts/spike-mcp.mjs --write # py 动作面(期望面由 ACTION_TO_PY_TOOL 推出) + AUTOGAMER_CONFORMANCE_PY=live npm test # 带真 action server 的比对分支 + ``` + 版本 pin 读**实际装好的** `@deepseek-ai/dsh-*` 版本(必须彼此一致且等于 peer pin),有桌面版时再与 + `$DSH_HOME/dsh-runtimes/*/runtime.json` 的 `desktopVersion` 交叉校验;`turn/end` 这类事件名改由 + `@deepseek-ai/dsh-session` 的 `KNOWN_SESSION_EVENT_TYPES` 判定。⚠️ 公开包的 `latest` 标签是陈旧的 + `0.0.1-rc.1`,正确代际在 `next`——devDeps 要写死精确版本。 +- `Config` 必须是 **原生 Schemastery** schema;用 zod 会被宿主拒(`Config is not a native + Schemastery schema`),而**缺 Config 声明会让该行被判 `config=absent`,preset 子挂载被静默 + 跳过**——这就是 missing-tools 的根因。已锁死:`tests/conformance/contracts.spec.ts`。 +- 未配置的可选 tuple 会被 Schemastery 归一化成**真值 `[undefined, undefined]`**,所以 + `if (config.screenSize)` 恒真。判定数值本身走 `configuredScreenSize()`(`src/coord.ts`), + 接线由 `tests/apply.spec.ts` 钉住——它断言 action server 真收到的坐标。 +- 定义里的 `timeoutMs` **不会被注册表执行**(R15);超时由 `src/actionClient.ts` 的 + `Promise.race` 自实现,并观测 `exec.signal`。 +- 裸 schema 注册合法(官方 MCP 路径也这样),代价是**输入校验归我们**:非空、正数、跨字段、 + 显式对象节点的 `additionalProperties`(差集登记为 G34)。 +- `src/dsh-types.ts` 是宿主契约的镜像(DSH 不随包发 `.d.ts`)。**每次升级 DSH 都要复核这个文件。** + +## 已知债务(编号见 registry) + +| 编号 | 一句话 | +|---|---| +| G30 | 工具不进 agent 工具面(P0 阻塞点);头号候选是 `inject` 的服务在该 realm 不可解析 | +| G31 | 引入流仍靠手工;交付要迁 `install_bundle` | +| G32 | 缺 `locale/en.json` / `locale/zh.json` 的 `meta.title`/`meta.description` 与 icon(R19) | +| G33 | 闸门/熔断/审批策略内建在工具正文里,与官方「不要内建策略」相反(R13) | +| G34 | 裸 schema 的输入自校验缺口 | + +py 侧对本包**零改动**:action server 原样调用。录屏(D12)与 helper 复刻(D13)在 P2。 diff --git a/plugins/dsh-autogamer/client.js b/plugins/dsh-autogamer/client.js new file mode 100644 index 000000000..a0fe2af3e --- /dev/null +++ b/plugins/dsh-autogamer/client.js @@ -0,0 +1,165 @@ +/** + * dsh-autogamer browser half (Phase 3). + * + * Hand-written in the deployment's ModuleLoader format — the shape comes + * verbatim from the official decoration template + * (packages/preset/agent-preset/skills/cordis-plugin-development/templates/decoration/client.js): + * `window.__ModuleLoader__.load({ id, factory(require) })`, React from + * `require('react')` (browser module table, never bundled), minimal + * `inject: ['slots']`, and `ctx.slots.inject(slot, () => ctx.slots.register( + * { name, id, order[, key] }, Component))`. + * + * Deliberately NOT compiled: tsc (this repo's only builder) cannot emit the + * ModuleLoader format — the deployment's own client build tooling + * (packages/client/tsdown.client.ts) owns that. So this file is plain JS and + * dependency-free; keep it that way (no imports besides require('react')). + * + * Slot choices (all verified, Phase 0): `tool.call.toolview` is the keyed + * conversation slot under `conversation.chat.node`; cells key by wire tool + * name (R18; ui-cordis registers cordis_define/cordis_run/... the same way). + * The conversation-level card slot, the RPC helper, the per-plugin settings + * slot, and any binary frame push do NOT exist upstream — this file must + * never reference them (locked by tests/clientHalf.spec.ts). + * + * Owner props are read tolerantly: the exact prop contract lives only in the + * live inspect catalog (`cordis_inspect what:"client"`), so no card may assume + * a field. Live data channels (polling, controls) need a mounted proof first; + * until then every card renders the call's own args/result and nothing else. + * No custom styling: plain elements inherit the host theme (R18: no Host + * Client imports, token names unverified, so no style attributes at all). + */ +window.__ModuleLoader__.load({ + id: 'dsh-autogamer', + factory(require) { + const React = require('react'); + const h = React.createElement; + + /** Call record from the binding site: result first, then tolerant fallbacks. */ + function recordOf(props) { + if (!props || typeof props !== 'object') return {}; + const candidates = [props.result, props.value, props.output]; + for (const candidate of candidates) { + if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) return candidate; + } + return {}; + } + + function textOf(value) { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + + /** Up to 12 printable rows; long values clip like the Host envelopes do. */ + function rowsOf(record) { + return Object.entries(record) + .filter((entry) => entry[1] !== undefined && entry[1] !== null) + .slice(0, 12) + .map((entry) => { + const text = textOf(entry[1]); + return [entry[0], text.length > 200 ? text.slice(0, 200) + '…' : text]; + }); + } + + function Card(title, badge, rows) { + return h('section', { className: 'dsh-autogamer-card' }, + h('h3', { key: 't' }, title), + badge ? h('p', { key: 'b' }, h('strong', null, badge)) : null, + h('dl', { key: 'd' }, rows.flatMap((row) => [ + h('dt', { key: row[0] + '-k' }, row[0]), + h('dd', { key: row[0] + '-v' }, row[1]), + ]))); + } + + function pick(record, keys, fallback) { + for (const key of keys) { + const value = record[key]; + if (value !== undefined && value !== null && value !== '') return String(value); + } + return fallback; + } + + function RunTaskCard(props) { + const record = recordOf(props); + return Card( + 'AutoGamer task', + pick(record, ['status'], 'running'), + rowsOf(record), + ); + } + + function ManageCard(props) { + const record = recordOf(props); + const current = pick(record, ['current_step'], '?'); + const total = pick(record, ['total_steps'], '?'); + return Card( + 'AutoGamer task state', + pick(record, ['state'], 'unknown') + ' · step ' + current + '/' + total, + rowsOf(record), + ); + } + + function InspectCard(props) { + const record = recordOf(props); + const steps = Array.isArray(record.steps) ? record.steps.length : 0; + return Card( + 'AutoGamer trace', + pick(record, ['final_status'], 'unknown') + ' · ' + steps + ' steps shown', + rowsOf(record), + ); + } + + function DiagnoseCard(props) { + const record = recordOf(props); + const crashes = Array.isArray(record.recent_crashes) ? record.recent_crashes.length : 0; + return Card( + 'AutoGamer diagnosis', + 'adb ' + pick(record, ['adb_connected'], '?') + ' · helper ' + + pick(record, ['helper_installed'], '?') + ' · crashes ' + crashes, + rowsOf(record), + ); + } + + function DeviceStateCard(props) { + const record = recordOf(props); + return Card( + 'AutoGamer device state', + 'connected ' + pick(record, ['connected'], '?'), + rowsOf(record), + ); + } + + // [wire tool name, cell id, component]. The device-state cell doubles as + // the DeviceMirror placeholder: with no binary channel upstream, the + // provider-side screenshot_path renders as a path row (Phase 4 revisits + // only if a stream transport is ever proven). + const CELLS = [ + ['autogamer_run_task', 'dsh-autogamer-run-task', RunTaskCard], + ['autogamer_manage_task', 'dsh-autogamer-manage-task', ManageCard], + ['autogamer_inspect_trace', 'dsh-autogamer-inspect-trace', InspectCard], + ['autogamer_diagnose', 'dsh-autogamer-diagnose', DiagnoseCard], + ['autogamer_get_device_state', 'dsh-autogamer-device-state', DeviceStateCard], + ]; + + return { + inject: ['slots'], + apply(ctx) { + for (const cell of CELLS) { + const key = cell[0]; + const id = cell[1]; + const component = cell[2]; + ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ + name: 'tool.call.toolview', + id, + order: 10, + key, + }, component)); + } + }, + }; + }, +}); diff --git a/plugins/dsh-autogamer/cordis.patch.yml b/plugins/dsh-autogamer/cordis.patch.yml new file mode 100644 index 000000000..c7d6076ce --- /dev/null +++ b/plugins/dsh-autogamer/cordis.patch.yml @@ -0,0 +1,90 @@ +# AutoGamer device tools for DSH. +# Bundle patches are insert lists: each row adds an entry to the composed +# profile tree (id/name/config — see cordis-plugin-loader Entry Options). +# The preset's child entry carries its own distinct row id — a bare +# `- name:` item would inherit the root row's id, which the root copy also +# uses; sessions mount the preset's plugins, the root row is inert for them. +- insert: + - id: dsh-autogamer + name: dsh-autogamer + - id: preset-autogamer + name: "@deepseek-ai/dsh-agent-preset" + config: + id: autogamer + name: AutoGamer + description: Single-layer device execution agent (flash/pro merged, S11 router). + plugins: + # Identity/instruction delivery. An agent preset cannot mount the + # prompt registry itself, so this scope-only row is the official way + # for a preset to change what the model is told (G45). No {{...}} + # groups: unregistered references throw before any model request, + # and `cwd` is deliberately not one of the built-in variables. + - id: persona + name: "@deepseek-ai/dsh-persona" + config: + prefix: | + You are AutoGamer, an Android device operator. You drive one device + through the run_device_action tool, which enforces the safety gate + and checks that each action visibly changed the screen; you supply + intent, targeting and the final report. + + 1. One action per call, with a one-sentence reason. No narration + between navigation or typing actions. + 2. The turn ends only through report_task_status: call it exactly + once, as soon as the goal is met or no path forward remains. + After that call, do not act on the device again, do not wait, + do not report again. + 3. A handle such as #7 is valid only for the get_ui_hierarchy dump + it came from. After any action that changes the screen, dump + again before using a handle. Use normalized [x,y] coordinates + only when no element matches. + 4. Never repeat a refused action verbatim: re-observe, change the + target or the approach. + 5. Judge the screen only from screenshots; never claim a result + you have not seen. + 6. Audit like a checker at every milestone and at the end: + restate the goal, list what changed on screen (each action + reports effect plus a category), and record assertion + failures in report_task_status tests_failed. An incident + field means three straight failures: stop, audit the + attempts so far, then change the plan or report blocked. + Never audit from memory — quote the action results. + - id: dsh-autogamer-inner + name: dsh-autogamer + - name: "@deepseek-ai/dsh-tool-jobs" + # Self-inspection (TODO §零 Step 11, user-approved): the official cordis + # preset mounts this same row (no config) to hand sessions + # cordis_inspect_list/query — the R9 inspector for "which tools does THIS + # agent actually see". Without it the AutoGamer face has no checker at + # all (Step 10: the model can only restate its context). Placement + # mirrors the shipped cordis preset: right before the skills rows. + - id: tool-cordis + name: "@deepseek-ai/dsh-tool-cordis" + # Skills ship in this package's skills/ dir. Resolving the root at load + # time is the official patch-level form (web-app/presets/cordis.patch.yml + # does exactly this for dsh-agent-preset): createRequire(baseUrl) anchors + # on OUR installed package, so no absolute path is baked into the bundle. + # baseUrl is a host-injected loader variable - R6 prints !!js inertly, so + # only a live mount proves it resolves here. + - id: skill-filesystem + name: "@deepseek-ai/dsh-skill-filesystem" + config: + customSkillDirs: + - !!js process.getBuiltinModule('node:path').join(process.getBuiltinModule('node:path').dirname(process.getBuiltinModule('node:module').createRequire(baseUrl).resolve('dsh-autogamer/package.json')), 'skills') + - id: tool-skill + name: "@deepseek-ai/dsh-tool-skill" + # D17: self-install capability. Preset-backed sessions use their own + # tool-plugin-manager row (packages/boot/plugin-manager/README.md), + # and shipped presets ship it disabled - enabling it hands the model + # the ability to install Host code, which runs in-process outside the + # workspace sandbox. Every plugin_manager action needs + # danger-full-access or per-call approval. + # Track the SERVICE's condition instead of `false`: the plugin-manager + # row itself is `disabled: !!js "!ctx.get('profileContext')"`, so an + # unconditionally enabled tool row would ask for a service that is + # absent in a non-profile-backed boot, and a missing service rejects + # the whole preset mount (D16). The shipped Creator preset does exactly + # this. + - id: tool-plugin-manager + name: "@deepseek-ai/dsh-plugin-manager/tools" + disabled: !!js "!ctx.get('profileContext')" diff --git a/plugins/dsh-autogamer/icon.svg b/plugins/dsh-autogamer/icon.svg new file mode 100644 index 000000000..32a067e75 --- /dev/null +++ b/plugins/dsh-autogamer/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/plugins/dsh-autogamer/locale/en.json b/plugins/dsh-autogamer/locale/en.json new file mode 100644 index 000000000..61636e68b --- /dev/null +++ b/plugins/dsh-autogamer/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "AutoGamer Device", + "description": "Operates one Android device through the run_device_action router tool: safety gate, per-action effect verification, and terminal reporting via report_task_status." + } +} diff --git a/plugins/dsh-autogamer/locale/zh.json b/plugins/dsh-autogamer/locale/zh.json new file mode 100644 index 000000000..aac3b961f --- /dev/null +++ b/plugins/dsh-autogamer/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "AutoGamer 设备执行", + "description": "通过 run_device_action 路由工具操作一台 Android 设备:内置安全闸门、逐动作效果判定,终态由 report_task_status 汇报。" + } +} diff --git a/plugins/dsh-autogamer/package.json b/plugins/dsh-autogamer/package.json new file mode 100644 index 000000000..9e757476c --- /dev/null +++ b/plugins/dsh-autogamer/package.json @@ -0,0 +1,71 @@ +{ + "name": "dsh-autogamer", + "version": "0.1.0", + "private": true, + "description": "AutoGamer device-execution content plugin for DSH: run_device_action router tool, device gate/breaker, and the autogamer agent preset.", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + }, + "client": { + "platform": "web", + "immediately": true, + "inject": ["slots"] + } + }, + "type": "module", + "main": "./dist/index.js", + "icon": "./icon.svg", + "exports": { + ".": "./dist/index.js", + "./client": "./client.js", + "./cordis.patch.yml": "./cordis.patch.yml", + "./locale/*.json": "./locale/*.json", + "./icon": "./icon.svg", + "./package.json": "./package.json" + }, + "files": [ + "dist/**/*.js", + "client.js", + "cordis.patch.yml", + "skills/**/SKILL.md", + "locale/*.json", + "icon.svg" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run" + }, + "dependencies": { + "@deepseek-ai/schemastery": "~3.18.4", + "@modelcontextprotocol/client": "2.0.0", + "zod": "^4.6.5" + }, + "peerDependencies": { + "@deepseek-ai/dsh-agent-preset": "0.2.0-rc.2", + "@deepseek-ai/dsh-tools": "0.2.0-rc.2" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-tools": { + "optional": true + }, + "@deepseek-ai/dsh-agent-preset": { + "optional": true + } + }, + "devDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-agent": "0.2.0-rc.2", + "@deepseek-ai/dsh-attachment": "0.2.0-rc.2", + "@deepseek-ai/dsh-invariants": "0.2.0-rc.2", + "@deepseek-ai/dsh-llm": "0.2.0-rc.2", + "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2", + "@deepseek-ai/dsh-scope": "0.2.0-rc.2", + "@deepseek-ai/dsh-session": "0.2.0-rc.2", + "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2", + "@deepseek-ai/dsh-tools": "0.2.0-rc.2", + "@types/node": "^24.19.1", + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } +} diff --git a/plugins/dsh-autogamer/pnpm-lock.yaml b/plugins/dsh-autogamer/pnpm-lock.yaml new file mode 100644 index 000000000..460b730ac --- /dev/null +++ b/plugins/dsh-autogamer/pnpm-lock.yaml @@ -0,0 +1,1292 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: false + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@deepseek-ai/schemastery': + specifier: ~3.18.4 + version: 3.18.4 + '@modelcontextprotocol/client': + specifier: 2.0.0 + version: 2.0.0 + zod: + specifier: ^4.6.5 + version: 4.6.5 + devDependencies: + '@deepseek-ai/cordis': + specifier: ~4.0.4 + version: 4.0.4 + '@deepseek-ai/dsh-agent': + specifier: 0.2.0-rc.2 + version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)))(@deepseek-ai/dsh-session@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))))(@deepseek-ai/dsh-system-prompt@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))))(@deepseek-ai/dsh-typert-protocol@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-util-values@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)) + '@deepseek-ai/dsh-attachment': + specifier: 0.2.0-rc.2 + version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-brand@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)) + '@deepseek-ai/dsh-invariants': + specifier: 0.2.0-rc.2 + version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-llm': + specifier: 0.2.0-rc.2 + version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-sandbox': + specifier: 0.2.0-rc.2 + version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-session@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)))) + '@deepseek-ai/dsh-scope': + specifier: 0.2.0-rc.2 + version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)) + '@deepseek-ai/dsh-session': + specifier: 0.2.0-rc.2 + version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))) + '@deepseek-ai/dsh-system-prompt': + specifier: 0.2.0-rc.2 + version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))) + '@deepseek-ai/dsh-tools': + specifier: 0.2.0-rc.2 + version: 0.2.0-rc.2(d9c706823d495ae40b69c79e14cd5802) + '@types/node': + specifier: ^24.19.1 + version: 24.19.1 + typescript: + specifier: ^5.6.0 + version: 5.9.3 + vitest: + specifier: ^2.1.0 + version: 2.1.9(@types/node@24.19.1) + +packages: + + '@deepseek-ai/cordis@4.0.4': + resolution: {integrity: sha512-obgyxqWAmFn3Re8kvsuUnyW+ihrz6eJCnJO4fh1cQzDtmPYz/zzVeUkH9R94I0OwSVOocK67Kgakm04j/oQXzg==} + hasBin: true + peerDependencies: + '@deepseek-ai/cordis-plugin-include': ~1.0.9 + '@deepseek-ai/cordis-plugin-loader': ~1.0.5 + peerDependenciesMeta: + '@deepseek-ai/cordis-plugin-include': + optional: true + '@deepseek-ai/cordis-plugin-loader': + optional: true + + '@deepseek-ai/cosmokit@1.8.5': + resolution: {integrity: sha512-LXsrlem9z8dq4sLflj2yuCuYX7KqhdzF5hZly3eZyuTo8d6oDSOXuEgC5DbQWKW+lksm6zmOutQLsP/ma6wR6A==} + + '@deepseek-ai/dsh-agent@0.2.0-rc.2': + resolution: {integrity: sha512-CUkbU+c1G0ETXkpoLkpAFo5midz66FQGtlgQOP9pzvU9H3zJQVl4H5LrhhGVbEXKLFgdbtQiOynEW8hpWn3y0Q==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + '@deepseek-ai/dsh-invariants': 0.2.0-rc.2 + '@deepseek-ai/dsh-llm': 0.2.0-rc.2 + '@deepseek-ai/dsh-scope': 0.2.0-rc.2 + '@deepseek-ai/dsh-session': 0.2.0-rc.2 + '@deepseek-ai/dsh-session-projection': 0.2.0-rc.2 + '@deepseek-ai/dsh-system-prompt': 0.2.0-rc.2 + '@deepseek-ai/dsh-typert-protocol': 0.2.0-rc.2 + '@deepseek-ai/dsh-util-values': 0.2.0-rc.2 + '@deepseek-ai/dsh-workspace': 0.2.0-rc.2 + peerDependenciesMeta: + '@deepseek-ai/dsh-workspace': + optional: true + + '@deepseek-ai/dsh-attachment@0.2.0-rc.2': + resolution: {integrity: sha512-asUDLv+mfb30vjjlQtMI9aBnC+kmPoUxS1ooadhwAd8wssIlLetq97nzC5W1qd0GQGwZtPG/gpljVAMcSwPr1g==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + '@deepseek-ai/dsh-brand': 0.2.0-rc.2 + + '@deepseek-ai/dsh-brand@0.2.0-rc.2': + resolution: {integrity: sha512-A5XlN4tIgP0qObkYFhcZ7Oz/tM2oESEZqLwS0Qq+sM1ivGx6o4AMPgJdfKQlwLzqk0cMLNtAaVBHfiYoElt4lw==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + + '@deepseek-ai/dsh-invariants@0.2.0-rc.2': + resolution: {integrity: sha512-QOVc0HpRsS8ekvprb45FlxieDvgR/cAMaxW289HASjMoV/U/B54FpbkHuPBw4EnTw07/sPkjx2B/23+YqE0cbg==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + + '@deepseek-ai/dsh-llm@0.2.0-rc.2': + resolution: {integrity: sha512-6QFrQn/h0iNqCfPpgQidnHtLSpA99A7ZND/uYKaGSd/4BGP8KBhuRd0w63e9s4u+hfW8jc3j29WKcNLIOeUfcA==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + + '@deepseek-ai/dsh-sandbox@0.2.0-rc.2': + resolution: {integrity: sha512-luKF7ey6HRZkGhImqLCB5Npt4f4omDRk1r+x5qQNqqdNOuN9LXciKDMNPfnSbHfKCcqSZbH3sK+APOmKzRAhVA==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + '@deepseek-ai/dsh-llm': 0.2.0-rc.2 + '@deepseek-ai/dsh-session': 0.2.0-rc.2 + + '@deepseek-ai/dsh-scope@0.2.0-rc.2': + resolution: {integrity: sha512-066p7Wv74GNWt4xA5Cyvl+7c/i9KrNEDALYh2/hu0VsJPLb4T9He+TY7y1bld9OgungmqyTy1n/cB2ZrVskvxA==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + '@deepseek-ai/dsh-invariants': 0.2.0-rc.2 + + '@deepseek-ai/dsh-session@0.2.0-rc.2': + resolution: {integrity: sha512-wj+6MeqCYbDcbEvKH3puHyEGdjyu51JwBoN6Ua2tz6griLufDgLM5HpSU40L5xgpbOfrYVhXZs4vRgOrMlFY2g==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + '@deepseek-ai/dsh-scope': 0.2.0-rc.2 + + '@deepseek-ai/dsh-system-prompt@0.2.0-rc.2': + resolution: {integrity: sha512-ZZNk2gkXFV8CIT0IOZXS+pFaxNVJaRknYyH2vc4ZgZAiHKT+RqUYNr8e1F5Kp0AaN0DNlcTkjOGP5MCxeXfDjg==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + '@deepseek-ai/dsh-invariants': 0.2.0-rc.2 + '@deepseek-ai/dsh-llm': 0.2.0-rc.2 + '@deepseek-ai/dsh-scope': 0.2.0-rc.2 + + '@deepseek-ai/dsh-timeout@0.2.0-rc.2': + resolution: {integrity: sha512-NDob8b8DJDB/sDPz3P7xhrkxOh1O9FbN1EZRKsxP4fuTfp4hrQU7aekYS5qZENFKoR8dBJMTh/zcEBw4m31glQ==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + + '@deepseek-ai/dsh-tools@0.2.0-rc.2': + resolution: {integrity: sha512-vquUz8PjHWE4zaS3wH70IpYjL441p2UlUcG8TN2yJZN1RhDkuiHum4bWCsjEPyWLS/aB1QJvzdXC2EK/PRkbsQ==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + '@deepseek-ai/dsh-agent': 0.2.0-rc.2 + '@deepseek-ai/dsh-invariants': 0.2.0-rc.2 + '@deepseek-ai/dsh-llm': 0.2.0-rc.2 + '@deepseek-ai/dsh-ptc-runtime': 0.2.0-rc.2 + '@deepseek-ai/dsh-sandbox': 0.2.0-rc.2 + '@deepseek-ai/dsh-sandbox-policy': 0.2.0-rc.2 + '@deepseek-ai/dsh-scope': 0.2.0-rc.2 + '@deepseek-ai/dsh-session': 0.2.0-rc.2 + '@deepseek-ai/dsh-system-prompt': 0.2.0-rc.2 + '@deepseek-ai/dsh-user-approval': 0.2.0-rc.2 + + '@deepseek-ai/dsh-typert-protocol@0.2.0-rc.2': + resolution: {integrity: sha512-95RvIDcVac2BWdv1IxN6yKlRV+8eEvkofqSxV+d0r8x1kx2EIWEsbdxz6C+coYIm+8e03MiN+rwOt8J7sLfe7Q==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + + '@deepseek-ai/dsh-util-crypto@0.2.0-rc.2': + resolution: {integrity: sha512-5OvvmepY69uXTagUMlnoHqg7euI4ZMifLgfdV8xZO20OslWGGiRCkJbZRyDtcJG9GsXWz9UimfWmUu52zKXJ5w==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + + '@deepseek-ai/dsh-util-values@0.2.0-rc.2': + resolution: {integrity: sha512-qgpsbgiZABuE9UvgNaVo9b2HiWwLeU93CTl9PFXqE0jN8ap1ALxzQsLGvr/q2MJuWDYqnFBg60XnqnY8ZxPWEg==} + peerDependencies: + '@deepseek-ai/cordis': ~4.0.4 + + '@deepseek-ai/schemastery@3.18.4': + resolution: {integrity: sha512-SSXO6tYuyrIqKVbmOnIq0s+riUywYzouFMcnBluHF9n4KMo2G8HvEzhCYj6pj2617/glOjbJuVInJ9hfONsjkg==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@modelcontextprotocol/client@2.0.0': + resolution: {integrity: sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==} + engines: {node: '>=20'} + + '@modelcontextprotocol/core@2.0.0': + resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} + engines: {node: '>=20'} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-android-arm-eabi@4.64.3': + resolution: {integrity: sha512-a31GFbL70GlGUGhn8goVjRLtdDG1urLCJFWLZ7JcxdHK9pPvCohLFtYLgJDdNbtrNZSrJl1u9bK05ISsSC2EpA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.64.3': + resolution: {integrity: sha512-IRv757RLiPGlNxAX5JXU0eiUlUPvKiwFRbkqLd+gywptLK4uSEg1qkl6en6r0cXMazSnWUWOBABv+QfuLV3n5Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.64.3': + resolution: {integrity: sha512-UkzI35Fnz6+MJNATdnjnZ3TpdG/rXab8h7oDOD9R0zDVIFmuuYrvgUj8V8mh+A//+LR4TlEQo4ZW4RhyRtWv3A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.64.3': + resolution: {integrity: sha512-C3AyMtAnKQ/jSkZ0chIoWbPXRbFz8JnPLhl1ULecEhcaOGkT19xt7KmO8BulQxt9S/XideHRXvsVQUbGj4JCOg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.64.3': + resolution: {integrity: sha512-M8yAdBMyHh14JLeXDNGvEkljzuHdcoLBQ399O4Fp4nvX51lueuEY/5U2G9Ci0D6/bQlBp/3JLiBQH55STwwbwg==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.64.3': + resolution: {integrity: sha512-WQjuOKGh2FN98ZK6i0uM6M6YqnPEds85p3oRkrxiEeTQo/p7RpuU8c9MwQ88LfgwVAzohumlzbQQSikz4Zw9rw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.64.3': + resolution: {integrity: sha512-5tDLZG5s9WyVKoEHgwos6FpKrgpotl61EYeHSLSVkQzMzTsaClwEr8mok9rm3y734zjiGInG4dfw/56ebWVvEQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.64.3': + resolution: {integrity: sha512-Mh5KbLeplM2HX6lQI18jsxSsqvPW8d+Hyk8HtY6Zl93woMitRSCZ92rBEzq6v8bfZGlpyMgj4P/sKW4KCjhH3A==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.64.3': + resolution: {integrity: sha512-QZrrwHnznTv6y4iiHtCqDN+ZsF4qNFQObMOLgPrbxhh9O5o4rgoUe5OnkqG/ceCObMrAjhQt+eQ6HSDzBFgVMw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.64.3': + resolution: {integrity: sha512-4vLehpXSwlkltnvk5ETB+Hz+d3xayZS3NamZ5Vq4bSo+IUoIALovEJF1YgWPMjHwN7R5o/R4BpP9h3q8EK1xAA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.64.3': + resolution: {integrity: sha512-SEN7TTTOdXJKZmsWKOnQeVfgy3Dje/08ISXWwFhTggQ3SxjuIaChvmOB5ZDZqJNlBjaXrV6nAHraqEAvdLGqcw==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.64.3': + resolution: {integrity: sha512-1LDsQMRMCEdGF9Dih5qyh6fF1LK3NnA+pAtE2+9AgZwUdXyd19TOBVC9crSB63iZBHR4vOG7h8gN/LcUH3J7xw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.64.3': + resolution: {integrity: sha512-v3D2tAfRriqGOi+5Ac5CiYbBo2NXtLVxJlludTvUZRj502lVMFeVIuvvWbz4qCrotn3e3Uvpvh7FcMpG2QxxXQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.64.3': + resolution: {integrity: sha512-db5q6dP189V2+JkMpsXBl8ym6bC0x8ydZ47fUJZ9YdM6T2OF5uqNzX9HaAZHRP/lqjA9bJeNwi0qw1/2/37dUA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.64.3': + resolution: {integrity: sha512-nArAVDmrL5ix9UoipcKEsYJw4kMUQCIPYRWuYF2POOC7jd9rmN95d/48ewsYFC/OdtwEx6VYYWYxrp8FbMOU2Q==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.64.3': + resolution: {integrity: sha512-2dc7LElu7EVSvpQT8epNnBLa02wlqilNYqHN0FA/RaKqCr9U9ZFYQ0Eh4sfgSzLneLDuJsJRjcezR/l2UyYGXA==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.64.3': + resolution: {integrity: sha512-9PYq2IEzSwXcvQLERcT9LKXRzw8J05GFTbJHA2anJR2WmlRjA1esgXVYhanp+bmjKsDVoZeU2L7Z2pRClLcZTw==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.64.3': + resolution: {integrity: sha512-vMh+mt0cw1BHldd8cqaU6A5s1W4EUKl6+8MRsv86mj954L4xyruKD7qPXMUI2icImadAZARK10h0onaGTLmCVg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.64.3': + resolution: {integrity: sha512-PpiKU+sWbqDGc0U0su8oXiyeyqm0UQjmaaPKHktX+DaLe6YKJwW2YVD0f2AXHdVvblZbRqpwRVClWP2F5HCqrQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.64.3': + resolution: {integrity: sha512-/3enY/AmaIt8IS0fWjX4ztYVPBqkj49cQTy/hCryhBFaEzDfJwwEolq9sxvyWAM2qmsmVuPdeRg5JXSrmeqvJg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.64.3': + resolution: {integrity: sha512-zsEP/QDuet52hhwvi5T1VPg0/Tkms0m3zkVv7CPsznvhdO4pL4oJTMz26EJsiCe18OeLKPgpTu3XVO3C1eq3ew==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.64.3': + resolution: {integrity: sha512-4ESknW4Db+kqvazVOfsReYJaHxcIsOqcgwjKV2J1tG5Bn4RVf5ocTQKSGuq2HWBJcOLuyim8IWX8DuH6nKmELA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.64.3': + resolution: {integrity: sha512-3B8VCQyIyv7FQxYYhb3HpR3bIpronR20txjMSffSoz/sm/DDs3/49CFpEueOsvq9qW9sWoI/6pi6mtciD9NXFg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.64.3': + resolution: {integrity: sha512-YOoDkKh2oQxOxRB5D9AR8sj3qpMShpmyTkjojiBjMGigz3caV7UvCnXWWUQC0TBmrZ5zuHe2pWl8S+cMfvXzRg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.64.3': + resolution: {integrity: sha512-qTv4bG+eKJKifzxNJevMUh8L6CjJrz9/Kn1IbP2F5bo0tBzNIR7HMg4vni6XmDzcKUl00lHHY+J5ZgjbGiujOQ==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@24.19.1': + resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==} + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + postcss@8.5.29: + resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.64.3: + resolution: {integrity: sha512-iIMWbptLYsZdGTJMH8ngApXdsCatE6bCMv2eqGIfGE1++kwh7TCWBdWKK0FKC0yx++196vGK5VdagzRXSLMa8A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + +snapshots: + + '@deepseek-ai/cordis@4.0.4': + dependencies: + '@deepseek-ai/cosmokit': 1.8.5 + '@standard-schema/spec': 1.1.0 + + '@deepseek-ai/cosmokit@1.8.5': {} + + '@deepseek-ai/dsh-agent@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)))(@deepseek-ai/dsh-session@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))))(@deepseek-ai/dsh-system-prompt@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))))(@deepseek-ai/dsh-typert-protocol@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-util-values@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))': + dependencies: + '@deepseek-ai/cordis': 4.0.4 + '@deepseek-ai/dsh-invariants': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-llm': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-scope': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)) + '@deepseek-ai/dsh-session': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))) + '@deepseek-ai/dsh-system-prompt': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))) + '@deepseek-ai/dsh-typert-protocol': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-util-values': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + + '@deepseek-ai/dsh-attachment@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-brand@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))': + dependencies: + '@deepseek-ai/cordis': 4.0.4 + '@deepseek-ai/dsh-brand': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + + '@deepseek-ai/dsh-brand@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4 + + '@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4 + '@deepseek-ai/schemastery': 3.18.4 + + '@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4 + '@deepseek-ai/dsh-brand': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-timeout': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-typert-protocol': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-util-crypto': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-util-values': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/schemastery': 3.18.4 + zod: 4.6.5 + + '@deepseek-ai/dsh-sandbox@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-session@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))))': + dependencies: + '@deepseek-ai/cordis': 4.0.4 + '@deepseek-ai/dsh-llm': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-session': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))) + '@deepseek-ai/dsh-util-values': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + + '@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))': + dependencies: + '@deepseek-ai/cordis': 4.0.4 + '@deepseek-ai/dsh-invariants': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + + '@deepseek-ai/dsh-session@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)))': + dependencies: + '@deepseek-ai/cordis': 4.0.4 + '@deepseek-ai/dsh-brand': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-llm': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-scope': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)) + '@deepseek-ai/dsh-util-values': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + + '@deepseek-ai/dsh-system-prompt@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)))': + dependencies: + '@deepseek-ai/cordis': 4.0.4 + '@deepseek-ai/dsh-invariants': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-llm': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-scope': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)) + '@deepseek-ai/schemastery': 3.18.4 + + '@deepseek-ai/dsh-timeout@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4 + + '@deepseek-ai/dsh-tools@0.2.0-rc.2(d9c706823d495ae40b69c79e14cd5802)': + dependencies: + '@deepseek-ai/cordis': 4.0.4 + '@deepseek-ai/dsh-agent': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)))(@deepseek-ai/dsh-session@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))))(@deepseek-ai/dsh-system-prompt@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))))(@deepseek-ai/dsh-typert-protocol@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-util-values@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)) + '@deepseek-ai/dsh-brand': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-invariants': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-llm': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-sandbox': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-session@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)))) + '@deepseek-ai/dsh-scope': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)) + '@deepseek-ai/dsh-session': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))) + '@deepseek-ai/dsh-system-prompt': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-llm@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))(@deepseek-ai/dsh-scope@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4))) + '@deepseek-ai/dsh-util-values': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/schemastery': 3.18.4 + + '@deepseek-ai/dsh-typert-protocol@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4 + '@deepseek-ai/dsh-brand': 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + + '@deepseek-ai/dsh-util-crypto@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4 + + '@deepseek-ai/dsh-util-values@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)': + dependencies: + '@deepseek-ai/cordis': 4.0.4 + + '@deepseek-ai/schemastery@3.18.4': + dependencies: + '@deepseek-ai/cosmokit': 1.8.5 + '@standard-schema/spec': 1.1.0 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@modelcontextprotocol/client@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + jose: 6.2.12 + pkce-challenge: 5.0.1 + zod: 4.6.5 + + '@modelcontextprotocol/core@2.0.0': + dependencies: + zod: 4.6.5 + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@rollup/rollup-android-arm-eabi@4.64.3': + optional: true + + '@rollup/rollup-android-arm64@4.64.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.64.3': + optional: true + + '@rollup/rollup-darwin-x64@4.64.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.64.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.64.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.64.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.64.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.64.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.64.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.64.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.64.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.64.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.64.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.64.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.64.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.64.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.64.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.64.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.64.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.64.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.64.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.64.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.64.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.64.3': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@types/estree@1.0.9': {} + + '@types/node@24.19.1': + dependencies: + undici-types: 7.24.6 + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@24.19.1))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@24.19.1) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + assertion-error@2.0.1: {} + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + + expect-type@1.4.0: {} + + fsevents@2.3.3: + optional: true + + isexe@2.0.0: {} + + jose@6.2.12: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + ms@2.1.3: {} + + nanoid@3.3.20: {} + + path-key@3.1.1: {} + + pathe@1.1.2: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + pkce-challenge@5.0.1: {} + + postcss@8.5.29: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + rollup@4.64.3: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.64.3 + '@rollup/rollup-android-arm64': 4.64.3 + '@rollup/rollup-darwin-arm64': 4.64.3 + '@rollup/rollup-darwin-x64': 4.64.3 + '@rollup/rollup-freebsd-arm64': 4.64.3 + '@rollup/rollup-freebsd-x64': 4.64.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.64.3 + '@rollup/rollup-linux-arm-musleabihf': 4.64.3 + '@rollup/rollup-linux-arm64-gnu': 4.64.3 + '@rollup/rollup-linux-arm64-musl': 4.64.3 + '@rollup/rollup-linux-loong64-gnu': 4.64.3 + '@rollup/rollup-linux-loong64-musl': 4.64.3 + '@rollup/rollup-linux-ppc64-gnu': 4.64.3 + '@rollup/rollup-linux-ppc64-musl': 4.64.3 + '@rollup/rollup-linux-riscv64-gnu': 4.64.3 + '@rollup/rollup-linux-riscv64-musl': 4.64.3 + '@rollup/rollup-linux-s390x-gnu': 4.64.3 + '@rollup/rollup-linux-x64-gnu': 4.64.3 + '@rollup/rollup-linux-x64-musl': 4.64.3 + '@rollup/rollup-openbsd-x64': 4.64.3 + '@rollup/rollup-openharmony-arm64': 4.64.3 + '@rollup/rollup-win32-arm64-msvc': 4.64.3 + '@rollup/rollup-win32-ia32-msvc': 4.64.3 + '@rollup/rollup-win32-x64-gnu': 4.64.3 + '@rollup/rollup-win32-x64-msvc': 4.64.3 + fsevents: 2.3.3 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.2: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + typescript@5.9.3: {} + + undici-types@7.24.6: {} + + vite-node@2.1.9(@types/node@24.19.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@24.19.1) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@24.19.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.29 + rollup: 4.64.3 + optionalDependencies: + '@types/node': 24.19.1 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@24.19.1): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@24.19.1)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@24.19.1) + vite-node: 2.1.9(@types/node@24.19.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.19.1 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + zod@4.6.5: {} diff --git a/plugins/dsh-autogamer/scripts/conformance-live.mjs b/plugins/dsh-autogamer/scripts/conformance-live.mjs new file mode 100644 index 000000000..fd7560c09 --- /dev/null +++ b/plugins/dsh-autogamer/scripts/conformance-live.mjs @@ -0,0 +1,120 @@ +/** + * G5 Tier 3: the live half of the conformance story, scripted so it stops + * living in someone's memory. Two layers, never merged into one verdict: + * + * declared — `dsh --dump-config` composes the tree with our bundle patch. + * This proves the patch is *well formed*, not that anything mounted + * (rule R6; and R25: it does not overturn a stronger live finding). + * live — the session log, the only durable truth about what a session + * actually did (rule R24). Tool *invocation* is visible here; + * tool *registration* is not — that is `cordis_inspect_query` / + * the roster's `broken` flag (rule R9), which needs the running app. + * + * node plugins/dsh-autogamer/scripts/conformance-live.mjs \ + * --expect-tool run_device_action --expect-tool report_task_status + * + * Env: DSH_BIN overrides the launcher path; AUTOGAMER_SESSION picks a session id. + */ +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const arg = (name, fallback) => { + const i = process.argv.indexOf(`--${name}`); + return i === -1 ? fallback : process.argv[i + 1]; +}; +const flagList = (name) => { + const i = process.argv.indexOf(`--${name}`); + return i === -1 ? [] : process.argv.slice(i + 1).filter((v) => !v.startsWith("--")); +}; + +const profile = arg("profile", "web"); +const patch = resolve(arg("patch", join(PLUGIN_ROOT, "cordis.patch.yml"))); +const dshBin = arg("dsh", process.env.DSH_BIN ?? "D:/DeepSeek Harness/resources/runtime/cli/bin/dsh.cmd"); +const expectTools = flagList("expect-tool"); +const cwd = resolve(arg("cwd", join(PLUGIN_ROOT, "spike"))); + +function declaredLayer() { + // A .cmd launcher has to go through the shell on Windows, and the install + // directory contains a space — quote it or the shell runs "D:/DeepSeek". + const run = spawnSync(`"${dshBin}"`, ["--profile", profile, "--patch", patch, "--dump-config"], { + encoding: "utf8", + shell: true, + }); + const out = `${run.stdout ?? ""}${run.stderr ?? ""}`; + const rows = ["preset-autogamer", "persona", "skill-filesystem", "tool-plugin-manager"]; + console.log(`[declared] dsh --profile ${profile} --dump-config exit=${run.status}`); + console.log(`[declared] unmatched hits: ${(out.match(/unmatched/gi) ?? []).length}`); + for (const row of rows) { + console.log(`[declared] row ${row}: ${out.includes(row) ? "present" : "ABSENT"}`); + } + return run.status === 0 && !/unmatched/i.test(out); +} + +/** Workspace session logs are keyed by the sanitized cwd, wrapped in `--`. */ +function sessionDir() { + const dshHome = process.env.DSH_HOME ?? join(homedir(), ".dsh"); + // DSH collapses runs of separators: "D:\DEV\x" → "--D-DEV-x--". + const ws = join(dshHome, "sessions", `--${cwd.replace(/[^A-Za-z0-9]+/g, "-")}--`); + if (!existsSync(ws)) throw new Error(`no session directory for ${ws} — run a spike first or pass --cwd`); + const wanted = process.env.AUTOGAMER_SESSION; + const dirs = readdirSync(ws) + .filter((name) => name.startsWith("session-")) + .map((name) => join(ws, name)) + .filter((p) => existsSync(join(p, "session.v4.jsonl.zstd"))); + const chosen = wanted ? dirs.find((p) => p.endsWith(wanted)) : dirs.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0]; + if (!chosen) throw new Error(wanted ? `session ${wanted} not under ${ws}` : `no readable session under ${ws}`); + return chosen; +} + +/** One zstd frame per record, so the stream reader is the only correct form. */ +function readSession(dir) { + const script = `import json,sys,zstandard +with open(sys.argv[1],'rb') as fh, zstandard.ZstdDecompressor().stream_reader(fh) as r: + raw=r.read() +print(json.dumps([json.loads(l) for l in raw.decode('utf-8').splitlines() if l.strip()]))`; + const out = execFileSync("uv", ["run", "python", "-c", script, join(dir, "session.v4.jsonl.zstd")], { + cwd: resolve(PLUGIN_ROOT, "../.."), + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + return JSON.parse(out.trim()); +} + +function liveLayer(records) { + const types = new Map(); + const calls = new Map(); + for (const record of records) { + types.set(record.type, (types.get(record.type) ?? 0) + 1); + if (record.type === "tool/call" && record.data?.name) { + calls.set(record.data.name, (calls.get(record.data.name) ?? 0) + 1); + } + } + console.log(`\n[live] ${records.length} session records`); + console.log(`[live] event types: ${[...types].map(([t, n]) => `${t}=${n}`).sort().join(" ")}`); + console.log(`[live] tools invoked: ${[...calls].map(([t, n]) => `${t}=${n}`).sort().join(" ") || "none"}`); + // Turn accounting is what G41/G44 rest on: every turn must end, and the + // plugin's own nudge only makes sense when a turn ended without a report. + const ended = types.get("turn/end") ?? 0; + const reported = calls.get("report_task_status") ?? 0; + console.log(`[live] turn/end=${ended} report_task_status=${reported}${ended > reported ? " (a turn ended unreported → nudge path)" : ""}`); + const missing = expectTools.filter((name) => !calls.has(name)); + for (const name of expectTools) { + console.log(`[live] expected tool ${name}: ${calls.has(name) ? `called ${(calls.get(name) ?? 0)}x` : "NOT INVOKED"}`); + } + console.log(`[live] NOTE: registration is not observable here — verify it with cordis_inspect_query / the roster (R9).`); + return missing.length === 0; +} + +const dir = sessionDir(); +console.log(`[live] session: ${basename(dir)} (workspace cwd: ${cwd})`); +const declared = declaredLayer(); +const live = liveLayer(readSession(dir)); +console.log(`\nverdict: declared=${declared ? "ok" : "FAIL"} live=${live ? "ok" : "FAIL"}`); +if (!declared || !live) process.exitCode = 1; +if (!live) { + console.error("A tool that was never invoked may simply not have been asked for; re-run the task, and check registration separately before calling this a plugin defect."); +} diff --git a/plugins/dsh-autogamer/scripts/dump-proxy.mjs b/plugins/dsh-autogamer/scripts/dump-proxy.mjs new file mode 100644 index 000000000..2fbbac7a4 --- /dev/null +++ b/plugins/dsh-autogamer/scripts/dump-proxy.mjs @@ -0,0 +1,47 @@ +/** + * Logging proxy for LLM spike debugging: 127.0.0.1:1235 -> 127.0.0.1:1234. + * Dumps each request body and response status/head to spike/proxy-dump.log, + * then streams the response back untouched. + */ +import http from "node:http"; +import { appendFileSync } from "node:fs"; + +const LOG = new URL("./proxy-dump.log", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"); +const TARGET = { host: "127.0.0.1", port: 1234 }; + +const server = http.createServer((req, res) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8"); + appendFileSync(LOG, `\n===== ${new Date().toISOString()} ${req.method} ${req.url} =====\n${body.slice(0, 400_000)}\n----- response -----\n`); + + const upstream = http.request( + { host: TARGET.host, port: TARGET.port, path: req.url, method: req.method, headers: { ...req.headers, host: `${TARGET.host}:${TARGET.port}` } }, + (upstreamRes) => { + appendFileSync(LOG, `status=${upstreamRes.statusCode}\n`); + res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers); + upstreamRes.pipe(res); + let preview = ""; + upstreamRes.on("data", (piece) => { + preview += piece.toString("utf8"); + if (preview.length > 40_000) { + appendFileSync(LOG, `${preview.slice(0, 40_000)}\n...[truncated]\n`); + preview = ""; + } + }); + upstreamRes.on("end", () => { + if (preview.length > 0) appendFileSync(LOG, `${preview}\n`); + }); + }, + ); + upstream.on("error", (error) => { + appendFileSync(LOG, `upstream error: ${error.message}\n`); + res.writeHead(502); + res.end(String(error)); + }); + upstream.end(body.length > 0 ? body : undefined); + }); +}); + +server.listen(1235, "127.0.0.1", () => console.log("[proxy] listening on 127.0.0.1:1235 -> 1234")); diff --git a/plugins/dsh-autogamer/scripts/mock-action-server.mjs b/plugins/dsh-autogamer/scripts/mock-action-server.mjs new file mode 100644 index 000000000..71651acc2 --- /dev/null +++ b/plugins/dsh-autogamer/scripts/mock-action-server.mjs @@ -0,0 +1,162 @@ +/** + * Dependency-free MCP stdio action server returning canned success for + * every tool (S10 spike: isolates model-dispatch behavior from device + * availability). Line-delimited JSON-RPC over stdio. + */ +import { createInterface } from "node:readline"; + +const TOOLS = [ + "tap", "long_press_on", "swipe", "back", "launch_app", "stop_app", + "open_link", "focus_and_input_text", "focus_and_clear_text", + "erase_one_char", "press_key", "take_screenshot", "get_ui_hierarchy", + "compare_ui_frames", "list_devices", +]; + +/** + * list_devices mirrors artemis/runtime/device_pool.DeviceStatus.to_dict() key for key (G64), + * because the pool reads those fields to decide eligibility. Emitting anything else here would be + * inventing contract -- the mistake changelogs #51 records for `ratio`. + * MOCK_ENUM=failed models "could not ask adb", which must stay distinct from an empty fleet. + */ +const ROSTER = ["mock-device"]; +const DEVICE_STATUS_KEYS = [ + "serial", "state", "model", "product", "is_emulator", "is_busy", + "active_pid", "active_task_desc", "active_session_id", "acquired_at", +]; + +function listDevicesPayload(includeLockState) { + const enumerated = process.env.MOCK_ENUM !== "failed"; + // MOCK_LOCK picks the Keyguard verdict when the caller asked for one: "locked", + // "unknown" (null, which must fail closed downstream) or the default "open". + const lockMode = process.env.MOCK_LOCK ?? "open"; + const isLocked = lockMode === "locked" ? true : lockMode === "unknown" ? null : false; + const devices = enumerated + ? ROSTER.map((serial) => ({ + serial, + state: "device", + model: null, + product: null, + is_emulator: true, + is_busy: false, + active_pid: null, + active_task_desc: null, + active_session_id: null, + acquired_at: null, + // Asked -> the key is present (null allowed). Not asked -> the key is absent entirely; + // py's admission reads those two differently (G65). + ...(includeLockState === true ? { is_locked: isLocked } : {}), + })) + : []; + for (const device of devices) { + const expected = DEVICE_STATUS_KEYS.length + (includeLockState === true ? 1 : 0); + if (Object.keys(device).length !== expected) { + throw new Error("mock list_devices payload drifted from DeviceStatus.to_dict()"); + } + } + return JSON.stringify({ enumerated, devices }); +} + +/** + * take_screenshot answers a real PNG header (1080x2400 IHDR) so the plugin's + * screenshot probe resolves dimensions and the 0-1000 → pixel path is + * testable live without a device. Everything else keeps the legacy text. + */ +const SCREENSHOT_PNG = (() => { + const bytes = Buffer.alloc(33); + [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].forEach((b, i) => { + bytes[i] = b; + }); + bytes.writeUInt32BE(13, 8); + bytes.write("IHDR", 12, "ascii"); + bytes.writeUInt32BE(1080, 16); + bytes.writeUInt32BE(2400, 20); + return bytes.toString("base64"); +})(); + +/** + * compare_ui_frames answers in the py action surface's own shape (G39 route A): + * `json.dumps({"changed": changed})` and nothing else -- adb_server.py emits exactly that one + * key, so a stub carrying an extra field would be inventing contract (changelogs #51). + * MOCK_EFFECT picks the verdict so the offline drill can exercise both branches and the + * refusal path (`Error:` prefix) without a device. + */ +const effectVerdict = () => { + const mode = process.env.MOCK_EFFECT ?? "changed"; + if (mode === "error") return "Error: mock refusing to compare frames"; + return JSON.stringify({ changed: mode !== "unchanged" }); +}; + +function mockText(toolName, toolArgs) { + if (toolName === "take_screenshot") return SCREENSHOT_PNG; + if (toolName === "compare_ui_frames") return effectVerdict(); + if (toolName === "list_devices") return listDevicesPayload(toolArgs?.include_lock_state === true); + return "Success"; +} + +function handle(message) { + if (message === undefined || typeof message !== "object") return undefined; + const { id, method, params } = message; + if (id === undefined) return undefined; // notification + // Every response carries the JSON-RPC version marker: the MCP SDK parses + // each line with JSONRPCMessageSchema (jsonrpc: literal "2.0") and only + // skips SyntaxErrors — a valid-JSON reply without this field throws out of + // the reader and the pending request dies as "SdkError: Request timed out" + // (G56). The real py server gets the field from the official SDK. + const frame = { jsonrpc: "2.0" }; + switch (method) { + case "initialize": + return { + ...frame, + id, + result: { + protocolVersion: params?.protocolVersion ?? "2025-06-18", + capabilities: { tools: {} }, + serverInfo: { name: "mock-action-server", version: "0.1.0" }, + }, + }; + case "tools/list": + return { + ...frame, + id, + result: { + tools: TOOLS.map((name) => ({ + name, + description: `mock ${name}`, + inputSchema: { type: "object", properties: {} }, + })), + }, + }; + case "tools/call": { + const name = params?.name ?? "unknown"; + console.error(`[mock] tools/call ${name} ${JSON.stringify(params?.arguments ?? {})}`); + const delay = Number(process.env.MOCK_DELAY_MS ?? 0); + if (delay <= 0) { + return { ...frame, id, result: { content: [{ type: "text", text: mockText(name, params?.arguments) }] } }; + } + // Async reply after the delay: exercises gate blocking and concurrent + // turns (S10-③) without a real device. + setTimeout(() => { + write({ ...frame, id, result: { content: [{ type: "text", text: mockText(name, params?.arguments) }] } }); + }, delay); + return undefined; + } + case "ping": + return { ...frame, id, result: {} }; + default: + return { ...frame, id, error: { code: -32601, message: `method not found: ${method}` } }; + } +} + +const write = (payload) => process.stdout.write(`${JSON.stringify(payload)}\n`); +const lineReader = createInterface({ input: process.stdin }); +lineReader.on("line", (line) => { + const trimmed = line.trim(); + if (trimmed.length === 0) return; + try { + const response = handle(JSON.parse(trimmed)); + if (response !== undefined) write(response); + } catch (error) { + console.error(`[mock] bad line: ${error.message}`); + } +}); +console.error("[mock] action server ready"); diff --git a/plugins/dsh-autogamer/scripts/spike-mcp.mjs b/plugins/dsh-autogamer/scripts/spike-mcp.mjs new file mode 100644 index 000000000..ceb9133e5 --- /dev/null +++ b/plugins/dsh-autogamer/scripts/spike-mcp.mjs @@ -0,0 +1,99 @@ +/** + * P0 MCP direct-connect spike / G5 Tier 2 action-surface generator. + * Run from the repo root so `uv run` resolves the project env: + * ARTEMIS_MOCK_DRIVER=1 node plugins/dsh-autogamer/scripts/spike-mcp.mjs + * ARTEMIS_MOCK_DRIVER=1 node plugins/dsh-autogamer/scripts/spike-mcp.mjs --write + * + * Verifies: the pipe (list + call round-trip), the real action-tool surface, + * and result normalization. Actions may fail with adb-down messages when no + * device/server is available — that still exercises the environment path. + * + * This is the ONLY place the expected py tool surface is derived: from the + * plugin's own `ACTION_TO_PY_TOOL` mapping against the live server. The + * fixture used to be a fourth hand-kept copy of the same claim. + */ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { ActionClient } from "../dist/actionClient.js"; +import { unwrapText } from "../dist/mcpText.js"; +import { ACTION_TO_PY_TOOL } from "../dist/tools/runDeviceAction.js"; + +const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const FIXTURE = join(PLUGIN_ROOT, "tests/fixtures/py-action-tools.json"); + +/** Every py tool the plugin's router claims to reach. */ +export const mappedPyTools = () => [...new Set(Object.values(ACTION_TO_PY_TOOL).filter((t) => t !== null))].sort(); + +export function actionClient() { + const command = (process.env.SPIKE_COMMAND ?? "uv run artemis mcp --type adb").split(" "); + return new ActionClient({ + command, + env: { ...process.env, ARTEMIS_MOCK_DRIVER: process.env.ARTEMIS_MOCK_DRIVER ?? "1" }, + callTimeoutMs: 120_000, + }); +} + +/** Live tool list from a real action server, with the mapping checked against it. */ +export async function liveActionSurface(client = actionClient()) { + const tools = await client.listTools(); + return { client, tools: [...tools].sort() }; +} + +const localDate = () => new Date(Date.now() - new Date().getTimezoneOffset() * 60_000).toISOString().slice(0, 10); + +export function buildFixture(tools) { + return { + captured: localDate(), + source: "live MCP stdio listing of `uv run artemis mcp --type adb` (node plugins/dsh-autogamer/scripts/spike-mcp.mjs --write)", + pyActionServerTools: tools, + notes: [ + "2026-10-11: compare_ui_frames added (G39 route A) - the read-only effect verdict the plugin router calls, delegating to artemis/utils/image_diff.check_ui_change.", + "Contract quirks locked by live capture (see docs/migration/inventory/03 + dsh-verification §一):", + "legacy PIXEL coordinate contract — 0-1000 normalization happens in this plugin (src/coord.ts)", + "results are plain strings: Success / Failed / Error: ... / Error executing tool ...", + "real flag is `--type adb` (not --server); stdio transport default", + "generated from the live server; the expected surface is ACTION_TO_PY_TOOL, no longer a copy kept here", + ], + }; +} + +const isCli = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +async function main() { + const client = actionClient(); + try { + const expected = mappedPyTools(); + const { tools } = await liveActionSurface(client); + console.log(`[spike] tools (${tools.length}):`, tools.join(", ")); + const missing = expected.filter((name) => !tools.includes(name)); + if (missing.length > 0) throw new Error(`mapping names a tool the server does not expose: ${missing.join(", ")}`); + + if (process.argv.includes("--write")) { + writeFileSync(FIXTURE, `${JSON.stringify(buildFixture(tools), null, 2)}\n`); + console.log(`[spike] wrote tests/fixtures/py-action-tools.json (${tools.length} tools)`); + } else if (existsSync(FIXTURE)) { + const recorded = JSON.parse(readFileSync(FIXTURE, "utf8")).pyActionServerTools ?? []; + const drifted = tools.filter((t) => !recorded.includes(t)).concat(recorded.filter((t) => !tools.includes(t))); + if (drifted.length > 0) { + console.error(`[spike] fixture is out of date with the live server: ${drifted.join(", ")} — re-run with --write`); + process.exitCode = 1; + } + } + + for (const [name, args] of [["take_screenshot", {}], ["get_ui_hierarchy", {}]]) { + const text = unwrapText(await client.callTool(name, args)); + if (text.startsWith("Error:")) { + console.log(`[spike] ${name} → environment error (expected when adb/device is down):`); + console.log(" ", text.slice(0, 140)); + } else { + console.log(`[spike] ${name} → OK:`, text.slice(0, 100), text.length > 100 ? "…" : ""); + } + } + console.log("[spike] OK — pipe, tool surface, and normalization verified"); + } finally { + await client.dispose(); + } +} + +if (isCli) await main(); diff --git a/plugins/dsh-autogamer/skills/autogamer-flash/SKILL.md b/plugins/dsh-autogamer/skills/autogamer-flash/SKILL.md new file mode 100644 index 000000000..b970bb950 --- /dev/null +++ b/plugins/dsh-autogamer/skills/autogamer-flash/SKILL.md @@ -0,0 +1,52 @@ +--- +name: autogamer-flash +description: Single-layer device execution policy — when to act directly, when verification escalates, and how to report the outcome. +--- + +# AutoGamer device execution + +You drive one Android device through the `run_device_action` tool. The tool +enforces safety and verification itself; your job is intent, targeting, and +honest reporting. + +## Acting + +1. One action per `run_device_action` call, with a one-sentence `reason`. +2. Prefer the `#N` handle from the latest `get_ui_hierarchy` output as + `target: {index: N}`; use `[x,y]` coordinates (0-1000) only when no element matches. +3. Handles are only valid for the dump they came from: after any action that + changes the screen, call `get_ui_hierarchy` again before using `#N`. +4. If an action is refused, do not repeat it verbatim: re-observe, adjust the + target, or change approach. The tool escalates to verification on its own. +5. Keep bursts tight: navigation and typing need no commentary between calls. + +## Planning (complex tasks only) + +For multi-step goals, state a short numbered plan first and follow it. For +single-screen tasks, skip the plan and act. Never rewrite an existing plan +mid-task; append progress notes instead. Break the plan into milestones with +one `verify` item each: a milestone is done only when its verify item holds on +a screenshot you just took. An `incident` field in a result means three +straight failures: stop, audit the attempts so far against the plan, then +change the plan or report blocked. + +## Strictness + +The deployment runs one of four strictness levels (plugin `strictness` config, +default `final`): +- `off` — every action runs once; no retries, no incidents. Only for + single deterministic taps you can already see. +- `final` — full verification ladder; audit yourself only at the end + (see Reporting). +- `checkpoints` — full ladder; each `incident` opens a milestone audit. +- `strict` — full ladder; self-check every mutating action result and audit + every milestone, not just the exit. + +## Reporting + +Call `report_task_status` exactly once at the end: +- `completed` — the goal is met (mention assertion failures via `tests_failed` + if any checks you performed did not pass). +- `blocked` — the goal is not met and no path forward remains. Say what state + the device is in and what blocked you. Never report `completed` for a + partially done goal. diff --git a/plugins/dsh-autogamer/spike/headless-spike.patch.yml b/plugins/dsh-autogamer/spike/headless-spike.patch.yml new file mode 100644 index 000000000..1be27b96e --- /dev/null +++ b/plugins/dsh-autogamer/spike/headless-spike.patch.yml @@ -0,0 +1,44 @@ +# S10 spike overlay for the headless profile (applies last via --patch). +# Root-level inserts activate but are NOT visible to the headless agent — +# its tool scope is the dsh-base group. So the registry/preset/plugin rows +# are inserted INTO that group; the root copy of the plugin is disabled to +# avoid duplicate ids. +- id: llm-pi-ai + name: "@deepseek-ai/dsh-llm-pi-ai" + config: + providers: + local: + apiKeyEnv: LOCAL_API_KEY + api: openai-completions + baseURL: http://127.0.0.1:1234/v1 + models: + - id: qwen3.6-35b-a3b-mtp + +- id: agent-default-model + name: "@deepseek-ai/dsh-agent-default-model" + config: + provider: local + model: qwen3.6-35b-a3b-mtp + +- id: dsh-autogamer + disabled: true + +- id: "@deepseek-ai/dsh-base" + insert: + - id: agent-preset-registry + name: "@deepseek-ai/dsh-agent-preset-registry" + config: + default: autogamer + - id: preset-autogamer + name: "@deepseek-ai/dsh-agent-preset" + config: + id: autogamer + plugins: + - name: dsh-autogamer-inner + - name: "@deepseek-ai/dsh-tool-jobs" + - id: dsh-autogamer-inner + name: dsh-autogamer + config: + actionServerCommand: + - node + - D:/DEV/autoGamer/plugins/dsh-autogamer/scripts/mock-action-server.mjs diff --git a/plugins/dsh-autogamer/spike/run-headless.cmd b/plugins/dsh-autogamer/spike/run-headless.cmd new file mode 100644 index 000000000..d15060e93 --- /dev/null +++ b/plugins/dsh-autogamer/spike/run-headless.cmd @@ -0,0 +1,3 @@ +@echo off +rem S10 spike shim: forwards to dsh.cmd with safe quoting (msys positional quirk). +call "D:\DeepSeek Harness\resources\runtime\cli\bin\dsh.cmd" --profile headless --patch "D:\DEV\autoGamer\plugins\dsh-autogamer\spike\headless-spike.patch.yml" --json %* diff --git a/plugins/dsh-autogamer/spike/web-live.patch.yml b/plugins/dsh-autogamer/spike/web-live.patch.yml new file mode 100644 index 000000000..c5f36269c --- /dev/null +++ b/plugins/dsh-autogamer/spike/web-live.patch.yml @@ -0,0 +1,121 @@ +# Live web-profile overlay for the S10 spike (applies last via --patch). +# +# Preset shape here MATCHES cordis.patch.yml (the shipped delivery form): +# the AutoGamer preset carries only our own device tool row plus the host's +# tool-jobs row. The wide "standard + inner" list that used to sit here was a +# bisect rig for G30; G30 is closed, so measurements taken on that shape would +# not describe what we ship (gap tracked as G37). +# +# The root bundle copy stays disabled so exactly one instance can register. +# +# LLM rig: baseURL 1235 = scripts/dump-proxy.mjs -> LM Studio on 1234, so the +# wire shows exactly which tools the host sends. Set it back to 1234 to run +# without the logging proxy (proxy must be listening, or every turn dies on +# ECONNREFUSED). +- id: llm-pi-ai + name: "@deepseek-ai/dsh-llm-pi-ai" + config: + providers: + local: + apiKeyEnv: LOCAL_API_KEY + api: openai-completions + baseURL: http://127.0.0.1:1235/v1 + models: + - id: qwen3.6-35b-a3b-mtp + # Declared input modalities. The schema default is [], which the host + # reads as NEGATIVE capability: an image tool result came back to the + # model as "[image omitted because this model accepts text only]" + # even though the route really does take pixels (verified directly + # against LM Studio). G36. + input: + - text + - image + +- id: agent-default-model + name: "@deepseek-ai/dsh-agent-default-model" + config: + provider: local + model: qwen3.6-35b-a3b-mtp + +- id: agent-preset-registry + name: "@deepseek-ai/dsh-agent-preset-registry" + config: + default: autogamer + +- id: dsh-autogamer + disabled: true + +- id: preset-autogamer + name: "@deepseek-ai/dsh-agent-preset" + config: + id: autogamer + name: AutoGamer + description: Single-layer device execution agent (flash/pro merged, S11 router). + order: 50 + plugins: + # Same persona row as cordis.patch.yml (G45: without it the model never + # receives the device contract at all). Keep the two presets in sync. + - id: persona + name: "@deepseek-ai/dsh-persona" + config: + prefix: | + You are AutoGamer, an Android device operator. You drive one device + through the run_device_action tool, which enforces the safety gate + and checks that each action visibly changed the screen; you supply + intent, targeting and the final report. + + 1. One action per call, with a one-sentence reason. No narration + between navigation or typing actions. + 2. The turn ends only through report_task_status: call it exactly + once, as soon as the goal is met or no path forward remains. + After that call, do not act on the device again, do not wait, + do not report again. + 3. A handle such as #7 is valid only for the get_ui_hierarchy dump + it came from. After any action that changes the screen, dump + again before using a handle. Use normalized [x,y] coordinates + only when no element matches. + 4. Never repeat a refused action verbatim: re-observe, change the + target or the approach. + 5. Judge the screen only from screenshots; never claim a result + you have not seen. + 6. Audit like a checker at every milestone and at the end: + restate the goal, list what changed on screen (each action + reports effect plus a category), and record assertion + failures in report_task_status tests_failed. An incident + field means three straight failures: stop, audit the + attempts so far, then change the plan or report blocked. + Never audit from memory — quote the action results. + - id: dsh-autogamer-inner + name: dsh-autogamer + config: + actionServerCommand: + - D:/DEV/autoGamer/.venv/Scripts/python.exe + - -m + - artemis.interfaces.cli.main + - mcp + - --type + - adb + actionServerEnv: + ADB_DEVICE_SERIAL: "emulator-5554" + - name: "@deepseek-ai/dsh-tool-jobs" + # Same tool-cordis row as cordis.patch.yml (TODO §零 Step 11; tests/ + # deliveryShape.spec.ts compares the two presets' contributed plugin names). + - id: tool-cordis + name: "@deepseek-ai/dsh-tool-cordis" + # Same skill wiring as cordis.patch.yml (tests/deliveryShape.spec.ts compares + # the two presets' contributed plugin names). + - id: skill-filesystem + name: "@deepseek-ai/dsh-skill-filesystem" + config: + customSkillDirs: + - !!js process.getBuiltinModule('node:path').join(process.getBuiltinModule('node:path').dirname(process.getBuiltinModule('node:module').createRequire(baseUrl).resolve('dsh-autogamer/package.json')), 'skills') + - id: tool-skill + name: "@deepseek-ai/dsh-tool-skill" + # D17 row, kept identical to cordis.patch.yml (tests/deliveryShape.spec.ts + # compares the two presets' contributed plugin names). The condition tracks + # the plugin-manager service, which is itself off without a profileContext: + # `disabled: false` would ask for an absent service and D16 rejects the + # whole preset mount. + - id: tool-plugin-manager + name: "@deepseek-ai/dsh-plugin-manager/tools" + disabled: !!js "!ctx.get('profileContext')" diff --git a/plugins/dsh-autogamer/spike/wire-tools.py b/plugins/dsh-autogamer/spike/wire-tools.py new file mode 100644 index 000000000..143d56206 --- /dev/null +++ b/plugins/dsh-autogamer/spike/wire-tools.py @@ -0,0 +1,67 @@ +"""Pair-compare the captured LLM wire: does the host put tools on the request? + +Block header shape in scripts/proxy-dump.log is exactly: + ===== ===== +Splitting on "===== " mis-bounds every block because that header line also ends +with " =====", so parse line-anchored. Run: python -X utf8 spike/wire-tools.py +""" + +import json +import re + +LOG = r"D:/DEV/autoGamer/plugins/dsh-autogamer/scripts/proxy-dump.log" +HEADER = re.compile(r"^===== (\S+) (\w+) (\S+) =====\s*$") + +with open(LOG, encoding="utf-8", errors="replace") as fh: + lines = fh.read().split("\n") + +blocks = [] +current = None +for ln in lines: + m = HEADER.match(ln) + if m: + if current: + blocks.append(current) + current = {"ts": m.group(1), "method": m.group(2), "path": m.group(3), "body": []} + elif current is not None: + current["body"].append(ln) +if current: + blocks.append(current) + +print(f"blocks parsed: {len(blocks)}") +paths: dict[str, int] = {} +for b in blocks: + paths[b["path"]] = paths.get(b["path"], 0) + 1 +print(f"paths: {paths}") + +for b in blocks: + if "chat/completions" not in b["path"]: + continue + text = "\n".join(b["body"]) + resp = text.find("----- response -----") + req_text = text[:resp] if resp > 0 else text + start = req_text.find("{") + if start < 0: + print(f"{b['ts']} NO REQUEST BODY") + continue + try: + payload = json.loads(req_text[start:].strip()) + except json.JSONDecodeError as err: + print(f"{b['ts']} UNPARSEABLE request body: {err}") + continue + tools = payload.get("tools") + names = [] + if tools: + for t in tools: + fn = t.get("function") if isinstance(t, dict) else None + names.append((fn or {}).get("name") or (t.get("name") if isinstance(t, dict) else "?")) + msg_count = len(payload.get("messages", [])) + tool_count = len(names) if tools else 0 + print( + f"{b['ts']} model={payload.get('model')!s:<24} msgs={msg_count:<3} tools={tool_count:<3} " + f"payload-keys={sorted(payload.keys())}" + ) + if names: + print(f" ALL: {', '.join(sorted(n for n in names if n))}") + if "tool_choice" in payload: + print(f" tool_choice={payload['tool_choice']!r}") diff --git a/plugins/dsh-autogamer/src/actionClient.ts b/plugins/dsh-autogamer/src/actionClient.ts new file mode 100644 index 000000000..9851edf4c --- /dev/null +++ b/plugins/dsh-autogamer/src/actionClient.ts @@ -0,0 +1,87 @@ +/** + * MCP stdio client wrapper around the upstream py action server + * (`artemis mcp --server adb`). One lazily-started session per plugin + * process; a dead transport is replaced on the next call (the py side + * tolerates re-entry — verified: ActionSession rebuilds after transport + * death). Deliberately NOT `dsh-mcp-client`: its registered tools would + * bypass the device gate (upstream-rethink §三.1). + */ +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; + +export interface ActionClientOptions { + /** Full argv of the py action server, e.g. ["python","-m","artemis.interfaces.cli.main","mcp","--server","adb"]. */ + command: string[]; + env?: Record; + callTimeoutMs: number; +} + +export class ActionClient { + private client?: Client; + private starting?: Promise; + + constructor(private readonly options: ActionClientOptions) {} + + async callTool(name: string, args: Record, signal?: AbortSignal): Promise { + // A timed-out request cannot be cancelled in place, so on timeout we tear + // the session down and let the next call respawn it (a hung response would + // otherwise poison the transport — the mcp-1.29 lesson, tool side). + const client = await this.ensure(); + const timeout = new Promise((_, reject) => { + const timer = setTimeout(() => { + void this.dispose(); + reject(new Error(`action server did not answer ${name} within ${this.options.callTimeoutMs}ms`)); + }, this.options.callTimeoutMs); + timer.unref?.(); + }); + // G10: forward the host cancellation into the SDK request so an abort + // settles promptly instead of waiting out the response or our timeout. + const attempt = signal + ? client.callTool({ name, arguments: args }, { signal }) + : client.callTool({ name, arguments: args }); + const result = await Promise.race([attempt, timeout]); + return (result as { structuredContent?: unknown }).structuredContent ?? result; + } + + /** Tools the server advertises; used by the P0 spike sanity check. */ + async listTools(): Promise { + const client = await this.ensure(); + const listed = await client.listTools(); + return (listed.tools ?? []).map((tool) => tool.name); + } + + async dispose(): Promise { + const client = this.client; + this.client = undefined; + this.starting = undefined; + await client?.close().catch(() => undefined); + } + + private ensure(): Promise { + if (this.client) return Promise.resolve(this.client); + this.starting ??= this.start(); + return this.starting; + } + + private async start(): Promise { + const transport = new StdioClientTransport({ + command: this.options.command[0] ?? "python", + args: this.options.command.slice(1), + env: this.options.env, + }); + const client = new Client({ name: "dsh-autogamer", version: "0.1.0" }); + try { + await client.connect(transport); + } catch (error) { + // A failed connect must not poison later calls: ensure() caches this + // promise, so without resetting it every subsequent call would rethrow + // the same stale connection error instead of respawning the server. + this.starting = undefined; + await transport.close().catch(() => undefined); + throw error; + } + this.client = client; + this.starting = undefined; + return client; + } +} diff --git a/plugins/dsh-autogamer/src/artemis/adapter.ts b/plugins/dsh-autogamer/src/artemis/adapter.ts new file mode 100644 index 000000000..f761a555e --- /dev/null +++ b/plugins/dsh-autogamer/src/artemis/adapter.ts @@ -0,0 +1,31 @@ +/** + * Backend selection for the external ARTEMIS provider (Phase 2). + * + * The scheme draws an adapter choosing between MCP and CLI backends at + * runtime. Phase 0 cut the CLI backend (the old `run` has no JSON contract), + * so exactly one backend kind remains and this adapter is deliberately a + * factory: it centralises construction, documents the selection rule a second + * backend would plug into, and refuses early with an actionable error when no + * provider is configured — instead of letting the first tool die on spawn. + */ +import { McpArtemisBackend } from "./mcpBackend.js"; +import type { ArtemisBackend } from "./types.js"; + +export interface AdapterOptions { + command: string[]; + cwd?: string; + callTimeoutMs: number; +} + +export function createArtemisBackend(options: AdapterOptions): ArtemisBackend { + // No refusal here on an unconfigured provider: every tool runs the + // preflight gate first, and the repo check refuses faster with the same + // fix. Throwing in the factory would break apply() itself on a fresh + // install, turning "not configured yet" into a mount failure instead of a + // per-tool refusal. + return new McpArtemisBackend({ + command: options.command, + cwd: options.cwd, + callTimeoutMs: options.callTimeoutMs, + }); +} diff --git a/plugins/dsh-autogamer/src/artemis/mcpBackend.ts b/plugins/dsh-autogamer/src/artemis/mcpBackend.ts new file mode 100644 index 000000000..3dae22388 --- /dev/null +++ b/plugins/dsh-autogamer/src/artemis/mcpBackend.ts @@ -0,0 +1,352 @@ +/** + * MCP stdio backend for the external ARTEMIS provider (Phase 2). + * + * Spawns the provider's MCP server (`python -m mcp_server` under the + * configured repo path by default — Phase 0 read the command off our own + * history; provider installs vary, so the full argv stays configurable) and + * talks to it with @modelcontextprotocol/client, the same SDK shape as the + * in-repo action client. The session is lazy: constructing the backend never + * spawns anything, so apply() stays spawn-free; the first tool call connects. + * A dead transport is dropped and rebuilt on the next call. + * + * Request/response mapping is locked against the fork's old face + * (`pre-contraction:mcp_server/tools/*.py`), NOT the scheme text — see + * `types.ts` for the deviation table. Payload rules: + * - dict answers arrive stringified (or structured) → parsed to objects; + * - device state arrives as PLAIN TEXT (`file://…`, a hierarchy dump, or + * `Error: …`) because that tool returns `str`, not a dict. + * + * Error contract (matches the plugin's classifier vocabulary): + * - transport/connect/timeout failures → `environment:` (the provider or the + * machine is at fault, safe to report and retry later); + * - a payload outside the documented shape → `internal:` (our mapping or + * provider drift — not the device, do not retry the same call). + */ +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import type { + ArtemisBackend, + DeviceState, + DeviceStateArgs, + DiagnoseArgs, + Diagnosis, + InspectArgs, + ManageArgs, + TaskInfo, + TaskStartArgs, + TaskStatus, + TaskStep, + TraceView, +} from "./types.js"; + +export interface McpBackendOptions { + command: string[]; + cwd?: string; + callTimeoutMs: number; +} + +/** Minimal transport face, so tests can inject a fake without the SDK. */ +export interface McpConnection { + callTool( + params: { name: string; arguments: Record }, + options?: { signal?: AbortSignal }, + ): Promise; + close(): Promise; +} + +async function openConnection(options: McpBackendOptions): Promise { + const transport = new StdioClientTransport({ + command: options.command[0] ?? "python", + args: options.command.slice(1), + cwd: options.cwd, + }); + const client = new Client({ name: "dsh-autogamer-artemis", version: "0.1.0" }); + try { + await client.connect(transport); + } catch (error) { + await transport.close().catch(() => undefined); + throw error; + } + return client as unknown as McpConnection; +} + +function readString(record: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.length > 0) return value; + } + return undefined; +} + +function readBoolean(record: Record, keys: string[]): boolean | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "boolean") return value; + } + return undefined; +} + +function readNumber(record: Record, keys: string[]): number | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + } + return undefined; +} + +function readStringArray(record: Record, keys: string[]): string[] { + for (const key of keys) { + const value = record[key]; + if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string"); + } + return []; +} + +/** + * The provider's dict tools answer with a string payload (verified on the old + * face: every tool returns `str`). Prefer a structured result when the SDK + * hands one over, else parse the first text block as JSON. + */ +function payloadObject(answer: unknown, tool: string): Record { + const record = (answer ?? {}) as { + structuredContent?: unknown; + content?: Array<{ type?: unknown; text?: unknown }>; + }; + if (record.structuredContent !== undefined) { + if (typeof record.structuredContent === "object" && record.structuredContent !== null) { + return record.structuredContent as Record; + } + throw new Error( + `internal: ${tool} answered with a non-object structured result (provider drift; do not retry the same call)`, + ); + } + const text = extractText(answer); + if (text === undefined) { + throw new Error( + `internal: ${tool} answered with no text payload (provider drift; do not retry the same call)`, + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(text) as unknown; + } catch { + throw new Error( + `internal: ${tool} answered with non-JSON text (provider drift; do not retry the same call)`, + ); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error( + `internal: ${tool} answered with a non-object JSON payload (provider drift; do not retry the same call)`, + ); + } + return parsed as Record; +} + +/** First text block of an SDK result, or a string structured result. */ +function extractText(answer: unknown): string | undefined { + const record = (answer ?? {}) as { + structuredContent?: unknown; + content?: Array<{ type?: unknown; text?: unknown }>; + }; + if (typeof record.structuredContent === "string") return record.structuredContent; + if (!Array.isArray(record.content)) return undefined; + const block = record.content.find((entry) => typeof entry?.text === "string"); + return typeof block?.text === "string" ? block.text : undefined; +} + +export class McpArtemisBackend implements ArtemisBackend { + private connection?: McpConnection; + private starting?: Promise; + + constructor( + private readonly options: McpBackendOptions, + private readonly connect: (options: McpBackendOptions) => Promise = openConnection, + ) {} + + /** + * The old face takes a REQUIRED view_type and answers plain text: a + * `file://…` screenshot path, a hierarchy dump, or an `Error: …` refusal. + */ + async getDeviceState(args: DeviceStateArgs, signal?: AbortSignal): Promise { + const params: Record = { + view_type: args.includeScreenshot === true ? "screenshot" : "hierarchy", + }; + if (args.deviceSerial !== undefined) params.device_serial = args.deviceSerial; + let answer: unknown; + try { + answer = await this.call("mobile_get_device_state", params, signal); + } catch (error) { + throw new Error( + `environment: ARTEMIS provider did not answer mobile_get_device_state (${(error as Error)?.message ?? error})`, + ); + } + const text = extractText(answer); + if (text === undefined) { + throw new Error( + "internal: mobile_get_device_state answered with no text payload (provider drift; do not retry the same call)", + ); + } + if (text.startsWith("Error:")) { + throw new Error(`environment: provider refused device state (${text.slice(0, 300)})`); + } + if (args.includeScreenshot === true) { + return { deviceSerial: args.deviceSerial ?? "", connected: true, screenshotPath: text }; + } + return { deviceSerial: args.deviceSerial ?? "", connected: true, screenSummary: text }; + } + + async startTask(args: TaskStartArgs, signal?: AbortSignal): Promise { + const params: Record = { + task_desc: args.instruction, + model: args.profile, + }; + if (args.deviceSerial !== undefined) params.device_serial = args.deviceSerial; + const payload = await this.dictCall("mobile_run_task", params, signal); + const taskId = readString(payload, ["trace_id", "task_id"]); + if (taskId === undefined) { + throw new Error( + "internal: mobile_run_task answered with no trace_id (provider drift; do not retry the same call)", + ); + } + return { + taskId, + profile: args.profile, + ...(args.deviceSerial !== undefined ? { deviceSerial: args.deviceSerial } : {}), + startedAt: new Date().toISOString(), + }; + } + + async manageTask(args: ManageArgs, signal?: AbortSignal): Promise { + const params: Record = { action: args.action, trace_id: args.taskId }; + if (args.instruction !== undefined) params.instruction = args.instruction; + const payload = await this.dictCall("mobile_manage_task", params, signal); + const steps = Array.isArray(payload.steps) + ? (payload.steps as Record[]) + : Array.isArray(payload.recent_actions) + ? (payload.recent_actions as Record[]) + : []; + return { + taskId: args.taskId, + state: readString(payload, ["state", "status"]), + currentStep: readNumber(payload, ["current_step", "step", "currentStep"]), + totalSteps: readNumber(payload, ["total_steps", "totalSteps"]), + lastAction: readString(payload, ["last_action", "lastAction"]), + recentSteps: steps.map(mapStep), + elapsedSeconds: readNumber(payload, ["elapsed_seconds", "elapsedSeconds", "elapsed"]), + finalSummary: readString(payload, ["final_summary", "finalSummary", "summary"]), + }; + } + + async inspectTrace(args: InspectArgs, signal?: AbortSignal): Promise { + const payload = await this.dictCall("mobile_inspect_trace", { action: "view_summary", trace_id: args.traceId }, signal); + const raw = Array.isArray(payload.steps) ? (payload.steps as Record[]) : []; + return { + taskId: args.traceId, + finalStatus: readString(payload, ["final_status", "finalStatus", "status", "state"]), + totalSteps: readNumber(payload, ["total_steps", "totalSteps"]) ?? raw.length, + steps: raw.map(mapStep), + artifactPaths: readStringArray(payload, ["artifact_paths", "artifacts", "artifactPaths"]), + }; + } + + async diagnose(args: DiagnoseArgs, signal?: AbortSignal): Promise { + const params: Record = {}; + if (args.deviceSerial !== undefined) params.device_serial = args.deviceSerial; + const payload = await this.dictCall("mobile_diagnose", params, signal); + const crashes = Array.isArray(payload.recent_crashes) + ? (payload.recent_crashes as Record[]) + : []; + return { + deviceSerial: readString(payload, ["device_serial", "serial"]) ?? args.deviceSerial, + adbConnected: readBoolean(payload, ["adb_connected", "connected"]), + helperInstalled: readBoolean(payload, ["helper_installed"]), + helperVersion: readString(payload, ["helper_version", "helperVersion"]), + recentCrashes: crashes.map((entry) => ({ + package: readString(entry, ["package", "package_name"]), + timestamp: readString(entry, ["timestamp", "time"]), + stackExcerpt: readString(entry, ["stack_excerpt", "stack", "stackExcerpt"]), + })), + logcatPath: readString(payload, ["logcat_path", "logcatPath"]), + screenshotPath: readString(payload, ["screenshot_path", "screenshotPath"]), + recommendations: readStringArray(payload, ["recommendations", "fixes", "fix_steps"]), + }; + } + + async dispose(): Promise { + const connection = this.connection; + this.connection = undefined; + this.starting = undefined; + await connection?.close().catch(() => undefined); + } + + private async dictCall( + name: string, + params: Record, + signal?: AbortSignal, + ): Promise> { + let answer: unknown; + try { + answer = await this.call(name, params, signal); + } catch (error) { + if (error instanceof Error && error.message.startsWith("internal:")) throw error; + throw new Error(`environment: ARTEMIS provider did not answer ${name} (${(error as Error)?.message ?? error})`); + } + return payloadObject(answer, name); + } + + private async call( + name: string, + params: Record, + signal?: AbortSignal, + ): Promise { + const connection = await this.ensure(); + const timeout = new Promise((_, reject) => { + const timer = setTimeout(() => { + void this.dispose(); + reject(new Error(`ARTEMIS provider did not answer ${name} within ${this.options.callTimeoutMs}ms`)); + }, this.options.callTimeoutMs); + timer.unref?.(); + }); + return await Promise.race([ + connection.callTool({ name, arguments: params }, signal ? { signal } : undefined), + timeout, + ]); + } + + private ensure(): Promise { + if (this.connection) return Promise.resolve(this.connection); + this.starting ??= this.start(); + return this.starting; + } + + private async start(): Promise { + try { + const connection = await this.connect(this.options); + this.connection = connection; + return connection; + } finally { + this.starting = undefined; + } + } +} + +export function mapStep(raw: Record): TaskStep { + const step: TaskStep = {}; + const index = readNumber(raw, ["index", "step_number", "step", "n"]); + if (index !== undefined) step.index = index; + const action = readString(raw, ["action"]); + if (action !== undefined) step.action = action; + const reasoning = readString(raw, ["reasoning", "reason"]); + if (reasoning !== undefined) step.reasoning = reasoning; + const screenshot = readString(raw, ["screenshot_path", "screenshot"]); + if (screenshot !== undefined) step.screenshotPath = screenshot; + const timestamp = readString(raw, ["timestamp", "time"]); + if (timestamp !== undefined) step.timestamp = timestamp; + const duration = readNumber(raw, ["duration_ms", "durationMs", "duration"]); + if (duration !== undefined) step.durationMs = duration; + const status = readString(raw, ["status", "state"]); + if (status !== undefined) step.status = status; + const ok = readBoolean(raw, ["ok", "success"]); + if (ok !== undefined) step.ok = ok; + return step; +} diff --git a/plugins/dsh-autogamer/src/artemis/types.ts b/plugins/dsh-autogamer/src/artemis/types.ts new file mode 100644 index 000000000..75766221d --- /dev/null +++ b/plugins/dsh-autogamer/src/artemis/types.ts @@ -0,0 +1,115 @@ +/** + * External ARTEMIS backend surface (dsh-autogamer rectification, Phase 2). + * + * Request shapes below are locked against the fork's old top-level MCP face + * (`pre-contraction:mcp_server/tools/*.py`), not against the scheme text — + * the scheme's assumed backend params were wrong in detail: + * - run takes `task_desc`/`model` ("Flash"/"Pro") and answers `trace_id` + * (no timeout/max-steps params exist — accepted by our tool, not forwarded); + * - device state takes a REQUIRED `view_type` ("screenshot"|"hierarchy") and + * answers plain text (`file://…`, a hierarchy dump, or `Error: …`), NOT JSON; + * - manage actions are exactly status/stop/inject_instruction over `trace_id`; + * - inspect actions are view_summary/search/view_step_screenshots/view_step_details; + * - diagnose takes no logcat/screenshot flags (device_serial only). + * A live provider newer than the fork may differ — refine on live evidence. + */ + +export interface DeviceStateArgs { + deviceSerial?: string; + includeScreenshot?: boolean; +} + +export interface DeviceState { + deviceSerial: string; + connected: boolean; + currentPackage?: string; + currentActivity?: string; + screenSummary?: string; + screenshotPath?: string; + helperInstalled?: boolean; +} + +export interface TaskStartArgs { + instruction: string; + profile: "Flash" | "Pro"; + deviceSerial?: string; +} + +export interface TaskInfo { + taskId: string; + profile: string; + deviceSerial?: string; + startedAt: string; +} + +export type ManageAction = "status" | "stop" | "inject_instruction"; + +export interface ManageArgs { + taskId: string; + action: ManageAction; + instruction?: string; +} + +export interface TaskStep { + index?: number; + action?: string; + reasoning?: string; + screenshotPath?: string; + timestamp?: string; + durationMs?: number; + status?: string; + ok?: boolean; +} + +export interface TaskStatus { + taskId: string; + state?: string; + currentStep?: number; + totalSteps?: number; + lastAction?: string; + recentSteps?: TaskStep[]; + elapsedSeconds?: number; + finalSummary?: string; +} + +export interface InspectArgs { + traceId: string; +} + +export interface TraceView { + taskId: string; + finalStatus?: string; + totalSteps?: number; + steps: TaskStep[]; + artifactPaths: string[]; +} + +export interface DiagnoseArgs { + deviceSerial?: string; +} + +export interface CrashEntry { + package?: string; + timestamp?: string; + stackExcerpt?: string; +} + +export interface Diagnosis { + deviceSerial?: string; + adbConnected?: boolean; + helperInstalled?: boolean; + helperVersion?: string; + recentCrashes: CrashEntry[]; + logcatPath?: string; + screenshotPath?: string; + recommendations: string[]; +} + +export interface ArtemisBackend { + getDeviceState(args: DeviceStateArgs, signal?: AbortSignal): Promise; + startTask(args: TaskStartArgs, signal?: AbortSignal): Promise; + manageTask(args: ManageArgs, signal?: AbortSignal): Promise; + inspectTrace(args: InspectArgs, signal?: AbortSignal): Promise; + diagnose(args: DiagnoseArgs, signal?: AbortSignal): Promise; + dispose(): Promise; +} diff --git a/plugins/dsh-autogamer/src/breaker.ts b/plugins/dsh-autogamer/src/breaker.ts new file mode 100644 index 000000000..3a2fd9542 --- /dev/null +++ b/plugins/dsh-autogamer/src/breaker.ts @@ -0,0 +1,86 @@ +/** + * Device-dimension circuit breaker (S4): closed → open after + * `allowedFails` consecutive environment-level failures → half-open after + * `cooldownMs` → a single probe message decides. Task-level failures never + * count (upstream semantics preserved from the py queue service). + */ + +export type BreakerState = "closed" | "open" | "half-open"; + +export interface BreakerOptions { + allowedFails: number; + cooldownMs: number; + now?: () => number; +} + +interface KeyState { + envFailures: number; + openedAt?: number; + probeInFlight: boolean; +} + +export class DeviceBreaker { + private readonly states = new Map(); + private readonly allowedFails: number; + private readonly cooldownMs: number; + private readonly now: () => number; + + constructor(options: BreakerOptions) { + this.allowedFails = Math.max(1, options.allowedFails); + this.cooldownMs = Math.max(0, options.cooldownMs); + this.now = options.now ?? Date.now; + } + + stateOf(key: string): BreakerState { + const state = this.states.get(key); + if (!state || state.openedAt === undefined) return "closed"; + if (this.now() - state.openedAt >= this.cooldownMs) return "half-open"; + return "open"; + } + + /** + * Whether an action may proceed now. Admitting a half-open probe marks it + * in flight so only one probe passes per cooldown window. + */ + canAdmit(key: string): boolean { + const state = this.stateOf(key); + if (state === "closed") return true; + if (state === "open") return false; + const record = this.states.get(key); + if (record?.probeInFlight) return false; + if (record) record.probeInFlight = true; + return true; + } + + /** Env failure at probe time re-opens with a fresh cooldown. */ + recordEnvironmentFailure(key: string): void { + const record = this.ensure(key); + record.envFailures += 1; + if (record.envFailures >= this.allowedFails || record.openedAt !== undefined) { + record.openedAt = this.now(); + record.probeInFlight = false; + } + } + + recordTaskFailure(key: string): void { + // Task-level failures say nothing about the device; keep the breaker as is. + this.ensure(key); + } + + recordSuccess(key: string): void { + this.states.delete(key); + } + + reset(key: string): void { + this.states.delete(key); + } + + private ensure(key: string): KeyState { + let record = this.states.get(key); + if (!record) { + record = { envFailures: 0, probeInFlight: false }; + this.states.set(key, record); + } + return record; + } +} diff --git a/plugins/dsh-autogamer/src/classify.ts b/plugins/dsh-autogamer/src/classify.ts new file mode 100644 index 000000000..788fa8c16 --- /dev/null +++ b/plugins/dsh-autogamer/src/classify.ts @@ -0,0 +1,67 @@ +/** + * Tool-layer error classification (S4, R2' rewrite): the breaker counts + * environment-level failures only. In the tool world the signal is the + * ActionResult code / transport state, not DB row shapes (that was the + * py-worker-era rule, now gone with the direct-plugin route). + */ + +/** Mirror of the py ActionCode enum (artemis/mcp/action_types.py). */ +export type ActionCode = + | "OK" + | "INVALID_ARGS" + | "TARGET_NOT_FOUND" + | "DEVICE_ERROR" + | "PACKAGE_NOT_FOUND" + | "TIMEOUT" + | "UNSUPPORTED"; + +const TASK_LEVEL_CODES: ReadonlySet = new Set([ + "INVALID_ARGS", + "TARGET_NOT_FOUND", + "PACKAGE_NOT_FOUND", + "UNSUPPORTED", +]); + +const ENVIRONMENT_MARKERS = [ + "device", + "adb", + "helper", + "unavailable", + "offline", + "timeout", + "timed out", + "connection", + "refused", + "503", + "502", + "not authorized", + "unauthorized", +] as const; + +export interface ActionResultLike { + ok: boolean; + code?: ActionCode | string; + message?: string; +} + +export type FailureClass = "environment" | "task"; + +export function classifyActionFailure(result: ActionResultLike): FailureClass { + const code = result.code as ActionCode | undefined; + if (code && TASK_LEVEL_CODES.has(code)) return "task"; + if (code === "DEVICE_ERROR" || code === "TIMEOUT") return "environment"; + return classifyMessage(result.message ?? ""); +} + +export function classifyTransportError(error: unknown): FailureClass { + // Action server dead / never started / transport torn down: environment by + // definition — the device may be fine but we cannot reach the executor. + return "environment"; +} + +export function classifyMessage(message: string): FailureClass { + const lowered = message.toLowerCase(); + return ENVIRONMENT_MARKERS.some((marker) => lowered.includes(marker)) + ? "environment" + : "task"; +} diff --git a/plugins/dsh-autogamer/src/coord.ts b/plugins/dsh-autogamer/src/coord.ts new file mode 100644 index 000000000..9bc04a486 --- /dev/null +++ b/plugins/dsh-autogamer/src/coord.ts @@ -0,0 +1,125 @@ +/** + * Screen-dimension sourcing and the 0-1000 ↔ pixel conversion (G24). + * The upstream stdio action server keeps the legacy PIXEL contract; our + * tool boundary speaks 0-1000 normalized, so conversions live here. + * Dimensions come from a probe screenshot: PNG IHDR or JPEG SOF scan. + */ + +export interface ScreenSize { + width: number; + height: number; +} + +export function parseImageSize(bytes: Uint8Array): ScreenSize | undefined { + if (isPng(bytes)) return parsePngSize(bytes); + if (isJpeg(bytes)) return parseJpegSize(bytes); + return undefined; +} + +function byteAt(bytes: Uint8Array, index: number): number { + return bytes[index] ?? 0; +} + +function isPng(bytes: Uint8Array): boolean { + return ( + bytes.length > 24 && + byteAt(bytes, 0) === 0x89 && byteAt(bytes, 1) === 0x50 && byteAt(bytes, 2) === 0x4e && byteAt(bytes, 3) === 0x47 + ); +} + +function parsePngSize(bytes: Uint8Array): ScreenSize { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return { width: view.getUint32(16), height: view.getUint32(20) }; +} + +function isJpeg(bytes: Uint8Array): boolean { + return bytes.length > 4 && byteAt(bytes, 0) === 0xff && byteAt(bytes, 1) === 0xd8; +} + +/** Walk JPEG markers for the first SOFn segment (frame header carries dims). */ +function parseJpegSize(bytes: Uint8Array): ScreenSize | undefined { + let offset = 2; + while (offset + 9 < bytes.length) { + if (byteAt(bytes, offset) !== 0xff) { + offset += 1; + continue; + } + const marker = byteAt(bytes, offset + 1); + if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9)) { + offset += 2; + continue; + } + const length = (byteAt(bytes, offset + 2) << 8) | byteAt(bytes, offset + 3); + const isSof = + marker >= 0xc0 && + marker <= 0xcf && + marker !== 0xc4 && + marker !== 0xc8 && + marker !== 0xcc; + if (isSof) { + if (offset + 9 > bytes.length) return undefined; + const height = (byteAt(bytes, offset + 5) << 8) | byteAt(bytes, offset + 6); + const width = (byteAt(bytes, offset + 7) << 8) | byteAt(bytes, offset + 8); + return { width, height }; + } + offset += 2 + length; + } + return undefined; +} + +/** + * A declared `screenSize` is optional, but Schemastery resolves an unset tuple + * to a truthy `[undefined, undefined]` array rather than `undefined`, so + * presence has to be judged by the numbers — a truthiness check on the array + * itself always passes. + */ +export function configuredScreenSize(raw?: readonly unknown[]): ScreenSize | undefined { + const [width, height] = raw ?? []; + if (typeof width !== "number" || typeof height !== "number") return undefined; + if (!Number.isFinite(width) || !Number.isFinite(height)) return undefined; + if (width <= 0 || height <= 0) return undefined; + return { width, height }; +} + +/** Decode a base64 screenshot far enough to read its dimensions. */ +export function imageSizeFromBase64(base64: string): ScreenSize | undefined { + const head = atLeast(base64, 64 * 1024); + return parseImageSize(head); +} + +/** Full decode of a base64 screenshot (data-URL prefix tolerated). */ +export function decodeImageBase64(base64: string): Uint8Array { + const raw = stripDataUrl(base64); + return typeof Buffer === "undefined" ? binaryToBytes(atob(raw)) : new Uint8Array(Buffer.from(raw, "base64")); +} + +/** Media type the host attachment store will accept, sniffed from the header. */ +export function imageMediaTypeOf(bytes: Uint8Array): "image/png" | "image/jpeg" | undefined { + if (isPng(bytes)) return "image/png"; + if (isJpeg(bytes)) return "image/jpeg"; + return undefined; +} + +function stripDataUrl(base64: string): string { + return base64.startsWith("data:") ? base64.slice(base64.indexOf(",") + 1) : base64; +} + +function binaryToBytes(binary: string): Uint8Array { + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i); + return out; +} + +function atLeast(base64: string, bytes: number): Uint8Array { + // 4 base64 chars → 3 bytes; take enough head chars, strip data URLs. + const raw = stripDataUrl(base64); + const chars = Math.min(raw.length, Math.ceil((bytes / 3) * 4)); + return binaryToBytes(atob(raw.slice(0, chars))); +} + +export function toPixels( + normalized: number, + dimension: number, +): number { + return Math.max(0, Math.min(dimension - 1, Math.round((normalized / 1000) * dimension))); +} diff --git a/plugins/dsh-autogamer/src/devicePool.ts b/plugins/dsh-autogamer/src/devicePool.ts new file mode 100644 index 000000000..466c5bf63 --- /dev/null +++ b/plugins/dsh-autogamer/src/devicePool.ts @@ -0,0 +1,325 @@ +/** + * Device scheduling (S3/G3/G28) on top of the per-device FIFO gate. + * + * Two addressing modes, per the glossary: an explicit `device_serial` queues on that device's + * chain, while an `auto` request is served from the free pool and *parked on at most one device* + * (G28). The parking rule is the upstream lesson quoted in `runtime/device_lock.py:332` -- an + * unclaimed ticket used to be counted into every device's queue, so one waiting message blocked + * machines that were sitting idle. `inventory/02:236` lists exactly this as something a TS rewrite + * tends to "clean up" and must not. + * + * So a waiting ticket never holds a global slot and never blocks another device: `pump()` walks + * the arrival queue and admits every ticket whose own conditions are met right now, rather than + * stopping at the first unsatisfiable head. + */ +import type { GateHandle } from "./gate.js"; +import { DeviceGate, GateAbortedError } from "./gate.js"; +import { deviceKeyOf } from "./tools/runDeviceAction.js"; + +export interface PoolOptions { + gate: DeviceGate; + defaultDeviceKey: string; + /** Roster for the auto pool. Empty (the default) means: behave exactly like the old single key. */ + devices?: string[]; + /** Concurrent device ceiling across the whole plugin; 0 (default) means unlimited. */ + maxConcurrentDevices?: number; + /** Devices refused by the breaker (S4 cooldown) are not eligible for auto assignment. */ + isCoolingDown?: (key: string) => boolean; + /** + * G64: asks the action surface which devices exist. `undefined` means "could not ask" and must + * never be read as "no devices". Only consulted when `devices` is empty. + */ + rosterSource?: () => Promise; + /** Throttle for `rosterSource`; the py pool caches its own snapshot underneath. */ + rosterTtlMs?: number; + logger?: { info: (message: string) => void; warn: (message: string) => void }; + /** Injectable clock so the throttle is testable without sleeping. */ + now?: () => number; +} + +export interface PoolLease extends GateHandle { + /** The device this call actually ran on -- explicit serial, or the auto assignment. */ + readonly key: string; +} + +/** + * One roster entry. `state` is adb's own device state ("device" / "offline" / "unauthorized", + * `device_pool.py:38`); it is `undefined` when the operator pinned the roster in config, because + * a name on its own carries no state. Unknown state must never read as "not ready" -- that is the + * fail-open half of `_explicit_serial_error` (`device_pool.py:370-373`). + */ +export interface PoolDevice { + readonly serial: string; + readonly state?: string; + readonly isBusy?: boolean; + /** + * Keyguard, G65. Three states matter and they are not interchangeable: + * `undefined` = never asked (a pinned config roster, or `include_lock_state` off) -> must not + * reject anything; `null` = asked and the probe could not tell -> **fail closed**, exactly like + * py's "Lock State Unknown" (adb_probe.py:452 -> tasks.py:174); `true` = locked. + */ + readonly isLocked?: boolean | null; +} + +interface Ticket { + readonly want: string | undefined; + readonly signal?: AbortSignal; + settle: (lease: PoolLease) => void; + fail: (error: unknown) => void; + queued: boolean; +} + +export class DevicePool { + private readonly gate: DeviceGate; + private readonly defaultDeviceKey: string; + private readonly devices: string[]; + private readonly maxConcurrent: number; + private readonly isCoolingDown: (key: string) => boolean; + private readonly queue: Ticket[] = []; + private readonly assigned = new Set(); + private admitted = 0; + private discovered: PoolDevice[] = []; + private enumerated = false; + private nextRefreshAt = 0; + private refreshing?: Promise; + private readonly rosterSource?: () => Promise; + private readonly rosterTtlMs: number; + private readonly logger: { info: (message: string) => void; warn: (message: string) => void }; + private readonly clock: () => number; + + constructor(options: PoolOptions) { + this.gate = options.gate; + this.defaultDeviceKey = options.defaultDeviceKey; + // A serial listed twice would be two pool entries for one device. + this.devices = [...new Set(options.devices ?? [])]; + this.maxConcurrent = options.maxConcurrentDevices ?? 0; + this.isCoolingDown = options.isCoolingDown ?? (() => false); + this.rosterSource = options.rosterSource; + this.rosterTtlMs = options.rosterTtlMs ?? 5_000; + this.logger = options.logger ?? { info: () => {}, warn: () => {} }; + this.clock = options.now ?? Date.now; + } + + /** Devices admitted right now (explicit and auto alike), for diagnostics and cap tests. */ + get busyDevices(): number { + return this.admitted; + } + + /** + * True when the auto pool has a roster; without one, auto keeps the historical single key. + * A configured roster wins over the discovered one. + */ + get hasPool(): boolean { + return this.poolDevices.length > 0; + } + + /** The roster the pool actually schedules against (G3/G64), as serials. */ + get poolDevices(): string[] { + return this.entries().map((device) => device.serial); + } + + /** + * Entries with state when we have it: a pinned config roster carries names only, and the + * readiness probe's answer (with state) wins when discovery is on. + */ + private entries(): PoolDevice[] { + if (this.devices.length > 0) { + return this.devices.map((serial) => ({ serial })); + } + return this.discovered; + } + + /** + * The explicit-serial half of the py submission gate, ported line for line from + * `_explicit_serial_error` (runtime/device_pool.py:366-385): reject *only* when enumeration was + * actually answered and the roster is non-empty. An unanswered question or an empty roster means + * "don't know", and knowing-not is fail-open there by design -- a transient adb blip must not + * become a hard reject. Returns the refusal reason, or undefined when admissible. + * Lock-screen verdicts are deliberately absent: adb state cannot carry them (G65). + */ + admissibleExplicit(serial: string): string | undefined { + if (!this.enumerated) return undefined; + const roster = this.entries(); + if (roster.length === 0) return undefined; + const found = roster.find((device) => device.serial === serial); + if (found === undefined) return `device ${serial} is not connected`; + if (found.state !== undefined && found.state !== "device") { + return `device ${serial} is attached but not ready (state: ${found.state})`; + } + // The half of py's second gate that this slice can see: locked, or asked-and-unknown. + // "Never asked" must not fall into either branch -- that would turn a pinned roster into a + // fleet of rejects. + if (found.isLocked === true) return `device ${serial} is locked`; + if (found.isLocked === null) return `device ${serial} lock state is unknown`; + return undefined; + } + + /** + * Refresh the discovered roster, throttled to one call per `rosterTtlMs`. + * Resolving to `undefined` means "could not ask" and keeps the previous roster: collapsing that + * into an empty list would make a transient adb blip look like an empty fleet -- the distinction + * `try_list_devices_async` exists to make upstream. + */ + async refreshRoster(): Promise { + if (!this.rosterSource) return; + // An operator-pinned roster makes discovery pointless: skip the RPC entirely. + if (this.devices.length > 0) return; + const now = this.clock(); + if (now < this.nextRefreshAt) return; + this.nextRefreshAt = now + this.rosterTtlMs; + if (this.refreshing) return this.refreshing; + this.refreshing = (async () => { + try { + const roster = await this.rosterSource?.(); + if (roster !== undefined) { + const seen = new Map(); + for (const device of roster) { + if (device.serial.length > 0) seen.set(device.serial, device); + } + this.discovered = [...seen.values()]; + this.enumerated = true; + } else { + this.enumerated = false; + } + } catch (error) { + this.logger.warn(`[pool] roster enumeration failed, keeping ${this.poolDevices.length} device(s): ${String(error)}`); + } finally { + this.refreshing = undefined; + } + })(); + return this.refreshing; + } + + /** + * The lock key used *before* acquiring, for breaker/guard decisions. An explicit serial is + * known immediately; an auto request without a roster still resolves to the default key, and an + * auto request with a roster resolves to the default key only as a fallback -- the guard must + * treat that as "any device" (see `refusesAuto`). + */ + resolveKey(args: Record): string { + const serial = this.explicitSerial(args); + if (serial !== undefined) return serial; + if (this.hasPool) return this.poolDevices[0] ?? this.defaultDeviceKey; + return this.defaultDeviceKey; + } + + /** Every pool device is in cooldown, so an auto call cannot be admitted anywhere. */ + refusesAuto(): boolean { + if (!this.hasPool) return this.isCoolingDown(this.defaultDeviceKey); + return this.poolDevices.every((device) => this.isCoolingDown(device)); + } + + /** Queue for the device an auto call would be parked on, or undefined when the pool decides. */ + pendingCount(key: string): number { + return this.queue.filter((ticket) => ticket.want === key).length; + } + + async acquire(args: Record, signal?: AbortSignal): Promise { + await this.refreshRoster(); + const ticket: Ticket = { + want: this.explicitSerial(args), + signal, + settle: () => {}, + fail: () => {}, + queued: true, + }; + const granted = new Promise((resolve, reject) => { + ticket.settle = resolve; + ticket.fail = reject; + }); + this.queue.push(ticket); + this.pump(); + + if (signal?.aborted) { + this.drop(ticket); + throw new GateAbortedError(ticket.want ?? this.defaultDeviceKey); + } + return await granted; + } + + /** + * Admit whatever can be admitted right now, in arrival order. Scanning past a ticket whose + * device is busy (instead of stopping at the head) is what keeps one unclaimed message from + * idling out the rest of the fleet. + */ + private pump(): void { + for (const ticket of [...this.queue]) { + if (!ticket.queued) continue; + const device = this.eligibleDevice(ticket.want); + if (device === undefined) continue; + ticket.queued = false; + this.admitted += 1; + this.assigned.add(device); + void this.gate + .acquire(device, ticket.signal) + .then((handle) => { + ticket.settle({ + key: device, + release: () => { + if (handle === undefined) return; + handle.release(); + this.admitted -= 1; + this.assigned.delete(device); + this.pump(); + }, + }); + }) + .catch((error) => { + // The waiter bailed (abort) or the chain rejected: give the slot back and let the + // next queued ticket take this device. + this.admitted -= 1; + this.assigned.delete(device); + this.pump(); + if (!(error instanceof GateAbortedError)) throw error; + }); + } + } + + /** The device this ticket may take now, or undefined when it must keep waiting. */ + private eligibleDevice(want: string | undefined): string | undefined { + if (this.maxConcurrent > 0 && this.admitted >= this.maxConcurrent) return undefined; + if (want !== undefined) { + if (this.assigned.has(want)) return undefined; + if (this.gate.busyCount(want) > 0) return undefined; + return want; + } + if (!this.hasPool) { + // No roster: auto keeps the historical shared key, so behaviour is byte-for-byte the old one. + return this.assigned.has(this.defaultDeviceKey) || this.gate.busyCount(this.defaultDeviceKey) > 0 + ? undefined + : this.defaultDeviceKey; + } + // Cooldown-skipping is the pool's own job for auto: the guard refuses a *known* cooling device, + // but an auto request has no device until this line picks one (S4/G33). + // The ladder mirrors device_pool.py:461-472 -- ready and idle first, then ready, then any + // attached device; the last rung is what keeps a single-device fleet working when adb reports + // it busy (py's own "fallback to any attached device"). + // A locked or unknown-lock device is skipped and the next one gets the turn -- py's probe + // does the same when the preferred serial is unusable (adb_probe.py:427-446). + const free = this.entries().filter( + (device) => + !this.assigned.has(device.serial) && + !this.isCoolingDown(device.serial) && + device.isLocked !== true && + device.isLocked !== null, + ); + const ready = free.filter((device) => device.state === undefined || device.state === "device"); + return ( + ready.find((device) => !device.isBusy && this.gate.busyCount(device.serial) === 0)?.serial ?? + ready.find((device) => this.gate.busyCount(device.serial) === 0)?.serial ?? + ready[0]?.serial ?? + free.find((device) => this.gate.busyCount(device.serial) === 0)?.serial + ); + } + + private explicitSerial(args: Record): string | undefined { + const serial = (args as { device_serial?: unknown } | null)?.device_serial; + if (typeof serial !== "string" || serial.length === 0) return undefined; + return deviceKeyOf(args, this.defaultDeviceKey); + } + + private drop(ticket: Ticket): void { + const at = this.queue.indexOf(ticket); + if (at >= 0) this.queue.splice(at, 1); + } +} diff --git a/plugins/dsh-autogamer/src/dsh-types.ts b/plugins/dsh-autogamer/src/dsh-types.ts new file mode 100644 index 000000000..e2fad2ef6 --- /dev/null +++ b/plugins/dsh-autogamer/src/dsh-types.ts @@ -0,0 +1,194 @@ +/** + * Minimal DSH-side contracts used by this plugin. + * + * Two kinds of entry, and the difference is deliberate: + * - anything a published `@deepseek-ai/*` package declares is **imported**, not + * restated — a hand copy is a claim that silently rots on upgrade (rule R21; + * the attachment mirror below lost exactly its branded `attachmentId` that way). + * - shapes only observable through the Host (which ships nothing outside the + * bundle) stay hand-mirrored here with the version and source they were + * verified against, so one DSH upgrade re-checks them all in this file. + */ +import type { ImageAttachmentRef, SaveImageAttachment } from "@deepseek-ai/dsh-attachment"; +import type { Agent } from "@deepseek-ai/dsh-agent"; +import type { SessionId } from "@deepseek-ai/dsh-session"; +import type { MessageId, MessageSourceMap, UserMessage } from "@deepseek-ai/dsh-llm"; +import type { ToolGuard } from "@deepseek-ai/dsh-tools"; + +/** + * Our message source, declared the way the host says to declare one: + * "`MessageSourceMap` … Merge-extensible sum type — each producer declares its + * own `kind` in its own module; there is no shared catch-all `plugin` kind" + * (`@deepseek-ai/dsh-llm` lib/types/message.d.ts:101-106). The retired + * `{kind:'plugin', plugin:}` wrapper is rejected by session format V4 (R17). + */ +declare module "@deepseek-ai/dsh-llm" { + interface MessageSourceMap { + autogamer: { + readonly kind: "autogamer"; + readonly reason: string; + }; + } +} + +export interface DshLogger { + info(message: string): void; + warn(message: string): void; + error(message: string): void; +} + +/** + * Durable, content-addressed image reference (`@deepseek-ai/dsh-attachment`, + * lib/types/types.d.ts:7). The host owns the bytes: a producer hands them to + * `ctx.attachments.saveImages()` and only the returned reference ever enters the + * session log — image bytes never do ("会话事件和模型可见的附件块包含该引用及其 + * 元数据,绝不包含 … base64 数据"). `attachmentId` is a branded opaque id, so + * nothing here may parse or pattern-match it. + */ +export type DshImageAttachment = ImageAttachmentRef; + +/** The `AttachmentStore` seam, narrowed to what this plugin calls. */ +export interface DshAttachments { + saveImages(inputs: readonly SaveImageAttachment[]): Promise; +} + +/** + * The `SystemPrompt` seam, narrowed to the single member this plugin calls. + * Hand-mirrored with provenance (R21): `@deepseek-ai/dsh-system-prompt` + * 0.2.0-rc.2 lib/types/index.d.ts exposes `section()` (no `contribute`/`prefix` + * method exists), and the official skill confirms the usage — + * "Add prompt text with `ctx.systemPrompt.section()`" + * (cordis-plugin-development/references/practices.md). Section input is + * `{ name, order, text }` (numeric order, not priority). + */ +export interface DshSystemPrompt { + section(input: { name: string; order: number; text: string }): unknown; +} + +/** + * Agent/session surface for the G44 turn-end enforcement, narrowed to the two + * members this plugin calls. `id` is the session-backed agent identity + * (`@deepseek-ai/dsh-agent` lib/types/types.d.ts:11-13), which is why the ledger + * and the `turn/end` listener key on the same value. + */ +export type DshAgent = Pick; + +export interface DshAgentsService { + get(id: SessionId): DshAgent | undefined; + /** + * Declared at docs/subsystems/core.zh.md:612; live-verified to return the + * session id during a model-driven tool call (CHANGELOGS #15), so the ledger + * and the `turn/end` listener key on the same value. + */ + currentInitiator?(): DshAgent | undefined; +} + +export interface DshSessionRef { + id: SessionId; +} + +export interface DshSessionEvent { + type: string; +} + +/** + * A real `UserMessage`. It requires a branded `id` ("Stable identity preserved + * across every representation boundary", dsh-llm message.d.ts:110-120); the + * official mint is `createUserMessage`, but that is a RUNTIME import and + * `@deepseek-ai/dsh-llm` is only a devDependency here, so the id is branded + * locally with the exported `MessageId` cast instead — same identity, no new + * packaged dependency at load time (unit-proven; the live nudge landing in the + * session log is CHANGELOGS #15). + */ +export type DshUserMessage = UserMessage; + +/** Brand a message id without importing the host package at runtime. */ +export const messageId = (id: string): MessageId => id as MessageId; + +/** + * Structural copy of the `JsonValue` shape (declared by + * `@deepseek-ai/dsh-util-values`, which dsh-tools' own types import for + * `presentationMeta`): string | number | boolean | null | arrays / string-keyed + * objects thereof. Kept local because that package is not a direct dependency + * and pnpm isolation cannot resolve a type-only import of it; being a closed + * universal shape it cannot rot the way behavioral mirrors do (R21). + */ +export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + +export interface DshEventOptions { + global?: boolean; +} + +export interface DshContext { + logger: DshLogger; + tools: { + register(definition: unknown): () => void; + /** + * Monotonic execution guard (S4 熔断拒绝出口,R13):plain-context 注册即全局生效, + * 返回 reason 拒绝、返回 undefined 放行,且后续监听器无法翻转(`@deepseek-ai/dsh-tools` + * lib/types/index.d.ts:514-522, 646-655)。 + */ + guard(guard: ToolGuard): () => void; + }; + /** + * Host attachment service. Declared in `inject`, because cordis throws on the + * property GET of an undeclared service rather than returning undefined. + */ + attachments: DshAttachments; + agents: DshAgentsService; + /** + * Host system-prompt service. Declared in `inject`, because cordis throws on the + * property GET of an undeclared service rather than returning undefined. + * Resolvability is proven in-process by the Tier-1 mount test (runtimeBoot + * boots the real dsh-system-prompt service with this plugin's own inject list). + */ + systemPrompt: DshSystemPrompt; + /** + * Cordis typed events. `session/event` is scope-filtered, so the G44 listener + * passes `{ global: true }` (packages/core/scope/README.zh.md:75 - "带 + * `{ global: true }` 的监听器绕过筛选"). + */ + on( + event: "session/event", + handler: (session: DshSessionRef, payload: DshSessionEvent) => void, + options?: DshEventOptions, + ): () => void; + get?(key: string): unknown; + /** + * Cordis effect: the callback runs IMMEDIATELY (it is the setup body) and + * its returned function becomes the teardown. Verified against upstream + * sources (mount.ts:208 `ctx.effect(() => () => { tree.root.stop() })`, + * agent-preset-registry/src/index.ts:112, app-boot service.ts:110) — see + * changelogs #10: an early version passed the teardown AS the callback and + * disposed its own tool registrations during apply(). + */ + effect?(setup: () => unknown): void; +} + +export interface ToolExec { + signal: AbortSignal; + /** + * Declares this call's successful result terminal for the current agent turn: + * "The agent loop stops after committing this successful result batch" + * (`ToolExecutionSuccess.concludesTurn`, @deepseek-ai/dsh-tools types). + * `execute` receives a full `ToolRunContext`, so a reporting tool can end the + * turn instead of letting the model keep calling tools after it already + * reported the outcome (G41). + */ + concludeTurn?(): void; +} + +/* + * Tool definition types come straight from the installed @deepseek-ai/dsh-tools + * package artifacts (R1 declared-surface usage; type-only imports are erased at + * runtime). The `any` parameters are deliberate: the official dialect + * (per-property `required: true`, mandatory `additionalProperties` on object + * nodes, ContentBlock[] renders) is enforced at REGISTRATION time by + * defineTool's own schema compiler, which throws on unsupported shapes — + * hand-mirroring the recursive spec types here only recreated them wrongly + * (InferValue over the broad union collapses to `never`). + */ +import type { DefineToolOptions } from "@deepseek-ai/dsh-tools"; + +/** Author-facing tool options as accepted by the official defineTool(). */ +export type ToolDefinitionInput = DefineToolOptions; diff --git a/plugins/dsh-autogamer/src/env/preflight.ts b/plugins/dsh-autogamer/src/env/preflight.ts new file mode 100644 index 000000000..f88a237b9 --- /dev/null +++ b/plugins/dsh-autogamer/src/env/preflight.ts @@ -0,0 +1,336 @@ +/** + * Environment preflight for the external ARTEMIS provider (Phase 1). + * + * Seven checks, straight from the scheme (§6): uv, Python ≥ 3.12, the provider + * repo path, the provider install, adb, an attached device, and the Helper. + * Deliberate deviations from the scheme text: + * - The provider may be installed two ways (G77): `uv tool install`, which puts + * `artemis` on PATH, or a project venv / editable checkout, which does not. + * `installed` and `helper` therefore accept either, probing the second form as + * `uv run --project --no-sync artemis …` — `--project` avoids the scheme's + * trap (bare `uv run` executes in the *caller's* directory, not the provider repo) + * and `--no-sync` keeps the probe read-only, because a preflight must never mutate + * the provider environment. + * - No schemastery `.description()` calls anywhere: that builder form is + * unverified on the installed schemastery, and the settings auto-form works + * without descriptions. + * + * The runner never rejects (every check catches) and never spawns a visible + * console on Windows (`windowsHide`). Results cache for 60 seconds so repeated + * tool calls in one turn do not re-probe. + */ +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +export interface PreflightInput { + uvPath: string; + artemisRepoPath: string; + /** + * G79: required on purpose. `artemis helper status` refuses to answer when several + * transports are ready ("pass --serial, or --all"), and an optional field is exactly + * how that got missed - the provider has no `--all`, so the caller must name a device. + */ + defaultDeviceSerial: string; +} + +export interface ExecResult { + /** Process exit code, or 1 when the process never started. */ + code: number; + stdout: string; + stderr: string; + /** Spawn-level detail (e.g. ENOENT) when the process never started. */ + errorMessage?: string; +} + +export interface PreflightDeps { + run(command: string[], options?: { timeoutMs?: number }): Promise; + exists(path: string): boolean; + now(): number; +} + +export interface PreflightCheck { + id: string; + ok: boolean; + detail?: string; + fix?: string; +} + +export interface PreflightResult { + ok: boolean; + checks: PreflightCheck[]; + finishedAt: string; +} + +export const PREFLIGHT_CACHE_MS = 60_000; + +function defaultRun(command: string[], options?: { timeoutMs?: number }): Promise { + return new Promise((resolve) => { + execFile( + command[0] ?? "", + command.slice(1), + { timeout: options?.timeoutMs ?? 15_000, windowsHide: true }, + (error, stdout, stderr) => { + const out = String(stdout ?? ""); + const err = String(stderr ?? ""); + if (!error) { + resolve({ code: 0, stdout: out, stderr: err }); + return; + } + const errno = (error as NodeJS.ErrnoException).code; + resolve({ + code: typeof errno === "number" ? errno : 1, + stdout: out, + stderr: err, + errorMessage: typeof errno === "string" ? `${errno}: ${error.message}` : error.message, + }); + }, + ); + }); +} + +const defaultDeps: PreflightDeps = { + run: defaultRun, + exists: (path) => existsSync(path), + now: () => Date.now(), +}; + +function fail(id: string, detail: string, fix: string): PreflightCheck { + return { id, ok: false, detail, fix }; +} + +function pass(id: string, detail?: string): PreflightCheck { + return { id, ok: true, detail }; +} + +function missingBinary(result: ExecResult): boolean { + return /ENOENT/i.test(result.errorMessage ?? ""); +} + +function parsePythonVersion(output: string): [number, number] | undefined { + const match = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(output); + if (!match) return undefined; + return [Number(match[1]), Number(match[2])]; +} + +function parseAdbDevices(output: string): string[] { + return output + .split("\n") + .map((line) => line.trim()) + .filter((line) => /^\S+\s+device$/.test(line)) + .map((line) => line.split(/\s+/)[0] as string); +} + +/** First line of output, for compact details. */ +function head(text: string): string { + return text.split("\n").map((line) => line.trim()).find((line) => line.length > 0) ?? ""; +} + +const ARTEMIS_NAME = /\bartemis\b/i; + +const INSTALL_FIX = + "Install ARTEMIS either way: `uv tool install -e ` (PATH form) or `uv sync --project ` (project venv form)"; + +/** + * The provider's own `--version` banner spells its name on the box border too, so pick the + * longest line naming it -- that is the one carrying the version, not the frame. + */ +function firstArtemisLine(text: string): string { + const lines = text + .split("\n") + .map((line) => line.replace(/[│┌┐└┘─]/gu, "").trim()) + .filter((line) => ARTEMIS_NAME.test(line)); + return lines.sort((a, b) => b.length - a.length)[0] ?? head(text); +} + +export interface PreflightRunner { + run(input: PreflightInput): Promise; +} + +export function createPreflight(deps?: Partial): PreflightRunner { + const d: PreflightDeps = { ...defaultDeps, ...deps }; + let cached: { at: number; result: PreflightResult } | undefined; + + async function uvCheck(input: PreflightInput): Promise { + const result = await d.run([input.uvPath, "--version"]); + if (result.code !== 0) { + return fail( + "uv", + missingBinary(result) + ? `no ${input.uvPath} binary (${result.errorMessage})` + : `exit ${result.code}: ${head(result.stderr)}`, + "Install uv: `curl -LsSf https://astral.sh/uv/install.sh | sh` (Windows: `irm https://astral.sh/uv/install.ps1 | iex`)", + ); + } + return pass("uv", head(result.stdout)); + } + + async function pythonCheck(): Promise { + let result = await d.run(["python3", "--version"]); + let binary = "python3"; + if (result.code !== 0 && missingBinary(result)) { + result = await d.run(["python", "--version"]); + binary = "python"; + } + if (result.code !== 0) { + return fail( + "python", + missingBinary(result) ? "no python3/python binary" : `exit ${result.code}: ${head(result.stderr)}`, + "Install Python 3.12+ and put it on PATH", + ); + } + const version = parsePythonVersion(`${result.stdout}\n${result.stderr}`); + if (!version) { + return fail("python", `unparseable version output from ${binary}`, "Install Python 3.12+ and put it on PATH"); + } + if (version[0] < 3 || (version[0] === 3 && version[1] < 12)) { + return fail( + "python", + `${binary} is ${version[0]}.${version[1]}, need 3.12+`, + "Install Python 3.12+ and put it on PATH", + ); + } + return pass("python", `${binary} ${version[0]}.${version[1]}`); + } + + async function repoCheck(input: PreflightInput): Promise { + if (input.artemisRepoPath.trim().length === 0) { + return fail("artemis-repo", "artemisRepoPath is not configured", "Set artemisRepoPath to the cloned ARTEMIS repository"); + } + const manifest = join(input.artemisRepoPath, "pyproject.toml"); + if (!d.exists(manifest)) { + return fail( + "artemis-repo", + `no pyproject.toml under ${input.artemisRepoPath}`, + "Set artemisRepoPath to the cloned ARTEMIS repository", + ); + } + return pass("artemis-repo", input.artemisRepoPath); + } + + /** The project-venv form of the provider; `--project` targets the repo, `--no-sync` keeps it read-only. */ + function venvProbe(input: PreflightInput, args: string[]): string[] { + return [input.uvPath, "run", "--project", input.artemisRepoPath.trim(), "--no-sync", "artemis", ...args]; + } + + function repoConfigured(input: PreflightInput): boolean { + return input.artemisRepoPath.trim().length > 0; + } + + async function installedCheck(input: PreflightInput): Promise { + const listed = await d.run([input.uvPath, "tool", "list"], { timeoutMs: 30_000 }); + if (listed.code !== 0) { + return fail( + "artemis-installed", + missingBinary(listed) ? `no ${input.uvPath} binary` : `exit ${listed.code}: ${head(listed.stderr)}`, + INSTALL_FIX, + ); + } + if (ARTEMIS_NAME.test(listed.stdout)) { + return pass("artemis-installed", `uv tool: ${head(listed.stdout)}`); + } + // G77: an editable checkout is invisible to `uv tool list`, so ask the repo's own env. + if (!repoConfigured(input)) { + return fail("artemis-installed", "`uv tool list` names no artemis install and artemisRepoPath is not set", INSTALL_FIX); + } + const venv = await d.run(venvProbe(input, ["--version"]), { timeoutMs: 30_000 }); + if (venv.code === 0 && ARTEMIS_NAME.test(`${venv.stdout}\n${venv.stderr}`)) { + return pass("artemis-installed", `project venv: ${firstArtemisLine(`${venv.stdout}\n${venv.stderr}`)}`); + } + return fail( + "artemis-installed", + `neither form found: \`uv tool list\` has no artemis, and \`uv run --project ${input.artemisRepoPath.trim()} --no-sync artemis --version` + + ` exited ${venv.code}: ${head(`${venv.stdout}\n${venv.stderr}`)}`, + INSTALL_FIX, + ); + } + + async function adbCheck(): Promise { + const result = await d.run(["adb", "version"]); + if (result.code !== 0) { + return fail( + "adb", + missingBinary(result) ? "no adb binary" : `exit ${result.code}: ${head(result.stderr)}`, + "Install Android SDK Platform Tools and put adb on PATH", + ); + } + return pass("adb", head(result.stdout)); + } + + async function devicesCheck(): Promise { + const result = await d.run(["adb", "devices"]); + if (result.code !== 0) { + return fail( + "adb-devices", + missingBinary(result) ? "no adb binary" : `exit ${result.code}: ${head(result.stderr)}`, + "Install Android SDK Platform Tools and put adb on PATH", + ); + } + const serials = parseAdbDevices(result.stdout); + if (serials.length === 0) { + return fail( + "adb-devices", + "no attached device in `adb devices`", + "Connect a phone or start an emulator, then `adb devices` must list it as `device`", + ); + } + return pass("adb-devices", serials.join(", ")); + } + + async function helperCheck(input: PreflightInput): Promise { + // G79: with more than one ready transport the provider refuses to guess + // (`Several devices are ready; pass --serial`), and `helper status` has no + // --all - so ask about the configured device when there is one. + const serial = (input.defaultDeviceSerial ?? "").trim(); + const args = serial.length > 0 ? ["helper", "status", "--serial", serial] : ["helper", "status"]; + const onPath = await d.run(["artemis", ...args]); + if (onPath.code === 0) return pass("helper", head(onPath.stdout)); + if (repoConfigured(input)) { + const venv = await d.run(venvProbe(input, args), { timeoutMs: 30_000 }); + if (venv.code === 0) return pass("helper", head(venv.stdout)); + return fail( + "helper", + `neither form answered: PATH \`artemis ${args.join(" ")}\` exited ${onPath.code}, ` + + `project venv exited ${venv.code}: ${head(`${venv.stdout}\n${venv.stderr}`)}`, + `${INSTALL_FIX}, then \`artemis helper install\``, + ); + } + return fail( + "helper", + missingBinary(onPath) + ? "no artemis binary on PATH" + : `exit ${onPath.code}: ${head(onPath.stdout)} ${head(onPath.stderr)}`.trim(), + `${INSTALL_FIX}, then \`artemis helper install\``, + ); + } + + return { + async run(input: PreflightInput): Promise { + const at = d.now(); + if (cached && at - cached.at < PREFLIGHT_CACHE_MS) return cached.result; + const checks = [ + await uvCheck(input), + await pythonCheck(), + await repoCheck(input), + await installedCheck(input), + await adbCheck(), + await devicesCheck(), + await helperCheck(input), + ]; + const result: PreflightResult = { + ok: checks.every((check) => check.ok), + checks, + finishedAt: new Date(at).toISOString(), + }; + cached = { at, result }; + return result; + }, + }; +} + +/** Single-line gate message for tool execute paths: reason plus the fix. */ +export function preflightErrorText(result: PreflightResult): string { + const first = result.checks.find((check) => !check.ok); + if (!first) return "environment: preflight reports failure with no failing check (plugin bug; do not retry)"; + return `environment: preflight failed at ${first.id} (${first.detail ?? "no detail"}). Fix: ${first.fix ?? "see the plugin README"}`; +} diff --git a/plugins/dsh-autogamer/src/gate.ts b/plugins/dsh-autogamer/src/gate.ts new file mode 100644 index 000000000..822d6f9d5 --- /dev/null +++ b/plugins/dsh-autogamer/src/gate.ts @@ -0,0 +1,118 @@ +/** + * Per-device FIFO gate (S3): the only self-built synchronization in the + * plugin. One wait-chain per lock key, with tracked pending so a cancelled + * waiter never blocks later ones. Deliberately no library dependency — + * DSH ships no semaphore/mutex (verified against 0.2.0-rc.2 source). + */ + +export interface GateHandle { + readonly key: string; + release(): void; +} + +export class DeviceGate { + private readonly tails = new Map>(); + private readonly waiters = new Map>(); + private readonly holders = new Map(); + + /** Holders that have been admitted but not yet released. */ + busyCount(key: string): number { + return this.holders.get(key) ?? 0; + } + + /** Waiters blocked on this key right now. */ + pendingCount(key: string): number { + return this.waiters.get(key)?.size ?? 0; + } + + /** + * Acquire the device. Resolves once the caller owns the device; the + * returned handle must be released (use try/finally). Abort to leave the + * queue without acquiring — the abort listener is attached synchronously + * so a queued waiter can bail out before its turn comes. + */ + async acquire(key: string, signal?: AbortSignal): Promise { + if (signal?.aborted) throw new GateAbortedError(key); + + const tail = this.tails.get(key) ?? Promise.resolve(); + let releaseTail!: () => void; + const chain = new Promise((resolve) => { + releaseTail = resolve; + }); + // The tail entry intentionally stays after release (bounded by device + // count); busy-ness is tracked separately in `holders`. + this.tails.set(key, chain); + + const waiter: Waiter = { resolve: () => {}, reject: () => {} }; + const pending = this.waiters.get(key) ?? new Set(); + pending.add(waiter); + this.waiters.set(key, pending); + + let aborted = false; + let released = false; + const onAbort = () => { + if (aborted) return; + aborted = true; + signal?.removeEventListener("abort", onAbort); + this.removeWaiter(key, waiter); + waiter.reject(new GateAbortedError(key)); + releaseTail(); + }; + if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + const acquired = new Promise((resolve, reject) => { + waiter.resolve = resolve; + waiter.reject = reject; + }); + + const previous = tail.then( + () => undefined, + () => undefined, + ); + void previous.then(() => { + if (aborted) return; // cancelled while queued — slot already passed on + if (signal?.aborted) { + onAbort(); + return; + } + this.removeWaiter(key, waiter); + this.holders.set(key, (this.holders.get(key) ?? 0) + 1); + waiter.resolve(); + }); + + await acquired; + return { + key, + release: () => { + if (aborted || released) return; + released = true; + signal?.removeEventListener("abort", onAbort); + const count = (this.holders.get(key) ?? 1) - 1; + if (count <= 0) this.holders.delete(key); + else this.holders.set(key, count); + releaseTail(); + }, + }; + } + + private removeWaiter(key: string, waiter: Waiter): void { + const pending = this.waiters.get(key); + if (!pending) return; + pending.delete(waiter); + if (pending.size === 0) this.waiters.delete(key); + } +} + +interface Waiter { + resolve: () => void; + reject: (error: unknown) => void; +} + +export class GateAbortedError extends Error { + constructor(readonly key: string) { + super(`device gate aborted while waiting for "${key}"`); + this.name = "GateAbortedError"; + } +} diff --git a/plugins/dsh-autogamer/src/hierarchy.ts b/plugins/dsh-autogamer/src/hierarchy.ts new file mode 100644 index 000000000..f6fffccfb --- /dev/null +++ b/plugins/dsh-autogamer/src/hierarchy.ts @@ -0,0 +1,225 @@ +/** + * Element targeting for `run_device_action` (S11 step 1). + * + * The action server answers `get_ui_hierarchy` with a JSON list of uiautomator + * nodes, and its own `index` field is a PER-CONTAINER ordinal (measured on the + * live device: 21 nodes carried only 5 distinct values), so it cannot address an + * element. The list the model reads therefore numbers nodes by position in that + * array (G42) — but a handle is RESOLVED by following the node's identity into + * the next hierarchy, never by taking array position N again (G67: the screen + * changes under a handle, and position resolution then taps whatever happens to + * sit at N). An earlier version scanned the payload for `` XML tags, + * which never matched and made `{index}` fail on every call. + */ + +export interface VisibleElement { + /** Our positional handle, 0-based, in array order. */ + index: number; + x: number; + y: number; + text?: string; + desc?: string; + resourceId?: string; + class?: string; + package?: string; + clickable?: boolean; + center?: { x: number; y: number }; +} + +interface RawNode { + text?: unknown; + "content-desc"?: unknown; + "resource-id"?: unknown; + class?: unknown; + package?: unknown; + clickable?: unknown; + parsed_bounds?: unknown; + bounds?: unknown; +} + +/** Parse the action server's hierarchy payload; undefined when it is not JSON. */ +export function parseVisibleElements(payload: string): VisibleElement[] | undefined { + let nodes: unknown; + try { + nodes = JSON.parse(payload); + } catch { + return undefined; + } + if (!Array.isArray(nodes)) return undefined; + const out: VisibleElement[] = []; + for (const node of nodes as RawNode[]) { + const center = centerOf(node); + if (!center) continue; + out.push({ + index: out.length, + x: center.x, + y: center.y, + text: typeof node?.text === "string" && node.text ? node.text : undefined, + desc: typeof node?.["content-desc"] === "string" && node["content-desc"] ? node["content-desc"] : undefined, + resourceId: typeof node?.["resource-id"] === "string" && node["resource-id"] ? node["resource-id"] : undefined, + class: typeof node?.class === "string" ? node.class : undefined, + package: typeof node?.package === "string" ? node.package : undefined, + clickable: node?.clickable === true || node?.clickable === "true", + }); + } + return out; +} + +/** + * The label a node is shown to the model by — and therefore the only identity a + * `#N` handle can carry: `formatVisibleElements` renders this and nothing else. + */ +export function labelOf( + el: Pick, +): string { + return el.text || el.desc || el.resourceId || el.class || ""; +} + +interface NodeIdentity { + label: string; + /** How far this kind of label may be followed, in device px (see MAX_HANDLE_SHIFT). */ + bound: number; + x: number; + y: number; +} + +// Upstream bounds EVERY shift it is willing to call "the same target moved" +// (precondition_xml.py:396-399: `100.0 * scale_factor`, 300 for a resource-id +// match), because "prefer disappeared over shifted" is the conservative +// direction (`:392-395`). scale_factor is 1 here: the plugin never works in +// downscaled screenshot space. An unbounded follow would let a node that +// vanished on this screen be silently answered by a same-labelled element on +// another one. +const MAX_HANDLE_SHIFT = 100; +const MAX_ID_HANDLE_SHIFT = 300; + +function identityOf(el: VisibleElement): NodeIdentity { + // The label is exactly what `formatVisibleElements` showed the model, so the + // precedence is shared with it (labelOf). Only the REACH depends on which + // field carried it: upstream gives a resource-id match more room than a text + // one, and a bare class name is shared by many nodes. + const label = labelOf(el); + if (el.text || el.desc) return { label, bound: MAX_HANDLE_SHIFT, x: el.x, y: el.y }; + if (el.resourceId) return { label, bound: MAX_ID_HANDLE_SHIFT, x: el.x, y: el.y }; + if (el.class) return { label, bound: MAX_HANDLE_SHIFT, x: el.x, y: el.y }; + // Nothing identifies it, so the only thing to match on is where it was. + return { label: `@${el.x},${el.y}`, bound: 0, x: el.x, y: el.y }; +} + +export type HandleResolution = + | { status: "unobserved" } + | { status: "unknown"; elementCount: number } + | { status: "gone"; label: string } + | { status: "resolved"; center: NodeCenter }; + +export type ObservedHandle = + | { status: "unobserved" } + | { status: "unknown"; elementCount: number } + | { status: "ok"; element: VisibleElement }; + +/** + * Does this handle belong to the list the model was shown? One definition of + * "the live list owns this #N", used both to refuse before a fetch and to + * resolve after one. + */ +export function handleFromObservation( + observed: VisibleElement[] | undefined, + index: number, +): ObservedHandle { + if (!observed) return { status: "unobserved" }; + const element = observed.find((el) => el.index === index); + if (!element) return { status: "unknown", elementCount: observed.length }; + return { status: "ok", element }; +} + +/** + * Where did the node the model was shown as `#index` (in `observed`, the live + * observation) go in the `fresh` hierarchy? Resolved by identity, never by + * array position (G67): position re-resolution silently taps "whatever sits at + * N now", which `inventory/04-agent-stack-config.md:203` lists as a safety + * defect. Same-label candidates are disambiguated by proximity, and a candidate + * further than that label can claim is treated as missing rather than moved. + */ +export function resolveHandle( + observed: VisibleElement[] | undefined, + fresh: VisibleElement[], + index: number, +): HandleResolution { + const handle = handleFromObservation(observed, index); + if (handle.status !== "ok") return handle; + const identity = identityOf(handle.element); + let best: { el: VisibleElement; distance: number } | undefined; + for (const el of fresh) { + if (identityOf(el).label !== identity.label) continue; + const distance = Math.hypot(el.x - identity.x, el.y - identity.y); + if (!best || distance < best.distance) best = { el, distance }; + } + // Nearest wins among same-labelled candidates, and a candidate further than + // this label's reach is NOT the node we meant - upstream would rather report + // it missing than act on it (`precondition_xml.py:392-395`). + if (!best || best.distance > identity.bound) { + return { status: "gone", label: identity.label }; + } + return { status: "resolved", center: { x: best.el.x, y: best.el.y, text: identity.label } }; +} + +/** + * The model-facing rendering of the same list. Kept deliberately compact: the + * handle comes first, then whatever identifies the node, then whether it is + * actionable. + */ +export function formatVisibleElements(elements: VisibleElement[]): string { + if (elements.length === 0) return "[]"; + const lines = elements.map((el) => { + const label = labelOf(el) || "(unlabeled)"; + const pkg = el.package ? ` pkg=${el.package}` : ""; + return `#${el.index} [${el.x},${el.y}]${el.clickable ? " clickable" : ""}${pkg} ${label}`; + }); + return lines.join("\n"); +} + +export interface NodeCenter { + x: number; + y: number; + text?: string; +} + +export function findNodeCenterBySubstring( + elements: VisibleElement[], + needle: string, +): NodeCenter | undefined { + const lowered = needle.toLowerCase(); + let best: { center: NodeCenter; exact: boolean } | undefined; + for (const el of elements) { + const candidates = [el.text, el.desc, el.resourceId].filter( + (value): value is string => Boolean(value), + ); + const hit = candidates.find((value) => value.toLowerCase().includes(lowered)); + if (hit === undefined) continue; + const exact = hit.toLowerCase() === lowered; + if (!best || (exact && !best.exact)) best = { center: { x: el.x, y: el.y, text: hit }, exact }; + if (best.exact) break; + } + return best?.center; +} + +function centerOf(node: RawNode): { x: number; y: number } | undefined { + const bounds = node?.parsed_bounds; + if (bounds && typeof bounds === "object") { + const { left, top, right, bottom } = bounds as Record; + if (typeof left === "number" && typeof top === "number" && typeof right === "number" && typeof bottom === "number") { + return midpoint(left, top, right, bottom); + } + } + // Legacy fallback: "bounds" as "[l,t][r,b]" style strings. + const numbers = typeof node?.bounds === "string" ? node.bounds.match(/-?\d+/g) : undefined; + if (numbers && numbers.length >= 4) { + return midpoint(Number(numbers[0]), Number(numbers[1]), Number(numbers[2]), Number(numbers[3])); + } + return undefined; +} + +function midpoint(left: number, top: number, right: number, bottom: number) { + if (right <= left || bottom <= top) return undefined; + return { x: Math.round((left + right) / 2), y: Math.round((top + bottom) / 2) }; +} diff --git a/plugins/dsh-autogamer/src/index.ts b/plugins/dsh-autogamer/src/index.ts new file mode 100644 index 000000000..1bfa6a4f6 --- /dev/null +++ b/plugins/dsh-autogamer/src/index.ts @@ -0,0 +1,484 @@ +/** + * dsh-autogamer plugin entry (rectified P1-P4, docs/TODO.md §六). + * Contracts verified through declared surfaces only (dsh --dump-config-schema, + * dsh --help, shipped READMEs — no asar reading, user ruling 2026-10-10): a + * cordis plugin module exports {name, inject, Config?, apply}; Config must be + * a native Schemastery schema; tools register through ctx.tools.register. + */ +import { ActionClient } from "./actionClient.js"; +import { DeviceBreaker } from "./breaker.js"; +import { DeviceGate } from "./gate.js"; +import { DevicePool, type PoolDevice } from "./devicePool.js"; +import { configuredScreenSize, imageSizeFromBase64, type ScreenSize } from "./coord.js"; +import { unwrapText } from "./mcpText.js"; +import { messageId, type DshContext, type DshUserMessage } from "./dsh-types.js"; +import { REPORT_NUDGE, TurnLedger } from "./turnLedger.js"; +import { defineReportTaskStatus } from "./tools/reportTaskStatus.js"; +import { defineRunDeviceAction, deviceKeyOf, ActionIncidents } from "./tools/runDeviceAction.js"; +import { defineGetDeviceState } from "./tools/getDeviceState.js"; +import { defineRunTask } from "./tools/runTask.js"; +import { defineManageTask } from "./tools/manageTask.js"; +import { defineInspectTrace } from "./tools/inspectTrace.js"; +import { defineDiagnose } from "./tools/diagnose.js"; +import { createArtemisBackend } from "./artemis/adapter.js"; +import { createPreflight } from "./env/preflight.js"; +import { ScrcpyManager } from "./scrcpy/server.js"; +import { setupRulesInjection, type RulesInjectionOutcome } from "./prompt/rulesInjector.js"; +import { LiveObservations } from "./observations.js"; +import { defineTool } from "@deepseek-ai/dsh-tools"; +import z from "@deepseek-ai/schemastery"; + +export const name = "dsh-autogamer"; + +/** + * Declaring Config is REQUIRED for the loader to apply our patch row's + * config: without a schema the row's config is treated as absent + * (Config.listConfigs status=absent) and the preset-scope child mount + * silently skips it — the root cause of the missing-tools spike finding. + * The schema must be native Schemastery (dsh rejects foreign schema objects + * with "Config is not a native Schemastery schema"). Typed as `object` + * because schemastery exports no nameable Schema type from this package. + */ +export const Config: object = z.object({ + actionServerCommand: z + .array(z.string()) + .default(["python", "-m", "artemis.interfaces.cli.main", "mcp", "--type", "adb"]), + actionServerEnv: z.dict(z.string()).default({}), + defaultDeviceKey: z.string().default("default"), + allowedFails: z.number().default(1), + breakerCooldownMs: z.number().default(30_000), + actionCallTimeoutMs: z.number().default(30_000), + screenSize: z.tuple([z.number(), z.number()]), + /** + * G3/G28 auto pool roster. Empty (the default) keeps the historical behaviour: every + * device-less call shares `defaultDeviceKey`. Filling it makes auto calls park on one free + * device each (G28) instead of queueing the whole fleet on one key. + */ + devices: z.array(z.string()).default([]), + /** G28 ceiling on devices admitted at once, across explicit and auto calls; 0 (default) = unlimited. */ + maxConcurrentDevices: z.number().default(0), + /** + * G64: ask the action surface (`list_devices`) who is attached instead of relying on `devices`. + * Default **false** on purpose -- turning discovery on changes scheduling semantics (auto spreads + * across machines instead of sharing one key), so it stays an explicit operator decision (#55). + */ + discoverDevices: z.boolean().default(false), + /** + * dsh-autogamer rectification: external ARTEMIS provider wiring. + * No `.description()` calls on purpose — that builder form is unverified on + * the installed schemastery, and the settings auto-form works without + * descriptions. `traceRetentionDays` and `defaultTimeoutSeconds` are + * reserved (spec-required, no consumer yet: no retention sweeper and no + * task-lifetime timeout policy exist); `injectRules` is read by the rules + * injector (Phase 5). + */ + artemisRepoPath: z.string().default(""), + artemisMcpCommand: z.array(z.string()).default(["python", "-m", "mcp_server"]), + uvPath: z.string().default("uv"), + defaultProfile: z.union(["flash", "pro"]).default("flash"), + defaultTimeoutSeconds: z.number().default(1800), + defaultDeviceSerial: z.string().default(""), + injectRules: z.boolean().default(true), + autoPreflight: z.boolean().default(true), + scrcpyEnabled: z.boolean().default(true), + scrcpyServerPath: z.string().default(""), + scrcpyMaxFps: z.number().default(30), + scrcpyMaxWidth: z.number().default(1080), + scrcpyBitrateMbps: z.number().default(4), + traceRetentionDays: z.number().default(7), + /** P1-a validator strictness (S11 step 5); default "final" like upstream. */ + strictness: z.union(["off", "final", "checkpoints", "strict"]).default("final"), +}); + +/** + * `tools` and `attachments` must BOTH be declared: cordis refuses the property + * GET itself ("cannot get property \"attachments\" without inject", observed + * live 2026-10-11), so an undeclared service is a runtime error, not an + * undefined lookup. Declaring one that cannot resolve in this scope leaves the + * row pending — the G30 shape — so this list is a live-verified claim, not an + * aspiration. + */ +export const inject = ["tools", "attachments", "agents", "systemPrompt"]; +// NOTE (reviewed P5 eve): the scheme also asks for 'settings' here, but that +// service is upstream-only (not installed) and an unresolvable inject in +// preset scope leaves the row pending — the G30 shape. It waits for a live +// mount proof, together with the settings section and the client settings +// card (both deferred for the same reason). + +/** Makes each nudge's message id unique within this process, across agents. */ +let nudgeSequence = 0; + +export type PluginConfig = { + actionServerCommand: string[]; + actionServerEnv: Record; + defaultDeviceKey: string; + allowedFails: number; + breakerCooldownMs: number; + actionCallTimeoutMs: number; + screenSize?: readonly unknown[]; + strictness?: "off" | "final" | "checkpoints" | "strict"; + devices?: string[]; + maxConcurrentDevices?: number; + discoverDevices?: boolean; + artemisRepoPath?: string; + artemisMcpCommand?: string[]; + uvPath?: string; + defaultProfile?: "flash" | "pro"; + defaultTimeoutSeconds?: number; + defaultDeviceSerial?: string; + injectRules?: boolean; + autoPreflight?: boolean; + scrcpyEnabled?: boolean; + scrcpyServerPath?: string; + scrcpyMaxFps?: number; + scrcpyMaxWidth?: number; + scrcpyBitrateMbps?: number; + traceRetentionDays?: number; +}; + +export async function apply(ctx: DshContext, config: PluginConfig = {} as PluginConfig): Promise { + const gate = new DeviceGate(); + const breaker = new DeviceBreaker({ + allowedFails: config.allowedFails, + cooldownMs: config.breakerCooldownMs, + }); + const pool = new DevicePool({ + gate, + defaultDeviceKey: config.defaultDeviceKey, + devices: config.devices ?? [], + maxConcurrentDevices: config.maxConcurrentDevices ?? 0, + isCoolingDown: (key) => !breaker.canAdmit(key), + rosterSource: config.discoverDevices ? () => enumerateRoster() : undefined, + }); + const incidents = new ActionIncidents(); + // dsh-autogamer rectification (Phase 4): the mirror manager is constructed + // (lazily — no spawn) and disposed with everything else, but no tool drives + // it yet: without a DSH binary channel there is no consumer for the video + // socket (Phase 0 verdict), so start() waits for a transport that exists. + // Disabled by config: no manager at all (this finally gives scrcpyEnabled + // a consumer — review item 13). + const scrcpy = + config.scrcpyEnabled !== false + ? new ScrcpyManager({ + maxWidth: config.scrcpyMaxWidth ?? 1080, + maxFps: config.scrcpyMaxFps ?? 30, + bitrateMbps: config.scrcpyBitrateMbps ?? 4, + ...(typeof config.defaultDeviceSerial === "string" && config.defaultDeviceSerial.length > 0 + ? { serial: config.defaultDeviceSerial } + : {}), + ...(typeof config.scrcpyServerPath === "string" && config.scrcpyServerPath.length > 0 + ? { serverPath: config.scrcpyServerPath } + : {}), + }) + : undefined; + // dsh-autogamer rectification (Phase 1): the external ARTEMIS backend and + // the shared preflight runner. The backend connects lazily on first use, so + // constructing it here spawns nothing; the preflight warm-up below only + // fills the 60s cache and never rejects. + const artemisBackend = createArtemisBackend({ + command: config.artemisMcpCommand ?? ["python", "-m", "mcp_server"], + cwd: + typeof config.artemisRepoPath === "string" && config.artemisRepoPath.length > 0 + ? config.artemisRepoPath + : undefined, + callTimeoutMs: config.actionCallTimeoutMs, + }); + const preflight = createPreflight(); + const checkEnv = () => + preflight.run({ + uvPath: config.uvPath ?? "uv", + artemisRepoPath: config.artemisRepoPath ?? "", + defaultDeviceSerial: config.defaultDeviceSerial ?? "", + }); + // G80: the rules outcome is recorded here so `autogamer_diagnose` can report *why* the + // provider contract is missing instead of the mount quietly skipping it. + let rulesOutcome: RulesInjectionOutcome = "disabled"; + if (config.autoPreflight !== false) { + // No ctx.on('ready') exists in the typings (Phase 0): warm the cache + // without blocking the mount instead. + void checkEnv().catch(() => undefined); + } + // G67: which #N handles exist is per (agent, device) and per plugin instance - the + // list the model was last shown, nothing more. + const observations = new LiveObservations(); + const client = new ActionClient({ + command: config.actionServerCommand, + env: config.actionServerEnv, + callTimeoutMs: config.actionCallTimeoutMs, + }); + + const screenSizeCache = new Map(); + /** + * G64 roster source: ask the action surface which devices exist (read-only `list_devices`). + * Returns `undefined` when the question itself could not be answered -- the py server keeps + * "could not ask adb" separate from "adb answered: no devices" exactly so a transient blip does + * not look like an empty fleet, and this side must honour that by reporting "don't know". + */ + async function enumerateRoster(): Promise { + let answer: string; + try { + // Discovery exists to feed admission, so it asks for Keyguard too: one RPC for the whole + // fleet. Per-serial probing would need a second throttle layer right here. + answer = unwrapText(await client.callTool("list_devices", { include_lock_state: true })); + } catch (error) { + logger.warn(`[pool] list_devices transport failed: ${(error as Error)?.message ?? error}`); + return undefined; + } + if (answer.startsWith("Error:")) { + logger.warn(`[pool] list_devices refused: ${answer.slice(0, 120)}`); + return undefined; + } + let parsed: { enumerated?: unknown; devices?: unknown }; + try { + parsed = JSON.parse(answer) as typeof parsed; + } catch (error) { + logger.warn(`[pool] list_devices returned non-JSON: ${String(error)}`); + return undefined; + } + if (parsed.enumerated !== true || !Array.isArray(parsed.devices)) return undefined; + return parsed.devices.flatMap((device) => { + const entry = device as { + serial?: unknown; + state?: unknown; + is_busy?: unknown; + is_locked?: unknown; + }; + if (typeof entry.serial !== "string" || entry.serial.length === 0) return []; + return [ + { + serial: entry.serial, + state: typeof entry.state === "string" ? entry.state : undefined, + isBusy: entry.is_busy === true, + // A missing key means "not asked"; an explicit JSON null means "asked, cannot tell". + // The two must not collapse: one must not reject, the other must (G65). + isLocked: + entry.is_locked === true || + entry.is_locked === false || + entry.is_locked === null + ? (entry.is_locked as boolean | null) + : undefined, + } satisfies PoolDevice, + ]; + }); + } + + async function resolveScreenSize(key: string): Promise { + const configured = configuredScreenSize(config.screenSize); + if (configured) return configured; + const cached = screenSizeCache.get(key); + if (cached) return cached; + // A dead server here is a transport failure, not a plugin defect (D3): + // report the probe as missed and let requireScreenSize classify it as + // environment, so the breaker opens instead of staying closed on internal. + let base64: string; + try { + base64 = unwrapText(await client.callTool("take_screenshot", {})); + } catch { + return undefined; + } + if (base64.startsWith("Error:")) return undefined; + const size = imageSizeFromBase64(base64); + if (size) screenSizeCache.set(key, size); + return size; + } + + const logger = { + info: (message: string) => ctx.logger.info(message), + warn: (message: string) => ctx.logger.warn(message), + }; + + // Raw ctx.tools.register() expects parameters to ALREADY be a full JSON + // Schema (the official MCP path passes server schemas verbatim); a named + // spec map goes through defineTool, whose parameterSchemaSpecToJsonSchema + // emits the object-rooted wire schema and validates args on execute (same + // form as the shipped tool-jobs). Registering the bare map produced + // `parameters.type` != "object" on the wire and the LLM API rejected the + // whole request with 400 (changelogs #10). The explicit + // instantiation skips const-generics inference, which exceeds TS's stack on + // our 13-parameter literal; the official compiler still validates the whole + // shape when this runs. + // G44: the report tool has to be the only way a turn ends. `session/event` is + // scope-filtered, so the listener registers with { global: true } - without it + // a preset-scoped listener only sees tool events and turn/end never arrives + // (packages/core/scope/README.zh.md:75; cordis EventOptions.global). + const ledger = new TurnLedger(); + const agentIdOfCall = (): string | undefined => { + try { + return ctx.agents.currentInitiator?.()?.id; + } catch (error) { + logger.warn(`[turn] initiator lookup failed: ${(error as Error)?.message ?? error}`); + return undefined; + } + }; + + ctx.on( + "session/event", + (session, event) => { + if (event?.type !== "turn/end") return; + if (!ledger.consumeTurn(session.id)) return; + const agent = ctx.agents.get(session.id); + if (!agent) { + logger.warn(`[turn] ${session.id} ended without a report but no agent handle`); + return; + } + logger.warn(`[turn] ${session.id} acted without reporting; nudging once`); + const message: DshUserMessage = { + id: messageId(`autogamer-nudge-${session.id}-${++nudgeSequence}`), + role: "user", + content: [{ type: "text", text: REPORT_NUDGE }], + // A self-declared kind: the retired {kind:'plugin', plugin} wrapper is + // rejected by session format V4 (rule R17), and dsh-llm declares + // MessageSourceMap merge-extensible for exactly this case. + source: { kind: "autogamer", reason: "unreported-turn" }, + }; + // Deferred out of the emit on purpose: Session.append rejects anything + // reentered while its acceptance/publication boundary is open + // (docs/subsystems/session.zh.md:609 "an append reentered while this + // acceptance/publication boundary is open also rejects before the log + // changes"), and calling followup() straight from this listener hit + // exactly that - "session append cannot reenter while another append is + // being published". The official round-driver never appends from its + // turn/end handler either; it only records state. + setTimeout(() => { + try { + agent.followup(message); + } catch (error) { + logger.warn(`[turn] followup refused: ${(error as Error)?.message ?? error}`); + } + }, 0); + }, + { global: true }, + ); + + // S4 熔断拒绝出口(R13/G33):冷却期的拒绝走单调同步 guard,不写进工具正文。 + // 只看 run_device_action 调用——别家工具不动,report_task_status(终态出口)永不拦。 + // plain-context 注册即全局生效;agent-less 执行同样被拒(guardReason 先读全局层)。 + ctx.tools.guard((execution) => { + if (execution.name !== "run_device_action") return undefined; + const args = (execution.arguments as Record | null) ?? {}; + const serial = args.device_serial; + if (typeof serial === "string" && serial.length > 0) { + // Two independent py submission gates, in py's order (tasks.py:140 then :160): readiness + // first, then cooldown. Neither reads a device here -- the guard is synchronous (R13), so it + // consumes the roster snapshot `refreshRoster` last refreshed. + const notReady = pool.admissibleExplicit(serial); + if (notReady !== undefined) return `${notReady} — pick another device or report blocked`; + if (breaker.canAdmit(serial)) return undefined; + return `device ${serial} is cooling down after environment-level failures — report progress and continue after the next message`; + } + // Auto has no device until the pool assigns one, so the only honest refusal here is "nowhere + // to go": any roster device being free of cooldown admits the call and the router re-checks. + if (!pool.refusesAuto()) return undefined; + return `every configured device is cooling down after environment-level failures — report progress and continue after the next message`; + }); + + ctx.tools.register(defineTool(defineRunDeviceAction({ + pool, + breaker, + client, + logger, + defaultDeviceKey: config.defaultDeviceKey, + strictness: config.strictness, + incidents, + observations, + resolveScreenSize, + onAction: () => ledger.markActed(agentIdOfCall()), + agentIdOfCall, + // Screenshots go to the host attachment store so the model gets a real + // image block and the log keeps only the content-addressed reference. + // Effect observation runs entirely in-tool: the router looks at the screen + // itself rather than trusting the model's account of what happened. The + // verdict itself is the action server's (G39 route A). + captureFrame: async () => { + const base64 = unwrapText(await client.callTool("take_screenshot", {})); + return base64.startsWith("Error:") ? undefined : base64; + }, + compareFrames: async (before, after, action, coordinates) => { + const args: Record = { before_b64: before, after_b64: after, action }; + if (coordinates) args.coordinates = coordinates; + const answer = unwrapText(await client.callTool("compare_ui_frames", args)); + if (answer.startsWith("Error:")) { + logger.warn(`[route] effect comparison refused: ${answer}`); + return undefined; + } + const parsed = JSON.parse(answer) as { changed?: unknown }; + return typeof parsed.changed === "boolean" ? parsed.changed : undefined; + }, + saveScreenshot: async (bytes, mediaType) => { + try { + const [ref] = await ctx.attachments.saveImages([ + { data: bytes, mediaType, name: "device-screenshot" }, + ]); + return ref; + } catch (error) { + // Admission can refuse one image (byte / pixel / dimension caps, or a + // header that fails to decode). A screenshot must still report its + // dimensions rather than fail the action. + logger.warn(`[media] attachment admission failed: ${(error as Error)?.message ?? error}`); + return undefined; + } + }, + }))); + ctx.tools.register( + defineTool(defineReportTaskStatus(() => ledger.markReported(agentIdOfCall()))), + ); + ctx.tools.register( + defineTool( + defineGetDeviceState({ + backend: artemisBackend, + checkEnv, + defaultDeviceSerial: config.defaultDeviceSerial ?? "", + }), + ), + ); + const autogamerDeps = { + backend: artemisBackend, + checkEnv, + defaultDeviceSerial: config.defaultDeviceSerial ?? "", + }; + ctx.tools.register( + defineTool( + defineRunTask({ + ...autogamerDeps, + defaultProfile: config.defaultProfile ?? "flash", + }), + ), + ); + ctx.tools.register(defineTool(defineManageTask(autogamerDeps))); + ctx.tools.register(defineTool(defineInspectTrace(autogamerDeps))); + ctx.tools.register( + defineTool( + defineDiagnose({ + ...autogamerDeps, + rulesStatus: () => rulesOutcome, + artemisRepoPath: config.artemisRepoPath ?? "", + }), + ), + ); + + // Tool registration ownership belongs to the tools registry itself (its + // entries are cordis effects on this plugin's fiber, exactly like the + // shipped tool-jobs plugin — it registers bare, with no effect wrapper). + // The resources needing an explicit teardown are the two MCP clients. Cordis + // effect() runs the callback NOW and keeps its RETURNED function as the + // disposer; passing teardown as the callback disposed our own tools during + // apply() (changelogs #10, the G30 root cause). + ctx.effect?.(() => () => { + void client.dispose(); + void artemisBackend.dispose(); + void scrcpy?.dispose(); + }); + + // dsh-autogamer rectification (Phase 5): provider rules into the system + // prompt via the official section() API. Enhancement-only: a missing repo, + // file, or content skips with a warning inside setupRulesInjection, so this + // call never throws and never blocks the mount. + rulesOutcome = setupRulesInjection( + ctx.systemPrompt, + { + artemisRepoPath: config.artemisRepoPath ?? "", + injectRules: config.injectRules !== false, + }, + logger.warn, + ); +} diff --git a/plugins/dsh-autogamer/src/mcpText.ts b/plugins/dsh-autogamer/src/mcpText.ts new file mode 100644 index 000000000..14ab69550 --- /dev/null +++ b/plugins/dsh-autogamer/src/mcpText.ts @@ -0,0 +1,20 @@ +/** + * The legacy action server answers in plain strings or MCP content arrays; + * every consumer needs the same text unwrapping. + */ + +export function unwrapText(structured: unknown): string { + if (typeof structured === "string") return structured; + const record = (structured ?? {}) as Record; + if (typeof record.result === "string") return record.result; + if (Array.isArray(record.content)) { + return record.content + .map((piece) => + typeof piece === "object" && piece !== null && "text" in piece + ? String((piece as { text: unknown }).text) + : "", + ) + .join("\n"); + } + return JSON.stringify(structured ?? {}); +} diff --git a/plugins/dsh-autogamer/src/observations.ts b/plugins/dsh-autogamer/src/observations.ts new file mode 100644 index 000000000..ce880a50d --- /dev/null +++ b/plugins/dsh-autogamer/src/observations.ts @@ -0,0 +1,26 @@ +import type { VisibleElement } from "./hierarchy.js"; + +/** + * The deliberately shallow memory behind `{index}` handles (G67): one live + * observation per (agent, device) — the element list the model was LAST SHOWN. + * A new observation replaces the old one; nothing accumulates and nothing + * expires, because the safety property (`stale #N never becomes a clickable + * target`, docs/migration/inventory/04-agent-stack-config.md:203) comes from + * re-checking a handle's identity against a fresh hierarchy at execution time, + * not from a TTL. + */ +export class LiveObservations { + private readonly latest = new Map(); + + record(agent: string, device: string, elements: VisibleElement[]): void { + this.latest.set(observationKey(agent, device), elements); + } + + get(agent: string, device: string): VisibleElement[] | undefined { + return this.latest.get(observationKey(agent, device)); + } +} + +export function observationKey(agent: string, device: string): string { + return `${agent}::${device}`; +} diff --git a/plugins/dsh-autogamer/src/prompt/rulesInjector.ts b/plugins/dsh-autogamer/src/prompt/rulesInjector.ts new file mode 100644 index 000000000..fe4e958d1 --- /dev/null +++ b/plugins/dsh-autogamer/src/prompt/rulesInjector.ts @@ -0,0 +1,84 @@ +/** + * ARTEMIS rules injection (Phase 5). + * + * Reads the provider's `mcp_server/rules.md` fresh on every mount (never + * cached — the file follows ARTEMIS updates) and contributes it as a + * `systemPrompt.section()`. That is the official prompt-extension API + * (dsh-system-prompt 0.2.0-rc.2 exposes section(), not contribute(); + * practices.md: "Add prompt text with ctx.systemPrompt.section()"), NOT a + * self-written session write — so R16 does not apply. The section order + * follows the scheme's priority number. + * + * Failure policy: injection is an enhancement, never a gate. An empty repo + * path, a missing file, an unreadable file, or empty content all skip with a + * warning and return false; this function never throws. + * + * Path correction (Phase 0): the file lives at `/mcp_server/rules.md` + * on the fork's evidence, not at the repo root as the scheme first wrote. + */ +import { join } from "node:path"; +import { existsSync, readFileSync } from "node:fs"; + +export const RULES_SECTION_NAME = "dsh-autogamer-rules"; +export const RULES_SECTION_ORDER = 50; + +export interface RulesInjectorConfig { + artemisRepoPath: string; + injectRules: boolean; +} + +export interface RulesInjectorDeps { + exists(path: string): boolean; + read(path: string): string; +} + +export interface SystemPromptFace { + section(input: { name: string; order: number; text: string }): unknown; +} + +const defaultDeps: RulesInjectorDeps = { + exists: (path) => existsSync(path), + read: (path) => readFileSync(path, "utf8"), +}; + +export function rulesFilePath(artemisRepoPath: string): string { + return join(artemisRepoPath, "mcp_server", "rules.md"); +} + +/** + * G80: the caller must be able to tell *why* nothing was injected. Returning a bare + * `false` let a missing provider repo look identical to "rules are off by choice", + * so the condition vanished silently in every real environment. + */ +export type RulesInjectionOutcome = "injected" | "disabled" | "no-repo" | "missing-file" | "read-failed" | "empty"; + +export function setupRulesInjection( + systemPrompt: SystemPromptFace, + config: RulesInjectorConfig, + logger: (message: string) => void, + deps: RulesInjectorDeps = defaultDeps, +): RulesInjectionOutcome { + if (!config.injectRules) return "disabled"; + if (config.artemisRepoPath.trim().length === 0) { + logger("[rules] injectRules is on but artemisRepoPath is empty; skipping rules injection"); + return "no-repo"; + } + const path = rulesFilePath(config.artemisRepoPath); + if (!deps.exists(path)) { + logger(`[rules] no rules.md at ${path}; skipping rules injection`); + return "missing-file"; + } + let content: string; + try { + content = deps.read(path); + } catch (error) { + logger(`[rules] cannot read ${path} (${(error as Error)?.message ?? error}); skipping rules injection`); + return "read-failed"; + } + if (content.trim().length === 0) { + logger(`[rules] ${path} is empty; skipping rules injection`); + return "empty"; + } + systemPrompt.section({ name: RULES_SECTION_NAME, order: RULES_SECTION_ORDER, text: content }); + return "injected"; +} diff --git a/plugins/dsh-autogamer/src/scrcpy/input.ts b/plugins/dsh-autogamer/src/scrcpy/input.ts new file mode 100644 index 000000000..f80574205 --- /dev/null +++ b/plugins/dsh-autogamer/src/scrcpy/input.ts @@ -0,0 +1,34 @@ +/** + * Touch-to-adb translation for the device mirror's downgrade route (Phase 4). + * + * The scrcpy control socket would be the native path, but it speaks a custom + * binary protocol to a socket this plugin cannot open (no DSH binary + * channel — Phase 0 verdict), so control travels the documented downgrade: + * plain `adb shell input …` commands. This module is pure argv construction; + * coordinates are device pixels (the caller converts), swipe duration + * defaults to 300ms. Tested without any device. + */ + +export type TouchEvent = + | { kind: "tap"; x: number; y: number } + | { kind: "swipe"; x1: number; y1: number; x2: number; y2: number; durationMs?: number }; + +export const DEFAULT_SWIPE_DURATION_MS = 300; + +/** `adb shell input tap|swipe …` argv tail (without the adb binary/serial prefix). */ +export function toAdbInputArgs(touch: TouchEvent): string[] { + const whole = (value: number): string => String(Math.round(value)); + if (touch.kind === "tap") { + return ["shell", "input", "tap", whole(touch.x), whole(touch.y)]; + } + return [ + "shell", + "input", + "swipe", + whole(touch.x1), + whole(touch.y1), + whole(touch.x2), + whole(touch.y2), + whole(touch.durationMs ?? DEFAULT_SWIPE_DURATION_MS), + ]; +} diff --git a/plugins/dsh-autogamer/src/scrcpy/server.ts b/plugins/dsh-autogamer/src/scrcpy/server.ts new file mode 100644 index 000000000..08cf7b40f --- /dev/null +++ b/plugins/dsh-autogamer/src/scrcpy/server.ts @@ -0,0 +1,299 @@ +/** + * scrcpy server lifecycle for the device mirror (Phase 4, Host side only). + * + * Every device-side fact below comes from scrcpy's own developer doc + * (Genymobile/scrcpy `doc/develop.md`, first-hand, fetched 2026-10-14): + * - push the jar to `/data/local/tmp/scrcpy-server.jar`, forward + * `tcp:{port}` to `localabstract:scrcpy`, then launch with + * `CLASSPATH=… app_process / com.genymobile.scrcpy.Server {version}` + * followed by `key=value` options (order irrelevant); + * - argv[0] MUST equal the server version exactly — no backward/forward + * compat, mismatch refuses to run. Unknown version therefore refuses + * instead of guessing; + * - verified option keys used here: `tunnel_forward`, `audio`, `control`, + * `cleanup`, `raw_stream`, `max_size`, `max_fps`, `video_bit_rate`; + * - the standalone raw recipe this mirrors disables audio/control/cleanup + * and streams raw H.264 (`raw_stream=true`). + * + * Deliberate non-goals (all Phase-0 verdicts, not gaps): + * - NO H.264 forwarding: DSH has no binary host→browser channel (Remote + * streams and events are JSON-only), so there is no consumer for the video + * socket. `stream.ts` does not exist on purpose. + * - NO auto-download: no distribution endpoint is wired (manual install from + * the only official source, with signature verification, is documented in + * the README). Resolution searches an explicit path, then the scrcpy + * binary's sidecar jar. + * - The stop path's `pkill` is best-effort and warn-only: no kill method is + * documented upstream. Residual-process freedom stays live-deferred. + * - Touch injection travels the spec's downgrade route (`adb shell input …`, + * see `input.ts`), not the control socket — there is no socket consumer. + * + * The manager is lazy: constructing it never spawns anything; only start() + * touches the machine, and dispose() on an idle manager is a no-op. + */ +import { spawn, execFile } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; +import { delimiter, dirname, join } from "node:path"; +import type { ExecResult } from "../env/preflight.js"; + +export const SCRCPY_DEVICE_JAR = "/data/local/tmp/scrcpy-server.jar"; +export const SCRCPY_DEFAULT_PORT = 27183; + +export interface ScrcpyDeps { + run(command: string[], options?: { timeoutMs?: number }): Promise; + spawnDetached(command: string[]): void; + exists(path: string): boolean; + listDir(path: string): string[]; + /** Resolve a binary through PATH (PATHEXT-aware on Windows). */ + which(binary: string): string | undefined; +} + +function defaultRun(command: string[], options?: { timeoutMs?: number }): Promise { + return new Promise((resolve) => { + execFile( + command[0] ?? "", + command.slice(1), + { timeout: options?.timeoutMs ?? 15_000, windowsHide: true }, + (error, stdout, stderr) => { + const out = String(stdout ?? ""); + const err = String(stderr ?? ""); + if (!error) { + resolve({ code: 0, stdout: out, stderr: err }); + return; + } + const errno = (error as NodeJS.ErrnoException).code; + resolve({ + code: typeof errno === "number" ? errno : 1, + stdout: out, + stderr: err, + errorMessage: typeof errno === "string" ? `${errno}: ${error.message}` : error.message, + }); + }, + ); + }); +} + +function defaultWhich(binary: string): string | undefined { + const pathValue = process.env.PATH ?? ""; + const extensions = + process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE").split(";") : [""]; + for (const dir of pathValue.split(delimiter)) { + if (!dir) continue; + for (const extension of extensions) { + const candidate = join(dir, binary + extension.toLowerCase()); + try { + if (existsSync(candidate)) return candidate; + } catch { + return undefined; + } + } + } + return undefined; +} + +const defaultDeps: ScrcpyDeps = { + run: defaultRun, + spawnDetached: (command) => { + const child = spawn(command[0] ?? "", command.slice(1), { + detached: true, + stdio: "ignore", + windowsHide: true, + }); + child.unref(); + }, + exists: (path) => existsSync(path), + listDir: (path) => { + try { + return readdirSync(path); + } catch { + return []; + } + }, + which: defaultWhich, +}; + +export interface ScrcpyResolution { + jarPath: string; + /** Exact server version (argv[0]); always present — unknown refuses. */ + version: string; + source: "explicit" | "sidecar"; +} + +function versionFromJarName(name: string): string | undefined { + return /scrcpy-server-v?(\d+\.\d+(?:\.\d+)?)/i.exec(name)?.[1]; +} + +function versionFromBinaryOutput(output: string): string | undefined { + return /scrcpy\s+(\d+\.\d+(?:\.\d+)?)/i.exec(output)?.[1]; +} + +export interface MirrorOptions { + maxWidth: number; + maxFps: number; + bitrateMbps: number; + port?: number; +} + +/** `adb push /data/local/tmp/scrcpy-server.jar` (+ optional `-s`). */ +export function buildPushArgs(jarPath: string, serial?: string): string[] { + return [...serialPrefix(serial), "push", jarPath, SCRCPY_DEVICE_JAR]; +} + +/** `adb forward tcp:{port} localabstract:scrcpy` (+ optional `-s`). */ +export function buildForwardArgs(port: number, serial?: string): string[] { + return [...serialPrefix(serial), "forward", `tcp:${port}`, "localabstract:scrcpy"]; +} + +/** + * The documented launch argv (develop.md "Execution"): CLASSPATH shell form + * plus the exact-match version and the raw-stream option set. + */ +export function buildLaunchArgs(jarVersion: string, options: MirrorOptions): string[] { + return [ + "shell", + `CLASSPATH=${SCRCPY_DEVICE_JAR}`, + "app_process", + "/", + "com.genymobile.scrcpy.Server", + jarVersion, + "tunnel_forward=true", + "audio=false", + "control=false", + "cleanup=false", + "raw_stream=true", + `max_size=${options.maxWidth}`, + `max_fps=${options.maxFps}`, + `video_bit_rate=${Math.round(options.bitrateMbps * 1_000_000)}`, + ]; +} + +/** `adb forward --remove tcp:{port}` (+ optional `-s`). */ +export function buildUnforwardArgs(port: number, serial?: string): string[] { + return [...serialPrefix(serial), "forward", "--remove", `tcp:${port}`]; +} + +function serialPrefix(serial?: string): string[] { + return serial ? ["-s", serial] : []; +} + +export interface ScrcpyManagerOptions extends MirrorOptions { + adbPath?: string; + serial?: string; + /** Explicit jar path (Config scrcpyServerPath); sidecar search otherwise. */ + serverPath?: string; +} + +export interface MirrorHandle { + port: number; + version: string; +} + +const INSTALL_HINT = + "Install scrcpy from the only official source (github.com/Genymobile/scrcpy, verify release signatures) " + + "and put `scrcpy` on PATH, or set the plugin scrcpyServerPath to the release's scrcpy-server jar"; + +export class ScrcpyManager { + private handle?: MirrorHandle; + + constructor( + private readonly options: ScrcpyManagerOptions, + private readonly deps: ScrcpyDeps = defaultDeps, + ) {} + + /** + * Locate a server jar and prove its exact version. Refuses (rather than + * launching a mismatched server, which the server itself would reject) + * when no versioned jar is found. + */ + async resolve(explicitPath?: string): Promise { + const configured = explicitPath ?? this.options.serverPath; + if (configured && configured.length > 0) { + if (!this.deps.exists(configured)) { + throw new Error(`environment: scrcpy server not found at ${configured}. Fix: ${INSTALL_HINT}`); + } + const version = versionFromJarName(configured); + if (version === undefined) { + throw new Error( + `environment: cannot read the server version from ${configured} (need scrcpy-server-vX.Y in the name; exact match is mandatory). Fix: ${INSTALL_HINT}`, + ); + } + return { jarPath: configured, version, source: "explicit" }; + } + const binary = this.deps.which("scrcpy"); + if (binary !== undefined) { + const dir = dirname(binary); + const jars = this.deps + .listDir(dir) + .filter((name) => /^scrcpy-server.*\.jar$/i.test(name)) + .sort() + .reverse(); + for (const name of jars) { + const version = versionFromJarName(name); + if (version !== undefined) return { jarPath: join(dir, name), version, source: "sidecar" }; + } + // A jar without a versioned name: fall back to the sibling binary's + // own version rather than guessing (exact match is mandatory). + if (jars.length > 0) { + const probed = await this.deps.run([binary, "--version"]); + if (probed.code === 0) { + const version = versionFromBinaryOutput(`${probed.stdout}\n${probed.stderr}`); + if (version !== undefined) { + return { jarPath: join(dir, jars[0] as string), version, source: "sidecar" }; + } + } + } + } + throw new Error(`environment: no scrcpy server jar found. Fix: ${INSTALL_HINT}`); + } + + /** Push, forward, and launch; records the handle for stop()/dispose(). */ + async start(): Promise { + if (this.handle) return this.handle; + const adb = this.options.adbPath ?? "adb"; + const port = this.options.port ?? SCRCPY_DEFAULT_PORT; + const { jarPath, version } = await this.resolve(); + const push = await this.deps.run([adb, ...buildPushArgs(jarPath, this.options.serial)]); + if (push.code !== 0) { + throw new Error(`environment: adb push of the scrcpy server failed (exit ${push.code}). Fix: check the device connection`); + } + const forward = await this.deps.run([adb, ...buildForwardArgs(port, this.options.serial)]); + if (forward.code !== 0) { + throw new Error(`environment: adb forward for the scrcpy tunnel failed (exit ${forward.code}). Fix: check the device connection`); + } + // The shell blocks while the server runs, so it launches detached; the + // forward/handshake race this opens is documented, not solved — solving + // it needs a live device (see the module header). + this.deps.spawnDetached([adb, ...serialPrefix(this.options.serial), ...buildLaunchArgs(version, { ...this.options, port })]); + this.handle = { port, version }; + return this.handle; + } + + /** Best-effort teardown: remove the forward, then try to kill the server. */ + async stop(logger?: (message: string) => void): Promise { + const handle = this.handle; + this.handle = undefined; + if (!handle) return; + const adb = this.options.adbPath ?? "adb"; + const unforward = await this.deps.run([adb, ...buildUnforwardArgs(handle.port, this.options.serial)]); + if (unforward.code !== 0) { + logger?.(`[mirror] forward --remove failed (exit ${unforward.code}); continuing to the kill step`); + } + // No kill method is documented upstream; pkill by the documented main + // class is the best available guess and stays warn-only because of it. + const kill = await this.deps.run([ + adb, + ...serialPrefix(this.options.serial), + "shell", + "pkill", + "-f", + "com.genymobile.scrcpy.Server", + ]); + if (kill.code !== 0) { + logger?.(`[mirror] server kill failed (exit ${kill.code}); check for a stayed scrcpy-server process on the device`); + } + } + + async dispose(): Promise { + await this.stop().catch(() => undefined); + } +} diff --git a/plugins/dsh-autogamer/src/tools/diagnose.ts b/plugins/dsh-autogamer/src/tools/diagnose.ts new file mode 100644 index 000000000..bf7ad5d59 --- /dev/null +++ b/plugins/dsh-autogamer/src/tools/diagnose.ts @@ -0,0 +1,136 @@ +/** + * `autogamer_diagnose` — environment and device diagnosis (Phase 2). + * + * Two sources merge here, and the merge is the point: the provider's + * `mobile_diagnose` (device_serial only — the old face takes no + * logcat/screenshot flags, so ours are accepted but not forwarded) plus OUR + * local preflight. The tool doubles as the manual preflight entry, so unlike + * its siblings it does NOT throw on a failed environment: it returns the + * report with the local fixes appended to the recommendations. Only when the + * provider itself is unreachable AND the environment is broken does it throw — + * and then the preflight text (the actionable one) wins over the transport + * error. + */ +import type { ArtemisBackend } from "../artemis/types.js"; +import { preflightErrorText, type PreflightResult } from "../env/preflight.js"; +import type { ToolDefinitionInput } from "../dsh-types.js"; +import { rulesFilePath, type RulesInjectionOutcome } from "../prompt/rulesInjector.js"; + +export interface DiagnoseDeps { + backend: ArtemisBackend; + checkEnv: () => Promise; + defaultDeviceSerial: string; + /** G80: why the provider rules did or did not reach the model, read at call time. */ + rulesStatus: () => RulesInjectionOutcome; + artemisRepoPath: string; +} + +export function defineDiagnose(deps: DiagnoseDeps): ToolDefinitionInput { + return { + name: "autogamer_diagnose", + description: + "Diagnose the Android device and the ARTEMIS environment (driven by Google ARTEMIS): " + + "adb connection, Helper install, recent crashes, and actionable fix recommendations. " + + "Also the manual entry for a fresh environment preflight.", + parameters: { + device_serial: { + type: "string", + description: "Target device serial; omit for the default device.", + }, + include_logcat: { + type: "boolean", + description: "Accepted; the provider decides what device logs to include.", + }, + include_screenshot: { + type: "boolean", + description: "Accepted; the provider decides whether to attach a screenshot.", + }, + }, + output: { + schema: { + type: "object", + properties: { + device_serial: { type: "string", required: true }, + adb_connected: { type: "boolean", required: true }, + helper_installed: { type: "boolean", required: true }, + helper_version: { type: "string" }, + recent_crashes: { + type: "array", + items: { type: "object", additionalProperties: true }, + }, + logcat_path: { type: "string" }, + screenshot_path: { type: "string" }, + recommendations: { + type: "array", + items: { type: "string" }, + }, + }, + additionalProperties: true, + }, + render: (_args, value) => { + const record = (value ?? {}) as Record; + const crashes = Array.isArray(record.recent_crashes) ? (record.recent_crashes as unknown[]).length : 0; + const fixes = Array.isArray(record.recommendations) ? (record.recommendations as unknown[]).length : 0; + return [ + { + type: "text", + text: + `device_serial=${String(record.device_serial ?? "")}; ` + + `adb_connected=${String(record.adb_connected ?? false)}; ` + + `helper_installed=${String(record.helper_installed ?? false)}; ` + + `recent_crashes=${crashes}; recommendations=${fixes}`, + }, + ]; + }, + }, + async execute(args, exec) { + const serial = + typeof args.device_serial === "string" && args.device_serial.length > 0 + ? args.device_serial + : deps.defaultDeviceSerial.length > 0 + ? deps.defaultDeviceSerial + : undefined; + const env = await deps.checkEnv(); + const localFixes = env.ok + ? [] + : env.checks.filter((check) => !check.ok && check.fix).map((check) => check.fix as string); + let diagnosis; + try { + diagnosis = await deps.backend.diagnose({ deviceSerial: serial }, exec?.signal); + } catch (error) { + if (!env.ok) throw new Error(preflightErrorText(env)); + throw error; + } + const recommendations = [...diagnosis.recommendations]; + for (const fix of localFixes) { + if (!recommendations.includes(fix)) recommendations.push(fix); + } + // G80: rules injection failing silently was the whole defect - a missing provider + // repo looked like a working plugin. `disabled` is the operator's choice, so it stays quiet. + const rulesStatus = deps.rulesStatus(); + if (rulesStatus !== "injected" && rulesStatus !== "disabled") { + const warning = + `rules are NOT in the model's context (reason: ${rulesStatus} from ${rulesFilePath( + deps.artemisRepoPath, + )}); ` + + "the device contract the tools rely on was never injected - set artemisRepoPath to a provider " + + "checkout that carries mcp_server/rules.md, or turn injectRules off explicitly"; + if (!recommendations.includes(warning)) recommendations.push(warning); + } + return { + device_serial: diagnosis.deviceSerial ?? serial ?? "", + adb_connected: diagnosis.adbConnected ?? false, + helper_installed: diagnosis.helperInstalled ?? false, + ...(diagnosis.helperVersion !== undefined ? { helper_version: diagnosis.helperVersion } : {}), + recent_crashes: diagnosis.recentCrashes.map((crash) => ({ + ...(crash.package !== undefined ? { package: crash.package } : {}), + ...(crash.timestamp !== undefined ? { timestamp: crash.timestamp } : {}), + ...(crash.stackExcerpt !== undefined ? { stack_excerpt: crash.stackExcerpt } : {}), + })), + ...(diagnosis.logcatPath !== undefined ? { logcat_path: diagnosis.logcatPath } : {}), + ...(diagnosis.screenshotPath !== undefined ? { screenshot_path: diagnosis.screenshotPath } : {}), + recommendations, + }; + }, + }; +} diff --git a/plugins/dsh-autogamer/src/tools/envGate.ts b/plugins/dsh-autogamer/src/tools/envGate.ts new file mode 100644 index 000000000..1298ba876 --- /dev/null +++ b/plugins/dsh-autogamer/src/tools/envGate.ts @@ -0,0 +1,11 @@ +/** + * Shared preflight gate for the autogamer_* tools (Phase 2). One vocabulary: + * a failed environment throws `environment:` with the fix, so every tool + * refuses the same way instead of each inventing its own refusal line. + */ +import { preflightErrorText, type PreflightResult } from "../env/preflight.js"; + +export async function requireEnv(checkEnv: () => Promise): Promise { + const env = await checkEnv(); + if (!env.ok) throw new Error(preflightErrorText(env)); +} diff --git a/plugins/dsh-autogamer/src/tools/getDeviceState.ts b/plugins/dsh-autogamer/src/tools/getDeviceState.ts new file mode 100644 index 000000000..61db75608 --- /dev/null +++ b/plugins/dsh-autogamer/src/tools/getDeviceState.ts @@ -0,0 +1,112 @@ +/** + * `autogamer_get_device_state` — read-only device query through the external + * ARTEMIS backend (Phase 1). + * + * Shape notes (all Phase-0 verdicts, not guesses): + * - `description` is a plain string: the `{en,zh}` object form does not exist + * on the installed dsh-tools (0.2.0-rc.2). + * - Parameter/output specs are plain JSON-schema maps compiled by + * `defineTool`, the same form as the shipped router tool (G30: the object + * root is what keeps the LLM API from 400ing the whole request). + * - Read-only, so `isConcurrencySafe` is unconditionally true (R14/S3: the + * host already runs independent reads concurrently; the device gate only + * serialises what must be serial). + * - The backend answers with a provider-side `screenshot_path`; Phase 1 passes + * it through untouched (no attachment download — that is the model-side + * media path of the existing router tool, not this tool's contract). + * - A failed preflight throws `environment:` with the fix (scheme §6); the + * backend's own classified errors pass through unmodified. + */ +import type { ArtemisBackend } from "../artemis/types.js"; +import type { PreflightResult } from "../env/preflight.js"; +import type { ToolDefinitionInput } from "../dsh-types.js"; +import { requireEnv } from "./envGate.js"; + +export interface GetDeviceStateDeps { + backend: ArtemisBackend; + checkEnv: () => Promise; + defaultDeviceSerial: string; +} + +export function defineGetDeviceState(deps: GetDeviceStateDeps): ToolDefinitionInput { + return { + name: "autogamer_get_device_state", + description: + "Query the Android device state through the external ARTEMIS backend (driven by Google ARTEMIS): " + + "connection, foreground app, a structured summary of the visible UI, and an optional screenshot path. " + + "Read-only; does not touch any task.", + parameters: { + device_serial: { + type: "string", + description: "Target device serial; omit for the default device.", + }, + include_screenshot: { + type: "boolean", + description: "Ask the backend to capture a screenshot and return its path.", + }, + }, + isConcurrencySafe: () => true, + output: { + schema: { + // Value-schema DSL: required-ness is per-property (`required: true`), + // never a top-level `required` array (that throws JsonSchemaError at + // register time — same rule as the shipped router tool). + type: "object", + properties: { + device_serial: { type: "string", required: true }, + connected: { type: "boolean", required: true }, + current_package: { type: "string" }, + current_activity: { type: "string" }, + screen_summary: { type: "string" }, + screenshot_path: { type: "string" }, + accessibility_helper_installed: { type: "boolean" }, + }, + additionalProperties: true, + }, + render: (_args, value) => { + const record = (value ?? {}) as Record; + const parts = [ + `device_serial=${String(record.device_serial ?? "")}`, + `connected=${String(record.connected ?? false)}`, + ]; + for (const key of [ + "current_package", + "current_activity", + "screen_summary", + "screenshot_path", + "accessibility_helper_installed", + ]) { + if (record[key] !== undefined) parts.push(`${key}=${String(record[key])}`); + } + return [{ type: "text", text: parts.join("; ") }]; + }, + }, + async execute(args, exec) { + await requireEnv(deps.checkEnv); + const serial = + typeof args.device_serial === "string" && args.device_serial.length > 0 + ? args.device_serial + : deps.defaultDeviceSerial.length > 0 + ? deps.defaultDeviceSerial + : undefined; + const state = await deps.backend.getDeviceState( + { + deviceSerial: serial, + includeScreenshot: args.include_screenshot === true, + }, + exec?.signal, + ); + return { + device_serial: state.deviceSerial, + connected: state.connected, + ...(state.currentPackage !== undefined ? { current_package: state.currentPackage } : {}), + ...(state.currentActivity !== undefined ? { current_activity: state.currentActivity } : {}), + ...(state.screenSummary !== undefined ? { screen_summary: state.screenSummary } : {}), + ...(state.screenshotPath !== undefined ? { screenshot_path: state.screenshotPath } : {}), + ...(state.helperInstalled !== undefined + ? { accessibility_helper_installed: state.helperInstalled } + : {}), + }; + }, + }; +} diff --git a/plugins/dsh-autogamer/src/tools/inspectTrace.ts b/plugins/dsh-autogamer/src/tools/inspectTrace.ts new file mode 100644 index 000000000..542f6d421 --- /dev/null +++ b/plugins/dsh-autogamer/src/tools/inspectTrace.ts @@ -0,0 +1,126 @@ +/** + * `autogamer_inspect_trace` — read a task's execution trace (Phase 2). + * + * Backend mapping is locked against the fork's old face: we call + * `view_summary` over the `trace_id` and shape the answer client-side. + * `summary` returns the first 3 steps, the last 3, plus any failed step not + * already covered (deduped by step index); `full` returns everything. + * `include_screenshots` is accepted for contract stability but has no provider + * counterpart on this action — screenshots ride inside the step records when + * the provider includes them. Failure detection is heuristic (a `fail`/`error` + * status, or an explicit false flag) because the provider never promised a + * failure vocabulary. + */ +import type { ArtemisBackend, TaskStep } from "../artemis/types.js"; +import type { PreflightResult } from "../env/preflight.js"; +import type { ToolDefinitionInput } from "../dsh-types.js"; +import { requireEnv } from "./envGate.js"; + +export interface InspectTraceDeps { + backend: ArtemisBackend; + checkEnv: () => Promise; +} + +const SUMMARY_EDGE = 3; + +function stepKey(step: TaskStep, position: number): string { + return step.index !== undefined ? `n:${step.index}` : `p:${position}`; +} + +function isFailed(step: TaskStep): boolean { + if (step.ok === false) return true; + return typeof step.status === "string" && /fail|error/i.test(step.status); +} + +export function summarizeSteps(steps: TaskStep[]): TaskStep[] { + if (steps.length <= SUMMARY_EDGE * 2) return steps; + const head = steps.slice(0, SUMMARY_EDGE); + const tail = steps.slice(-SUMMARY_EDGE); + const covered = new Set(); + head.forEach((step, position) => covered.add(stepKey(step, position))); + tail.forEach((step, position) => covered.add(stepKey(step, steps.length - SUMMARY_EDGE + position))); + const failed = steps.filter( + (step, position) => isFailed(step) && !covered.has(stepKey(step, position)), + ); + return [...head, ...failed, ...tail]; +} + +function renderStep(step: TaskStep): Record { + return { + ...(step.index !== undefined ? { index: step.index } : {}), + ...(step.action !== undefined ? { action: step.action } : {}), + ...(step.reasoning !== undefined ? { reasoning: step.reasoning } : {}), + ...(step.screenshotPath !== undefined ? { screenshot_path: step.screenshotPath } : {}), + ...(step.timestamp !== undefined ? { timestamp: step.timestamp } : {}), + ...(step.durationMs !== undefined ? { duration_ms: step.durationMs } : {}), + }; +} + +export function defineInspectTrace(deps: InspectTraceDeps): ToolDefinitionInput { + return { + name: "autogamer_inspect_trace", + description: + "Read an Android automation task's execution trace (driven by Google ARTEMIS). " + + "summary returns the first/last 3 steps plus any failed step; full returns everything.", + parameters: { + task_id: { type: "string", required: true, description: "The task_id from autogamer_run_task." }, + format: { + type: "string", + enum: ["summary", "full"], + description: "summary (default): edges plus failures; full: every step.", + }, + include_screenshots: { + type: "boolean", + description: "Accepted; screenshots ride inside step records when the provider includes them.", + }, + }, + output: { + schema: { + type: "object", + properties: { + task_id: { type: "string", required: true }, + final_status: { type: "string", required: true }, + total_steps: { type: "number", required: true }, + steps: { + type: "array", + items: { type: "object", additionalProperties: true }, + }, + artifact_paths: { + type: "array", + items: { type: "string" }, + }, + }, + additionalProperties: true, + }, + render: (_args, value) => { + const record = (value ?? {}) as Record; + const steps = Array.isArray(record.steps) ? (record.steps as unknown[]).length : 0; + return [ + { + type: "text", + text: + `task_id=${String(record.task_id ?? "")}; final_status=${String(record.final_status ?? "")}; ` + + `total_steps=${String(record.total_steps ?? steps)} (showing ${steps})`, + }, + ]; + }, + }, + async execute(args, exec) { + const taskId = args.task_id; + if (typeof taskId !== "string" || taskId.length === 0) { + throw new Error("task: task_id must be a non-empty string"); + } + await requireEnv(deps.checkEnv); + const view = await deps.backend.inspectTrace({ traceId: taskId }, exec?.signal); + const full = args.format === "full"; + const steps = full ? view.steps : summarizeSteps(view.steps); + return { + task_id: view.taskId, + final_status: view.finalStatus ?? "unknown", + total_steps: view.totalSteps ?? view.steps.length, + steps: steps.map(renderStep), + artifact_paths: view.artifactPaths, + }; + }, + }; +} diff --git a/plugins/dsh-autogamer/src/tools/manageTask.ts b/plugins/dsh-autogamer/src/tools/manageTask.ts new file mode 100644 index 000000000..d9be2841c --- /dev/null +++ b/plugins/dsh-autogamer/src/tools/manageTask.ts @@ -0,0 +1,122 @@ +/** + * `autogamer_manage_task` — status / stop / steer a running task (Phase 2). + * + * Backend mapping is locked against the fork's old face: our `task_id` goes + * out as `trace_id`, and the three actions (status/stop/inject_instruction) + * are verbatim provider actions. The answer is a free-form dict, so every + * field is read tolerantly (see the backend's alias lists); the only hard + * rule enforced HERE is the scheme's context budget: at most 5 recent steps + * ever reach the model, sliced off the tail. + */ +import type { ArtemisBackend, ManageAction } from "../artemis/types.js"; +import type { PreflightResult } from "../env/preflight.js"; +import type { ToolDefinitionInput } from "../dsh-types.js"; +import { requireEnv } from "./envGate.js"; + +export interface ManageTaskDeps { + backend: ArtemisBackend; + checkEnv: () => Promise; +} + +export const RECENT_ACTIONS_LIMIT = 5; + +const ACTIONS: ManageAction[] = ["status", "stop", "inject_instruction"]; + +export function defineManageTask(deps: ManageTaskDeps): ToolDefinitionInput { + return { + name: "autogamer_manage_task", + description: + "Query, stop, or steer a running Android automation task (driven by Google ARTEMIS). " + + "status returns the latest state with the last 5 steps; stop asks for termination; " + + "inject_instruction forwards a new instruction into the running task.", + parameters: { + task_id: { type: "string", required: true, description: "The task_id from autogamer_run_task." }, + action: { + type: "string", + required: true, + enum: ["status", "stop", "inject_instruction"], + description: "status: read state; stop: terminate; inject_instruction: steer with `instruction`.", + }, + instruction: { + type: "string", + description: "Required when action is inject_instruction.", + }, + }, + output: { + schema: { + type: "object", + properties: { + task_id: { type: "string", required: true }, + state: { type: "string", required: true }, + current_step: { type: "number" }, + total_steps: { type: "number" }, + last_action: { type: "string" }, + recent_actions: { + type: "array", + items: { type: "object", additionalProperties: true }, + }, + elapsed_seconds: { type: "number" }, + final_summary: { type: "string" }, + }, + additionalProperties: true, + }, + render: (_args, value) => { + const record = (value ?? {}) as Record; + const steps = Array.isArray(record.recent_actions) ? (record.recent_actions as unknown[]).length : 0; + return [ + { + type: "text", + text: + `task_id=${String(record.task_id ?? "")}; state=${String(record.state ?? "")}; ` + + `steps=${String(record.current_step ?? "?")}/${String(record.total_steps ?? "?")} (${steps} recent); ` + + `last=${String(record.last_action ?? "")}`, + }, + ]; + }, + }, + async execute(args, exec) { + const taskId = args.task_id; + if (typeof taskId !== "string" || taskId.length === 0) { + throw new Error("task: task_id must be a non-empty string"); + } + const action = args.action as ManageAction; + if (!ACTIONS.includes(action)) { + throw new Error(`task: action must be one of ${ACTIONS.join("/")}`); + } + if (action === "inject_instruction") { + const instruction = args.instruction; + if (typeof instruction !== "string" || instruction.trim().length === 0) { + throw new Error("task: instruction is required when action is inject_instruction"); + } + } + await requireEnv(deps.checkEnv); + const status = await deps.backend.manageTask( + { + taskId, + action, + ...(typeof args.instruction === "string" && args.instruction.length > 0 + ? { instruction: args.instruction } + : {}), + }, + exec?.signal, + ); + const recent = (status.recentSteps ?? []).slice(-RECENT_ACTIONS_LIMIT).map((step) => ({ + ...(step.index !== undefined ? { index: step.index } : {}), + ...(step.action !== undefined ? { action: step.action } : {}), + ...(step.reasoning !== undefined ? { reasoning: step.reasoning } : {}), + ...(step.screenshotPath !== undefined ? { screenshot_path: step.screenshotPath } : {}), + ...(step.timestamp !== undefined ? { timestamp: step.timestamp } : {}), + })); + return { + task_id: status.taskId, + state: status.state ?? "unknown", + ...(status.currentStep !== undefined ? { current_step: status.currentStep } : {}), + ...(status.totalSteps !== undefined ? { total_steps: status.totalSteps } : {}), + ...(status.lastAction !== undefined ? { last_action: status.lastAction } : {}), + recent_actions: recent, + ...(status.elapsedSeconds !== undefined ? { elapsed_seconds: status.elapsedSeconds } : {}), + ...(status.finalSummary !== undefined ? { final_summary: status.finalSummary } : {}), + }; + }, + }; +} diff --git a/plugins/dsh-autogamer/src/tools/reportTaskStatus.ts b/plugins/dsh-autogamer/src/tools/reportTaskStatus.ts new file mode 100644 index 000000000..ebc779585 --- /dev/null +++ b/plugins/dsh-autogamer/src/tools/reportTaskStatus.ts @@ -0,0 +1,72 @@ +/** + * report_task_status (S2): the structured end-of-task signal. Run-outcome + * dual-axis semantics preserved: `blocked` means the goal was not met, + * `tests_failed` counts assertion failures recorded along the way without + * flipping the task to failed by itself. + */ +import type { JsonValue, ToolDefinitionInput } from "../dsh-types.js"; + +export function defineReportTaskStatus(onReport?: () => void): ToolDefinitionInput { + return { + name: "report_task_status", + description: + "Report the final outcome of the device task. Call exactly once, when nothing else remains to do; " + + "reporting concludes the turn, so no further tool call is possible afterwards.", + parameters: { + status: { + type: "string", + required: true, + enum: ["completed", "blocked"], + description: "completed = goal met; blocked = goal not met and options are exhausted.", + }, + summary: { type: "string", required: true, description: "One paragraph: what was done and what the end state is." }, + tests_failed: { type: "number", description: "Count of assertion failures observed during the task." }, + }, + timeoutMs: 5_000, + output: { + schema: { + type: "object", + additionalProperties: false, + properties: { + status: { type: "string", required: true }, + tests_failed: { type: "number" }, + outcome: { type: "string", required: true }, + }, + }, + render: (_args, value) => { + const record = (value ?? {}) as Record; + const text = `task outcome recorded: ${String(record.outcome ?? record.status ?? "unknown")}`; + return [{ type: "text", text }]; + }, + // P1-b: the terminal facts replay with the result for exit audits. + presentationMeta: (_args, value) => { + const record = (value ?? {}) as { status?: unknown; tests_failed?: unknown; outcome?: unknown }; + const meta: { [key: string]: JsonValue } = {}; + if (typeof record.status === "string") meta.status = record.status; + meta.tests_failed = typeof record.tests_failed === "number" ? record.tests_failed : 0; + if (typeof record.outcome === "string") meta.outcome = record.outcome; + return meta; + }, + }, + async execute(args, exec) { + const status = args.status as string; + const testsFailed = typeof args.tests_failed === "number" ? args.tests_failed : 0; + // S2 defines the terminal state as this tool's result, which needs an + // enforcement point rather than a prompt convention: without concluding the + // turn the model kept acting (and re-reporting) after it had already + // reported `blocked` — see G41. + onReport?.(); + exec.concludeTurn?.(); + return { + status, + tests_failed: testsFailed, + outcome: + status === "completed" + ? testsFailed > 0 + ? "completed (with assertion failures on record)" + : "completed" + : "blocked", + }; + }, + }; +} diff --git a/plugins/dsh-autogamer/src/tools/runDeviceAction.ts b/plugins/dsh-autogamer/src/tools/runDeviceAction.ts new file mode 100644 index 000000000..f1fe15598 --- /dev/null +++ b/plugins/dsh-autogamer/src/tools/runDeviceAction.ts @@ -0,0 +1,1061 @@ +/** + * run_device_action (S11): the single execution tool. The gate/breaker/ + * classification live here — code-enforced, the model cannot bypass them. + * + * Contract notes (verified against artemis/mcp/adb_server.py): + * - the py action server keeps the legacy PIXEL contract; this tool speaks + * 0-1000 normalized and converts using probe-screenshot dimensions (G24) + * - results are plain strings ("Success" / "Failed" / "Error: ..."), not + * structuredContent; normalizeOutcome below + * - real tool names: tap / long_press_on / swipe / back / launch_app / + * stop_app / open_link / focus_and_input_text / focus_and_clear_text / + * erase_one_char / press_key / take_screenshot / get_ui_hierarchy + */ +import type { ActionClient } from "../actionClient.js"; +import type { DeviceBreaker } from "../breaker.js"; +import type { DevicePool, PoolLease } from "../devicePool.js"; +import { classifyMessage } from "../classify.js"; +import { + decodeImageBase64, + imageMediaTypeOf, + parseImageSize, + toPixels, + type ScreenSize, +} from "../coord.js"; +import type { DshImageAttachment, JsonValue, ToolDefinitionInput } from "../dsh-types.js"; +import { + findNodeCenterBySubstring, + formatVisibleElements, + handleFromObservation, + parseVisibleElements, + resolveHandle, + type VisibleElement, +} from "../hierarchy.js"; +import { LiveObservations } from "../observations.js"; +import { unwrapText } from "../mcpText.js"; + +export interface RouterLogger { + info(message: string): void; + warn(message: string): void; +} + +export const ACTIONS = [ + "click", + "long_press", + "swipe", + "input_text", + "press_key", + "back", + "erase_one_char", + "focus_and_clear_text", + "launch_app", + "stop_app", + "open_link", + "take_screenshot", + "get_ui_hierarchy", + "wait", +] as const; + +export type DeviceAction = (typeof ACTIONS)[number]; + +/** + * Contract table: agent action → upstream py action-server tool name + * (legacy stdio contract; conformance test locks this against the live + * surface fixture). `wait` is client-side and maps to null. + */ +export const ACTION_TO_PY_TOOL: Record = { + click: "tap", + long_press: "long_press_on", + swipe: "swipe", + input_text: "focus_and_input_text", + press_key: "press_key", + back: "back", + erase_one_char: "erase_one_char", + focus_and_clear_text: "focus_and_clear_text", + launch_app: "launch_app", + stop_app: "stop_app", + open_link: "open_link", + take_screenshot: "take_screenshot", + get_ui_hierarchy: "get_ui_hierarchy", + wait: null, +}; + +/** + * Actions that should move the screen. Observation-only actions + * (take_screenshot / get_ui_hierarchy / wait) are excluded so the router does + * not pay two extra captures for them. + */ +const EFFECT_OBSERVED_ACTIONS: ReadonlySet = new Set([ + "click", + "long_press", + "swipe", + "input_text", + "press_key", + "back", + "erase_one_char", + "focus_and_clear_text", + "launch_app", + "stop_app", + "open_link", +]); + +/** Model-facing budget for a hierarchy dump: ~27 rows at ~70 chars each. */ +const RENDER_HIERARCHY_BUDGET = 6_000; +/** Budget for any other string field in the result envelope. */ +const RENDER_TEXT_BUDGET = 200; + +/** Let navigation animations finish before the after-frame is taken. */ +const EFFECT_SETTLE_MS = 600; + +function settle(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + timer.unref?.(); + }); +} + +const KEYCODES: Record = { + home: "KEYCODE_HOME", + enter: "KEYCODE_ENTER", + delete: "KEYCODE_DEL", + tab: "KEYCODE_TAB", + search: "KEYCODE_SEARCH", + menu: "KEYCODE_MENU", + app_switch: "KEYCODE_APP_SWITCH", + volume_up: "KEYCODE_VOLUME_UP", + volume_down: "KEYCODE_VOLUME_DOWN", + power: "KEYCODE_POWER", +}; + +type DispatchResult = + | { ok: true; value: unknown } + | { ok: false; failure: "environment" | "task" | "internal"; message: string }; + +/** + * Breaker/gate device key (S4 矩阵:auto 端点默认键)。省略、非字符串或空串的 + * serial 一律落 configured default,保证 execute、guard、gate 三处同源——两处 + * 各写一遍曾让空串在 execute 落空键、在 guard 回 default。 + */ +export function deviceKeyOf(args: Record, fallback: string): string { + const serial = (args as { device_serial?: unknown } | null)?.device_serial; + return typeof serial === "string" && serial.length > 0 ? serial : fallback; +} + +/** Stable JSON for dedup keys: sorted object keys, reason excluded by caller. */ +function stableStringify(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + if (value !== null && typeof value === "object") { + const record = value as Record; + const keys = Object.keys(record).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(record[k])}`).join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +/** + * G13 in-flight dedup key: same agent, same device, same mutating action with + * the same arguments while one is still running. Read-only actions are + * idempotent polls and never deduped; sequential repeats are unaffected + * because the key clears when the first call settles. + */ +function inflightKey(agent: string, device: string, action: string, args: Record): string { + const rest = { ...args }; + delete rest.reason; + delete rest.device_serial; + return `${agent}::${device}::${action}::${stableStringify(rest)}`; +} + +/** + * G34: semantic input checks the wire schema cannot express. A present-but- + * malformed coordinate is a caller bug — it must fail fast instead of + * silently falling through to hierarchy search with a typo'd target. + */ +function isNormalizedPair(value: unknown): value is [number, number] { + return ( + Array.isArray(value) && + value.length === 2 && + typeof value[0] === "number" && + typeof value[1] === "number" && + Number.isFinite(value[0]) && + Number.isFinite(value[1]) && + value[0] >= 0 && + value[0] <= 1000 && + value[1] >= 0 && + value[1] <= 1000 + ); +} + +function assertNormalizedPair(value: unknown, field: string): asserts value is [number, number] { + if (!isNormalizedPair(value)) { + throw new Error(`task: ${field} must be [x,y] with finite numbers in 0-1000`); + } +} + +function assertNonEmptyString(value: unknown, field: string): void { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`task: ${field} must be a non-empty string`); + } +} + +function assertPositiveDuration(value: unknown, field: string): void { + if (value === undefined) return; + const n = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(n) || n <= 0) { + throw new Error(`task: ${field} must be a positive number of milliseconds`); + } +} + +/** + * Transport calls made while BUILDING the device call (hierarchy fetch, + * screenshot for its dimensions). A dead action server here is environment by + * definition (classifyTransportError) — rethrow prefixed so the dispatch + * classifier cannot mistake it for a task failure or a plugin-internal defect. + */ +async function callTransport( + client: { callTool(name: string, args: Record, signal?: AbortSignal): Promise }, + tool: string, + payload: Record, + signal?: AbortSignal, +): Promise { + try { + return await client.callTool(tool, payload, signal); + } catch (error) { + throw new Error(`environment: action transport failed: ${error instanceof Error ? error.message : String(error)}`); + } +} + +export type ValidatorStrictness = "off" | "final" | "checkpoints" | "strict"; + +/** Upstream pre-execution retry budget (max_pre_attempts = 3, total attempts). */ +const RESOLVE_ATTEMPTS = 3; + +/** Upstream incident threshold: consecutive failures that open an incident. */ +const INCIDENT_THRESHOLD = 3; + +export function isLadderOn(strictness: ValidatorStrictness | undefined): boolean { + return (strictness ?? "final") !== "off"; +} + +export function strictnessOf(options: RouterOptions): ValidatorStrictness { + return options.strictness ?? "final"; +} + +/** + * Consecutive task-failure counts per device (P1-a incident ledger). Health + * state stays in the breaker; this tracks TASK progress only: environment and + * internal outcomes never touch it. Lifetime matches gate/breaker (fresh per + * plugin instance, i.e. restart clears — the old stop-all equivalent). + * + * The ledger also carries the CLOSED notice (changelogs #62; upstream + * google/artemis calls this `last_closed_incident`): an incident the + * model was told about stays open until a later success closes it, and that + * success hands back a one-turn notice to settle the original intent. + * Upstream keys it off step numbers; this device has none (the host owns + * turn accounting), so the notice carries the consecutive-failure count + * instead. + */ +export interface IncidentClosure { + consecutive_failures: number; +} + +export class ActionIncidents { + private readonly counts = new Map(); + private readonly announced = new Set(); + private readonly closures = new Map(); + + noteFailure(key: string): number { + const next = (this.counts.get(key) ?? 0) + 1; + this.counts.set(key, next); + if (next >= INCIDENT_THRESHOLD) this.announced.add(key); + return next; + } + + noteSuccess(key: string): void { + if (this.announced.delete(key)) { + this.closures.set(key, { consecutive_failures: this.counts.get(key) ?? INCIDENT_THRESHOLD }); + } + this.counts.delete(key); + } + + /** + * Take the pending CLOSED notice for a device. Called once per model-facing + * result, which is what makes the notice one-turn rather than sticky. + */ + takeClosure(key: string): IncidentClosure | undefined { + const closure = this.closures.get(key); + if (closure === undefined) return undefined; + this.closures.delete(key); + return closure; + } + + count(key: string): number { + return this.counts.get(key) ?? 0; + } +} + +export type VerdictCategory = "disappeared" | "general"; + +export function incidentPrompt(failures: number, key: string, milestone: boolean): string { + const base = + `${failures} consecutive task failures on device ${key} — restate the goal, ` + + `list what was tried, then adjust the plan or report blocked via report_task_status`; + return milestone ? `Milestone audit: ${base}` : base; +} + +export function closurePrompt(closure: IncidentClosure, key: string): string { + // Kept inside RENDER_TEXT_BUDGET (G68 ①): the result envelope clips every + // non-hierarchy string at 200 chars, and a truncated notice reads as garbage + // exactly where it asks the model to settle an intent. The wording stays terse + // so the longest device serial (an adb endpoint) still fits whole. + return ( + `Execution incident CLOSED on ${key} after ${closure.consecutive_failures} failures: ` + + `settle the original intent — already served, still pending, or no longer needed.` + ); +} + +export const SELF_CHECK_PROMPT = + "Self-check: confirm the intended visible change happened; if not, adjust and continue, " + + "or report blocked via report_task_status."; + +export const PIXEL_GUIDANCE = + "No visible change. Take a screenshot, locate the target visually, and retry with " + + "{coordinate:[x,y]} from pixels or a fresh {index} from a new get_ui_hierarchy."; + +export interface RouterOptions { + /** S3/G3 scheduling: per-device chains plus the auto pool and the global ceiling. */ + pool: DevicePool; + breaker: DeviceBreaker; + client: ActionClient; + logger: RouterLogger; + defaultDeviceKey: string; + /** + * P1-a validator strictness (S11 step 5, plugin Config, default "final" like + * upstream `--verification-level`): "off" runs every action once with no + * ladder and no incident tracking; the other levels run the full ladder and + * differ only in audit prompting (final: exit path only, via persona; + * checkpoints: incident opens carry a milestone prompt; strict: every + * mutating action also carries a self-check prompt). + */ + strictness?: ValidatorStrictness; + /** Per-device consecutive task-failure counts; owned per plugin instance. */ + incidents?: ActionIncidents; + /** + * G67: the `#N` list each agent was last shown, per device. Owned by the + * plugin instance so it outlives one tool call; the ladder's own internal + * re-fetches never write it (only the model-visible result does). + */ + observations?: LiveObservations; + /** + * Calling-agent identity for the G13 in-flight dedup key and the G67 handle + * scope. Direct-drive tests leave it unset, and a host whose initiator lookup + * throws lands here too - so "anonymous" is a real shared lane, not only a + * test artefact (worst case those callers share one observation and one dedup + * key, never a silent positional tap). + */ + agentIdOfCall?(): string | undefined; + /** Screen dimensions for 0-1000 → pixel conversion; probe result cached per key. */ + resolveScreenSize(key: string): Promise; + /** + * Commit screenshot bytes to the host attachment store and get back the + * durable reference the model and UI can use. Absent (or returning undefined) + * keeps the result text-only — bytes never go into the tool payload. + */ + saveScreenshot?( + bytes: Uint8Array, + mediaType: DshImageAttachment["mediaType"], + ): Promise; + /** + * Effect observation, delegated to the action server's read-only + * `compare_ui_frames`: the verdict rules (status/nav masking, change ratio, + * tap ROI, and the "assume changed" fallback) are the device layer's, not + * ours to re-derive from pixels. + */ + /** Records that this agent acted in the current turn (G44 ledger). */ + onAction?(): void; + captureFrame?(): Promise; + compareFrames?( + beforeB64: string, + afterB64: string, + action: string, + coordinates?: number[], + ): Promise; +} + +interface TargetInput { + index?: number; + coordinate?: number[]; + text?: string; +} + +export function defineRunDeviceAction(options: RouterOptions): ToolDefinitionInput { + const { pool, breaker, client, logger } = options; + const incidents = options.incidents ?? new ActionIncidents(); + const observations = options.observations ?? new LiveObservations(); + const agentOfCall = (): string => options.agentIdOfCall?.() ?? "anonymous"; + const inflight = new Set(); + + async function resolvePixelTarget( + args: Record, + key: string, + signal: AbortSignal, + ): Promise<{ x: number; y: number }> { + const target = (args.target ?? {}) as TargetInput; + if (target.coordinate !== undefined) assertNormalizedPair(target.coordinate, "target.coordinate"); + if (typeof target.coordinate?.[0] === "number" && typeof target.coordinate?.[1] === "number") { + const size = await requireScreenSize(key); + return { + x: toPixels(target.coordinate[0], size.width), + y: toPixels(target.coordinate[1], size.height), + }; + } + // P1-a pre-execution ladder (upstream max_pre_attempts = 3): a missing + // target may be a transient screen, so re-fetch the hierarchy and retry. + // An UNREADABLE hierarchy is the bypass case — fail immediately instead of + // retrying a broken observation; a missing target spec is a caller bug and + // fails before any fetch. + const seeksHierarchy = + typeof target.index === "number" || (typeof target.text === "string" && target.text.length > 0); + if (!seeksHierarchy) { + throw new Error("task: this action needs a target — {coordinate:[x,y]} (0-1000), {index}, or {text}"); + } + // A handle is only meaningful against the list the model was shown, so two + // cases are caller bugs rather than a transient screen and are refused + // before the first fetch (G67): no observation at all, and a number that was + // never in the list. Re-fetching cannot fix either - only the identity of a + // node the model DID see can come back. + if (typeof target.index === "number") { + const pre = handlePrecheck(target.index, key); + if (pre) throw new Error(pre); + } + const attempts = isLadderOn(options.strictness) ? RESOLVE_ATTEMPTS : 1; + let lastError = ""; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const xml = unwrapText(await callTransport(client, "get_ui_hierarchy", {}, signal)); + const fresh = parseVisibleElements(xml); + if (fresh === undefined) { + throw new Error("task: hierarchy unreadable (not a node list) — re-run get_ui_hierarchy"); + } + const hit = resolveTarget(target, fresh, key); + if ("center" in hit) return hit.center; + if (!hit.retryable) throw new Error(hit.refusal); + lastError = hit.refusal; + } + throw new Error(lastError); + } + + /** The live observation for this agent + device, or undefined if it never looked. */ + function currentObservation(key: string): VisibleElement[] | undefined { + return observations.get(agentOfCall(), key); + } + + /** + * Refusals that no amount of re-fetching can turn into a resolution: the + * handle has no live list to belong to, or is not in it. Undefined means the + * handle is at least addressable and worth resolving. + */ + function handlePrecheck(index: number, key: string): string | undefined { + const handle = handleFromObservation(currentObservation(key), index); + if (handle.status === "unobserved") { + return `task: no live hierarchy for device ${key} — call get_ui_hierarchy first, then use a #N handle from its output`; + } + if (handle.status === "unknown") { + return ( + `task: #${index} is not in the live hierarchy for device ${key} ` + + `(it listed ${handle.elementCount} elements) — re-run get_ui_hierarchy and pick a #N from it` + ); + } + return undefined; + } + + /** + * Resolve one target against an already-parsed hierarchy. `{index}` follows + * the handle's identity into the new tree (G67) instead of taking array + * position N again; `{text}` matches content as it always did. `retryable` + * marks the ladder's own case — a node that is simply not on this screen yet. + */ + function resolveTarget( + target: TargetInput, + fresh: VisibleElement[], + key: string, + ): { center: { x: number; y: number } } | { refusal: string; retryable: boolean } { + if (typeof target.index === "number") { + const pre = handlePrecheck(target.index, key); + if (pre) return { refusal: pre, retryable: false }; + const hit = resolveHandle(currentObservation(key), fresh, target.index); + if (hit.status === "resolved") return { center: hit.center }; + if (hit.status === "gone") { + const label = hit.label ? ` ("${hit.label}")` : ""; + return { + refusal: + `task: #${target.index}${label} from the live hierarchy is no longer on device ${key} — ` + + `re-run get_ui_hierarchy, or use {coordinate:[x,y]} and say what you saw`, + retryable: true, + }; + } + // handlePrecheck rules both of these out, so reaching here is our own bug. + return { refusal: `task: #${target.index} cannot be resolved against the live hierarchy`, retryable: false }; + } + const center = findNodeCenterBySubstring(fresh, target.text as string); + if (center) return { center }; + return { + refusal: `task: no element matching text "${target.text}" in the current hierarchy`, + retryable: true, + }; + } + + async function requireScreenSize(key: string): Promise { + const size = await options.resolveScreenSize(key); + if (!size) { + throw new Error( + "environment: screen size unavailable (probe screenshot failed) — set the plugin `screenSize` config or check the device", + ); + } + return size; + } + + return { + name: "run_device_action", + description: + "Execute one action on the Android device. Actions: " + + ACTIONS.join(", ") + + ". Coordinates are [x,y] normalized to 0-1000 (top-left origin); targets may also " + + "be given as {index} into the latest hierarchy or {text} to find an element. One " + + "action per call; the call resolves after execution and effect observation.", + parameters: { + action: { type: "string", required: true, enum: [...ACTIONS], description: "Action to execute." }, + target: { + type: "object", + additionalProperties: false, + description: + "Where to act. {index} = the #N handle from YOUR latest get_ui_hierarchy output on this device (preferred; " + + "it follows that same element while it can still be recognised, and is refused rather than guessed " + + "when it cannot be — a handle you never saw does not exist); " + + "{coordinate:[x,y]} in 0-1000 as fallback; {text} matches text/content-desc/resource-id. " + + "Required by click/long_press/input_text/focus_and_clear_text.", + properties: { + index: { type: "number", description: "The #N handle from your latest get_ui_hierarchy output on this device." }, + coordinate: { type: "array", items: { type: "number" }, description: "[x,y] normalized to 0-1000." }, + text: { type: "string", description: "Substring to locate the element." }, + }, + }, + end_coordinate: { type: "array", items: { type: "number" }, description: "Swipe end [x,y] normalized to 0-1000." }, + target_description: { type: "string", description: "Short human description of the target, recorded with the action." }, + text: { type: "string", description: "Text for input_text (supports \\n)." }, + clear_before_input: { type: "boolean", description: "input_text: clear existing text first (default append)." }, + duration_ms: { type: "number", description: "Hold/swipe/wait duration in milliseconds." }, + key: { type: "string", description: "press_key key: home/enter/delete/tab/search/menu/app_switch/volume_up/volume_down/power, digits, or a raw KEYCODE_* name." }, + package: { type: "string", description: "Package name for launch_app/stop_app." }, + url: { type: "string", description: "URL for open_link." }, + device_serial: { type: "string", description: "Target device key; omit for the configured default." }, + reason: { type: "string", required: true, description: "One sentence on why this action moves the task forward." }, + }, + timeoutMs: 45_000, + /** + * S3/R14 并发声明:只读观察动作可并发,变更类独占(缺省 exclusive,fail-closed)。 + * 宿主调度只定"谁可并行",同一设备的串行仍由工具内闸门强制。 + */ + isConcurrencySafe: (args) => + (args as { action?: unknown }).action === "take_screenshot" || + (args as { action?: unknown }).action === "get_ui_hierarchy", + output: { + // Payloads are action-dependent (waited_ms for wait, the MCP record for + // device calls), so the object is explicitly OPEN (official rule: + // openness is declared, never accidental). render formats whatever came. + schema: { + type: "object", + additionalProperties: true, + }, + // P1-b checker facts (R16): the validator verdict rides on tool/result + // meta so milestone/exit audits replay it instead of trusting memory. + // Soft by design — unknown shapes project to {} rather than throwing. + presentationMeta: (_args, value) => { + const record = (value ?? {}) as { effect?: unknown; category?: unknown; incident?: unknown }; + const meta: { [key: string]: JsonValue } = {}; + if (typeof record.effect === "string") meta.effect = record.effect; + if (typeof record.category === "string") meta.category = record.category; + if (typeof record.incident === "object" && record.incident !== null) { + meta.incident = record.incident as JsonValue; + } + return meta; + }, + render: (_args, value) => { + const record = { ...(value ?? {}) } as Record; + // The attachment reference renders as an image block; it must not also + // be flattened into the text envelope (the host shape for a tool image + // result is [text, image]). + const image = record.image as DshImageAttachment | undefined; + delete record.image; + const parts = Object.entries(record) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => { + if (typeof v !== "string") return `${k}=${JSON.stringify(v)}`; + // A hierarchy dump is one long numbered list; clipping it at the + // generic budget left the model seeing 3 of 27 elements (G42). + const budget = k === "hierarchy" ? RENDER_HIERARCHY_BUDGET : RENDER_TEXT_BUDGET; + return `${k}=${v.length > budget ? `${v.slice(0, budget)}…` : v}`; + }); + const text = parts.length > 0 ? parts.join("; ") : "action completed"; + return image?.attachmentId + ? [{ type: "text", text }, { type: "image", attachment: image }] + : [{ type: "text", text }]; + }, + }, + async execute(args, exec) { + const action = args.action as DeviceAction; + // Before admission the pool has not chosen a device for an auto call, so the log line and + // the G13 dedup key use the anticipated device; breaker bookkeeping below is re-anchored to + // the device the pool actually assigned. + const anticipatedKey = pool.resolveKey(args); + let key = anticipatedKey; + const risk = action === "stop_app" ? "risky" : "normal"; + logger.info(`[route] action=${action} device=${key} risk=${risk} reason=${String(args.reason)}`); + options.onAction?.(); + + // No admission check here: cooling-down refusal lives in the registry + // guard (S4/R13/G33). The breaker below only RECORDS the outcome. + const throwTaskFailure = (message: string): never => { + breaker.recordTaskFailure(key); + logger.warn(`[route] refused (class=task): ${message}`); + if (isLadderOn(options.strictness) && !exec.signal.aborted) { + const failures = incidents.noteFailure(key); + if (failures >= INCIDENT_THRESHOLD) { + const milestone = strictnessOf(options) !== "final"; + throw new Error(`task: ${message} [incident: ${incidentPrompt(failures, key, milestone)}]`); + } + } + throw new Error(`task: ${message}`); + }; + // G13 in-flight dedup (mutating actions only): an identical call from + // the same agent while one is still running is a double-fire, not a + // retry — refuse it before queueing on the gate. Reads and sequential + // repeats are unaffected. + const dedup = EFFECT_OBSERVED_ACTIONS.has(action) + ? inflightKey(options.agentIdOfCall?.() ?? "anonymous", key, action, args) + : undefined; + if (dedup !== undefined && inflight.has(dedup)) { + throwTaskFailure( + `duplicate of the action already running on device ${key} — wait for its result instead of resending`, + ); + } + if (dedup !== undefined) inflight.add(dedup); + let lease: PoolLease; + try { + lease = await pool.acquire(args, exec.signal); + } catch (error) { + // An abort while queued never reached the device, so the in-flight mark has to go back: + // leaving it would refuse every later identical action as a "duplicate" forever. + if (dedup !== undefined) inflight.delete(dedup); + throw error; + } + key = lease.key; + try { + const result = await dispatch(action, args, key, exec).catch((error) => { + // Anything escaping dispatch is our own bug, not a device or task + // signal (G38) — except a user abort, which keeps its meaning. + if (exec.signal.aborted) { + return { ok: false, failure: "task", message: "action cancelled by the user" } as const; + } + return { + ok: false, + failure: "internal", + message: error instanceof Error ? error.message : String(error), + } as const; + }); + if (!result.ok) { + if (result.failure === "internal") { + logger.warn(`[route] internal (no breaker record): ${result.message}`); + throw new Error( + `internal: ${result.message} (plugin bug, not a device or task failure; do not retry the same call)`, + ); + } + if (result.failure === "environment") breaker.recordEnvironmentFailure(key); + else breaker.recordTaskFailure(key); + logger.warn(`[route] refused (class=${result.failure}): ${result.message}`); + if (result.failure === "task") throwTaskFailure(result.message); + throw new Error(`${result.failure}: ${result.message}`); + } + breaker.recordSuccess(key); + // Unchanged streaks count toward incident inside dispatch: resetting + // here would wipe them before they ever reach the threshold (D1). An + // unknown verdict is a detection failure (fail-open): it neither + // counts nor resets. + if (isLadderOn(options.strictness)) { + const effect = (result.value as { effect?: unknown } | null)?.effect; + if (effect !== "unknown" && effect !== "unchanged") incidents.noteSuccess(key); + } + logger.info(`[route] ok action=${action} device=${key}`); + // This is the ONE place a result reaches the model, so it is the one place + // a CLOSED notice can be attached - attached twice and the notice stops + // being one-turn (#62). + const closure = incidents.takeClosure(key); + if (closure === undefined) return result.value; + logger.info(`[route] incident closed device=${key} after ${closure.consecutive_failures} failures`); + return { + ...(result.value as Record), + incident_closed: closure, + closure_request: closurePrompt(closure, key), + }; + } finally { + if (dedup !== undefined) inflight.delete(dedup); + lease.release(); + } + }, + }; + + async function dispatch( + action: DeviceAction, + args: Record, + key: string, + exec: { signal: AbortSignal }, + isRetry = false, + ): Promise { + // wait is client-side: no device round-trip, nothing to break. + if (action === "wait") { + if (args.duration_ms !== undefined) { + const n = Number(args.duration_ms); + if (!Number.isFinite(n) || n < 0) { + return { ok: false, failure: "task", message: "duration_ms must be a finite number >= 0" }; + } + } + const ms = Math.min(60_000, Math.max(0, Number(args.duration_ms ?? 1000))); + await new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + timer.unref?.(); + }); + return { ok: true, value: { waited_ms: ms } }; + } + + let tool: string; + let payload: Record; + let value: unknown; + try { + const call = await buildCall(action, args, key, exec.signal); + tool = call.tool; + payload = call.payload; + value = call.value; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // G38: only prefixed errors carry a failure class. An unprefixed throw + // is our own defect — it must not pollute the breaker as a task failure + // (the old code read everything-not-environment as task, so plugin bugs + // counted as task failures and the model retried them). + if (message.startsWith("environment:")) { + return { ok: false, failure: "environment", message: message.replace(/^(environment|task):\s*/, "") }; + } + if (message.startsWith("task:")) { + return { ok: false, failure: "task", message: message.replace(/^(environment|task):\s*/, "") }; + } + return { ok: false, failure: "internal", message }; + } + + let structured: unknown; + const observesEffect = EFFECT_OBSERVED_ACTIONS.has(action); + const before = observesEffect ? await captureFrameSafe() : undefined; + try { + structured = await client.callTool(tool, payload, exec.signal); + } catch (error) { + if (exec.signal.aborted) { + return { ok: false, failure: "task", message: "action cancelled by the user" }; + } + const message = `action transport failed: ${String(error)}`; + logger.warn(`[route] ${message}`); + return { ok: false, failure: "environment", message }; + } + + const outcome = normalizeOutcome(structured); + if (!outcome.ok) { + return { ok: false, failure: classifyMessage(outcome.message), message: outcome.message }; + } + if (action === "get_ui_hierarchy") { + // Hand the model OUR numbering: the server's own `index` field is a + // per-container ordinal and cannot address an element (G42). Read the + // untruncated text — normalizeOutcome clips at 4000 chars, which would + // cut a real hierarchy dump mid-JSON and make it unparseable. + const elements = parseVisibleElements(unwrapText(structured)); + if (elements === undefined) { + // Same rule the resolution ladder already applies (G68 ⑤): an + // unreadable observation is the bypass case, so it must not come back + // to the model as a successful read of the screen. + return { + ok: false, + failure: "task", + message: + "hierarchy unreadable (not a node list) — re-run get_ui_hierarchy, " + + "or take a screenshot and use {coordinate:[x,y]}", + }; + } + // G67: this is the ONLY place the live observation is written. What the + // model is shown now defines which #N handles exist next turn. + observations.record(agentOfCall(), key, elements); + return { + ok: true, + value: { hierarchy: formatVisibleElements(elements), element_count: elements.length }, + }; + } + const observed = (value ?? outcome.value) as Record; + const strictness = strictnessOf(options); + const ladderOn = isLadderOn(options.strictness); + const finishSuccess = (val: Record): DispatchResult => { + if (isRetry) return { ok: true, value: val }; + if (ladderOn) incidents.noteSuccess(key); + if (strictness === "strict" && EFFECT_OBSERVED_ACTIONS.has(action)) { + return { ok: true, value: { ...val, self_check: SELF_CHECK_PROMPT } }; + } + return { ok: true, value: val }; + }; + // P1-a unchanged verdict (outer call only): pixel guidance, category, and + // a single incident count for the whole model call, however many device + // attempts the ladder ran. + const unchangedVerdict = (category: VerdictCategory): DispatchResult => { + const val: Record = { + ...observed, + effect: "unchanged", + category, + pixel_guidance: PIXEL_GUIDANCE, + }; + const failures = incidents.noteFailure(key); + if (failures >= INCIDENT_THRESHOLD) { + val.incident = { consecutive_failures: failures }; + val.audit_request = incidentPrompt(failures, key, strictness !== "final"); + } + return { ok: true, value: val }; + }; + if (!observesEffect) { + return finishSuccess(observed); + } + + // S11 step 3: the router observes the screen itself instead of trusting the + // model's reading of it. + await settle(EFFECT_SETTLE_MS); + const after = await captureFrameSafe(); + const coordinates = Array.isArray(payload.coordinates) ? payload.coordinates : undefined; + const changed = + before && after ? await compareFramesSafe(before, after, tool, coordinates) : undefined; + if (changed !== false) { + // Changed, or unknown (no verdict: fail-open — no ladder, no incident). + logger.info(`[route] effect=${changed ? "changed" : "unknown"} action=${action} device=${key}`); + if (changed === undefined) return { ok: true, value: { ...observed, effect: "unknown" } }; + return finishSuccess({ ...observed, effect: "changed" }); + } + logger.info(`[route] effect=unchanged action=${action} device=${key}`); + if (!ladderOn || isRetry) return { ok: true, value: { ...observed, effect: "unchanged" } }; + + // P1-a post-execution ladder: re-resolve an index/text target against a + // FRESH hierarchy and run the action once more as plain coordinates. + // Coordinate-called and target-less actions go straight to pixel guidance — + // bursts and launch_app never retry upstream. + // + // A transport death here is environment (the server died between tap and + // re-resolution), not a plugin defect: only genuinely unprefixed throws + // keep falling through to the execute-level internal bucket (D2). + let retry: { status: "found"; normalized: [number, number] } | { status: "missing" } | { status: "nontarget" }; + try { + retry = await reresolveAsCoordinate((args.target ?? {}) as TargetInput, key, exec.signal); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.startsWith("environment:")) { + return { ok: false, failure: "environment", message: message.replace(/^(environment|task):\s*/, "") }; + } + throw error; + } + if (retry.status === "found") { + const retried = await dispatch( + action, + { ...args, target: { coordinate: retry.normalized } }, + key, + exec, + true, + ); + if (!retried.ok) return retried; + const retriedValue = retried.value as Record; + if (retriedValue.effect === "changed") return finishSuccess(retriedValue); + if (retriedValue.effect === "unknown") return { ok: true, value: retriedValue }; + return unchangedVerdict("general"); + } + return unchangedVerdict(retry.status === "missing" ? "disappeared" : "general"); + } + + /** + * Re-resolve the ORIGINAL index/text target against a fresh hierarchy and + * return it as normalized coordinates for one retry. "missing" means the + * target is gone (disappeared); "nontarget" means retrying makes no sense + * (coordinate-called, target-less, or an unreadable hierarchy). + */ + async function reresolveAsCoordinate( + tgt: TargetInput, + deviceKey: string, + signal: AbortSignal, + ): Promise<{ status: "found"; normalized: [number, number] } | { status: "missing" } | { status: "nontarget" }> { + const retryable = + tgt.coordinate === undefined && + (typeof tgt.index === "number" || (typeof tgt.text === "string" && tgt.text.length > 0)); + if (!retryable) return { status: "nontarget" }; + const xml = unwrapText(await callTransport(client, "get_ui_hierarchy", {}, signal)); + const fresh = parseVisibleElements(xml); + if (fresh === undefined) return { status: "nontarget" }; + const hit = resolveTarget(tgt, fresh, deviceKey); + if (!("center" in hit)) return { status: "missing" }; + const center = hit.center; + const size = await requireScreenSize(deviceKey); + const nx = Math.max(0, Math.min(1000, Math.round((center.x / size.width) * 1000))); + const ny = Math.max(0, Math.min(1000, Math.round((center.y / size.height) * 1000))); + return { status: "found", normalized: [nx, ny] }; + } + + async function captureFrameSafe(): Promise { + try { + return await options.captureFrame?.(); + } catch (error) { + logger.warn(`[route] effect frame failed: ${(error as Error)?.message ?? error}`); + return undefined; + } + } + + async function compareFramesSafe( + before: string, + after: string, + tool: string, + coordinates?: number[], + ): Promise { + try { + return await options.compareFrames?.(before, after, tool, coordinates); + } catch (error) { + logger.warn(`[route] effect comparison failed: ${(error as Error)?.message ?? error}`); + return undefined; + } + } + + async function buildCall( + action: DeviceAction, + args: Record, + key: string, + signal: AbortSignal, + ): Promise<{ tool: string; payload: Record; value?: unknown }> { + switch (action) { + case "click": { + const { x, y } = await resolvePixelTarget(args, key, signal); + return { tool: "tap", payload: { coordinates: [x, y], times: 1, delay_ms: 100 } }; + } + case "long_press": { + assertPositiveDuration(args.duration_ms, "duration_ms"); + const { x, y } = await resolvePixelTarget(args, key, signal); + return { tool: "long_press_on", payload: { coordinates: [x, y], duration: Number(args.duration_ms ?? 1000) } }; + } + case "swipe": { + const size = await requireScreenSize(key); + const from = (args.target as TargetInput | undefined)?.coordinate; + const to = args.end_coordinate as number[] | undefined; + assertNormalizedPair(from, "target.coordinate"); + assertNormalizedPair(to, "end_coordinate"); + assertPositiveDuration(args.duration_ms, "duration_ms"); + return { + tool: "swipe", + payload: { + coordinates: [ + toPixels(from[0] as number, size.width), toPixels(from[1] as number, size.height), + toPixels(to[0] as number, size.width), toPixels(to[1] as number, size.height), + ], + duration: Number(args.duration_ms ?? 400), + }, + }; + } + case "input_text": { + assertNonEmptyString(args.text, "text"); + const { x, y } = await resolvePixelTarget(args, key, signal); + return { + tool: "focus_and_input_text", + payload: { + coordinates: [x, y], + text: String(args.text ?? ""), + clear_before_input: args.clear_before_input === true, + }, + }; + } + case "focus_and_clear_text": { + const { x, y } = await resolvePixelTarget(args, key, signal); + return { tool: "focus_and_clear_text", payload: { coordinates: [x, y] } }; + } + case "press_key": { + assertNonEmptyString(args.key, "key"); + const keyName = String(args.key ?? ""); + const keycode = mapKeycode(keyName); + if (keycode === undefined) { + throw new Error(`task: unknown key "${keyName}" (use home/enter/delete/... or a KEYCODE_* name)`); + } + return { tool: "press_key", payload: { keycode } }; + } + case "back": + return { tool: "back", payload: {} }; + case "erase_one_char": + return { tool: "erase_one_char", payload: {} }; + case "launch_app": + assertNonEmptyString(args.package, "package"); + return { tool: "launch_app", payload: { package_name: String(args.package ?? "") } }; + case "stop_app": + assertNonEmptyString(args.package, "package"); + return { tool: "stop_app", payload: { package_name: String(args.package ?? "") } }; + case "open_link": + assertNonEmptyString(args.url, "url"); + return { tool: "open_link", payload: { url: String(args.url ?? "") } }; + case "take_screenshot": { + const base64 = unwrapText(await callTransport(client, "take_screenshot", {}, signal)); + if (/^errorb/i.test(base64)) { + throw new Error(`environment: ${base64.replace(/^errorb:?s*/i, "").trim()}`); + } + const bytes = decodeImageBase64(base64); + const size = parseImageSize(bytes); + const mediaType = imageMediaTypeOf(bytes); + const image = mediaType ? await options.saveScreenshot?.(bytes, mediaType) : undefined; + return { + tool: "take_screenshot", + payload: {}, + value: { + screenshot: size ? `captured ${size.width}x${size.height}` : "captured", + ...(image ? { image } : {}), + }, + }; + } + case "get_ui_hierarchy": + return { tool: "get_ui_hierarchy", payload: {} }; + default: + // `wait` never reaches buildCall (handled client-side in dispatch). + throw new Error(`task: unsupported action "${action}"`); + } + } +} + +function mapKeycode(keyName: string): string | undefined { + if (/^KEYCODE_[A-Z0-9_]+$/.test(keyName)) return keyName; + if (/^\d$/.test(keyName)) return `KEYCODE_${keyName}`; + return KEYCODES[keyName.toLowerCase()]; +} + +/** + * The legacy server answers in plain strings: "Success" / "Failed" / + * "Error: ..." (and informational text). Normalize to one shape. + */ +function normalizeOutcome(structured: unknown): { + ok: boolean; + message: string; + value?: unknown; +} { + if (typeof structured === "string") { + const text = structured.trim(); + // Two observed error shapes: "Error: ..." and "Error executing tool ...". + if (/^error\b/i.test(text)) { + return { ok: false, message: text.replace(/^error\b:?\s*/i, "") }; + } + if (text === "Failed") return { ok: false, message: "device reported failure" }; + return { ok: true, message: text.slice(0, 400), value: { result: text.slice(0, 4000) } }; + } + const record = (structured ?? {}) as Record; + if (record.ok === true) return { ok: true, message: "", value: record }; + if (record.ok === false) { + return { ok: false, message: typeof record.message === "string" ? record.message : "device refused" }; + } + if (Array.isArray(record.content)) { + return normalizeOutcome(unwrapText(record)); + } + if (typeof record.result === "string") return normalizeOutcome(record.result); + return { ok: true, message: "", value: record }; +} diff --git a/plugins/dsh-autogamer/src/tools/runTask.ts b/plugins/dsh-autogamer/src/tools/runTask.ts new file mode 100644 index 000000000..df0cf66e9 --- /dev/null +++ b/plugins/dsh-autogamer/src/tools/runTask.ts @@ -0,0 +1,115 @@ +/** + * `autogamer_run_task` — start an Android automation task, return immediately + * (Phase 2). + * + * Backend mapping is locked against the fork's old face: our `instruction` + * goes out as `task_desc`, `profile` as `model` ("Flash"/"Pro"), and the + * answer's `trace_id` comes back as our `task_id`. Two accepted params have + * NO backend counterpart (verified signature): `timeout_seconds` and + * `max_steps` are accepted for contract stability but not forwarded — + * enforcement is the caller's polling loop via `autogamer_manage_task`. + * Defaults (`profile`, serial) fall back to the plugin Config. + */ +import type { ArtemisBackend } from "../artemis/types.js"; +import type { PreflightResult } from "../env/preflight.js"; +import type { ToolDefinitionInput } from "../dsh-types.js"; +import { requireEnv } from "./envGate.js"; + +export interface RunTaskDeps { + backend: ArtemisBackend; + checkEnv: () => Promise; + defaultProfile: "flash" | "pro"; + defaultDeviceSerial: string; +} + +export const MAX_INSTRUCTION_CHARS = 2000; + +export function defineRunTask(deps: RunTaskDeps): ToolDefinitionInput { + return { + name: "autogamer_run_task", + description: + "Start an Android automation task through the external ARTEMIS backend (driven by Google ARTEMIS). " + + "Returns immediately with a task_id; poll it with autogamer_manage_task. " + + "flash is fast/reactive, pro is thorough/multi-agent.", + parameters: { + instruction: { + type: "string", + required: true, + description: "Natural-language task instruction (non-empty, under 2000 characters).", + }, + profile: { + type: "string", + enum: ["flash", "pro"], + description: "Execution profile; omit for the configured default.", + }, + device_serial: { + type: "string", + description: "Target device serial; omit for the default device.", + }, + timeout_seconds: { + type: "number", + description: "Accepted but not enforced by the backend (no such provider param): poll and stop yourself.", + }, + max_steps: { + type: "number", + description: "Accepted but not forwarded (no such provider param).", + }, + }, + output: { + schema: { + type: "object", + properties: { + task_id: { type: "string", required: true }, + status: { type: "string", required: true }, + profile: { type: "string" }, + device_serial: { type: "string" }, + started_at: { type: "string" }, + }, + additionalProperties: true, + }, + render: (_args, value) => { + const record = (value ?? {}) as Record; + return [ + { + type: "text", + text: + `task_id=${String(record.task_id ?? "")}; status=${String(record.status ?? "")}; ` + + `profile=${String(record.profile ?? "")}; device_serial=${String(record.device_serial ?? "")}`, + }, + ]; + }, + }, + async execute(args, exec) { + const instruction = args.instruction; + if (typeof instruction !== "string" || instruction.trim().length === 0) { + throw new Error("task: instruction must be a non-empty string"); + } + if (instruction.length >= MAX_INSTRUCTION_CHARS) { + throw new Error(`task: instruction is ${instruction.length} chars, under-${MAX_INSTRUCTION_CHARS} only`); + } + await requireEnv(deps.checkEnv); + const profile = args.profile === "pro" ? "pro" : args.profile === "flash" ? "flash" : deps.defaultProfile; + const serial = + typeof args.device_serial === "string" && args.device_serial.length > 0 + ? args.device_serial + : deps.defaultDeviceSerial.length > 0 + ? deps.defaultDeviceSerial + : undefined; + const info = await deps.backend.startTask( + { + instruction, + profile: profile === "pro" ? "Pro" : "Flash", + ...(serial !== undefined ? { deviceSerial: serial } : {}), + }, + exec?.signal, + ); + return { + task_id: info.taskId, + status: "running", + profile, + device_serial: info.deviceSerial ?? serial ?? "", + started_at: info.startedAt, + }; + }, + }; +} diff --git a/plugins/dsh-autogamer/src/turnLedger.ts b/plugins/dsh-autogamer/src/turnLedger.ts new file mode 100644 index 000000000..6bcfe9760 --- /dev/null +++ b/plugins/dsh-autogamer/src/turnLedger.ts @@ -0,0 +1,59 @@ +/** + * Per-agent turn bookkeeping (G44). + * + * S2 defines the terminal state as `report_task_status`'s result, but a local + * model was observed repeatedly finishing a turn with plain text after doing the + * device work - no report, so the dual-axis outcome simply did not exist. A + * prompt-level obligation is disproven as the only measure, so the plugin tracks + * who acted and who reported, and nudges once when a turn ends without one. + * + * Keyed by agent id, which is the session id (the official round-driver looks + * agents up as `ctx.agents.get(session.id)`). + */ +export interface TurnState { + acted: number; + reported: boolean; + /** Followups already issued - the bound that keeps this from looping. */ + nudged: number; +} + +export const MAX_REPORT_NUDGES = 1; + +export class TurnLedger { + private readonly states = new Map(); + + private state(agentId: string): TurnState { + let state = this.states.get(agentId); + if (!state) { + state = { acted: 0, reported: false, nudged: 0 }; + this.states.set(agentId, state); + } + return state; + } + + markActed(agentId: string | undefined): void { + if (!agentId) return; + this.state(agentId).acted += 1; + } + + markReported(agentId: string | undefined): void { + if (!agentId) return; + this.state(agentId).reported = true; + } + + /** Consumes the turn: true when the agent must be asked to report. */ + consumeTurn(agentId: string | undefined): boolean { + if (!agentId) return false; + const state = this.state(agentId); + const needs = state.acted > 0 && !state.reported && state.nudged < MAX_REPORT_NUDGES; + if (needs) state.nudged += 1; + else this.states.delete(agentId); + return needs; + } +} + +/** The nudge text: ask for the report, forbid further device work. */ +export const REPORT_NUDGE = + "The previous turn ended after device actions without calling report_task_status. " + + "Report the outcome now with report_task_status (completed or blocked) and its summary. " + + "Do not call run_device_action or any other tool in this turn."; diff --git a/plugins/dsh-autogamer/tests/actionClient.spec.ts b/plugins/dsh-autogamer/tests/actionClient.spec.ts new file mode 100644 index 000000000..ee1a7b732 --- /dev/null +++ b/plugins/dsh-autogamer/tests/actionClient.spec.ts @@ -0,0 +1,50 @@ +/** + * ActionClient lifecycle: a failed connect must not poison later calls. + * `ensure()` caches the starting promise, so a rejected start has to clear + * the cache — otherwise every later call rethrows the same stale connection + * error instead of respawning the server (found via the G33 guard tests: the + * second spawn attempt surfaced a stale "Connection closed"). + */ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ActionClient } from "../src/actionClient.js"; + +let dirs: string[] = []; +let clients: ActionClient[] = []; + +afterEach(async () => { + for (const client of clients.splice(0)) await client.dispose().catch(() => undefined); + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function countFile(): { dir: string; file: string } { + const dir = mkdtempSync(join(tmpdir(), "g33-respawn-")); + dirs.push(dir); + const file = join(dir, "spawns.txt"); + writeFileSync(file, ""); + return { dir, file }; +} + +describe("ActionClient respawn", () => { + it("respawns the server after a failed connect instead of replaying the stale error", async () => { + const { file } = countFile(); + const client = new ActionClient({ + // Exits at once: the handshake never completes, so connect() rejects. + command: [ + process.execPath, + "-e", + "require('fs').appendFileSync(process.env.G33_COUNT_FILE, 'x'); process.exit(1)", + ], + env: { G33_COUNT_FILE: file }, + callTimeoutMs: 5_000, + }); + clients.push(client); + + await expect(client.callTool("back", {})).rejects.toThrow(); + await expect(client.callTool("back", {})).rejects.toThrow(); + // Two spawns, not one spawn plus a cached rejection. + expect(readFileSync(file, "utf8")).toBe("xx"); + }); +}); diff --git a/plugins/dsh-autogamer/tests/adapterEnvGate.spec.ts b/plugins/dsh-autogamer/tests/adapterEnvGate.spec.ts new file mode 100644 index 000000000..759f1f458 --- /dev/null +++ b/plugins/dsh-autogamer/tests/adapterEnvGate.spec.ts @@ -0,0 +1,42 @@ +/** + * Adapter and env-gate unit locks (Phase 2). The adapter is a factory (one + * backend kind survives the CLI cut); the gate is the single refusal + * vocabulary shared by every autogamer_* tool. + */ +import { describe, expect, it } from "vitest"; +import { createArtemisBackend } from "../src/artemis/adapter.js"; +import { McpArtemisBackend } from "../src/artemis/mcpBackend.js"; +import { requireEnv } from "../src/tools/envGate.js"; +import type { PreflightResult } from "../src/env/preflight.js"; + +describe("adapter: factory", () => { + it("builds the MCP backend without connecting", async () => { + const backend = createArtemisBackend({ command: ["python", "-m", "mcp_server"], callTimeoutMs: 1000 }); + expect(backend).toBeInstanceOf(McpArtemisBackend); + await backend.dispose(); + }); + + it("does not refuse an unconfigured provider at construction: tools gate first", async () => { + const backend = createArtemisBackend({ command: ["python"], callTimeoutMs: 1000 }); + expect(backend).toBeInstanceOf(McpArtemisBackend); + await backend.dispose(); + }); +}); + +describe("envGate: one refusal vocabulary", () => { + it("passes a healthy environment silently", async () => { + const healthy: PreflightResult = { ok: true, checks: [], finishedAt: "t" }; + await expect(requireEnv(async () => healthy)).resolves.toBeUndefined(); + }); + + it("throws environment: with the first fix on failure", async () => { + const broken: PreflightResult = { + ok: false, + checks: [{ id: "adb", ok: false, detail: "missing", fix: "install platform-tools" }], + finishedAt: "t", + }; + await expect(requireEnv(async () => broken)).rejects.toThrow( + /^environment: preflight failed at adb.*install platform-tools/, + ); + }); +}); diff --git a/plugins/dsh-autogamer/tests/apply.spec.ts b/plugins/dsh-autogamer/tests/apply.spec.ts new file mode 100644 index 000000000..143881e9e --- /dev/null +++ b/plugins/dsh-autogamer/tests/apply.spec.ts @@ -0,0 +1,701 @@ +/** + * apply() wiring lock. The unit tests around `configuredScreenSize` alone are + * not enough: the defect lived at the call site, where a truthiness check on + * the Schemastery-resolved tuple (`[undefined, undefined]`) read as + * "operator configured it" and fed undefined dimensions into the 0-1000 → + * pixel conversion. These tests drive the registered tool through apply() and + * assert the coordinates the py action server actually receives. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fileURLToPath } from "node:url"; +import { parseImageSize } from "../src/coord.js"; +import { + PIXEL_GUIDANCE, + SELF_CHECK_PROMPT, + closurePrompt, + incidentPrompt, +} from "../src/tools/runDeviceAction.js"; +import type { DshImageAttachment } from "../src/dsh-types.js"; + +/** A 1080x2400 PNG: signature + IHDR carrying the dimensions at byte 16/20. */ +function pngBytes(): Buffer { + const bytes = Buffer.alloc(33); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(bytes, 0); + bytes.writeUInt32BE(13, 8); + bytes.write("IHDR", 12, "ascii"); + bytes.writeUInt32BE(1080, 16); + bytes.writeUInt32BE(2400, 20); + return bytes; +} + +const harness = vi.hoisted(() => ({ + calls: [] as { tool: string; args: Record }[], + clientOptions: [] as Record[], + /** What compare_ui_frames should answer; undefined means "no verdict". */ + changed: true as boolean | undefined, + disposed: 0, +})); + +vi.mock("../src/actionClient.js", () => ({ + ActionClient: class { + constructor(options: Record) { + harness.clientOptions.push(options); + } + + async callTool(tool: string, args: Record) { + harness.calls.push({ tool, args }); + if (tool === "take_screenshot") { + return { content: [{ type: "text", text: pngBytes().toString("base64") }] }; + } + if (tool === "compare_ui_frames") { + return { + content: [{ type: "text", text: JSON.stringify({ changed: harness.changed }) }], + }; + } + if (tool === "get_ui_hierarchy") { + // Live shape: a JSON node list whose own `index` is per-container. + return { + content: [ + { + type: "text", + text: JSON.stringify([ + { index: "0", text: "亮度", "content-desc": "", "resource-id": "", class: "android.widget.TextView", package: "com.android.settings", clickable: "true", parsed_bounds: { left: 40, top: 400, right: 200, bottom: 460 } }, + { index: "1", text: "显示", "content-desc": "", "resource-id": "", class: "android.widget.TextView", package: "com.android.settings", clickable: "true", parsed_bounds: { left: 40, top: 600, right: 200, bottom: 660 } }, + ]), + }, + ], + }; + } + return { content: [{ type: "text", text: "Success" }] }; + } + + async dispose() { + harness.disposed += 1; + } + }, +})); + +type ToolDefinition = { + name: string; + execute(args: Record, exec: { signal: AbortSignal }): Promise; + output?: { + render(args: unknown, value: unknown): { type: string; text?: string; attachment?: unknown }[]; + presentationMeta?(args: unknown, value: unknown): Record; + }; +}; + +/** + * The preflight runner is module-mocked here so apply() never spawns real + * `uv`/`adb` processes across its dozens of wiring tests: the real runner's + * subprocess behaviour is locked in tests/preflight.spec.ts with injected + * doubles, and the warm-up call itself is asserted below. + */ +const preflightProbe = vi.hoisted(() => ({ runs: 0 })); + +vi.mock("../src/env/preflight.js", () => ({ + createPreflight: () => ({ + run: async () => { + preflightProbe.runs += 1; + return { ok: true, checks: [], finishedAt: "2026-10-14T00:00:00.000Z" }; + }, + }), + preflightErrorText: () => "environment: mocked preflight failure", +})); + +const { apply, Config } = await import("../src/index.js"); + +/** + * Cordis-faithful context double. `effect(setup)` runs `setup` IMMEDIATELY and + * keeps its returned function as the teardown (verified against upstream + * mount.ts:208 / agent-preset-registry/src/index.ts:112) — the double used to + * omit `effect` entirely, which let a teardown-as-callback bug (G30: apply() + * disposed its own tool registrations) pass CI while every live session lost + * the tool face. Services are real lookups, and `on(name, handler, options)` + * records the options so the scope-filter bypass (`{ global: true }`) is itself + * under test. + */ +function fakeContext( + definitions: ToolDefinition[], + attachments?: { saveImages(inputs: readonly unknown[]): Promise }, + agentId = "agent-1", +) { + const cleanups: unknown[] = []; + const listeners: Array<{ handler: (session: unknown, event: unknown) => void; options?: { global?: boolean } }> = []; + const guards: Array<(execution: { name: string; arguments: unknown }) => string | undefined> = []; + const followups: unknown[] = []; + const sections: { name: string; order: number; text: string }[] = []; + const agent = { id: agentId, followup: (message: unknown) => { followups.push(message); } }; + const context = { + logger: { info: () => {}, warn: () => {}, error: () => {} }, + tools: { + register(definition: unknown) { + definitions.push(definition as ToolDefinition); + return () => {}; + }, + guard(guard: (execution: { name: string; arguments: unknown }) => string | undefined) { + guards.push(guard); + return () => {}; + }, + }, + agents: { + get: (id: string) => (id === agentId ? agent : undefined), + currentInitiator: () => agent, + }, + on(event: string, handler: (session: unknown, payload: unknown) => void, options?: { global?: boolean }) { + if (event === "session/event") listeners.push({ handler, options }); + return () => {}; + }, + ...(attachments ? { attachments } : {}), + systemPrompt: { + section: (input: { name: string; order: number; text: string }) => { + sections.push(input); + return undefined; + }, + }, + effect(setup: () => unknown): void { + cleanups.push(setup()); + }, + }; + return { + context, + cleanups, + followups, + sections, + guards: () => guards, + listenerOptions: () => listeners.map((l) => l.options), + emit: (type: string, id = agentId) => { + for (const l of listeners) l.handler({ id }, { type }); + }, + }; +} + +async function registeredTools( + config: Record, + attachments?: { saveImages(inputs: readonly unknown[]): Promise }, +) { + const definitions: ToolDefinition[] = []; + harness.calls.length = 0; + const double = fakeContext(definitions, attachments); + await apply(double.context as never, config as never); + const find = (name: string) => { + const tool = definitions.find((d) => d.name === name); + if (!tool) throw new Error(`${name} was not registered (got: ${definitions.map((d) => d.name).join(", ")})`); + return tool; + }; + return { ...double, runDeviceAction: find("run_device_action"), reportTaskStatus: find("report_task_status") }; +} + +async function registerToolWith( + config: Record, + attachments?: { saveImages(inputs: readonly unknown[]): Promise }, +): Promise { + const definitions: ToolDefinition[] = []; + harness.calls.length = 0; + const { context } = fakeContext(definitions, attachments); + await apply(context as never, config as never); + const tool = definitions.find((definition) => definition.name === "run_device_action"); + if (!tool) throw new Error(`run_device_action was not registered (got: ${definitions.map((d) => d.name).join(", ")})`); + return tool; +} + +function exec(): { signal: AbortSignal } { + return { signal: new AbortController().signal }; +} + +beforeEach(() => { + harness.calls.length = 0; + harness.clientOptions.length = 0; + harness.changed = true; + harness.disposed = 0; + preflightProbe.runs = 0; +}); + +describe("apply(): screen size sourcing wiring", () => { + it("probes the screenshot when no screenSize is configured", async () => { + const tool = await registerToolWith(Config()); + await tool.execute({ action: "click", target: { coordinate: [500, 500] }, reason: "probe path" }, exec()); + + expect(harness.calls.map((call) => call.tool)).toContain("take_screenshot"); + const tap = harness.calls.find((call) => call.tool === "tap"); + expect(tap?.args.coordinates).toEqual([540, 1200]); + }); + + it("honours an explicit screenSize without probing", async () => { + const tool = await registerToolWith(Config({ screenSize: [720, 1600] })); + await tool.execute({ action: "click", target: { coordinate: [500, 500] }, reason: "configured" }, exec()); + + // Effect observation captures a before-frame and an after-frame, so "no + // probe" means exactly two captures around the tap, not three. + expect(harness.calls.filter((call) => call.tool === "take_screenshot")).toHaveLength(2); + const tap = harness.calls.find((call) => call.tool === "tap"); + expect(tap?.args.coordinates).toEqual([360, 800]); + }); + + it("never leaks the schema's empty tuple into pixel math", async () => { + const tool = await registerToolWith(Config()); + const result = await tool.execute( + { action: "click", target: { coordinate: [500, 500] }, reason: "NaN guard" }, + exec(), + ); + const tap = harness.calls.find((call) => call.tool === "tap"); + expect(tap, "the action must not be dispatched with unresolved dimensions").toBeDefined(); + expect(JSON.stringify((tap?.args as { coordinates: number[] }).coordinates)).not.toContain("null"); + expect(result).toBeDefined(); + }); + + it("passes the resolved action-server settings through to the client", async () => { + await registerToolWith(Config()); + expect(harness.clientOptions).toHaveLength(1); + expect(harness.clientOptions[0]?.command).toEqual([ + "python", + "-m", + "artemis.interfaces.cli.main", + "mcp", + "--type", + "adb", + ]); + }); +}); + +describe("apply(): registration lifetime (G30 regression)", () => { + it("returns teardown from effect() instead of running it during apply()", async () => { + const definitions: ToolDefinition[] = []; + harness.calls.length = 0; + const { context, cleanups } = fakeContext(definitions); + await apply(context as never, Config() as never); + + // All three tools must still be registered once apply() resolved: cordis + // effect() takes the RETURNED function as the disposer. The broken + // ancestor passed teardown as the callback body, so it executed inline, + // disposing both registrations within apply() while every official + // checker still reported the row "running". + expect(definitions.map((definition) => definition.name)).toEqual([ + "run_device_action", + "report_task_status", + "autogamer_get_device_state", + "autogamer_run_task", + "autogamer_manage_task", + "autogamer_inspect_trace", + "autogamer_diagnose", + ]); + expect(cleanups).toHaveLength(1); + expect(typeof cleanups[0]).toBe("function"); + // apply() itself must not have disposed anything synchronously. + expect(harness.disposed).toBe(0); + }); + + it("tears down the action client only when the effect disposer runs", async () => { + const definitions: ToolDefinition[] = []; + const { context, cleanups } = fakeContext(definitions); + await apply(context as never, Config() as never); + + const cleanup = cleanups[0] as () => void; + cleanup(); + expect(harness.disposed).toBe(1); + }); +}); + +describe("apply(): screenshot media path (G36)", () => { + const ref: DshImageAttachment = { + attachmentId: "opaque-id-from-the-store", + mediaType: "image/png", + bytes: 33, + width: 1080, + height: 2400, + }; + + it("commits bytes to the attachment store and renders [text, image]", async () => { + const saved: { data: Uint8Array; mediaType: string }[] = []; + const tool = await registerToolWith(Config(), { + saveImages: async (inputs) => { + saved.push(...(inputs as { data: Uint8Array; mediaType: string }[])); + return [ref]; + }, + }); + + const value = (await tool.execute({ action: "take_screenshot", reason: "look" }, exec())) as Record< + string, + unknown + >; + expect(saved).toHaveLength(1); + expect(saved[0].mediaType).toBe("image/png"); + expect(parseImageSize(saved[0].data)).toEqual({ width: 1080, height: 2400 }); + expect(value.image).toEqual(ref); + expect(value.screenshot).toBe("captured 1080x2400"); + + const blocks = tool.output!.render({}, value); + expect(blocks.map((block) => block.type)).toEqual(["text", "image"]); + expect(blocks[0].text).toBe("screenshot=captured 1080x2400"); + expect(blocks[1].attachment).toBe(ref); + }); + + it("keeps encoded bytes out of the persisted tool value", async () => { + const tool = await registerToolWith(Config(), { saveImages: async () => [ref] }); + const value = await tool.execute({ action: "take_screenshot", reason: "look" }, exec()); + // The session log records the result value; only the reference may appear. + expect(JSON.stringify(value)).not.toContain(pngBytes().toString("base64")); + }); + + it("still reports dimensions when the store refuses the image", async () => { + const tool = await registerToolWith(Config(), { + saveImages: async () => { + throw new Error("AttachmentError: exceeds maxImageBytes"); + }, + }); + const value = (await tool.execute({ action: "take_screenshot", reason: "look" }, exec())) as Record< + string, + unknown + >; + expect(value).toEqual({ screenshot: "captured 1080x2400" }); + expect(tool.output!.render({}, value)).toEqual([{ type: "text", text: "screenshot=captured 1080x2400" }]); + }); +}); + +describe("apply(): effect observation (S10 ② / G39 route A)", () => { + it("reports unchanged when the action server sees no screen change", async () => { + harness.changed = false; + const tool = await registerToolWith(Config({ screenSize: [720, 1600] })); + const value = (await tool.execute( + { action: "click", target: { coordinate: [500, 500] }, reason: "observe" }, + exec(), + )) as Record; + expect(value.effect).toBe("unchanged"); + }); + + it("reports changed when the action server sees the screen move", async () => { + harness.changed = true; + const tool = await registerToolWith(Config({ screenSize: [720, 1600] })); + const value = (await tool.execute( + { action: "back", reason: "observe" }, + exec(), + )) as Record; + expect(value.effect).toBe("changed"); + }); + + it("hands the tap coordinates to the verdict so the ROI rule can apply", async () => { + const tool = await registerToolWith(Config({ screenSize: [720, 1600] })); + await tool.execute({ action: "click", target: { coordinate: [500, 500] }, reason: "observe" }, exec()); + const compare = harness.calls.find((call) => call.tool === "compare_ui_frames"); + expect(compare?.args.action).toBe("tap"); + expect(compare?.args.coordinates).toEqual([360, 800]); + }); + + it("reports unknown rather than guessing when no verdict comes back", async () => { + harness.changed = undefined; + const tool = await registerToolWith(Config({ screenSize: [720, 1600] })); + const value = (await tool.execute({ action: "back", reason: "observe" }, exec())) as Record; + expect(value.effect).toBe("unknown"); + }); + + it("takes no observation frames for read-only actions", async () => { + const tool = await registerToolWith(Config({ screenSize: [720, 1600] })); + const value = (await tool.execute({ action: "get_ui_hierarchy", reason: "read" }, exec())) as Record< + string, + unknown + >; + expect(value).not.toHaveProperty("effect"); + expect(harness.calls.map((call) => call.tool)).toEqual(["get_ui_hierarchy"]); + }); +}); + +describe("apply(): turn conclusion on the reported outcome (G41)", () => { + async function reportWith(exec: { signal: AbortSignal; concludeTurn?(): void }) { + const definitions: ToolDefinition[] = []; + const { context } = fakeContext(definitions); + await apply(context as never, Config() as never); + const tool = definitions.find((d) => d.name === "report_task_status"); + if (!tool) throw new Error("report_task_status was not registered"); + return tool.execute( + { status: "blocked", summary: "device never answered", reason: "report" }, + exec, + ); + } + + it("concludes the agent turn once the outcome is reported", async () => { + let concluded = 0; + await reportWith({ signal: new AbortController().signal, concludeTurn: () => { concluded += 1; } }); + expect(concluded).toBe(1); + }); + + it("does not let a device action conclude the turn", async () => { + const tool = await registerToolWith(Config({ screenSize: [720, 1600] })); + let concluded = 0; + await tool.execute({ action: "back", reason: "act" }, { + signal: new AbortController().signal, + concludeTurn: () => { concluded += 1; }, + }); + expect(concluded).toBe(0); + }); +}); + +describe("apply(): element handles reach the device (G42)", () => { + it("resolves a #N handle to that element's pixel center", async () => { + const tool = await registerToolWith(Config({ screenSize: [1600, 900] })); + // G67: a handle is only meaningful against the list the model was shown. + await tool.execute({ action: "get_ui_hierarchy", reason: "look" }, exec()); + await tool.execute({ action: "click", target: { index: 1 }, reason: "tap 显示" }, exec()); + const tap = harness.calls.find((call) => call.tool === "tap"); + // #1 is the 显示 row at bounds [40,600]-[200,660] on a 1600x900 screen. + expect(tap?.args.coordinates).toEqual([120, 630]); + }); + + it("hands the model a numbered list rather than the raw per-container index", async () => { + const tool = await registerToolWith(Config({ screenSize: [1600, 900] })); + const value = (await tool.execute({ action: "get_ui_hierarchy", reason: "look" }, exec())) as Record< + string, + unknown + >; + expect(value.element_count).toBe(2); + const lines = String(value.hierarchy).split("\n"); + expect(lines[0]).toMatch(/^#0 /); + expect(lines[1]).toMatch(/^#1 .*显示$/); + }); +}); + +describe("apply(): unreported turns are nudged (G44 v2)", () => { + const CONFIG = Config({ screenSize: [720, 1600] }) as Record; + + it("registers its session listener to bypass scope filtering", async () => { + const tools = await registeredTools(CONFIG); + expect(tools.listenerOptions()).toEqual([{ global: true }]); + }); + + it("follows up once when a turn ended after device actions without reporting", async () => { + const tools = await registeredTools(CONFIG); + await tools.runDeviceAction.execute({ action: "back", reason: "act" }, exec()); + tools.emit("turn/end"); + // Rule R23: appending inside the emit is rejected by the host + // ("session append cannot reenter while another append is being published"), + // so the call must NOT have happened yet at this point in the same task. + expect(tools.followups).toHaveLength(0); + // The followup is deferred out of the append/publication boundary, so let the + // macrotask run before asserting it landed. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(tools.followups).toHaveLength(1); + const text = JSON.stringify(tools.followups[0]); + expect(text).toContain("report_task_status"); + expect(text).toContain("Do not call run_device_action"); + }); + + it("stays silent when the turn reported its outcome", async () => { + const tools = await registeredTools(CONFIG); + await tools.runDeviceAction.execute({ action: "back", reason: "act" }, exec()); + await tools.reportTaskStatus.execute({ status: "completed", summary: "done" }, exec()); + tools.emit("turn/end"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(tools.followups).toHaveLength(0); + }); + + it("never nudges twice for the same agent", async () => { + const tools = await registeredTools(CONFIG); + await tools.runDeviceAction.execute({ action: "back", reason: "act" }, exec()); + tools.emit("turn/end"); + await new Promise((resolve) => setTimeout(resolve, 0)); + await tools.runDeviceAction.execute({ action: "back", reason: "again" }, exec()); + tools.emit("turn/end"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(tools.followups).toHaveLength(1); + }); + + it("ignores other session events", async () => { + const tools = await registeredTools(CONFIG); + await tools.runDeviceAction.execute({ action: "back", reason: "act" }, exec()); + tools.emit("tool/call"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(tools.followups).toHaveLength(0); + }); +}); + +describe("apply(): the result envelope must not starve the hierarchy (G42)", () => { + const CONFIG = Config({ screenSize: [720, 1600] }) as Record; + + it("keeps a long hierarchy dump whole while clipping prose fields", async () => { + const t = await registeredTools(CONFIG); + const long = Array.from({ length: 40 }, (_, i) => `#${i} [10,20] clickable pkg=com.x label 设置项`).join("\n"); + const rendered = t.runDeviceAction.output.render( + {}, + { result: "Success", hierarchy: long, message: "x".repeat(400) }, + ) as Array<{ text?: string }>; + const text = rendered.map((part) => part.text ?? "").join(""); + + expect(text).toContain(long); // the whole list reaches the model + expect(text).toContain(`${"x".repeat(200)}…`); // other fields keep the generic budget + }); + + it("hands every prompt field over whole, not clipped mid-sentence (G68 ①)", async () => { + const t = await registeredTools(CONFIG); + // The longest serial this plugin can be asked to talk about: adb over the wire. + const serial = "192.168.1.100:5555"; + const fields = { + pixel_guidance: PIXEL_GUIDANCE, + self_check: SELF_CHECK_PROMPT, + audit_request: incidentPrompt(3, serial, true), + closure_request: closurePrompt({ consecutive_failures: 3 }, serial), + }; + for (const [key, text] of Object.entries(fields)) { + expect(text.length, `${key} is already over the envelope budget`).toBeLessThanOrEqual(200); + } + const rendered = t.runDeviceAction.output.render({}, { effect: "unchanged", ...fields }) as Array<{ + text?: string; + }>; + const seen = rendered.map((part) => part.text ?? "").join(""); + for (const [key, text] of Object.entries(fields)) { + expect(seen, `${key} reached the model truncated`).toContain(`${key}=${text}`); + } + }); + + it("renders the image as a block instead of flattening its reference", async () => { + const t = await registeredTools(CONFIG); + const rendered = t.runDeviceAction.output.render({}, { + result: "Success", + image: { attachmentId: "att-1", mediaType: "image/jpeg" }, + }) as Array<{ type: string; text?: string }>; + + expect(rendered.map((part) => part.type)).toEqual(["text", "image"]); + expect(rendered[0].text).not.toContain("att-1"); + }); +}); + +describe("apply(): checker facts persist on tool/result meta (P1-b)", () => { + const CONFIG = Config({ screenSize: [720, 1600] }) as Record; + + it("projects the validator verdict and nothing else", async () => { + const t = await registeredTools(CONFIG); + expect( + t.runDeviceAction.output.presentationMeta?.({}, { + result: "Success", + effect: "unchanged", + category: "disappeared", + pixel_guidance: "look", + incident: { consecutive_failures: 3 }, + }), + ).toEqual({ + effect: "unchanged", + category: "disappeared", + incident: { consecutive_failures: 3 }, + }); + }); + + it("projects an empty meta for verdict-less values instead of throwing", async () => { + const t = await registeredTools(CONFIG); + expect(t.runDeviceAction.output.presentationMeta?.({}, { result: "Success" })).toEqual({}); + expect(t.runDeviceAction.output.presentationMeta?.({}, null)).toEqual({}); + }); + + it("projects the terminal outcome for exit audits", async () => { + const t = await registeredTools(CONFIG); + expect( + t.reportTaskStatus.output?.presentationMeta?.({}, { + status: "blocked", + tests_failed: 2, + outcome: "blocked", + }), + ).toEqual({ status: "blocked", tests_failed: 2, outcome: "blocked" }); + }); +}); + + +describe("report_task_status: the outcome carries BOTH axes (changelogs #62)", () => { + const CONFIG = Config({ screenSize: [720, 1600] }) as Record; + + async function report(args: Record): Promise> { + const t = await registeredTools(CONFIG); + return (await t.reportTaskStatus.execute( + { summary: "done", reason: "report", ...args }, + exec(), + )) as Record; + } + + it("keeps assertion failures in the outcome of a completed task", async () => { + expect(await report({ status: "completed", tests_failed: 2 })).toEqual({ + status: "completed", + tests_failed: 2, + outcome: "completed (with assertion failures on record)", + }); + }); + + it("reports a clean completion without the failure suffix", async () => { + expect(await report({ status: "completed" })).toMatchObject({ + tests_failed: 0, + outcome: "completed", + }); + }); + + it("never lets a blocked report read as completed, failures or not", async () => { + expect(await report({ status: "blocked", tests_failed: 3 })).toMatchObject({ outcome: "blocked" }); + expect(await report({ status: "blocked" })).toMatchObject({ outcome: "blocked" }); + }); +}); + +describe("apply(): breaker refusal lives in a registry guard (G33)", () => { + const CONFIG = Config({ screenSize: [720, 1600] }) as Record; + + it("registers exactly one guard", async () => { + const definitions: ToolDefinition[] = []; + const double = fakeContext(definitions); + await apply(double.context as never, CONFIG as never); + expect(double.guards()).toHaveLength(1); + }); + + it("lets other tools and the report outlet through untouched", async () => { + const definitions: ToolDefinition[] = []; + const double = fakeContext(definitions); + await apply(double.context as never, CONFIG as never); + const [guard] = double.guards(); + expect(guard({ name: "job_submit", arguments: {} })).toBeUndefined(); + expect(guard({ name: "report_task_status", arguments: { status: "completed" } })).toBeUndefined(); + // Fresh breaker: our own tool is admitted too. + expect(guard({ name: "run_device_action", arguments: { action: "back" } })).toBeUndefined(); + }); +}); + +describe("apply(): external ARTEMIS wiring (dsh-autogamer Phase 1)", () => { + it("registers autogamer_get_device_state alongside the two router tools", async () => { + const definitions: ToolDefinition[] = []; + const double = fakeContext(definitions); + await apply(double.context as never, Config() as never); + expect(definitions.map((d) => d.name).sort()).toEqual([ + "autogamer_diagnose", + "autogamer_get_device_state", + "autogamer_inspect_trace", + "autogamer_manage_task", + "autogamer_run_task", + "report_task_status", + "run_device_action", + ]); + }); + + it("warms the preflight cache on mount unless autoPreflight is off", async () => { + const first: ToolDefinition[] = []; + await apply(fakeContext(first).context as never, Config() as never); + expect(preflightProbe.runs).toBeGreaterThanOrEqual(1); + preflightProbe.runs = 0; + const second: ToolDefinition[] = []; + await apply(fakeContext(second).context as never, Config({ autoPreflight: false }) as never); + expect(preflightProbe.runs).toBe(0); + }); +}); + +describe("apply(): provider rules reach the system prompt (Phase 5)", () => { + const REPO = fileURLToPath(new URL("./fixtures/fake-repo", import.meta.url)); + + it("contributes the provider rules file when configured", async () => { + const definitions: ToolDefinition[] = []; + const double = fakeContext(definitions); + await apply(double.context as never, Config({ artemisRepoPath: REPO }) as never); + expect(double.sections).toHaveLength(1); + expect(double.sections[0]?.name).toBe("dsh-autogamer-rules"); + expect(double.sections[0]?.text).toContain("mobile tester"); + }); + + it("skips silently without a repo path, and stays off when flagged off", async () => { + const plain: ToolDefinition[] = []; + const withoutRepo = fakeContext(plain); + await apply(withoutRepo.context as never, Config() as never); + expect(withoutRepo.sections).toHaveLength(0); + const flagged: ToolDefinition[] = []; + const off = fakeContext(flagged); + await apply(off.context as never, Config({ artemisRepoPath: REPO, injectRules: false }) as never); + expect(off.sections).toHaveLength(0); + }); +}); diff --git a/plugins/dsh-autogamer/tests/artemisMcpBackend.spec.ts b/plugins/dsh-autogamer/tests/artemisMcpBackend.spec.ts new file mode 100644 index 000000000..26244fc6f --- /dev/null +++ b/plugins/dsh-autogamer/tests/artemisMcpBackend.spec.ts @@ -0,0 +1,196 @@ +/** + * MCP backend unit locks (Phase 2). A fake connection stands in for the SDK + * transport, so no test here spawns a provider: request mapping, error + * vocabulary, laziness, timeouts, and disposal are all asserted against + * scripted answers. Request shapes are locked against the fork's old face + * (`pre-contraction:mcp_server/tools/*.py`), not the scheme text. + */ +import { describe, expect, it } from "vitest"; +import { McpArtemisBackend, type McpConnection } from "../src/artemis/mcpBackend.js"; + +interface Call { + name: string; + args: Record; +} + +function fakeConnection(answer?: unknown, error?: unknown): { + calls: Call[]; + closed: number; + connectCalls: number; + connect: () => Promise; +} { + const calls: Call[] = []; + let closed = 0; + let connectCalls = 0; + return { + calls, + get closed() { + return closed; + }, + get connectCalls() { + return connectCalls; + }, + connect: async () => { + connectCalls += 1; + return { + callTool: async (params: { name: string; arguments: Record }) => { + calls.push({ name: params.name, args: params.arguments }); + if (error !== undefined) throw error; + return answer; + }, + close: async () => { + closed += 1; + }, + }; + }, + }; +} + +function textResult(text: string): unknown { + return { content: [{ type: "text", text }] }; +} + +function backendWith( + fake: ReturnType, + timeoutMs = 1000, +): McpArtemisBackend { + return new McpArtemisBackend({ command: ["python", "-m", "mcp_server"], callTimeoutMs: timeoutMs }, fake.connect); +} + +describe("mcp backend: device state uses the text face, not JSON", () => { + it("asks for the hierarchy view by default and keeps the dump as the summary", async () => { + const fake = fakeConnection(textResult("ok")); + const state = await backendWith(fake).getDeviceState({}); + expect(fake.calls[0]).toMatchObject({ + name: "mobile_get_device_state", + args: { view_type: "hierarchy" }, + }); + expect(state).toMatchObject({ deviceSerial: "", connected: true, screenSummary: "ok" }); + }); + + it("asks for the screenshot view on request and passes the file path through", async () => { + const fake = fakeConnection(textResult("file:///tmp/shot.png")); + const state = await backendWith(fake).getDeviceState({ deviceSerial: "em-1", includeScreenshot: true }); + expect(fake.calls[0]?.args).toEqual({ view_type: "screenshot", device_serial: "em-1" }); + expect(state).toMatchObject({ deviceSerial: "em-1", connected: true, screenshotPath: "file:///tmp/shot.png" }); + }); + + it("an Error: refusal is environment:, never a state", async () => { + const fake = fakeConnection(textResult("Error: Failed to initialize/lock Android device controller: gone")); + await expect(backendWith(fake).getDeviceState({})).rejects.toThrow(/^environment: provider refused device state/); + }); + + it("a dead transport is environment:, safe to report and retry later", async () => { + const fake = fakeConnection(undefined, new Error("spawn python ENOENT")); + await expect(backendWith(fake).getDeviceState({})).rejects.toThrow(/^environment: /); + }); +}); + +describe("mcp backend: task methods speak the old face", () => { + it("startTask sends task_desc/model and reads trace_id back as the task", async () => { + const fake = fakeConnection(textResult(JSON.stringify({ trace_id: "t-1", status: "running" }))); + const backend = backendWith(fake); + const info = await backend.startTask({ instruction: "open settings", profile: "Pro", deviceSerial: "em-1" }); + expect(fake.calls[0]).toMatchObject({ + name: "mobile_run_task", + args: { task_desc: "open settings", model: "Pro", device_serial: "em-1" }, + }); + expect(info.taskId).toBe("t-1"); + }); + + it("startTask without a trace_id is our mapping failure (internal:)", async () => { + const fake = fakeConnection(textResult(JSON.stringify({ status: "running" }))); + await expect( + backendWith(fake).startTask({ instruction: "x", profile: "Flash" }), + ).rejects.toThrow(/^internal: /); + }); + + it("manageTask forwards action over trace_id", async () => { + const fake = fakeConnection(textResult(JSON.stringify({ state: "running", current_step: 3 }))); + const status = await backendWith(fake).manageTask({ taskId: "t-1", action: "status" }); + expect(fake.calls[0]).toMatchObject({ + name: "mobile_manage_task", + args: { action: "status", trace_id: "t-1" }, + }); + expect(status).toMatchObject({ taskId: "t-1", state: "running", currentStep: 3 }); + }); + + it("inspectTrace always opens view_summary over the trace", async () => { + const fake = fakeConnection( + textResult(JSON.stringify({ final_status: "succeeded", steps: [{ index: 0, action: "tap" }] })), + ); + const view = await backendWith(fake).inspectTrace({ traceId: "t-1" }); + expect(fake.calls[0]).toMatchObject({ + name: "mobile_inspect_trace", + args: { action: "view_summary", trace_id: "t-1" }, + }); + expect(view.finalStatus).toBe("succeeded"); + expect(view.steps).toHaveLength(1); + }); + + it("diagnose forwards only the serial; the old face takes no logcat/screenshot flags", async () => { + const fake = fakeConnection( + textResult(JSON.stringify({ adb_connected: true, helper_installed: true, recommendations: ["x"] })), + ); + const diagnosis = await backendWith(fake).diagnose({ deviceSerial: "em-1" }); + expect(fake.calls[0]).toMatchObject({ name: "mobile_diagnose", args: { device_serial: "em-1" } }); + expect(diagnosis).toMatchObject({ adbConnected: true, recommendations: ["x"] }); + }); + + it("non-JSON text from a dict tool is internal:, not the device", async () => { + const fake = fakeConnection(textResult("definitely not json")); + await expect(backendWith(fake).manageTask({ taskId: "t", action: "status" })).rejects.toThrow( + /^internal: /, + ); + }); + + it("a non-object structured result is internal: too", async () => { + const fake = fakeConnection({ structuredContent: "just a string" }); + await expect(backendWith(fake).manageTask({ taskId: "t", action: "status" })).rejects.toThrow( + /^internal: /, + ); + }); + + it("a dict answer with no text block at all is internal:", async () => { + const fake = fakeConnection({ content: [{ type: "image" }] }); + await expect(backendWith(fake).manageTask({ taskId: "t", action: "status" })).rejects.toThrow( + /^internal: /, + ); + }); +}); + +describe("mcp backend: lifecycle", () => { + it("constructing and disposing never connects", async () => { + const fake = fakeConnection({ structuredContent: { connected: true } }); + const backend = backendWith(fake); + await backend.dispose(); + expect(fake.connectCalls).toBe(0); + expect(fake.closed).toBe(0); + }); + + it("a hung provider call times out and drops the poisoned session", async () => { + const fake = fakeConnection(new Promise(() => undefined)); + const backend = backendWith(fake, 20); + await expect(backend.getDeviceState({})).rejects.toThrow(/within 20ms/); + expect(fake.closed).toBe(1); + }); + + it("a failed connect does not poison the next call", async () => { + let attempts = 0; + const backend = new McpArtemisBackend( + { command: ["python", "-m", "mcp_server"], callTimeoutMs: 1000 }, + async () => { + attempts += 1; + if (attempts === 1) throw new Error("spawn python ENOENT"); + return { + callTool: async () => ({ content: [{ type: "text", text: "" }] }), + close: async () => undefined, + }; + }, + ); + await expect(backend.getDeviceState({})).rejects.toThrow(/^environment: /); + const state = await backend.getDeviceState({}); + expect(state.screenSummary).toBe(""); + expect(attempts).toBe(2); + }); +}); diff --git a/plugins/dsh-autogamer/tests/breaker.spec.ts b/plugins/dsh-autogamer/tests/breaker.spec.ts new file mode 100644 index 000000000..dba053a67 --- /dev/null +++ b/plugins/dsh-autogamer/tests/breaker.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { DeviceBreaker } from "../src/breaker.js"; + +describe("DeviceBreaker", () => { + it("opens after allowedFails environment failures and blocks admission", () => { + const breaker = new DeviceBreaker({ allowedFails: 1, cooldownMs: 60_000 }); + expect(breaker.canAdmit("d")).toBe(true); + breaker.recordEnvironmentFailure("d"); + expect(breaker.stateOf("d")).toBe("open"); + expect(breaker.canAdmit("d")).toBe(false); + }); + + it("never opens on task-level failures", () => { + const breaker = new DeviceBreaker({ allowedFails: 1, cooldownMs: 60_000 }); + for (let i = 0; i < 10; i += 1) breaker.recordTaskFailure("d"); + expect(breaker.stateOf("d")).toBe("closed"); + expect(breaker.canAdmit("d")).toBe(true); + }); + + it("moves to half-open after cooldown and admits exactly one probe", () => { + let clock = 0; + const breaker = new DeviceBreaker({ + allowedFails: 1, + cooldownMs: 1_000, + now: () => clock, + }); + breaker.recordEnvironmentFailure("d"); + expect(breaker.canAdmit("d")).toBe(false); + + clock += 1_001; + expect(breaker.stateOf("d")).toBe("half-open"); + expect(breaker.canAdmit("d")).toBe(true); + expect(breaker.canAdmit("d")).toBe(false); // single probe + }); + + it("a failing probe re-opens with a fresh cooldown; success closes", () => { + let clock = 0; + const breaker = new DeviceBreaker({ + allowedFails: 1, + cooldownMs: 1_000, + now: () => clock, + }); + breaker.recordEnvironmentFailure("d"); + clock += 1_001; + expect(breaker.canAdmit("d")).toBe(true); + breaker.recordEnvironmentFailure("d"); + clock += 500; + expect(breaker.stateOf("d")).toBe("open"); // fresh window, not half-open + + clock += 501; + expect(breaker.canAdmit("d")).toBe(true); + breaker.recordSuccess("d"); + expect(breaker.stateOf("d")).toBe("closed"); + expect(breaker.canAdmit("d")).toBe(true); + }); + + it("keys are isolated", () => { + const breaker = new DeviceBreaker({ allowedFails: 1, cooldownMs: 60_000 }); + breaker.recordEnvironmentFailure("phone-a"); + expect(breaker.stateOf("phone-a")).toBe("open"); + expect(breaker.stateOf("phone-b")).toBe("closed"); + }); + + it("counts to allowedFails before opening", () => { + const breaker = new DeviceBreaker({ allowedFails: 2, cooldownMs: 60_000 }); + breaker.recordEnvironmentFailure("d"); + expect(breaker.stateOf("d")).toBe("closed"); + expect(breaker.canAdmit("d")).toBe(true); + breaker.recordEnvironmentFailure("d"); + expect(breaker.stateOf("d")).toBe("open"); + expect(breaker.canAdmit("d")).toBe(false); + }); + + it("reset() clears an open breaker (restart lands fresh)", () => { + const breaker = new DeviceBreaker({ allowedFails: 1, cooldownMs: 60_000 }); + breaker.recordEnvironmentFailure("d"); + expect(breaker.stateOf("d")).toBe("open"); + breaker.reset("d"); + expect(breaker.stateOf("d")).toBe("closed"); + expect(breaker.canAdmit("d")).toBe(true); + }); +}); diff --git a/plugins/dsh-autogamer/tests/classify.spec.ts b/plugins/dsh-autogamer/tests/classify.spec.ts new file mode 100644 index 000000000..67335ad94 --- /dev/null +++ b/plugins/dsh-autogamer/tests/classify.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { classifyActionFailure, classifyMessage, classifyTransportError } from "../src/classify.js"; + +describe("classify", () => { + it("treats transport errors as environment", () => { + expect(classifyTransportError(new Error("connection closed"))).toBe("environment"); + }); + + it("maps action codes to the right failure class", () => { + expect(classifyActionFailure({ ok: false, code: "TARGET_NOT_FOUND" })).toBe("task"); + expect(classifyActionFailure({ ok: false, code: "INVALID_ARGS" })).toBe("task"); + expect(classifyActionFailure({ ok: false, code: "PACKAGE_NOT_FOUND" })).toBe("task"); + expect(classifyActionFailure({ ok: false, code: "DEVICE_ERROR", message: "" })).toBe("environment"); + expect(classifyActionFailure({ ok: false, code: "TIMEOUT" })).toBe("environment"); + }); + + it("falls back to marker matching for uncoded failures", () => { + expect(classifyMessage("adb: device offline")).toBe("environment"); + expect(classifyMessage("no element matched 'submit button'")).toBe("task"); + expect(classifyMessage("connection refused by helper")).toBe("environment"); + }); + + it("reads a legacy bare Failed as environment (fail-safe, S4)", () => { + // normalizeOutcome maps the legacy "Failed" string to "device reported + // failure"; the device marker then classifies environment. An + // indistinguishable failure counts toward the breaker rather than slipping + // through — that is the fail-safe side of the asymmetry, pinned here so a + // future reclassification is deliberate. + expect(classifyMessage("device reported failure")).toBe("environment"); + }); + + it("a successful result never needs classification", () => { + expect(classifyActionFailure({ ok: true, code: "OK" })).toBe("task"); + }); +}); diff --git a/plugins/dsh-autogamer/tests/clientHalf.spec.ts b/plugins/dsh-autogamer/tests/clientHalf.spec.ts new file mode 100644 index 000000000..f257cd1dd --- /dev/null +++ b/plugins/dsh-autogamer/tests/clientHalf.spec.ts @@ -0,0 +1,77 @@ +/** + * Browser-half locks (Phase 3). client.js is hand-written plain JS in the + * deployment's ModuleLoader format (tsc cannot emit it), so it cannot be + * imported under node — these locks assert on its text instead, the same way + * deliveryShape locks the two patch files against each other: + * - the manifest half (dsh.client + ./client export + files entry); + * - the proven call shape (ModuleLoader id, require('react'), slots + * inject/register, the five wire-tool keys on tool.call.toolview); + * - NEGATIVE locks: the cut APIs (conversation.card, ctx.rpc, per-plugin + * settings slot, binary push, fetch/WebSocket polling) must never reappear. + */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const CLIENT = readFileSync(fileURLToPath(new URL("../client.js", import.meta.url)), "utf-8"); +const MANIFEST = JSON.parse( + readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf-8"), +) as { + dsh?: { client?: { platform?: string; immediately?: boolean; inject?: string[] } }; + exports?: Record; + files?: string[]; +}; + +describe("browser half: manifest declares the client artifact", () => { + it("carries a dsh.client section in the proven shape", () => { + expect(MANIFEST.dsh?.client?.platform).toBe("web"); + expect(MANIFEST.dsh?.client?.immediately).toBe(true); + expect(MANIFEST.dsh?.client?.inject).toEqual(["slots"]); + }); + + it("exports ./client and ships client.js", () => { + expect(MANIFEST.exports?.["./client"]).toBe("./client.js"); + expect(MANIFEST.files).toContain("client.js"); + }); +}); + +describe("browser half: client.js keeps the proven ModuleLoader shape", () => { + it("registers the loader id and takes React from the browser module table", () => { + expect(CLIENT).toContain("window.__ModuleLoader__.load({"); + expect(CLIENT).toContain("id: 'dsh-autogamer'"); + expect(CLIENT).toContain("require('react')"); + }); + + it("injects slots and registers all five task toolviews by wire key", () => { + expect(CLIENT).toContain("inject: ['slots']"); + expect(CLIENT).toContain("ctx.slots.inject('tool.call.toolview'"); + for (const tool of [ + "autogamer_run_task", + "autogamer_manage_task", + "autogamer_inspect_trace", + "autogamer_diagnose", + "autogamer_get_device_state", + ]) { + expect(CLIENT, `toolview key for ${tool}`).toContain(`'${tool}'`); + } + }); + + it("imports nothing but react: no second framework, no host client code", () => { + expect(CLIENT).not.toMatch(/require\(['"`](?!react['"`])/); + expect(CLIENT).not.toContain("from '@deepseek-ai/"); + }); + + it("never references the cut APIs (Phase 0 verdicts)", () => { + for (const banned of [ + "conversation.card", + "ctx.rpc", + "settings.plugin.item", + "WebSocket", + "VideoDecoder", + "multipart/x-mixed-replace", + ]) { + expect(CLIENT, `cut API must stay out: ${banned}`).not.toContain(banned); + } + expect(CLIENT).not.toMatch(/\bfetch\s*\(/); + }); +}); diff --git a/plugins/dsh-autogamer/tests/conformance/breakerGuard.spec.ts b/plugins/dsh-autogamer/tests/conformance/breakerGuard.spec.ts new file mode 100644 index 000000000..01a5e7fc3 --- /dev/null +++ b/plugins/dsh-autogamer/tests/conformance/breakerGuard.spec.ts @@ -0,0 +1,161 @@ +/** + * G33 locks on the real registry: the cooling-down refusal lives in a + * monotonic `ctx.tools.guard`, not in the tool body. + * + * Assembly: real service graph + our plugin with an unspawnable action-server + * command, so the first call fails at the transport (environment failure, + * opens the breaker with the default allowedFails: 1). The next same-key call + * must be denied by the guard — proving refusal without any device round-trip + * — while a different key still goes through to the transport. Agent-less + * execution is enough: a plain-context guard applies globally. + */ +import { afterEach, describe, expect, it } from "vitest"; +import { Context } from "@deepseek-ai/cordis"; +import * as pluginModule from "../../src/index.js"; + +const SERVICE_PACKAGES = [ + "@deepseek-ai/dsh-invariants", + "@deepseek-ai/dsh-system-prompt", + "@deepseek-ai/dsh-tools", + "@deepseek-ai/dsh-agent", + "@deepseek-ai/dsh-attachment", + "@deepseek-ai/dsh-session", +] as const; + +const BAD_ACTION_SERVER = ["g33-no-such-binary-xyz"]; + +let seq = 0; + +async function boot(withScreenSize = true): Promise { + const root = new Context(); + for (const specifier of SERVICE_PACKAGES) { + const mod = (await import(specifier)) as { default: unknown }; + await root.plugin(mod.default as never); + } + await root.plugin( + { + name: pluginModule.name, + Config: pluginModule.Config, + inject: pluginModule.inject, + apply: pluginModule.apply, + } as never, + { + actionServerCommand: BAD_ACTION_SERVER, + ...(withScreenSize ? { screenSize: [1080, 2400] } : {}), + defaultDeviceKey: "g33d", + actionCallTimeoutMs: 5_000, + } as never, + ); + return root; +} + +function call(root: Context, name: string, args: Record) { + seq += 1; + return (root.tools as { + execute(input: unknown): Promise<{ isError: boolean; error?: { message?: string } }>; + }).execute({ + callId: `g33-${name}-${seq}`, + name, + arguments: args, + signal: new AbortController().signal, + }); +} + +afterEach(() => { + seq = 0; +}); + +describe("conformance: breaker refusal through the registry guard (G33)", () => { + it("denies the same device while cooling down", async () => { + const root = await boot(); + const first = await call(root, "run_device_action", { action: "back", reason: "open the breaker" }); + expect(first.isError).toBe(true); + expect(JSON.stringify(first)).toContain("environment: action transport failed"); + + const second = await call(root, "run_device_action", { + action: "take_screenshot", + reason: "guarded", + }); + expect(second.isError).toBe(true); + expect(JSON.stringify(second)).toContain("cooling down"); + }); + + it("still admits a different device key", async () => { + const root = await boot(); + await call(root, "run_device_action", { action: "back", reason: "open the breaker" }); + + const other = await call(root, "run_device_action", { + action: "take_screenshot", + device_serial: "g33-other", + reason: "other key", + }); + expect(other.isError).toBe(true); + const text = JSON.stringify(other); + expect(text).toContain("environment: action transport failed"); + expect(text).not.toContain("cooling down"); + }); + + it("classifies a dead-server probe as environment, then cools down (D3)", async () => { + const root = await boot(false); + // No screenSize configured: the probe screenshot hits the dead server. + const first = await call(root, "run_device_action", { + action: "click", + target: { coordinate: [500, 500] }, + reason: "probe dies", + }); + expect(first.isError).toBe(true); + expect(JSON.stringify(first)).toContain("environment: screen size unavailable"); + + const second = await call(root, "run_device_action", { + action: "take_screenshot", + reason: "guarded", + }); + expect(second.isError).toBe(true); + expect(JSON.stringify(second)).toContain("cooling down"); + }); + + it("never guards the report outlet", async () => { + const root = await boot(); + await call(root, "run_device_action", { action: "back", reason: "open the breaker" }); + + // report_task_status with no prior report: succeeds trivially (validation + // of required fields is the DSL's), proving the guard let it through. + const report = await call(root, "report_task_status", { status: "completed", summary: "g33" }); + expect(report.isError).toBe(false); + }); + + it("keys an empty serial to the default (execute and guard agree)", async () => { + const root = await boot(); + // Open the breaker through an explicitly empty serial… + const first = await call(root, "run_device_action", { + action: "back", + device_serial: "", + reason: "open via empty serial", + }); + expect(first.isError).toBe(true); + expect(JSON.stringify(first)).toContain("environment: action transport failed"); + // …then the omitted-serial (default-key) call must see the same hold. + // Before deviceKeyOf, execute keyed "" while the guard keyed "default". + const second = await call(root, "run_device_action", { + action: "take_screenshot", + reason: "default key sees the hold", + }); + expect(second.isError).toBe(true); + expect(JSON.stringify(second)).toContain("cooling down"); + }); + + it("marks only read-only actions concurrency-safe", async () => { + const root = await boot(); + const runDeviceAction = (root.tools as { + get(name: string): { isConcurrencySafe?: (args: unknown) => boolean } | undefined; + }).get("run_device_action"); + const classify = (args: unknown) => runDeviceAction?.isConcurrencySafe?.(args); + // defineTool validates before delegating: schema-valid read-only calls are + // parallel, everything else (including schema-invalid input) is exclusive. + expect(classify({ action: "take_screenshot", reason: "look" })).toBe(true); + expect(classify({ action: "get_ui_hierarchy", reason: "look" })).toBe(true); + expect(classify({ action: "click", target: { index: 0 }, reason: "tap" })).not.toBe(true); + expect(classify({ action: "launch_app", package: "com.example", reason: "go" })).not.toBe(true); + expect(classify({ action: "take_screenshot" })).toBe(false); + }); +}); diff --git a/plugins/dsh-autogamer/tests/conformance/contracts.spec.ts b/plugins/dsh-autogamer/tests/conformance/contracts.spec.ts new file mode 100644 index 000000000..6e4250d97 --- /dev/null +++ b/plugins/dsh-autogamer/tests/conformance/contracts.spec.ts @@ -0,0 +1,283 @@ +/** + * Conformance contracts (P0/G5). Each section names the runtime or source it + * reads its truth from, and none of them is a hand-typed list any more: + * - py action tools : live MCP server (scripts/spike-mcp.mjs) / its fixture + * - DSH generation : the @deepseek-ai/* packages actually installed + * - session events : KNOWN_SESSION_EVENT_TYPES exported by @deepseek-ai/dsh-session + * The in-process real-runtime assertions live in runtimeBoot.spec.ts. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import Schema from "@deepseek-ai/schemastery"; +import { KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION } from "@deepseek-ai/dsh-session"; +import { Config } from "../../src/index.js"; +import { ACTION_TO_PY_TOOL, ACTIONS } from "../../src/tools/runDeviceAction.js"; +import { buildFixture, mappedPyTools } from "../../scripts/spike-mcp.mjs"; +import { typescriptFiles } from "../support/srcFiles.js"; + +/** Minimal view of a Schemastery object node (the package exports no type name). */ +type SchemaObject = { + type: string; + dict: Record; +}; +type ConfigCallable = (data?: unknown) => Record; + +const fixtureRoot = fileURLToPath(new URL("../fixtures", import.meta.url)); + +function readFixture(name: string): Record { + return JSON.parse(readFileSync(`${fixtureRoot}/${name}`, "utf8")); +} + +const manifest = JSON.parse( + readFileSync(fileURLToPath(new URL("../../package.json", import.meta.url)), "utf8"), +) as { peerDependencies?: Record }; + +describe("conformance: py action server surface", () => { + const fixture = readFixture("py-action-tools.json"); + const realTools = fixture.pyActionServerTools as string[]; + + it("every mapped py tool exists in the action server surface", () => { + const unknown = mappedPyTools().filter((tool: string) => !realTools.includes(tool)); + expect(unknown).toEqual([]); + }); + + it("covers every agent action with a py mapping", () => { + expect(Object.keys(ACTION_TO_PY_TOOL).sort()).toEqual([...ACTIONS].sort()); + }); + + it("the fixture is a generated record, not a list someone maintains by hand", () => { + // Same builder the --write path uses, so a hand-typed name or a name added + // out of order fails here even without a live server (the live drift check + // is `node scripts/spike-mcp.mjs`, which CI runs). + const generated = buildFixture(realTools); + expect(String(fixture.source)).toContain("spike-mcp.mjs"); + expect(fixture.source).toBe(generated.source); + expect(/^\d{4}-\d{2}-\d{2}$/.test(String(fixture.captured)), "captured must be a date stamp").toBe(true); + expect(realTools, "the generator emits a sorted, deduplicated list").toEqual( + [...new Set(realTools)].sort(), + ); + }); + + it("covers every py tool this plugin calls, including the ones outside the mapping", () => { + // `compare_ui_frames` (G39 route A) is called directly from index.ts, so a + // mapping-only check would not notice the server renaming it. Build-phase + // transport calls go through the `callTransport` wrapper (G33: a dead + // server must classify environment, never task/internal), which the + // extractor reads with the same spelling. + // `list_devices` (G64) joins for the same reason: the pool asks the server who is attached, + // and that call is not a device action, so no action mapping would ever cover it. + const direct = new Set(); + for (const file of typescriptFiles(fileURLToPath(new URL("../../src", import.meta.url)))) { + const text = readFileSync(file, "utf8"); + for (const match of text.matchAll(/client\.callTool\(\s*"([a-z_]+)"/g)) direct.add(match[1]); + for (const match of text.matchAll(/callTransport\(\s*client\s*,\s*"([a-z_]+)"/g)) direct.add(match[1]); + } + expect([...direct].sort(), "src should still contain direct call sites").toEqual([ + "compare_ui_frames", + "get_ui_hierarchy", + "list_devices", + "take_screenshot", + ]); + const unreachable = [...direct].filter((tool) => !realTools.includes(tool)); + expect(unreachable, "the plugin calls py tools the server does not expose").toEqual([]); + expect(realTools.length, "the recorded surface should never shrink silently").toBeGreaterThanOrEqual(14); + }); + + // Opt-in, because it costs a real server spawn: `AUTOGAMER_CONFORMANCE_PY=live`. + // CI keeps `npm test` timing-independent; run the same comparison locally with + // `node scripts/spike-mcp.mjs`, which exits non-zero on drift. + it.skipIf(process.env.AUTOGAMER_CONFORMANCE_PY !== "live")( + "the live action server still exposes the recorded surface", + async () => { + const { liveActionSurface } = await import("../../scripts/spike-mcp.mjs"); + const spawned = (await import("../../scripts/spike-mcp.mjs")).actionClient(); + try { + const { tools } = await liveActionSurface(spawned); + expect(tools).toEqual(realTools); + for (const tool of mappedPyTools()) expect(tools).toContain(tool); + } finally { + await spawned.dispose(); + } + }, + ); +}); + +describe("conformance: DSH peer version pin", () => { + const scopeDir = fileURLToPath(new URL("../../node_modules/@deepseek-ai", import.meta.url)); + + it("pins every installed harness package to one verified generation", () => { + const peers = Object.entries(manifest.peerDependencies ?? {}); + expect(peers.length, "peerDependencies must state the verified generation").toBeGreaterThan(0); + for (const [pkg, range] of peers) { + expect(pkg, "peer dependency should target @deepseek-ai scope").toMatch(/^@deepseek-ai\//); + expect(range, `${pkg} must pin the same generation`).toBe(peers[0][1]); + } + + const installed = readdirSync(scopeDir) + .filter((name) => name.startsWith("dsh-")) + .map((name) => [name, JSON.parse(readFileSync(join(scopeDir, name, "package.json"), "utf8")).version as string]); + expect(installed.length, "no @deepseek-ai/dsh-* packages installed").toBeGreaterThan(0); + for (const [name, version] of installed) { + expect(version, `${name} is not the pinned generation`).toBe(peers[0][1]); + } + // The pin must be carried by something the test graph actually boots, or it + // could drift away from the runtime it claims to describe. + const peerNames = peers.map(([pkg]) => pkg.replace("@deepseek-ai/", "")); + expect(installed.map(([name]) => name).filter((name) => peerNames.includes(name))).toContain("dsh-tools"); + }); + + it("cross-checks the pin against a locally installed DSH runtime when there is one", () => { + const dshHome = process.env.DSH_HOME ?? join(homedir(), ".dsh"); + const runtimes = join(dshHome, "dsh-runtimes"); + const candidates = existsSync(runtimes) ? readdirSync(runtimes) : []; + const found = candidates + .map((dir) => join(runtimes, dir, "runtime.json")) + .filter((path) => existsSync(path)) + .map((path) => JSON.parse(readFileSync(path, "utf8")).desktopVersion as string); + if (found.length === 0) { + // No desktop install in this environment (CI is one): the pin still has + // to name a released generation, which the check above already enforced. + return; + } + const pin = manifest.peerDependencies?.["@deepseek-ai/dsh-agent-preset"] ?? ""; + for (const version of found) { + expect(version.split(".").slice(0, 2).join("."), "pinned generation vs installed runtime").toBe( + pin.split(".").slice(0, 2).join("."), + ); + } + }); +}); + +/** + * G44 keys the turn-end enforcement off `turn/end`, and R17 off session format + * V4. Both were claims copied out of docs; the packages now say it directly. + */ +describe("conformance: real DSH session vocabulary", () => { + const known = KNOWN_SESSION_EVENT_TYPES as ReadonlySet; + + it("the session event types this plugin relies on are real", () => { + for (const type of ["turn/end", "turn/start", "tool/call", "tool/result", "user/message"]) { + expect(known.has(type), `${type} is not in KNOWN_SESSION_EVENT_TYPES (${known.size} entries)`).toBe(true); + } + }); + + it("the session log is still the format the plugin's message shape assumes", () => { + expect(SESSION_FORMAT_VERSION).toBe(4); + }); +}); + +/** + * The loader rejects foreign schema objects ("Config is not a native + * Schemastery schema"), and a plugin row whose Config is missing is reported + * as status=absent — which silently skips the preset-scope child mount, i.e. + * the tools never register. Both failure modes are locked here; runtimeBoot.spec + * proves the loader side of the second one by refusing a bad config. + */ +describe("conformance: plugin Config is a native Schemastery schema", () => { + const schemaSymbols = new Set(Object.getOwnPropertySymbols(Schema.prototype).map(String)); + + it("carries the Schemastery brand symbol on the prototype", () => { + // Guards the discriminator itself: if instanceof ever stops meaning + // "native", the assertions below would pass vacuously. + expect(schemaSymbols).toContain("Symbol(schemastery)"); + expect({} instanceof Schema).toBe(false); + expect(null instanceof Schema).toBe(false); + }); + + it("exports a native object schema at the top level", () => { + expect(Config instanceof Schema, "Config must be a native Schemastery schema").toBe(true); + expect((Config as SchemaObject).type).toBe("object"); + }); + + it("declares every field natively — a mixed zod/Schemastery schema is a half migration", () => { + const fields = (Config as SchemaObject).dict; + expect(Object.keys(fields).sort()).toEqual( + [ + "actionCallTimeoutMs", + "actionServerCommand", + "actionServerEnv", + "allowedFails", + "artemisMcpCommand", + "artemisRepoPath", + "autoPreflight", + "breakerCooldownMs", + "defaultDeviceKey", + "defaultDeviceSerial", + "defaultProfile", + "defaultTimeoutSeconds", + "devices", + "discoverDevices", + "injectRules", + "maxConcurrentDevices", + "screenSize", + "scrcpyBitrateMbps", + "scrcpyEnabled", + "scrcpyMaxFps", + "scrcpyMaxWidth", + "scrcpyServerPath", + "strictness", + "traceRetentionDays", + "uvPath", + ].sort(), + ); + for (const [name, sub] of Object.entries(fields)) { + expect(sub instanceof Schema, `${name} must be a native Schemastery schema`).toBe(true); + } + expect(fields.actionServerCommand.type).toBe("array"); + expect(fields.actionServerEnv.type).toBe("dict"); + expect(fields.screenSize.type).toBe("tuple"); + // G3/G28: the roster is an array of serials and the ceiling a plain number. + expect(fields.devices.type).toBe("array"); + expect(fields.maxConcurrentDevices.type).toBe("number"); + expect(fields.discoverDevices.type).toBe("boolean"); + }); + + it("applies declared defaults and leaves the un-defaulted tuple null-filled", () => { + const resolved = (Config as ConfigCallable)(); + expect(resolved.actionServerCommand).toEqual([ + "python", + "-m", + "artemis.interfaces.cli.main", + "mcp", + "--type", + "adb", + ]); + expect(resolved.actionServerEnv).toEqual({}); + expect(resolved.defaultDeviceKey).toBe("default"); + expect(resolved.allowedFails).toBe(1); + expect(resolved.breakerCooldownMs).toBe(30_000); + expect(resolved.strictness).toBe("final"); + expect(resolved.actionCallTimeoutMs).toBe(30_000); + // G3/G28 defaults must equal the historical behaviour: no roster, no ceiling. + expect(resolved.devices).toEqual([]); + expect(resolved.maxConcurrentDevices).toBe(0); + // G64 discovery stays off by default: turning it on spreads auto calls across machines, + // which is a scheduling-semantics change and therefore an explicit operator decision. + expect(resolved.discoverDevices).toBe(false); + // dsh-autogamer Phase 1: the external-provider wiring defaults to + // "unconfigured but harmless" — empty repo path, PATH uv/artemis, warm-up on. + expect(resolved.artemisRepoPath).toBe(""); + expect(resolved.artemisMcpCommand).toEqual(["python", "-m", "mcp_server"]); + expect(resolved.uvPath).toBe("uv"); + expect(resolved.defaultProfile).toBe("flash"); + expect(resolved.defaultTimeoutSeconds).toBe(1800); + expect(resolved.defaultDeviceSerial).toBe(""); + expect(resolved.injectRules).toBe(true); + expect(resolved.autoPreflight).toBe(true); + expect(resolved.scrcpyServerPath).toBe(""); + expect(resolved.traceRetentionDays).toBe(7); + // The trap `configuredScreenSize()` exists to survive: an unset optional + // tuple comes back as a truthy `[undefined, undefined]` array, never + // undefined, so `if (config.screenSize)` always looks "configured". + expect(Array.isArray(resolved.screenSize)).toBe(true); + expect(Boolean(resolved.screenSize), "unset tuple is still a truthy array").toBe(true); + expect(resolved.screenSize).toEqual([undefined, undefined]); + }); + + it("rejects a malformed value rather than coercing it silently", () => { + expect(() => (Config as ConfigCallable)({ actionServerCommand: "not-an-array" })).toThrow(); + }); +}); diff --git a/plugins/dsh-autogamer/tests/conformance/followup.spec.ts b/plugins/dsh-autogamer/tests/conformance/followup.spec.ts new file mode 100644 index 000000000..400ccad4f --- /dev/null +++ b/plugins/dsh-autogamer/tests/conformance/followup.spec.ts @@ -0,0 +1,194 @@ +/** + * G52 Tier 1: the report-nudge path driven by the REAL session store and agent + * registry instead of apply.spec.ts's fake event bus. + * + * What is real here: + * - the `turn/end` we react to is committed by `@deepseek-ai/dsh-session`'s own + * `Session.append` and published through cordis' post-commit `session/event`; + * - the session is entered through a `createScope` realm and the nudge fires with + * `ctx.agents.get(session.id)` resolving through the real `AgentRegistry`; + * - the action hop goes through the real `ToolRuntime.execute` policy pipeline + * inside `ctx.agents.withInitiator(...)`, which is what lets the ledger key on + * the initiating agent at all; + * - the scope filter is not asserted as an option but OBSERVED: a control + * listener owned by a child scope misses the realm-entered `turn/end` while + * its `{ global: true }` sibling receives it. That asymmetry is the G44 v1 + * failure mode, reproduced on host machinery. + * + * Deliberately not claimed here: our plugin's listener runs untagged at the root + * context in this harness, so its own admission is the "untagged listeners pass" + * branch; the `{ global: true }` requirement applies to the preset realm where the + * bundle mounts it, and apply.spec.ts still locks the registration options. + * The device result is irrelevant by design - the action server stub exits at once, + * and `markActed` happens at the top of `execute` (verified in the G52 probes). + * Live-only: the host accepting `source.kind:'autogamer'` into session format V4 + * (CHANGELOGS #15). + */ +import { afterEach, describe, expect, it } from "vitest"; +import { Context } from "@deepseek-ai/cordis"; +import { createScope } from "@deepseek-ai/dsh-scope"; +import * as pluginModule from "../../src/index.js"; + +const SERVICE_PACKAGES = [ + "@deepseek-ai/dsh-invariants", + "@deepseek-ai/dsh-system-prompt", + "@deepseek-ai/dsh-tools", + "@deepseek-ai/dsh-agent", + "@deepseek-ai/dsh-attachment", + "@deepseek-ai/dsh-session", +] as const; + +/** Exits immediately: the ledger marks acted before any transport attempt. */ +const STUB_ACTION_SERVER = [process.execPath, "-e", "process.exit(0)"]; + +type SessionLike = { id: string; append(type: string, data: unknown): unknown }; + +let cleanups: Array<() => unknown> = []; + +afterEach(async () => { + for (const dispose of cleanups.splice(0)) await dispose(); +}); + +async function bootRealTurnPath() { + const root = new Context(); + for (const specifier of SERVICE_PACKAGES) { + const mod = (await import(specifier)) as { default: unknown }; + await root.plugin(mod.default as never); + } + + const followups: unknown[] = []; + const globalControl: string[] = []; + const plainControl: string[] = []; + + // Two realms, the way the live topology has them: the session's Agent realm, and + // the preset realm that owns our listener. Dispatch travels UP the scope chain + // and never down, so a listener tagged to the other realm is excluded unless it + // bypasses the filter. + const agentRealm = createScope(root, Symbol("g52-agent-realm")); + const presetRealm = createScope(root, Symbol("g52-preset-realm")); + cleanups.push(() => presetRealm.dispose(), () => agentRealm.dispose()); + presetRealm.ctx.on( + "session/event", + (session: SessionLike, event: { type: string }) => globalControl.push(`${session?.id}/${event?.type}`), + { global: true }, + ); + presetRealm.ctx.on( + "session/event", + (session: SessionLike, event: { type: string }) => plainControl.push(`${session?.id}/${event?.type}`), + ); + + const session = await new Promise((resolve) => { + agentRealm.ctx.inject(["sessions"], (ctx: any) => resolve(ctx.sessions.create("g52-session"))); + }); + + const agent = { + id: session.id, + session, + status: "idle", + followup: (message: unknown) => { + followups.push(message); + }, + cancel: () => {}, + whenIdle: async () => {}, + send: () => {}, + steer: () => {}, + inject: () => {}, + }; + const disposeAgent = await (root.agents as any).register(agent); + cleanups.push(() => disposeAgent?.()); + + await root.plugin( + { + name: pluginModule.name, + Config: pluginModule.Config, + inject: pluginModule.inject, + apply: pluginModule.apply, + } as never, + { actionServerCommand: STUB_ACTION_SERVER, screenSize: [1080, 2400] } as never, + ); + + /** Run one call through the real policy pipeline with the agent as initiator. */ + const act = (name: string, args: Record) => + (root.agents as any).withInitiator(agent, () => + (root.tools as any).execute({ + callId: `g52-${name}-${followups.length}-${plainControl.length}`, + name, + arguments: args, + signal: new AbortController().signal, + }), + ); + + const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + const endTurn = () => session.append("turn/end", { turn: 1, reason: { kind: "completed" } }); + + return { root, session, agent, act, flush, endTurn, followups, globalControl, plainControl }; +} + +describe("conformance: the report nudge on a real turn/end (G52 Tier 1)", () => { + it("a real committed turn/end nudges exactly once, and only outside the emit", async () => { + const { act, endTurn, followups, flush } = await bootRealTurnPath(); + await act("run_device_action", { action: "back", reason: "g52 probe" }); + endTurn(); + + // R23 on the real emit path: the append is still publishing, so the nudge must + // not have been attempted synchronously inside the emit. + expect(followups).toHaveLength(0); + await flush(); + expect(followups).toHaveLength(1); + }); + + it("the nudge satisfies the real UserMessage contract", async () => { + const { act, endTurn, followups, flush } = await bootRealTurnPath(); + await act("run_device_action", { action: "back", reason: "g52 probe" }); + endTurn(); + await flush(); + + const message = followups[0] as { + id?: string; + role?: string; + content?: { type: string; text: string }[]; + source?: { kind?: string; reason?: string }; + }; + // `id` is required by dsh-llm's MessageBase ("stable identity preserved across + // every representation boundary"); the hand mirror this replaced shipped none. + expect(message.id, "nudge carries no message id").toBeTruthy(); + expect(message.role).toBe("user"); + expect(message.source).toEqual({ kind: "autogamer", reason: "unreported-turn" }); + const text = message.content?.map((block) => block.text ?? "").join("") ?? ""; + expect(text).toContain("report_task_status"); + expect(text).toContain("Do not call run_device_action"); + }); + + it("shows the scope filter that { global: true } exists to bypass", async () => { + const { act, endTurn, flush, globalControl, plainControl } = await bootRealTurnPath(); + await act("run_device_action", { action: "back", reason: "g52 probe" }); + endTurn(); + await flush(); + + // Same session, same event, two listeners in the SAME scope: only the global + // one is admitted, because the session was entered in this very scope while + // dispatch travels up the chain and never down. + expect(plainControl, "a non-global listener missed nothing - filter not exercised").toHaveLength(0); + expect(globalControl).toContain("g52-session/turn/end"); + }); + + it("stays silent when the turn reported through the real pipeline", async () => { + const { act, endTurn, followups, flush } = await bootRealTurnPath(); + await act("run_device_action", { action: "back", reason: "g52 probe" }); + await act("report_task_status", { status: "completed", summary: "g52 done" }); + endTurn(); + await flush(); + expect(followups).toHaveLength(0); + }); + + it("never nudges twice for one agent across real turn/ends", async () => { + const { act, endTurn, followups, flush } = await bootRealTurnPath(); + await act("run_device_action", { action: "back", reason: "g52 probe" }); + endTurn(); + await flush(); + await act("run_device_action", { action: "back", reason: "g52 again" }); + endTurn(); + await flush(); + expect(followups).toHaveLength(1); + }); +}); diff --git a/plugins/dsh-autogamer/tests/conformance/runtimeBoot.spec.ts b/plugins/dsh-autogamer/tests/conformance/runtimeBoot.spec.ts new file mode 100644 index 000000000..b6b75e868 --- /dev/null +++ b/plugins/dsh-autogamer/tests/conformance/runtimeBoot.spec.ts @@ -0,0 +1,141 @@ +/** + * G5 Tier 1: the model-facing contract, asserted against the REAL DSH runtime + * instead of a fixture that someone re-captured by hand. + * + * This boots the real service graph the plugin declares it depends on + * (`@deepseek-ai/dsh-tools` and the services it requires) on a bare cordis + * context and reads the registry the way the upstream tool-catalog generator + * does — "BOOTS each tool plugin on a real context and reads ctx.tools.schemas()" + * (docs/tool-catalog.md). No model, no network, no device. + * + * What this adds over the hand-written harness in apply.spec.ts: that harness + * collects `ctx.tools.register` calls into an array, so it proves our own + * registration call, not that the real registry accepted it. The 400-whole- + * request-rejected bug (changelogs #10) lived exactly in that gap. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { Context } from "@deepseek-ai/cordis"; +import * as pluginModule from "../../src/index.js"; +import { typescriptFiles } from "../support/srcFiles.js"; + +/** The services our plugin's `inject` list resolves through, in mount order. */ +const SERVICE_PACKAGES = [ + "@deepseek-ai/dsh-invariants", + "@deepseek-ai/dsh-system-prompt", + "@deepseek-ai/dsh-tools", + "@deepseek-ai/dsh-agent", + "@deepseek-ai/dsh-attachment", +] as const; + +type WireSchema = { + name: string; + description?: string; + parameters?: { type?: string; properties?: Record; required?: string[] }; +}; + +const plugin = { + name: pluginModule.name, + Config: pluginModule.Config, + inject: pluginModule.inject, + apply: pluginModule.apply, +}; + +async function bootServices(): Promise { + const ctx = new Context(); + for (const specifier of SERVICE_PACKAGES) { + const mod = (await import(specifier)) as { default: unknown }; + // Each package's default export is a cordis plugin (class or function). + await ctx.plugin(mod.default as never); + } + return ctx; +} + +async function bootWithPlugin(): Promise { + const ctx = await bootServices(); + await ctx.plugin(plugin as never); + return ctx; +} + +describe("conformance: real DSH runtime tool registry (G5 Tier 1)", () => { + it("the plugin mounts on the real service graph it declares", async () => { + const ctx = await bootWithPlugin(); + // Reaching this line at all is the assertion: cordis only loads a plugin + // while every injected service is available, so `inject` stays an + // live-verified claim rather than an aspiration (G30's root cause). + expect(typeof ctx.tools.register).toBe("function"); + expect(typeof ctx.agents.currentInitiator).toBe("function"); + }); + + it("registers exactly the seven tools the preset advertises", async () => { + const ctx = await bootWithPlugin(); + expect(ctx.tools.schemas().map((schema: WireSchema) => schema.name).sort()).toEqual([ + "autogamer_diagnose", + "autogamer_get_device_state", + "autogamer_inspect_trace", + "autogamer_manage_task", + "autogamer_run_task", + "report_task_status", + "run_device_action", + ]); + }); + + it("mounts with systemPrompt in inject: the Tier-1 in-process resolvability proof", async () => { + // Cordis refuses the property GET of an undeclared service and leaves a + // row pending when a declared one cannot resolve — so reaching a mounted + // plugin with 'systemPrompt' in its own inject list proves the rules + // injector's service resolves in preset scope (G30's root cause, inverted + // into a lock). Text reaching model context still needs a live session. + expect(pluginModule.inject).toContain("systemPrompt"); + const ctx = await bootWithPlugin(); + expect(typeof ctx.tools.register).toBe("function"); + }); + + it("compiles every tool through defineTool into an object-rooted wire schema", async () => { + const ctx = await bootWithPlugin(); + const schemas = ctx.tools.schemas() as WireSchema[]; + for (const schema of schemas) { + // The historical failure: registering a bare parameter map produced + // `parameters.type !== "object"` and the LLM API rejected the ENTIRE + // request, not just this tool (changelogs #10). + expect(schema.parameters?.type, `${schema.name} parameters must be an object root`).toBe("object"); + const properties = Object.keys(schema.parameters?.properties ?? {}); + expect(properties.length, `${schema.name} declares no parameters`).toBeGreaterThan(0); + for (const required of schema.parameters?.required ?? []) { + expect(properties, `${schema.name} requires "${required}" without declaring it`).toContain(required); + } + } + }); + + it("resolves a tool through the real registry, not a collected array", async () => { + const ctx = await bootWithPlugin(); + const tool = ctx.tools.get("run_device_action"); + expect(tool?.name).toBe("run_device_action"); + expect(typeof tool?.execute).toBe("function"); + }); + + it("declares every Host service its own code touches", () => { + // The mount above proves the other direction (a declared-but-absent service + // never loads). This one catches under-declaration, which is silent until + // the first property access throws `cannot get property … without inject`. + const builtins = new Set(["logger", "effect", "on", "emit", "once", "off", "extend", "plugin", "inject", "get", "provide", "root"]); + const srcRoot = fileURLToPath(new URL("../../src", import.meta.url)); + const consumed = new Set(); + for (const file of typescriptFiles(srcRoot)) { + for (const match of readFileSync(file, "utf8").matchAll(/\bctx\.([a-zA-Z_]+)/g)) consumed.add(match[1]); + } + expect([...consumed].sort(), "src should still reach for Host services").not.toHaveLength(0); + const services = [...consumed].filter((name) => !builtins.has(name)).sort(); + expect(services, "src consumes services that are not declared in `inject`").toEqual([...pluginModule.inject].sort()); + }); + + it("rejects a malformed config through the real Config validation path", async () => { + const ctx = await bootServices(); + // Only proven by calling Config() ourselves today; here the loader is the + // one that must refuse, because that is what a profile row actually does. + await expect( + ctx.plugin(plugin as never, { actionServerCommand: "not-an-array" }) as unknown as Promise, + ).rejects.toThrow(); + }); +}); diff --git a/plugins/dsh-autogamer/tests/conformance/scopeVisibility.spec.ts b/plugins/dsh-autogamer/tests/conformance/scopeVisibility.spec.ts new file mode 100644 index 000000000..09c8feb86 --- /dev/null +++ b/plugins/dsh-autogamer/tests/conformance/scopeVisibility.spec.ts @@ -0,0 +1,87 @@ +/** + * G57 lock: tools registered from a scope-mounted plugin are scope-local. + * + * Mechanism (no product code changed): dsh-tools `register()` files the entry + * through `ScopedLayers.effect(this.ctx, …)`, tagged to the calling scope; but + * `schemas()`/`get()` with no scope argument are the GLOBAL view, so a bare + * `scope.ctx` read shows nothing and the global view correctly hides scoped + * registrations (D16 isolation). The G52 probe's "empty in scope and root" was + * this reader artifact, not a mount failure. Pass `scopeOf(ctx)` and both + * tools resolve. + */ +import { afterEach, describe, expect, it } from "vitest"; +import { Context } from "@deepseek-ai/cordis"; +import { createScope, scopeOf } from "@deepseek-ai/dsh-scope"; +import * as pluginModule from "../../src/index.js"; + +const SERVICE_PACKAGES = [ + "@deepseek-ai/dsh-invariants", + "@deepseek-ai/dsh-system-prompt", + "@deepseek-ai/dsh-tools", + "@deepseek-ai/dsh-agent", + "@deepseek-ai/dsh-attachment", + "@deepseek-ai/dsh-session", +] as const; + +const STUB_ACTION_SERVER = [process.execPath, "-e", "process.exit(0)"]; + +let cleanups: Array<() => unknown> = []; + +afterEach(async () => { + for (const dispose of cleanups.splice(0)) await dispose(); +}); + +describe("conformance: scope-mounted tools resolve through the scope key (G57)", () => { + it("sees both tools with scopeOf(ctx) and hides them from the global view", async () => { + const root = new Context(); + for (const specifier of SERVICE_PACKAGES) { + const mod = (await import(specifier)) as { default: unknown }; + await root.plugin(mod.default as never); + } + + const presetRealm = createScope(root, Symbol("g57-realm")); + cleanups.push(() => presetRealm.dispose()); + await presetRealm.ctx.plugin( + { + name: pluginModule.name, + Config: pluginModule.Config, + inject: pluginModule.inject, + apply: pluginModule.apply, + } as never, + { actionServerCommand: STUB_ACTION_SERVER, screenSize: [1080, 2400] } as never, + ); + + let inside: { keyType: string; schemas: string[]; get: string | null } | null = null; + await presetRealm.ctx.plugin({ + name: "g57-reader", + inject: ["tools"], + apply: (ctx: { + tools: { + schemas(scope?: unknown): Array<{ name: string }>; + get(name: string, scope?: unknown): { name: string } | undefined; + }; + }) => { + const key = scopeOf(ctx as never); + inside = { + keyType: typeof key, + schemas: ctx.tools.schemas(key as never).map((schema) => schema.name).sort(), + get: ctx.tools.get("run_device_action", key as never)?.name ?? null, + }; + }, + } as never); + + expect(inside?.keyType).toBe("symbol"); + expect(inside?.schemas).toEqual([ + "autogamer_diagnose", + "autogamer_get_device_state", + "autogamer_inspect_trace", + "autogamer_manage_task", + "autogamer_run_task", + "report_task_status", + "run_device_action", + ]); + expect(inside?.get).toBe("run_device_action"); + // Scoped registrations do not leak into the global view (D16 isolation). + expect(root.tools.schemas()).toHaveLength(0); + }); +}); diff --git a/plugins/dsh-autogamer/tests/coord.spec.ts b/plugins/dsh-autogamer/tests/coord.spec.ts new file mode 100644 index 000000000..10b9c0c49 --- /dev/null +++ b/plugins/dsh-autogamer/tests/coord.spec.ts @@ -0,0 +1,76 @@ +/** + * Screen-size sourcing (G24). Two regression locks live here: + * - byteAt used to call itself, so parseImageSize threw RangeError on EVERY + * input and the probe-screenshot fallback path was dead; + * - Schemastery resolves an unset optional `screenSize` tuple to a truthy + * `[undefined, undefined]` array (not `undefined`), so a truthiness check + * on the array treated the default config as an explicit screen size. + */ +import { describe, expect, it } from "vitest"; +import { configuredScreenSize, imageSizeFromBase64, parseImageSize } from "../src/coord.js"; + +function pngBytes(width: number, height: number): Uint8Array { + const bytes = Buffer.alloc(33); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(bytes, 0); + bytes.writeUInt32BE(13, 8); // IHDR chunk length + bytes.write("IHDR", 12, "ascii"); + bytes.writeUInt32BE(width, 16); + bytes.writeUInt32BE(height, 20); + return new Uint8Array(bytes); +} + +function jpegBytes(width: number, height: number): Uint8Array { + const bytes = Buffer.alloc(16); + bytes[0] = 0xff; + bytes[1] = 0xd8; // SOI + bytes[2] = 0xff; + bytes[3] = 0xc0; // SOF0 + bytes[4] = 0x00; + bytes[5] = 0x11; // segment length + bytes[6] = 8; // sample precision + bytes.writeUInt16BE(height, 7); + bytes.writeUInt16BE(width, 9); + return new Uint8Array(bytes); +} + +describe("parseImageSize", () => { + it("reads dimensions from a PNG header", () => { + expect(parseImageSize(pngBytes(1080, 2400))).toEqual({ width: 1080, height: 2400 }); + }); + + it("reads dimensions from a JPEG SOF segment", () => { + expect(parseImageSize(jpegBytes(720, 1600))).toEqual({ width: 720, height: 1600 }); + }); + + it("returns undefined for a non-image buffer instead of throwing", () => { + const garbage = new Uint8Array(Buffer.from("not an image payload at all")); + expect(() => parseImageSize(garbage)).not.toThrow(); + expect(parseImageSize(garbage)).toBeUndefined(); + }); + + it("decodes through imageSizeFromBase64", () => { + const base64 = Buffer.from(pngBytes(1440, 3200)).toString("base64"); + expect(imageSizeFromBase64(base64)).toEqual({ width: 1440, height: 3200 }); + }); +}); + +describe("configuredScreenSize", () => { + it("accepts a real declared size", () => { + expect(configuredScreenSize([1080, 2400])).toEqual({ width: 1080, height: 2400 }); + }); + + it("treats the Schemastery empty-tuple shape as unset", () => { + expect(configuredScreenSize([undefined, undefined])).toBeUndefined(); + expect(configuredScreenSize([null, null])).toBeUndefined(); + }); + + it("treats a missing value as unset", () => { + expect(configuredScreenSize(undefined)).toBeUndefined(); + }); + + it("rejects non-numeric and non-positive entries", () => { + expect(configuredScreenSize(["1080", "2400"])).toBeUndefined(); + expect(configuredScreenSize([0, 0])).toBeUndefined(); + expect(configuredScreenSize([-1, 2400])).toBeUndefined(); + }); +}); diff --git a/plugins/dsh-autogamer/tests/dedup.spec.ts b/plugins/dsh-autogamer/tests/dedup.spec.ts new file mode 100644 index 000000000..aa3d57d9c --- /dev/null +++ b/plugins/dsh-autogamer/tests/dedup.spec.ts @@ -0,0 +1,212 @@ +/** + * G13 in-flight dedup locks. An identical mutating call from the same agent + * while one is still running is a double-fire, not a retry: the second is + * refused before queueing on the gate. Reads are idempotent polls and never + * deduped; sequential repeats (the first settled) always run — so legitimate + * double-taps, volume steps, and before/after screenshots are unaffected. + */ +import { beforeEach, describe, expect, it } from "vitest"; +import { DeviceBreaker } from "../src/breaker.js"; +import { defaultPool } from "./support/pool.js"; +import { defineRunDeviceAction } from "../src/tools/runDeviceAction.js"; + +function hierarchyWith(target: boolean): string { + return JSON.stringify( + target + ? [ + { index: "0", text: "亮度", "content-desc": "", "resource-id": "", class: "t", package: "p", clickable: "true", parsed_bounds: { left: 40, top: 400, right: 200, bottom: 460 } }, + { index: "1", text: "显示", "content-desc": "", "resource-id": "", class: "t", package: "p", clickable: "true", parsed_bounds: { left: 40, top: 600, right: 200, bottom: 660 } }, + ] + : [ + { index: "0", text: "亮度", "content-desc": "", "resource-id": "", class: "t", package: "p", clickable: "true", parsed_bounds: { left: 40, top: 400, right: 200, bottom: 460 } }, + ], + ); +} + +const harness = { + calls: [] as { tool: string; args: Record }[], + tapResolvers: [] as Array<(value: unknown) => void>, +}; + +let currentAgent = "agent-1"; + +function setup() { + harness.calls.length = 0; + harness.tapResolvers.length = 0; + const tool = defineRunDeviceAction({ + pool: defaultPool(), + breaker: new DeviceBreaker({ allowedFails: 99, cooldownMs: 0 }), + client: { + callTool: async (name: string, args: Record) => { + harness.calls.push({ tool: name, args }); + if (name === "get_ui_hierarchy") { + return { content: [{ type: "text", text: hierarchyWith(true) }] }; + } + if (name === "tap") { + return new Promise((resolve) => { + harness.tapResolvers.push(() => + resolve({ content: [{ type: "text", text: "Success" }] }), + ); + }); + } + return { content: [{ type: "text", text: "Success" }] }; + }, + } as never, + logger: { info: () => {}, warn: () => {} }, + defaultDeviceKey: "d0", + agentIdOfCall: () => currentAgent, + resolveScreenSize: async () => ({ width: 720, height: 1600 }), + captureFrame: async () => "frame", + compareFrames: async () => true, + }); + return tool; +} + +function exec(): { signal: AbortSignal } { + return { signal: new AbortController().signal }; +} + +function taps(): { tool: string; args: Record }[] { + return harness.calls.filter((call) => call.tool === "tap"); +} + +/** + * G67: `{index}` handles only resolve against what the observing agent was + * shown. Looking happens before any device is leased - the gate serializes a + * read behind a running action, so observing mid-test would queue, not poll. + */ +async function observe(tool: ReturnType, asAgent?: string): Promise { + const previous = currentAgent; + if (asAgent) currentAgent = asAgent; + await tool.execute({ action: "get_ui_hierarchy", reason: "t" }, exec() as never); + currentAgent = previous; +} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +beforeEach(() => { + harness.calls.length = 0; + harness.tapResolvers.length = 0; + currentAgent = "agent-1"; +}); + +describe("in-flight dedup (G13)", () => { + it("refuses an identical call while one is still running", async () => { + const tool = setup(); + await observe(tool); + const first = tool.execute( + { action: "click", target: { index: 1 }, reason: "first" }, + exec() as never, + ); + await flush(); + await flush(); + expect(taps()).toHaveLength(1); + await expect( + tool.execute({ action: "click", target: { index: 1 }, reason: "second" }, exec() as never), + ).rejects.toThrow(/^task: duplicate of the action already running/); + expect(taps()).toHaveLength(1); + harness.tapResolvers.splice(0).forEach((resolve) => resolve()); + const value = (await first) as Record; + expect(value.effect).toBe("changed"); + }); + + it("admits sequential repeats once the first settles", async () => { + const tool = setup(); + await observe(tool); + const one = tool.execute({ action: "click", target: { index: 1 }, reason: "one" }, exec() as never); + await flush(); + await flush(); + await flush(); + expect(taps()).toHaveLength(1); + harness.tapResolvers.splice(0).forEach((resolve) => resolve()); + await one; + const two = tool.execute( + { action: "click", target: { index: 1 }, reason: "two" }, + exec() as never, + ); + await flush(); + await flush(); + expect(taps()).toHaveLength(2); + harness.tapResolvers.splice(0).forEach((resolve) => resolve()); + const twoValue = (await two) as Record; + expect(twoValue.effect).toBe("changed"); + expect(taps()).toHaveLength(2); + }); + + it("admits the same call from another agent", async () => { + const tool = setup(); + await observe(tool); + // Each agent has to look at the screen itself (G67 scopes handles per agent). + await observe(tool, "agent-2"); + const first = tool.execute( + { action: "click", target: { index: 1 }, reason: "first" }, + exec() as never, + ); + await flush(); + await flush(); + expect(taps()).toHaveLength(1); + currentAgent = "agent-2"; + const second = tool.execute( + { action: "click", target: { index: 1 }, reason: "second" }, + exec() as never, + ); + let secondRejected: unknown = null; + second.catch((error: unknown) => { + secondRejected = error; + }); + await flush(); + await flush(); + // Same device serializes on the gate, but the second call passed dedup + // (not refused as a duplicate): it is queued, not rejected. + expect(secondRejected).toBeNull(); + expect(taps()).toHaveLength(1); + harness.tapResolvers.splice(0).forEach((resolve) => resolve()); + expect(((await first) as Record).effect).toBe("changed"); + // The gate released: the second call reaches the device now. + await flush(); + await flush(); + expect(taps()).toHaveLength(2); + harness.tapResolvers.splice(0).forEach((resolve) => resolve()); + expect(((await second) as Record).effect).toBe("changed"); + }); + + it("never dedups read-only polls", async () => { + const tool = setup(); + const a = await tool.execute({ action: "get_ui_hierarchy", reason: "a" }, exec() as never); + const b = await tool.execute({ action: "get_ui_hierarchy", reason: "b" }, exec() as never); + expect((a as Record).element_count).toBe(2); + expect((b as Record).element_count).toBe(2); + }); + + it("admits a different target while one runs", async () => { + const tool = setup(); + await observe(tool); + const first = tool.execute( + { action: "click", target: { index: 1 }, reason: "first" }, + exec() as never, + ); + await flush(); + await flush(); + expect(taps()).toHaveLength(1); + const second = tool.execute( + { action: "click", target: { index: 0 }, reason: "other button" }, + exec() as never, + ); + let secondRejected: unknown = null; + second.catch((error: unknown) => { + secondRejected = error; + }); + await flush(); + await flush(); + // Same device serializes on the gate: queued behind the first, refused by nothing. + expect(secondRejected).toBeNull(); + expect(taps()).toHaveLength(1); + harness.tapResolvers.splice(0).forEach((resolve) => resolve()); + expect(((await first) as Record).effect).toBe("changed"); + await flush(); + await flush(); + expect(taps()).toHaveLength(2); + harness.tapResolvers.splice(0).forEach((resolve) => resolve()); + expect(((await second) as Record).effect).toBe("changed"); + }); +}); diff --git a/plugins/dsh-autogamer/tests/deliveryShape.spec.ts b/plugins/dsh-autogamer/tests/deliveryShape.spec.ts new file mode 100644 index 000000000..b049f069f --- /dev/null +++ b/plugins/dsh-autogamer/tests/deliveryShape.spec.ts @@ -0,0 +1,85 @@ +/** + * The shipped bundle patch and the live overlay must describe ONE delivery + * shape. Both replace the whole preset config (rule R5), so a row added to only + * one of them silently means the thing we measured is not the thing we ship - + * that was G37, and G45 (the persona row missing from both) is the same failure + * one level up. `--dump-config` (R6) only proves each file composes; nothing + * else compares them. + */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const FILES = { + shipped: new URL("../cordis.patch.yml", import.meta.url), + live: new URL("../spike/web-live.patch.yml", import.meta.url), +}; + +/** Everything after the preset's `plugins:` key. */ +function presetPlugins(url: URL): string { + const text = readFileSync(fileURLToPath(url), "utf-8"); + const at = text.indexOf("\n plugins:"); + const from = at >= 0 ? at : text.indexOf("\n plugins:"); + if (from < 0) throw new Error(`${url.pathname} has no preset plugins block`); + return text.slice(from); +} + +/** Plugin names contributed by the preset (item-level `name:` keys). */ +function contributedNames(url: URL): string[] { + return [...presetPlugins(url).matchAll(/^\s*(?:- )?name: "?([^"\n]+?)"?\s*$/gm)].map((m) => m[1].trim()); +} + +/** The persona `prefix:` block scalar, dedented. */ +function personaPrefix(url: URL): string { + const lines = presetPlugins(url).split("\n"); + const start = lines.findIndex((l) => /^\s*prefix: \|\s*$/.test(l)); + if (start < 0) throw new Error(`${url.pathname} has no persona prefix`); + const indent = lines[start].match(/^\s*/)![0].length; + const body: string[] = []; + for (const line of lines.slice(start + 1)) { + if (line.trim() && !line.startsWith(" ".repeat(indent + 2))) break; + body.push(line.slice(indent + 2)); + } + return body.join("\n").trim(); +} + +describe("delivery shape: shipped patch and live overlay agree", () => { + it("contribute the same preset plugins, including the persona row (G45)", () => { + const shipped = contributedNames(FILES.shipped); + expect(shipped).toContain("@deepseek-ai/dsh-persona"); + expect(shipped).toContain("dsh-autogamer"); + expect(shipped).toContain("@deepseek-ai/dsh-tool-jobs"); + expect(shipped).toContain("@deepseek-ai/dsh-tool-cordis"); + expect(contributedNames(FILES.live)).toEqual(shipped); + }); + + it("carry byte-identical persona instructions", () => { + expect(personaPrefix(FILES.live)).toBe(personaPrefix(FILES.shipped)); + }); + + it("keeps the instructions free of prompt variables", () => { + // Unresolved `{{...}}` groups make assembly throw before any model request, + // and the loop registers only provider/model (never cwd). + expect(personaPrefix(FILES.shipped)).not.toMatch(/\{\{|\}\}/); + }); + + it("carries the checker audit discipline (P1-b)", () => { + // Byte-identical across both files by the test above: assert once here. + const prefix = personaPrefix(FILES.shipped); + expect(prefix).toContain("Audit like a checker"); + expect(prefix).toContain("tests_failed"); + expect(prefix).toContain("incident"); + }); + + it("ships the strictness ladder and plan scaffold in the flash skill (P1-c)", () => { + const skill = readFileSync( + fileURLToPath(new URL("../skills/autogamer-flash/SKILL.md", import.meta.url)), + "utf-8", + ); + for (const level of ["`off`", "`final`", "`checkpoints`", "`strict`"]) { + expect(skill).toContain(level); + } + expect(skill).toContain("incident"); + expect(skill).toContain("verify"); + }); +}); diff --git a/plugins/dsh-autogamer/tests/deviceKey.spec.ts b/plugins/dsh-autogamer/tests/deviceKey.spec.ts new file mode 100644 index 000000000..4e035dd23 --- /dev/null +++ b/plugins/dsh-autogamer/tests/deviceKey.spec.ts @@ -0,0 +1,23 @@ +/** + * deviceKeyOf matrix lock (S4 auto 端点默认键): an omitted, non-string, or + * empty serial falls back to the configured default, so execute, guard, and + * gate key the same device. The two call sites used to diverge on "". + */ +import { describe, expect, it } from "vitest"; +import { deviceKeyOf } from "../src/tools/runDeviceAction.js"; + +describe("deviceKeyOf", () => { + it("falls back when the serial is omitted", () => { + expect(deviceKeyOf({ action: "back" }, "default")).toBe("default"); + }); + + it("falls back on empty or non-string serials", () => { + expect(deviceKeyOf({ device_serial: "" }, "default")).toBe("default"); + expect(deviceKeyOf({ device_serial: 42 }, "default")).toBe("default"); + expect(deviceKeyOf({ device_serial: null }, "default")).toBe("default"); + }); + + it("keeps an explicit serial", () => { + expect(deviceKeyOf({ device_serial: "emulator-5554" }, "default")).toBe("emulator-5554"); + }); +}); diff --git a/plugins/dsh-autogamer/tests/devicePool.spec.ts b/plugins/dsh-autogamer/tests/devicePool.spec.ts new file mode 100644 index 000000000..55784f501 --- /dev/null +++ b/plugins/dsh-autogamer/tests/devicePool.spec.ts @@ -0,0 +1,293 @@ +/** + * G3/G28 device scheduling locks. The rules under test are quoted where they come from: + * parking ("auto 占住一台空闲设备", glossary G28), the claimed-only rule from + * `runtime/device_lock.py:332` (an unclaimed ticket must not be counted into every device's + * queue), and the "do not clean this up in the TS rewrite" warning at `inventory/02:236`. + */ +import { describe, expect, it } from "vitest"; +import { DeviceGate } from "../src/gate.js"; +import { DevicePool, type PoolDevice } from "../src/devicePool.js"; + +const hold = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function build(options: { devices?: string[]; max?: number; cooling?: string[] } = {}) { + const gate = new DeviceGate(); + const pool = new DevicePool({ + gate, + defaultDeviceKey: "default", + devices: options.devices ?? [], + maxConcurrentDevices: options.max ?? 0, + isCoolingDown: (key) => (options.cooling ?? []).includes(key), + }); + return { gate, pool }; +} + +describe("DevicePool (G3/G28)", () => { + it("keeps the historical single-key behaviour when no roster is configured", async () => { + const { pool } = build(); + const lease = await pool.acquire({ action: "tap" }); + expect(lease.key).toBe("default"); // default: no devices configured -> old shared key + lease.release(); + const named = await pool.acquire({ action: "tap", device_serial: "emulator-5554" }); + expect(named.key).toBe("emulator-5554"); + named.release(); + }); + + it("parks an auto request on exactly one free device (G28)", async () => { + const { pool } = build({ devices: ["A", "B"] }); + const first = await pool.acquire({ action: "tap" }); + const second = await pool.acquire({ action: "tap" }); + expect([first.key, second.key].sort()).toEqual(["A", "B"]); + expect(pool.busyDevices).toBe(2); + first.release(); + second.release(); + }); + + it("does not let a waiting auto ticket idle out the rest of the fleet", async () => { + // device_lock.py:332 -- the parked ticket used to be counted into every device's queue, so a + // single unclaimed message blocked machines that were sitting idle. Here every pool device is + // busy, so the auto ticket must wait *without* holding anything: a later request for a device + // outside the pool still runs immediately. + const { gate, pool } = build({ devices: ["A", "B"] }); + const a = await pool.acquire({ action: "tap", device_serial: "A" }); + const b = await pool.acquire({ action: "tap", device_serial: "B" }); + const pendingAuto = pool.acquire({ action: "tap" }); + await hold(5); + expect(gate.busyCount("A")).toBe(1); + expect(pool.busyDevices).toBe(2); // the parked ticket has claimed nothing + + const outside = await pool.acquire({ action: "tap", device_serial: "emulator-5556" }); + expect(outside.key).toBe("emulator-5556"); + outside.release(); + + a.release(); + const auto = await pendingAuto; + expect(auto.key).toBe("A"); + auto.release(); + b.release(); + }); + + it("honours the global concurrency ceiling", async () => { + const { pool } = build({ devices: ["A", "B", "C"], max: 2 }); + const a = await pool.acquire({ action: "tap" }); + const b = await pool.acquire({ action: "tap" }); + expect(pool.busyDevices).toBe(2); + let thirdSettled = false; + const third = pool.acquire({ action: "tap" }).then((lease) => { + thirdSettled = true; + return lease; + }); + await hold(10); + expect(thirdSettled).toBe(false); + a.release(); + const c = await third; + expect(thirdSettled).toBe(true); + expect(["A", "B", "C"]).toContain(c.key); + b.release(); + c.release(); + }); + + it("skips devices in breaker cooldown when assigning auto work (S4)", async () => { + const { pool } = build({ devices: ["A", "B"], cooling: ["A"] }); + const lease = await pool.acquire({ action: "tap" }); + expect(lease.key).toBe("B"); + lease.release(); + }); + + it("schedules auto work on a discovered roster (G64)", async () => { + let calls = 0; + const gate = new DeviceGate(); + const pool = new DevicePool({ + gate, + defaultDeviceKey: "default", + rosterSource: async () => { + calls += 1; + return [{ serial: "emulator-5554", state: "device" }, { serial: "emulator-5556", state: "device" }]; + }, + }); + const first = await pool.acquire({ action: "tap" }); + const second = await pool.acquire({ action: "tap" }); + expect([first.key, second.key].sort()).toEqual(["emulator-5554", "emulator-5556"]); + expect(calls).toBe(1); // throttled: one enumeration for the burst, not one per call + first.release(); + second.release(); + }); + + it("keeps the last good roster when enumeration cannot be answered (fail-open)", async () => { + // device_pool.py:354-356 -- "could not ask adb" must stay distinct from "no devices attached". + let answer: PoolDevice[] | undefined = [{ serial: "A", state: "device" }]; + let clock = 0; + const gate = new DeviceGate(); + const pool = new DevicePool({ + gate, + defaultDeviceKey: "default", + rosterSource: async () => answer, + rosterTtlMs: 1_000, + now: () => clock, + }); + const first = await pool.acquire({ action: "tap" }); + expect(first.key).toBe("A"); + first.release(); + + answer = undefined; // the question failed this time + clock += 2_000; // past the throttle window, so it really asks again + const second = await pool.acquire({ action: "tap" }); + expect(second.key).toBe("A"); // not "default": the fleet did not vanish + second.release(); + expect(pool.poolDevices).toEqual(["A"]); + }); + + it("prefers a configured roster over the discovered one", async () => { + const gate = new DeviceGate(); + let asked = 0; + const pool = new DevicePool({ + gate, + defaultDeviceKey: "default", + devices: ["pinned-1"], + rosterSource: async () => { + asked += 1; + return [{ serial: "discovered-1", state: "device" }]; + }, + now: () => 0, + rosterTtlMs: 0, + }); + const lease = await pool.acquire({ action: "tap" }); + expect(lease.key).toBe("pinned-1"); + lease.release(); + expect(asked).toBe(0); // no RPC at all when the operator pinned a roster + }); + + it("gives a freed device to the earliest queued auto ticket, not to whoever scans first", async () => { + // The claimed-only half of inventory/02:236: after a device frees, the ticket that asked first + // keeps its place. Written because the first version of this lock could not tell a reversed + // scan order from the correct one -- mutation check said "green", so the lock was vacuous. + const { pool } = build({ devices: ["A", "B"] }); + const first = await pool.acquire({ action: "tap" }); + const second = await pool.acquire({ action: "tap" }); + const early = pool.acquire({ action: "tap" }); + const late = pool.acquire({ action: "tap" }); + await hold(5); + + const settled: string[] = []; + const trackedEarly = early.then((lease) => { + settled.push(`early:${lease.key}`); + return lease; + }); + const trackedLate = late.then((lease) => { + settled.push(`late:${lease.key}`); + return lease; + }); + + first.release(); // exactly one device becomes available + const winner = await trackedEarly; + expect(settled).toEqual([`early:${first.key}`]); // the earlier ticket got it + second.release(); + const loser = await trackedLate; + expect(settled[1]).toBe(`late:${second.key}`); + winner.release(); + loser.release(); + }); + + describe("submission admission (P2 A 片)", () => { + // Ported from runtime/device_pool.py (_explicit_serial_error), the first of py's two + // submission gates (the old FastAPI console that invoked it before the readiness probe was + // removed in P4; this TS pool now owns admission). Lock-screen verdicts are G65 and + // deliberately absent here. + async function discovered(...devices: PoolDevice[]) { + const gate = new DeviceGate(); + const pool = new DevicePool({ + gate, + defaultDeviceKey: "default", + rosterSource: async () => devices, + }); + // Refresh directly rather than "acquire once to force it": with every device locked or + // unknown there is nothing to acquire, and an auto call would legitimately never resolve. + await pool.refreshRoster(); + return pool; + } + + it("rejects an explicit serial that is not attached, only when enumeration answered", async () => { + const pool = await discovered({ serial: "A", state: "device" }); + expect(pool.admissibleExplicit("B")).toBe("device B is not connected"); + expect(pool.admissibleExplicit("A")).toBeUndefined(); + }); + + it("rejects an attached-but-not-ready serial and names the state", async () => { + const pool = await discovered({ serial: "A", state: "unauthorized" }); + expect(pool.admissibleExplicit("A")).toBe("device A is attached but not ready (state: unauthorized)"); + }); + + it("fails open when the question could not be answered", async () => { + // device_pool.py:370-373 -- an indeterminate or empty enumeration must not hard-reject. + const gate = new DeviceGate(); + let answer: PoolDevice[] | undefined = [{ serial: "A", state: "offline" }]; + const pool = new DevicePool({ + gate, + defaultDeviceKey: "default", + rosterSource: async () => answer, + rosterTtlMs: 0, + }); + const lease = await pool.acquire({ action: "tap" }); + lease.release(); + expect(pool.admissibleExplicit("A")).toContain("not ready"); + + answer = undefined; // next refresh cannot answer + const again = await pool.acquire({ action: "tap" }); + again.release(); + expect(pool.admissibleExplicit("A")).toBeUndefined(); + }); + + it("fails open on an empty enumeration instead of rejecting every serial", async () => { + const pool = await discovered(); // enumerated=true, roster=[] + expect(pool.admissibleExplicit("anything")).toBeUndefined(); + }); + + it("never rejects on a pinned config roster, which carries no state", async () => { + const gate = new DeviceGate(); + const pool = new DevicePool({ gate, defaultDeviceKey: "default", devices: ["pinned-1"] }); + expect(pool.admissibleExplicit("pinned-1")).toBeUndefined(); + expect(pool.admissibleExplicit("other")).toBeUndefined(); + }); + + it("prefers a ready and idle device over a ready but busy one (device_pool.py:461-472)", async () => { + const gate = new DeviceGate(); + const pool = new DevicePool({ + gate, + defaultDeviceKey: "default", + rosterSource: async () => [ + { serial: "busy-one", state: "device", isBusy: true }, + { serial: "idle-one", state: "device", isBusy: false }, + ], + }); + const lease = await pool.acquire({ action: "tap" }); + expect(lease.key).toBe("idle-one"); + lease.release(); + }); + + it("rejects an explicit serial that is locked", async () => { + const pool = await discovered({ serial: "A", state: "device", isLocked: true }); + expect(pool.admissibleExplicit("A")).toBe("device A is locked"); + }); + + it("fails closed on an asked-but-unknown lock state, and that is NOT the same as not asking", async () => { + // adb_probe.py:452 treats "Lock State Unknown" exactly like "Device Locked"; the router then + // 409s (tasks.py:174). A device nobody asked about must not fall into that branch. + const asked = await discovered({ serial: "A", state: "device", isLocked: null }); + expect(asked.admissibleExplicit("A")).toBe("device A lock state is unknown"); + + const notAsked = await discovered({ serial: "A", state: "device" }); + expect(notAsked.admissibleExplicit("A")).toBeUndefined(); + }); + + it("auto skips locked and unknown devices (adb_probe.py:427-446 switches instead of dispatching)", async () => { + const pool = await discovered( + { serial: "locked-one", state: "device", isLocked: true }, + { serial: "unknown-one", state: "device", isLocked: null }, + { serial: "open-one", state: "device", isLocked: false }, + ); + const lease = await pool.acquire({ action: "tap" }); + expect(lease.key).toBe("open-one"); + lease.release(); + }); + }); +}); diff --git a/plugins/dsh-autogamer/tests/diagnose.spec.ts b/plugins/dsh-autogamer/tests/diagnose.spec.ts new file mode 100644 index 000000000..23a70cb1e --- /dev/null +++ b/plugins/dsh-autogamer/tests/diagnose.spec.ts @@ -0,0 +1,122 @@ +/** + * `autogamer_diagnose` unit locks (Phase 2). The merge of provider report and + * local preflight is the whole point: a broken environment is REPORTED (with + * its fixes appended), not thrown — only a dead provider on top of a broken + * environment throws, and then the preflight text wins. + */ +import { describe, expect, it } from "vitest"; +import type { ArtemisBackend, Diagnosis } from "../src/artemis/types.js"; +import type { PreflightResult } from "../src/env/preflight.js"; +import { defineDiagnose } from "../src/tools/diagnose.js"; +import type { RulesInjectionOutcome } from "../src/prompt/rulesInjector.js"; + +const HEALTHY: PreflightResult = { ok: true, checks: [], finishedAt: "t" }; +const BROKEN: PreflightResult = { + ok: false, + checks: [{ id: "adb-devices", ok: false, detail: "none attached", fix: "plug it in" }], + finishedAt: "t", +}; + +const REPORT: Diagnosis = { + deviceSerial: "em-1", + adbConnected: true, + helperInstalled: true, + helperVersion: "v3", + recentCrashes: [{ package: "com.x", timestamp: "t", stackExcerpt: "boom" }], + recommendations: ["restart adb"], +}; + +function defineWith(options?: { + report?: Diagnosis; + env?: PreflightResult; + failBackend?: Error; + rulesStatus?: RulesInjectionOutcome; +}): { + tool: ReturnType; + serials: (string | undefined)[]; +} { + const serials: (string | undefined)[] = []; + const backend: ArtemisBackend = { + getDeviceState: async () => ({ deviceSerial: "", connected: false }), + startTask: async (args) => ({ taskId: "t", profile: args.profile, startedAt: "t" }), + manageTask: async (args) => ({ taskId: args.taskId }), + inspectTrace: async (args) => ({ taskId: args.traceId, steps: [], artifactPaths: [] }), + diagnose: async (args) => { + serials.push(args.deviceSerial); + if (options?.failBackend) throw options.failBackend; + return options?.report ?? REPORT; + }, + dispose: async () => undefined, + }; + const tool = defineDiagnose({ + backend, + checkEnv: async () => options?.env ?? HEALTHY, + defaultDeviceSerial: "", + rulesStatus: () => options?.rulesStatus ?? "injected", + artemisRepoPath: "/repo", + }); + return { tool, serials }; +} + +function exec(): { signal: AbortSignal } { + return { signal: new AbortController().signal }; +} + +describe("diagnose: the merge", () => { + it("passes the provider report through on a healthy environment", async () => { + const { tool, serials } = defineWith(); + const value = (await tool.execute({ device_serial: "em-1" }, exec())) as Record; + expect(serials).toEqual(["em-1"]); + expect(value).toMatchObject({ + device_serial: "em-1", + adb_connected: true, + helper_installed: true, + helper_version: "v3", + recommendations: ["restart adb"], + }); + expect((value.recent_crashes as { package?: string }[])[0]?.package).toBe("com.x"); + }); + + it("surfaces a missing provider rules file instead of skipping it silently (G80)", async () => { + const { tool } = defineWith({ rulesStatus: "missing-file" }); + const value = (await tool.execute({}, exec())) as Record; + const recos = value.recommendations as string[]; + const warning = recos.find((entry) => entry.includes("rules are NOT in the model's context")); + expect(warning).toBeDefined(); + expect(warning).toContain("reason: missing-file"); + expect(warning).toContain("mcp_server/rules.md"); + // the provider's own recommendation must survive the append + expect(recos).toContain("restart adb"); + }); + + it("stays quiet when the operator turned rules injection off on purpose", async () => { + const { tool } = defineWith({ rulesStatus: "disabled" }); + const value = (await tool.execute({}, exec())) as Record; + expect((value.recommendations as string[]).some((entry) => entry.includes("rules are NOT"))).toBe(false); + }); + + it("appends local preflight fixes instead of throwing on a broken environment", async () => { + const { tool } = defineWith({ env: BROKEN }); + const value = (await tool.execute({}, exec())) as { recommendations: string[] }; + expect(value.recommendations).toEqual(["restart adb", "plug it in"]); + }); + + it("does not duplicate a fix both sides already name", async () => { + const { tool } = defineWith({ + env: BROKEN, + report: { ...REPORT, recommendations: ["plug it in"] }, + }); + const value = (await tool.execute({}, exec())) as { recommendations: string[] }; + expect(value.recommendations).toEqual(["plug it in"]); + }); + + it("a dead provider over a broken environment throws the preflight text", async () => { + const { tool } = defineWith({ env: BROKEN, failBackend: new Error("environment: gone") }); + await expect(tool.execute({}, exec())).rejects.toThrow(/preflight failed at adb-devices/); + }); + + it("a dead provider over a healthy environment rethrows the transport error", async () => { + const { tool } = defineWith({ failBackend: new Error("environment: gone") }); + await expect(tool.execute({}, exec())).rejects.toThrow(/^environment: gone/); + }); +}); diff --git a/plugins/dsh-autogamer/tests/fixtures/fake-repo/mcp_server/rules.md b/plugins/dsh-autogamer/tests/fixtures/fake-repo/mcp_server/rules.md new file mode 100644 index 000000000..9ccb9f80d --- /dev/null +++ b/plugins/dsh-autogamer/tests/fixtures/fake-repo/mcp_server/rules.md @@ -0,0 +1,3 @@ +# Sample provider rules (test fixture for the rules injector wiring) + +Think like a mobile tester: assert what the screen shows, never what memory says. diff --git a/plugins/dsh-autogamer/tests/fixtures/py-action-tools.json b/plugins/dsh-autogamer/tests/fixtures/py-action-tools.json new file mode 100644 index 000000000..ce67e592d --- /dev/null +++ b/plugins/dsh-autogamer/tests/fixtures/py-action-tools.json @@ -0,0 +1,29 @@ +{ + "captured": "2026-10-13", + "source": "live MCP stdio listing of `uv run artemis mcp --type adb` (node plugins/dsh-autogamer/scripts/spike-mcp.mjs --write)", + "pyActionServerTools": [ + "back", + "compare_ui_frames", + "erase_one_char", + "focus_and_clear_text", + "focus_and_input_text", + "get_ui_hierarchy", + "launch_app", + "list_devices", + "long_press_on", + "open_link", + "press_key", + "stop_app", + "swipe", + "take_screenshot", + "tap" + ], + "notes": [ + "2026-10-11: compare_ui_frames added (G39 route A) - the read-only effect verdict the plugin router calls, delegating to artemis/utils/image_diff.check_ui_change.", + "Contract quirks locked by live capture (see docs/migration/inventory/03 + dsh-verification §一):", + "legacy PIXEL coordinate contract — 0-1000 normalization happens in this plugin (src/coord.ts)", + "results are plain strings: Success / Failed / Error: ... / Error executing tool ...", + "real flag is `--type adb` (not --server); stdio transport default", + "generated from the live server; the expected surface is ACTION_TO_PY_TOOL, no longer a copy kept here" + ] +} diff --git a/plugins/dsh-autogamer/tests/gate.spec.ts b/plugins/dsh-autogamer/tests/gate.spec.ts new file mode 100644 index 000000000..bdfdc1370 --- /dev/null +++ b/plugins/dsh-autogamer/tests/gate.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { DeviceGate, GateAbortedError } from "../src/gate.js"; + +describe("DeviceGate", () => { + it("serializes acquirers on the same key in FIFO order", async () => { + const gate = new DeviceGate(); + const order: string[] = []; + + const first = gate.acquire("serial-1"); + const second = gate.acquire("serial-1"); + + const a = first.then(async (handle) => { + order.push("a-start"); + await new Promise((resolve) => setTimeout(resolve, 20)); + order.push("a-end"); + handle.release(); + }); + const b = second.then(async (handle) => { + order.push("b-start"); + handle.release(); + }); + await Promise.all([a, b]); + + expect(order).toEqual(["a-start", "a-end", "b-start"]); + }); + + it("does not serialize different keys", async () => { + const gate = new DeviceGate(); + const first = gate.acquire("a"); + const second = gate.acquire("b"); + const [handleA, handleB] = await Promise.all([first, second]); + handleA.release(); + handleB.release(); + expect(gate.busyCount("a")).toBe(0); + }); + + it("abort while waiting leaves the queue without acquiring", async () => { + const gate = new DeviceGate(); + const holder = await gate.acquire("serial-1"); + const controller = new AbortController(); + + const waiting = gate.acquire("serial-1", controller.signal); + const waitingForever = gate.acquire("serial-1"); + controller.abort(); + + await expect(waiting).rejects.toBeInstanceOf(GateAbortedError); + holder.release(); + + // The aborted waiter must not have consumed the slot: the next waiter runs. + const next = await waitingForever; + next.release(); + expect(gate.busyCount("serial-1")).toBe(0); + }); + + it("reports pending and busy counts", async () => { + const gate = new DeviceGate(); + const holder = await gate.acquire("d"); + let releaseWaiting!: () => void; + const waiting = gate.acquire("d").then((handle) => { + releaseWaiting = handle.release; + return handle; + }); + await Promise.resolve(); + expect(gate.busyCount("d")).toBe(1); + expect(gate.pendingCount("d")).toBe(1); + holder.release(); + const handle = await waiting; + expect(gate.pendingCount("d")).toBe(0); + releaseWaiting(); + }); +}); diff --git a/plugins/dsh-autogamer/tests/getDeviceState.spec.ts b/plugins/dsh-autogamer/tests/getDeviceState.spec.ts new file mode 100644 index 000000000..03d02c99b --- /dev/null +++ b/plugins/dsh-autogamer/tests/getDeviceState.spec.ts @@ -0,0 +1,142 @@ +/** + * `autogamer_get_device_state` unit locks (Phase 1). Backend and preflight are + * doubles: the preflight gate, serial sanitising, field mapping, and the + * wire-shape basics live here; the compiled wire schema is locked by the + * conformance suite (runtimeBoot asserts every tool's object root). + */ +import { describe, expect, it } from "vitest"; +import type { ArtemisBackend, DeviceState } from "../src/artemis/types.js"; +import type { PreflightResult } from "../src/env/preflight.js"; +import { defineGetDeviceState } from "../src/tools/getDeviceState.js"; + +const HEALTHY: PreflightResult = { ok: true, checks: [], finishedAt: "2026-10-14T00:00:00.000Z" }; + +function backendReturning(state: DeviceState): { + backend: ArtemisBackend; + calls: { deviceSerial?: string; includeScreenshot?: boolean }[]; +} { + const calls: { deviceSerial?: string; includeScreenshot?: boolean }[] = []; + return { + calls, + backend: { + getDeviceState: async (args) => { + calls.push(args); + return state; + }, + dispose: async () => undefined, + }, + }; +} + +function defineWith(overrides?: { + backend?: ArtemisBackend; + env?: PreflightResult; + defaultDeviceSerial?: string; +}): { tool: ReturnType; calls: unknown[] } { + const { backend, calls } = backendReturning({ + deviceSerial: "em-1", + connected: true, + currentPackage: "com.example", + screenshotPath: "/tmp/shot.png", + }); + const tool = defineGetDeviceState({ + backend: overrides?.backend ?? backend, + checkEnv: async () => overrides?.env ?? HEALTHY, + defaultDeviceSerial: overrides?.defaultDeviceSerial ?? "", + }); + return { tool, calls }; +} + +describe("get_device_state: definition", () => { + it("registers under the autogamer_ prefix with a plain-string description", () => { + const { tool } = defineWith(); + expect(tool.name).toBe("autogamer_get_device_state"); + expect(typeof tool.description).toBe("string"); + }); + + it("declares the serial/screenshot params and requires the identity fields", () => { + const { tool } = defineWith(); + const parameters = tool.parameters as Record; + expect(parameters.device_serial?.type).toBe("string"); + expect(parameters.include_screenshot?.type).toBe("boolean"); + const schema = tool.output as { + schema: { properties: Record }; + }; + expect(schema.schema.properties.device_serial?.required).toBe(true); + expect(schema.schema.properties.connected?.required).toBe(true); + expect(schema.schema.properties.current_package?.required).not.toBe(true); + }); + + it("is declared concurrency-safe: reads never need the device gate", () => { + const { tool } = defineWith(); + expect(tool.isConcurrencySafe?.({})).toBe(true); + }); +}); + +describe("get_device_state: execute", () => { + it("refuses on a failed preflight with the fix attached", async () => { + const { tool } = defineWith({ + env: { + ok: false, + checks: [{ id: "adb-devices", ok: false, detail: "none attached", fix: "plug it in" }], + finishedAt: "t", + }, + }); + await expect(tool.execute({}, { signal: new AbortController().signal })).rejects.toThrow( + /environment: preflight failed at adb-devices.*plug it in/, + ); + }); + + it("prefers the call arg, then the configured default, then omits", async () => { + const first = defineWith({ defaultDeviceSerial: "em-default" }); + await first.tool.execute({ device_serial: "em-arg" }, { signal: new AbortController().signal }); + expect(first.calls).toEqual([{ deviceSerial: "em-arg", includeScreenshot: false }]); + + const second = defineWith({ defaultDeviceSerial: "em-default" }); + await second.tool.execute({}, { signal: new AbortController().signal }); + expect(second.calls).toEqual([{ deviceSerial: "em-default", includeScreenshot: false }]); + + const third = defineWith({ defaultDeviceSerial: "" }); + await third.tool.execute({ device_serial: "" }, { signal: new AbortController().signal }); + expect(third.calls).toEqual([{ deviceSerial: undefined, includeScreenshot: false }]); + }); + + it("maps the backend state onto the output record", async () => { + const { tool } = defineWith(); + const value = (await tool.execute( + { include_screenshot: true }, + { signal: new AbortController().signal }, + )) as Record; + expect(value).toMatchObject({ + device_serial: "em-1", + connected: true, + current_package: "com.example", + screenshot_path: "/tmp/shot.png", + }); + expect(value.current_activity).toBeUndefined(); + }); + + it("renders a one-line text envelope naming device and connection", async () => { + const { tool } = defineWith(); + const rendered = tool.output?.render( + {}, + { device_serial: "em-1", connected: true }, + ) as { type: string; text?: string }[]; + expect(rendered).toHaveLength(1); + expect(rendered[0]?.text).toContain("device_serial=em-1"); + expect(rendered[0]?.text).toContain("connected=true"); + }); + + it("backend errors pass through with their classification intact", async () => { + const failing: ArtemisBackend = { + getDeviceState: async () => { + throw new Error("environment: provider is down"); + }, + dispose: async () => undefined, + }; + const { tool } = defineWith({ backend: failing }); + await expect( + tool.execute({}, { signal: new AbortController().signal }), + ).rejects.toThrow(/^environment: provider is down/); + }); +}); diff --git a/plugins/dsh-autogamer/tests/hierarchy.spec.ts b/plugins/dsh-autogamer/tests/hierarchy.spec.ts new file mode 100644 index 000000000..15f736e03 --- /dev/null +++ b/plugins/dsh-autogamer/tests/hierarchy.spec.ts @@ -0,0 +1,192 @@ +/** + * Element numbering and targeting (G42) plus handle resolution by identity + * (G67) — the handle follows the node it was shown as, never array position N. + * The fixture mirrors the live action + * server payload shape measured on 2026-10-11: a JSON node list whose own + * `index` field is a per-container ordinal (repeated values), with + * `parsed_bounds` carrying the geometry. The old resolver scanned this for + * `` XML tags and so failed every `{index}` call — with no test to catch it. + */ +import { describe, expect, it } from "vitest"; +import { + findNodeCenterBySubstring, + formatVisibleElements, + parseVisibleElements, + resolveHandle, +} from "../src/hierarchy.js"; + +function node(index: string, bounds: [number, number, number, number], extra: Record = {}) { + return { + index, + text: "", + "content-desc": "", + "resource-id": "", + class: "android.widget.TextView", + package: "com.android.settings", + clickable: "false", + parsed_bounds: { left: bounds[0], top: bounds[1], right: bounds[2], bottom: bounds[3] }, + ...extra, + }; +} + +const PAYLOAD = JSON.stringify([ + node("0", [6, 6, 66, 30], { text: "18:42", "resource-id": "com.android.systemui:id/clock" }), + node("1", [0, 36, 1600, 305]), + node("0", [40, 400, 200, 460], { text: "亮度", clickable: "true" }), + node("1", [40, 600, 200, 660], { text: "显示", "content-desc": "显示 (Display)", clickable: "true" }), + node("2", [40, 800, 200, 860], { "resource-id": "com.android.settings:id/dark" }), + // No usable geometry: must not consume a handle. + node("0", [10, 10, 10, 10]), +]); + +describe("hierarchy: positional numbering (G42)", () => { + it("numbers elements by position, not by the payload's per-container index", () => { + const elements = parseVisibleElements(PAYLOAD)!; + expect(elements.map((el) => el.index)).toEqual([0, 1, 2, 3, 4]); + expect(new Set(elements.map((el) => el.index)).size).toBe(elements.length); + }); + + it("drops nodes without a real area before numbering them", () => { + const elements = parseVisibleElements(PAYLOAD)!; + expect(elements).toHaveLength(5); + expect(elements[4].resourceId).toBe("com.android.settings:id/dark"); + }); + + it("resolves a handle against the list the model was shown, not thin air", () => { + const observed = parseVisibleElements(PAYLOAD)!; + expect(resolveHandle(observed, observed, 2)).toEqual({ + status: "resolved", + center: { x: 120, y: 430, text: "亮度" }, + }); + // A handle outside the live observation is a caller bug, not a transient screen. + expect(resolveHandle(observed, observed, 5)).toEqual({ status: "unknown", elementCount: 5 }); + expect(resolveHandle(undefined, observed, 0)).toEqual({ status: "unobserved" }); + }); + + it("follows a handle to where its identity moved, never to whatever sits at N", () => { + const observed = parseVisibleElements(PAYLOAD)!; + // Three nodes inserted before 亮度, and it drifted 5px on its own. + const shifted = parseVisibleElements( + JSON.stringify([ + node("0", [0, 0, 40, 40], { text: "新" }), + node("1", [0, 40, 40, 80], { text: "更" }), + node("2", [0, 80, 40, 120], { text: "新" }), + node("0", [45, 405, 205, 465], { text: "亮度", clickable: "true" }), + node("1", [40, 900, 200, 960], { text: "显示", "content-desc": "显示 (Display)" }), + ]), + )!; + // Positional resolution would return this tree's #2 ("新" at (20,100)). + expect(resolveHandle(observed, shifted, 2)).toEqual({ + status: "resolved", + center: { x: 125, y: 435, text: "亮度" }, + }); + expect(resolveHandle(observed, shifted, 4)).toEqual({ + status: "gone", + label: "com.android.settings:id/dark", + }); + }); + + it("takes the nearest same-labelled node, and refuses when even that is out of reach", () => { + const observed = parseVisibleElements( + JSON.stringify([node("0", [40, 400, 200, 460], { text: "删除" })]), + )!; + const two = parseVisibleElements( + JSON.stringify([ + node("0", [900, 1800, 1060, 1860], { text: "删除" }), + node("1", [45, 405, 205, 465], { text: "删除" }), + ]), + )!; + expect(resolveHandle(observed, two, 0)).toMatchObject({ + status: "resolved", + center: { x: 125, y: 435 }, + }); + // Only the far twin is left: that is another element wearing the same label, + // not the node that moved, so it must not get the tap. + const onlyFar = parseVisibleElements( + JSON.stringify([node("0", [900, 1800, 1060, 1860], { text: "删除" })]), + )!; + expect(resolveHandle(observed, onlyFar, 0)).toEqual({ status: "gone", label: "删除" }); + }); + + it("matches a node with nothing to identify it by its exact position only", () => { + const bare = { + index: "0", + text: "", + "content-desc": "", + "resource-id": "", + class: "", + package: "", + clickable: "false", + parsed_bounds: { left: 10, top: 20, right: 30, bottom: 40 }, + }; + const observed = parseVisibleElements(JSON.stringify([bare]))!; + expect(formatVisibleElements(observed)).toContain("(unlabeled)"); + expect(resolveHandle(observed, observed, 0)).toMatchObject({ + status: "resolved", + center: { x: 20, y: 30 }, + }); + const moved = parseVisibleElements( + JSON.stringify([{ ...bare, parsed_bounds: { left: 60, top: 80, right: 80, bottom: 100 } }]), + )!; + expect(resolveHandle(observed, moved, 0)).toEqual({ status: "gone", label: "@20,30" }); + }); + + it("only follows a bare class name a short way (content identifies, class does not)", () => { + const observed = parseVisibleElements(PAYLOAD)!; + // #1 was a class-labeled TextView container centered at (800,170). + const far = parseVisibleElements(JSON.stringify([node("0", [1200, 1400, 1360, 1460])]))!; + expect(resolveHandle(observed, far, 1)).toEqual({ + status: "gone", + label: "android.widget.TextView", + }); + const near = parseVisibleElements(JSON.stringify([node("0", [0, 120, 1600, 380])]))!; + expect(resolveHandle(observed, near, 1)).toMatchObject({ status: "resolved" }); + }); + + it("matches text, content-desc and resource-id, preferring an exact label", () => { + const elements = parseVisibleElements(PAYLOAD)!; + expect(findNodeCenterBySubstring(elements, "亮度")).toEqual({ x: 120, y: 430, text: "亮度" }); + expect(findNodeCenterBySubstring(elements, "Display")).toEqual({ x: 120, y: 630, text: "显示 (Display)" }); + expect(findNodeCenterBySubstring(elements, "com.android.settings:id/dark")).toEqual({ + x: 120, + y: 830, + text: "com.android.settings:id/dark", + }); + expect(findNodeCenterBySubstring(elements, "no-such-label")).toBeUndefined(); + }); + + it("renders the handle first so the model quotes something we can resolve", () => { + const rendered = formatVisibleElements(parseVisibleElements(PAYLOAD)!); + const lines = rendered.split("\n"); + expect(lines).toHaveLength(5); + expect(lines[0]).toContain("#0 [36,18]"); + expect(lines[2]).toMatch(/^#2 \[120,430\] clickable pkg=com.android.settings 亮度$/); + }); + + it("returns undefined for a payload that is not the JSON node list", () => { + expect(parseVisibleElements("")).toBeUndefined(); + expect(resolveHandle(parseVisibleElements("[]"), parseVisibleElements(PAYLOAD)!, 0)).toEqual({ + status: "unknown", + elementCount: 0, + }); + }); +}); + +describe("hierarchy: the model must see the whole list", () => { + it("renders far more rows than the generic string budget allows", () => { + const rendered = formatVisibleElements(parseVisibleElements(PAYLOAD)!); + // The envelope used to clip every string at 200 chars, which cut a real + // 27-element dump after 3 rows. + expect(rendered.length).toBeGreaterThan(200); + const wide = formatVisibleElements( + Array.from({ length: 27 }, (_, i) => ({ + index: i, + x: i, + y: i, + text: `设置项 ${i} label text that is reasonably long`, + })), + ); + expect(wide.split("\n")).toHaveLength(27); + expect(wide.length).toBeGreaterThan(200); + }); +}); diff --git a/plugins/dsh-autogamer/tests/inspectTrace.spec.ts b/plugins/dsh-autogamer/tests/inspectTrace.spec.ts new file mode 100644 index 000000000..4134a9f14 --- /dev/null +++ b/plugins/dsh-autogamer/tests/inspectTrace.spec.ts @@ -0,0 +1,99 @@ +/** + * `autogamer_inspect_trace` unit locks (Phase 2). Summary slicing (first 3 + + * last 3 + uncovered failures, deduped by step index), the full passthrough, + * and the failure heuristic live here — including the pure `summarizeSteps` + * export, which is where the edge arithmetic is cheapest to pin down. + */ +import { describe, expect, it } from "vitest"; +import type { ArtemisBackend } from "../src/artemis/types.js"; +import type { PreflightResult } from "../src/env/preflight.js"; +import { defineInspectTrace, summarizeSteps } from "../src/tools/inspectTrace.js"; +import type { TaskStep } from "../src/artemis/types.js"; + +const HEALTHY: PreflightResult = { ok: true, checks: [], finishedAt: "t" }; + +function steps(count: number, failedAt?: number[]): TaskStep[] { + return Array.from({ length: count }, (_, index) => ({ + index, + action: `tap ${index}`, + ...(failedAt?.includes(index) ? { status: "failed" } : {}), + })); +} + +function defineWith(all: TaskStep[]): { + tool: ReturnType; + calls: unknown[]; +} { + const calls: unknown[] = []; + const backend: ArtemisBackend = { + getDeviceState: async () => ({ deviceSerial: "", connected: false }), + startTask: async (args) => ({ taskId: "t", profile: args.profile, startedAt: "t" }), + manageTask: async (args) => ({ taskId: args.taskId }), + inspectTrace: async (args) => { + calls.push(args); + return { taskId: args.traceId, finalStatus: "failed", totalSteps: all.length, steps: all, artifactPaths: ["/t"] }; + }, + diagnose: async () => ({ recentCrashes: [], recommendations: [] }), + dispose: async () => undefined, + }; + return { tool: defineInspectTrace({ backend, checkEnv: async () => HEALTHY }), calls }; +} + +function exec(): { signal: AbortSignal } { + return { signal: new AbortController().signal }; +} + +describe("summarizeSteps: edges plus uncovered failures", () => { + it("passes short traces through untouched", () => { + expect(summarizeSteps(steps(6))).toHaveLength(6); + }); + + it("returns head 3 plus tail 3 on a clean long trace", () => { + const out = summarizeSteps(steps(10)); + expect(out.map((step) => step.index)).toEqual([0, 1, 2, 7, 8, 9]); + }); + + it("pulls a failed middle step into the summary exactly once", () => { + const out = summarizeSteps(steps(10, [5])); + expect(out.map((step) => step.index)).toEqual([0, 1, 2, 5, 7, 8, 9]); + }); + + it("does not duplicate a failed step already on an edge", () => { + const out = summarizeSteps(steps(10, [8])); + expect(out.map((step) => step.index)).toEqual([0, 1, 2, 7, 8, 9]); + }); + + it("treats an explicit false flag as failed too", () => { + const all = steps(10); + all[4] = { index: 4, action: "tap", ok: false }; + expect(summarizeSteps(all).map((step) => step.index)).toEqual([0, 1, 2, 4, 7, 8, 9]); + }); +}); + +describe("inspect_trace: execute", () => { + it("defaults to summary and always opens view_summary over the trace", async () => { + const { tool, calls } = defineWith(steps(10, [5])); + const value = (await tool.execute({ task_id: "t-1" }, exec())) as { + steps: { index?: number }[]; + total_steps: number; + artifact_paths: string[]; + }; + expect(calls).toEqual([{ traceId: "t-1" }]); + expect(value.total_steps).toBe(10); + expect(value.steps.map((step) => step.index)).toEqual([0, 1, 2, 5, 7, 8, 9]); + expect(value.artifact_paths).toEqual(["/t"]); + }); + + it("full returns every step unsliced", async () => { + const { tool } = defineWith(steps(10, [5])); + const value = (await tool.execute({ task_id: "t-1", format: "full" }, exec())) as { + steps: unknown[]; + }; + expect(value.steps).toHaveLength(10); + }); + + it("refuses an empty task_id before the backend", async () => { + const { tool } = defineWith([]); + await expect(tool.execute({ task_id: "" }, exec())).rejects.toThrow(/task_id must be a non-empty string/); + }); +}); diff --git a/plugins/dsh-autogamer/tests/liveObservation.spec.ts b/plugins/dsh-autogamer/tests/liveObservation.spec.ts new file mode 100644 index 000000000..a6543197e --- /dev/null +++ b/plugins/dsh-autogamer/tests/liveObservation.spec.ts @@ -0,0 +1,239 @@ +/** + * G67 locks: a `#N` handle names the node the model was SHOWN at that position, + * so resolving it follows that node's identity into the next hierarchy — and a + * handle nobody observed can never turn into a tap. The resolver this replaces + * (changelogs #40) re-read array position N off a fresh tree, which silently + * acts on "whatever sits at N now" — the hazard + * `docs/migration/inventory/04-agent-stack-config.md:203` lists as a safety + * defect, not a nicety. + */ +import { beforeEach, describe, expect, it } from "vitest"; +import { DeviceBreaker } from "../src/breaker.js"; +import { LiveObservations } from "../src/observations.js"; +import { harness } from "./support/pool.js"; +import { ActionIncidents, defineRunDeviceAction } from "../src/tools/runDeviceAction.js"; + +function node(label: string, cx: number, cy: number): Record { + return { + index: "0", + text: label, + "content-desc": "", + "resource-id": "", + class: "android.widget.TextView", + package: "com.example", + clickable: "true", + parsed_bounds: { left: cx - 80, top: cy - 30, right: cx + 80, bottom: cy + 30 }, + }; +} + +const OBSERVED = JSON.stringify([node("亮度", 120, 430), node("显示", 120, 630)]); +// The same two targets with three new rows inserted in front: every array +// position below them moved, neither identity did. +const INSERTED = JSON.stringify([ + node("新1", 400, 40), + node("新2", 400, 90), + node("新3", 400, 140), + node("亮度", 120, 430), + node("显示", 120, 630), +]); +const WITHOUT_DISPLAY = JSON.stringify([node("亮度", 120, 430)]); +// #0 changed personality (a new 丙 took the first row) while 显示 stayed put. +const REPLACED_FIRST = JSON.stringify([node("丙", 999, 999), node("显示", 120, 630)]); + +const log: { tool: string; args: Record }[] = []; +let hierarchies: string[] = []; +let verdicts: (boolean | undefined)[] = []; +let agent = "agent-1"; + +function setup(options: { devices?: string[]; observations?: LiveObservations } = {}) { + log.length = 0; + hierarchies = []; + verdicts = []; + agent = "agent-1"; + const pool = harness(options.devices ?? []).pool; + const tool = defineRunDeviceAction({ + pool, + breaker: new DeviceBreaker({ allowedFails: 99, cooldownMs: 0 }), + client: { + callTool: async (name: string, args: Record) => { + log.push({ tool: name, args }); + if (name === "get_ui_hierarchy") { + return { content: [{ type: "text", text: hierarchies.length > 0 ? hierarchies.shift()! : OBSERVED }] }; + } + return { content: [{ type: "text", text: "Success" }] }; + }, + } as never, + logger: { info: () => {}, warn: () => {} }, + defaultDeviceKey: "default", + incidents: new ActionIncidents(), + observations: options.observations ?? new LiveObservations(), + agentIdOfCall: () => agent, + resolveScreenSize: async () => ({ width: 720, height: 1600 }), + captureFrame: async () => "frame", + compareFrames: async () => (verdicts.length > 0 ? verdicts.shift() : true), + }); + return tool; +} + +function exec(): { signal: AbortSignal } { + return { signal: new AbortController().signal }; +} + +async function observe(tool: ReturnType, device?: string): Promise { + await tool.execute({ action: "get_ui_hierarchy", ...(device ? { device_serial: device } : {}), reason: "t" }, exec() as never); + log.length = 0; +} + +function taps(): Record[] { + return log.filter((call) => call.tool === "tap").map((call) => call.args); +} + +function hierarchyCalls(): number { + return log.filter((call) => call.tool === "get_ui_hierarchy").length; +} + +beforeEach(() => { + log.length = 0; +}); + +describe("live observation: an unobserved handle is not a target", () => { + it("refuses before fetching anything, and never reaches the device", async () => { + const tool = setup(); + await expect( + tool.execute({ action: "click", target: { index: 1 }, reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: no live hierarchy for device/); + expect(taps()).toHaveLength(0); + expect(hierarchyCalls()).toBe(0); + }); + + it("still resolves a {text} target with no observation at all", async () => { + const tool = setup(); + await tool.execute({ action: "click", target: { text: "显示" }, reason: "t" }, exec() as never); + expect(taps()).toHaveLength(1); + expect(taps()[0].coordinates).toEqual([120, 630]); + }); +}); + +describe("live observation: resolution follows identity, not array position", () => { + it("keeps the tap on the observed node when the tree grows in front of it", async () => { + const tool = setup(); + await observe(tool); + hierarchies.push(INSERTED); + await tool.execute({ action: "click", target: { index: 1 }, reason: "t" }, exec() as never); + // Positional resolution would hit 新2 here. + expect(taps()).toHaveLength(1); + expect(taps()[0].coordinates).toEqual([120, 630]); + }); + + it("refuses a handle that is in the fresh tree but never was in the observation", async () => { + const tool = setup(); + await observe(tool); + hierarchies.push(INSERTED); + await expect( + tool.execute({ action: "click", target: { index: 4 }, reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: #4 is not in the live hierarchy for device/); + expect(taps()).toHaveLength(0); + // Re-fetching cannot put a number into a list the model already saw, so the + // ladder burns no attempts on it. + expect(hierarchyCalls()).toBe(0); + }); + + it("refuses when the observed node is gone, and says which one", async () => { + const tool = setup(); + await observe(tool); + hierarchies.push(WITHOUT_DISPLAY, WITHOUT_DISPLAY, WITHOUT_DISPLAY); + await expect( + tool.execute({ action: "click", target: { index: 1 }, reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: #1 \("显示"\) from the live hierarchy is no longer on device/); + expect(taps()).toHaveLength(0); + expect(hierarchyCalls()).toBe(3); + }); +}); + +describe("live observation: only what the model was shown renews a handle", () => { + it("does not let the ladder's own re-fetch extend the observation", async () => { + const tool = setup(); + await observe(tool); + // First click resolves through a tree whose #0 is 新1 — and that tree must + // not become the model's live observation. + hierarchies.push(INSERTED); + await tool.execute({ action: "click", target: { index: 1 }, reason: "t" }, exec() as never); + log.length = 0; + hierarchies.push(INSERTED); + await tool.execute({ action: "click", target: { index: 0 }, reason: "t" }, exec() as never); + // #0 is still 亮度 from the OBSERVED list; had the ladder replaced it, this + // would tap 新1 at [400,40]. + expect(taps()[0].coordinates).toEqual([120, 430]); + }); + + it("replaces the observation with the next one the model asks for", async () => { + const tool = setup(); + await observe(tool); + hierarchies.push(INSERTED); + await tool.execute({ action: "get_ui_hierarchy", reason: "t" }, exec() as never); + log.length = 0; + hierarchies.push(INSERTED); + await tool.execute({ action: "click", target: { index: 0 }, reason: "t" }, exec() as never); + expect(taps()[0].coordinates).toEqual([400, 40]); + }); +}); + +describe("live observation: what the ladder reads privately is not a new look", () => { + it("does not let the post-execution re-resolution claim a fresh observation", async () => { + const tool = setup(); + await observe(tool); // live list = OBSERVED: #0 亮度, #1 显示 + // Pre-execution read, then the re-resolution after the effect came back empty. + hierarchies.push(OBSERVED, REPLACED_FIRST); + verdicts.push(false, false); + const first = (await tool.execute( + { action: "click", target: { index: 1 }, reason: "t" }, + exec() as never, + )) as Record; + expect(first.effect).toBe("unchanged"); + expect(taps()).toHaveLength(2); // the retry stayed on 显示, which is still there + log.length = 0; + // #0 still means 亮度 from OBSERVED. Were the ladder's private read treated as + // a new look, #0 would now be 丙 and this would tap it. + hierarchies.push(REPLACED_FIRST, REPLACED_FIRST, REPLACED_FIRST); + await expect( + tool.execute({ action: "click", target: { index: 0 }, reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: #0 \("亮度"\) from the live hierarchy is no longer on device/); + expect(taps()).toHaveLength(0); + }); + + it("refuses a model-visible hierarchy read that is not a node list", async () => { + const tool = setup(); + hierarchies.push("not-json{{{"); + await expect( + tool.execute({ action: "get_ui_hierarchy", reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: hierarchy unreadable/); + // Nothing was observed, so no handle can be claimed either. + await expect( + tool.execute({ action: "click", target: { index: 0 }, reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: no live hierarchy for device/); + expect(taps()).toHaveLength(0); + }); +}); + +describe("live observation: scoped to the agent and the device that looked", () => { + it("gives a second agent no handles at all", async () => { + const tool = setup(); + await observe(tool); + agent = "agent-2"; + await expect( + tool.execute({ action: "click", target: { index: 1 }, reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: no live hierarchy for device/); + expect(taps()).toHaveLength(0); + }); + + it("does not serve another device the same handles", async () => { + const tool = setup({ devices: ["A", "B"] }); + await observe(tool, "A"); + await expect( + tool.execute({ action: "click", device_serial: "B", target: { index: 1 }, reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: no live hierarchy for device B/); + expect(taps()).toHaveLength(0); + await tool.execute({ action: "click", device_serial: "A", target: { index: 1 }, reason: "t" }, exec() as never); + expect(taps()).toHaveLength(1); + }); +}); diff --git a/plugins/dsh-autogamer/tests/manageTask.spec.ts b/plugins/dsh-autogamer/tests/manageTask.spec.ts new file mode 100644 index 000000000..d01a89b82 --- /dev/null +++ b/plugins/dsh-autogamer/tests/manageTask.spec.ts @@ -0,0 +1,86 @@ +/** + * `autogamer_manage_task` unit locks (Phase 2). The three branches, the + * inject-instruction requirement, the 5-step context budget, and tolerant + * field mapping live here; the backend double answers fixed dicts. + */ +import { describe, expect, it } from "vitest"; +import type { ArtemisBackend, ManageArgs } from "../src/artemis/types.js"; +import type { PreflightResult } from "../src/env/preflight.js"; +import { defineManageTask, RECENT_ACTIONS_LIMIT } from "../src/tools/manageTask.js"; + +const HEALTHY: PreflightResult = { ok: true, checks: [], finishedAt: "t" }; + +function defineWith(answer: Record): { + tool: ReturnType; + calls: ManageArgs[]; +} { + const calls: ManageArgs[] = []; + const backend: ArtemisBackend = { + getDeviceState: async () => ({ deviceSerial: "", connected: false }), + startTask: async (args) => ({ taskId: "t", profile: args.profile, startedAt: "t" }), + manageTask: async (args) => { + calls.push(args); + return { + taskId: args.taskId, + state: answer.state as string | undefined, + currentStep: answer.current_step as number | undefined, + totalSteps: answer.total_steps as number | undefined, + lastAction: answer.last_action as string | undefined, + recentSteps: (answer.steps as { index?: number }[] | undefined) ?? [], + elapsedSeconds: answer.elapsed as number | undefined, + finalSummary: answer.summary as string | undefined, + }; + }, + inspectTrace: async (args) => ({ taskId: args.traceId, steps: [], artifactPaths: [] }), + diagnose: async () => ({ recentCrashes: [], recommendations: [] }), + dispose: async () => undefined, + }; + return { tool: defineManageTask({ backend, checkEnv: async () => HEALTHY }), calls }; +} + +function exec(): { signal: AbortSignal } { + return { signal: new AbortController().signal }; +} + +describe("manage_task: branches", () => { + it("status forwards task_id over trace_id and maps the answer", async () => { + const { tool, calls } = defineWith({ state: "running", current_step: 4, total_steps: 9 }); + const value = (await tool.execute({ task_id: "t-1", action: "status" }, exec())) as Record; + expect(calls).toEqual([{ taskId: "t-1", action: "status" }]); + expect(value).toMatchObject({ task_id: "t-1", state: "running", current_step: 4, total_steps: 9 }); + }); + + it("inject without an instruction is a caller bug, refused before the backend", async () => { + const { tool, calls } = defineWith({}); + await expect(tool.execute({ task_id: "t-1", action: "inject_instruction" }, exec())).rejects.toThrow( + /instruction is required/, + ); + expect(calls).toHaveLength(0); + }); + + it("inject forwards the instruction", async () => { + const { tool, calls } = defineWith({ state: "running" }); + await tool.execute({ task_id: "t-1", action: "inject_instruction", instruction: "go back" }, exec()); + expect(calls).toEqual([{ taskId: "t-1", action: "inject_instruction", instruction: "go back" }]); + }); + + it("an unknown action is refused without calling the backend", async () => { + const { tool, calls } = defineWith({}); + await expect(tool.execute({ task_id: "t-1", action: "explode" }, exec())).rejects.toThrow( + /one of status\/stop\/inject_instruction/, + ); + expect(calls).toHaveLength(0); + }); +}); + +describe("manage_task: the 5-step context budget", () => { + it("never hands the model more than the newest 5 steps", async () => { + expect(RECENT_ACTIONS_LIMIT).toBe(5); + const steps = Array.from({ length: 8 }, (_, index) => ({ index, action: `tap ${index}` })); + const { tool } = defineWith({ state: "running", steps }); + const value = (await tool.execute({ task_id: "t-1", action: "status" }, exec())) as { + recent_actions: { index?: number }[]; + }; + expect(value.recent_actions.map((step) => step.index)).toEqual([3, 4, 5, 6, 7]); + }); +}); diff --git a/plugins/dsh-autogamer/tests/mockServer.spec.ts b/plugins/dsh-autogamer/tests/mockServer.spec.ts new file mode 100644 index 000000000..73e8b5779 --- /dev/null +++ b/plugins/dsh-autogamer/tests/mockServer.spec.ts @@ -0,0 +1,138 @@ +/** + * G56 locks: the mock action server speaks full JSON-RPC framing so the real + * MCP SDK client can complete the handshake. Drove `SdkError: Request timed + * out` before the `jsonrpc: "2.0"` marker was added, because the SDK parses + * every line with JSONRPCMessageSchema and only skips SyntaxErrors. + * + * This file uses the REAL ActionClient (real SDK transport over stdio), not + * the per-file ActionClient mock in apply.spec.ts (vitest module mocks are + * scoped to their own file, so that mock does not apply here). + */ +import { afterEach, describe, expect, it } from "vitest"; +import pyActionTools from "./fixtures/py-action-tools.json" with { type: "json" }; +import { ActionClient } from "../src/actionClient.js"; +import { decodeImageBase64, parseImageSize } from "../src/coord.js"; +import { unwrapText } from "../src/mcpText.js"; + +const fixtureTools: string[] = pyActionTools.pyActionServerTools; + +const clients: ActionClient[] = []; + +afterEach(async () => { + for (const client of clients.splice(0)) await client.dispose(); +}); + +function mockClient(): ActionClient { + const client = new ActionClient({ + command: [process.execPath, "scripts/mock-action-server.mjs"], + callTimeoutMs: 10_000, + }); + clients.push(client); + return client; +} + +/** Same server with one verdict knob set, mirroring how the router calls it. */ +async function withEffect(mode: string, use: (client: ActionClient) => Promise) { + const client = new ActionClient({ + command: [process.execPath, "scripts/mock-action-server.mjs"], + env: { MOCK_EFFECT: mode }, + callTimeoutMs: 10_000, + }); + try { + return await use(client); + } finally { + await client.dispose(); + } +} + +describe("mock action server handshake (G56)", () => { + it("lists the same action surface as the live py server", async () => { + const tools = await mockClient().listTools(); + // The fixture is generated from the real `artemis mcp --type adb` listing + // (spike-mcp.mjs --write). Comparing against it, instead of a second + // hand-typed list, is what catches drift: this mock kept serving 13 tools + // for a day after `compare_ui_frames` made the py surface 14 (changelogs #12), + // so the offline path silently lacked effect verification. + expect(tools.slice().sort()).toEqual([...fixtureTools].sort()); + }); + + it("answers compare_ui_frames in the py contract shape (G39 route A)", async () => { + const result = await mockClient().callTool("compare_ui_frames", { + before_b64: "aaa", + after_b64: "bbb", + action: "back", + }); + // Locked to the exact key set of the py server's `json.dumps({"changed": changed})`. + // An earlier version of this stub added `ratio` and asserted it, which pinned a contract + // the real action surface never emitted (changelogs #51). + expect(JSON.parse(unwrapText(result))).toEqual({ changed: true }); + }); + + it("can serve the unchanged verdict and a refusal, both offline", async () => { + // ActionClient replaces the child env with what it is given (that is why + // MOCK_DELAY_MS is passed the same way below), so the verdict knob has to + // come through the constructor, not through process.env of this test. + const verdict = await withEffect("unchanged", (client) => client.callTool("compare_ui_frames", {})); + expect(JSON.parse(unwrapText(verdict)).changed).toBe(false); + + const refused = await withEffect("error", (client) => client.callTool("compare_ui_frames", {})); + expect(unwrapText(refused).startsWith("Error:")).toBe(true); + }); + + it("answers take_screenshot with a decodable 1080x2400 PNG", async () => { + const result = await mockClient().callTool("take_screenshot", {}); + const size = parseImageSize(decodeImageBase64(unwrapText(result))); + expect(size).toEqual({ width: 1080, height: 2400 }); + }); + + it("answers tap with the legacy success text", async () => { + const result = await mockClient().callTool("tap", { coordinates: [100, 100] }); + expect(unwrapText(result)).toBe("Success"); + }); + + it("answers list_devices with the py key set, and adds is_locked only when asked", async () => { + const plain = JSON.parse(unwrapText(await mockClient().callTool("list_devices", {}))) as { + enumerated: boolean; + devices: Record[]; + }; + expect(plain.enumerated).toBe(true); + expect(Object.keys(plain.devices[0]).sort()).toEqual( + [ + "serial", + "state", + "model", + "product", + "is_emulator", + "is_busy", + "active_pid", + "active_task_desc", + "active_session_id", + "acquired_at", + ].sort(), + ); + // "not asked" must not be representable as null: admission treats unknown as fail-closed (G65). + expect("is_locked" in plain.devices[0]).toBe(false); + + const asked = JSON.parse( + unwrapText(await mockClient().callTool("list_devices", { include_lock_state: true })), + ) as { devices: { is_locked?: boolean | null }[] }; + expect(asked.devices[0].is_locked).toBe(false); + }); + + it("forwards host cancellation into the request instead of waiting it out", async () => { + const client = new ActionClient({ + command: [process.execPath, "scripts/mock-action-server.mjs"], + env: { MOCK_DELAY_MS: "8000" }, + callTimeoutMs: 30_000, + }); + clients.push(client); + const controller = new AbortController(); + const pending = client.callTool("back", {}, controller.signal); + const rejected = expect(pending).rejects.toThrow(); + controller.abort(); + // Settles on abort, far ahead of the 5 s mock delay and the 30 s timeout + // (the vitest 5 s test timeout is the backstop: without forwarding this + // test would time out instead of failing on an assertion). + await rejected; + }); +}); diff --git a/plugins/dsh-autogamer/tests/preflight.spec.ts b/plugins/dsh-autogamer/tests/preflight.spec.ts new file mode 100644 index 000000000..03f09edf7 --- /dev/null +++ b/plugins/dsh-autogamer/tests/preflight.spec.ts @@ -0,0 +1,270 @@ +/** + * Preflight unit locks (Phase 1). The runner takes injected process/fs/clock + * doubles, so no test here spawns anything: subprocess behaviour is asserted + * through scripted `run` answers, and the 60s cache through a manual clock. + */ +import { describe, expect, it } from "vitest"; +import { + createPreflight, + preflightErrorText, + type ExecResult, + type PreflightDeps, +} from "../src/env/preflight.js"; + +const INPUT = { uvPath: "uv", artemisRepoPath: "/repo", defaultDeviceSerial: "" }; + +function scripted( + answers: Record>, + options?: { exists?: (path: string) => boolean; now?: number }, +): { calls: string[][]; setNow: (value: number) => void; deps: PreflightDeps } { + const calls: string[][] = []; + let now = options?.now ?? 1_000_000; + return { + calls, + setNow: (value: number) => { + now = value; + }, + deps: { + run: async (command: string[]) => { + calls.push(command); + const hit = answers[command.join(" ")] ?? answers[command[0] as string] ?? { code: 0 }; + return { + code: hit.code ?? 0, + stdout: hit.stdout ?? "", + stderr: hit.stderr ?? "", + ...(hit.errorMessage ? { errorMessage: hit.errorMessage } : {}), + }; + }, + exists: options?.exists ?? (() => true), + now: () => now, + }, + }; +} + +function healthy(): Record> { + return { + "uv --version": { code: 0, stdout: "uv 0.8.0" }, + "python3 --version": { code: 0, stdout: "Python 3.13.2" }, + "uv tool list": { code: 0, stdout: "artemis v1.0\nsometool v2" }, + "adb version": { code: 0, stdout: "Android Debug Bridge version 1.0.41" }, + "adb devices": { code: 0, stdout: "List of devices attached\nemulator-5554\tdevice\n" }, + "artemis helper status": { code: 0, stdout: "helper: installed (v3)" }, + }; +} + +function failedId(result: { ok: boolean; checks: { id: string; ok: boolean }[] }): string[] { + return result.checks.filter((check) => !check.ok).map((check) => check.id); +} + +describe("preflight: the seven checks", () => { + it("passes a healthy environment with all seven checks", async () => { + const { deps } = scripted(healthy()); + const result = await createPreflight(deps).run(INPUT); + expect(result.ok).toBe(true); + expect(result.checks.map((check) => check.id)).toEqual([ + "uv", + "python", + "artemis-repo", + "artemis-installed", + "adb", + "adb-devices", + "helper", + ]); + expect(result.finishedAt).toBe(new Date(1_000_000).toISOString()); + }); + + it("a missing uv binary fails with the install fix", async () => { + const { deps } = scripted({ + ...healthy(), + "uv --version": { code: 1, errorMessage: "ENOENT: spawn uv ENOENT" }, + }); + const result = await createPreflight(deps).run(INPUT); + expect(result.ok).toBe(false); + expect(failedId(result)).toContain("uv"); + expect(preflightErrorText(result)).toMatch(/^environment: preflight failed at uv/); + expect(preflightErrorText(result)).toContain("astral.sh/uv"); + }); + + it("falls back from python3 to python, and fails only when both are gone", async () => { + const first = scripted({ + ...healthy(), + "python3 --version": { code: 1, errorMessage: "ENOENT: spawn python3 ENOENT" }, + python: { code: 0, stdout: "Python 3.12.8" }, + }); + const ok = await createPreflight(first.deps).run(INPUT); + expect(ok.ok).toBe(true); + expect(first.calls.map((call) => call[0])).toContain("python"); + + const second = scripted({ + ...healthy(), + "python3 --version": { code: 1, errorMessage: "ENOENT: spawn python3 ENOENT" }, + python: { code: 1, errorMessage: "ENOENT: spawn python ENOENT" }, + }); + const bad = await createPreflight(second.deps).run(INPUT); + expect(failedId(bad)).toContain("python"); + }); + + it("refuses Python below 3.12 and unparseable version output", async () => { + const old = scripted({ ...healthy(), "python3 --version": { code: 0, stdout: "Python 3.11.9" } }); + expect(failedId(await createPreflight(old.deps).run(INPUT))).toContain("python"); + const weird = scripted({ ...healthy(), "python3 --version": { code: 0, stdout: "no version here" } }); + expect(failedId(await createPreflight(weird.deps).run(INPUT))).toContain("python"); + }); + + it("fails the repo check on an empty path or a missing manifest", async () => { + const { deps } = scripted(healthy()); + const empty = await createPreflight(deps).run({ ...INPUT, artemisRepoPath: " " }); + expect(failedId(empty)).toContain("artemis-repo"); + const missing = scripted(healthy(), { exists: () => false }); + const gone = await createPreflight(missing.deps).run(INPUT); + expect(failedId(gone)).toContain("artemis-repo"); + }); + + it("fails the install check when neither install form names artemis", async () => { + const { deps } = scripted({ + ...healthy(), + "uv tool list": { code: 0, stdout: "sometool v2" }, + "uv run --project /repo --no-sync artemis --version": { code: 1, stderr: "error: Failed to spawn: `artemis`" }, + }); + const result = await createPreflight(deps).run(INPUT); + expect(failedId(result)).toEqual(["artemis-installed"]); + expect(preflightErrorText(result)).toContain("uv tool install -e"); + expect(preflightErrorText(result)).toContain("uv sync --project"); + }); + + it("accepts a project-venv provider that `uv tool list` cannot see (G77)", async () => { + const { deps } = scripted({ + ...healthy(), + "uv tool list": { code: 0, stdout: "No tools installed" }, + "uv run --project /repo --no-sync artemis --version": { + code: 0, + stdout: "┌──────────── ARTEMIS ────────────┐\n│ ARTEMIS device-action core v1.0 │\n└─────────────────────────────────┘", + }, + }); + const result = await createPreflight(deps).run(INPUT); + expect(result.ok).toBe(true); + const installed = result.checks.find((check) => check.id === "artemis-installed"); + // The detail must name the form that answered, and skip the box-drawing line. + expect(installed?.detail).toBe("project venv: ARTEMIS device-action core v1.0"); + }); + + it("short-circuits: a PATH-form install never spawns the venv probe", async () => { + const { calls, deps } = scripted(healthy()); + await createPreflight(deps).run(INPUT); + expect(calls.map((command) => command.join(" "))).not.toContain( + "uv run --project /repo --no-sync artemis --version", + ); + }); + + it("does not read a venv probe that exits 0 without naming artemis as an install", async () => { + const { deps } = scripted({ + ...healthy(), + "uv tool list": { code: 0, stdout: "sometool v2" }, + "uv run --project /repo --no-sync artemis --version": { code: 0, stdout: "" }, + }); + const result = await createPreflight(deps).run(INPUT); + expect(failedId(result)).toEqual(["artemis-installed"]); + }); + + it("never probes the venv without a repo path, and says so", async () => { + const { calls, deps } = scripted({ ...healthy(), "uv tool list": { code: 0, stdout: "sometool v2" } }); + const result = await createPreflight(deps).run({ ...INPUT, artemisRepoPath: "" }); + expect(failedId(result)).toEqual(["artemis-repo", "artemis-installed"]); + // `preflightErrorText` reports only the FIRST failure, so assert this check's own detail. + const installed = result.checks.find((check) => check.id === "artemis-installed"); + expect(installed?.detail).toContain("artemisRepoPath is not set"); + expect(calls.map((command) => command.join(" "))).not.toContain( + "uv run --project --no-sync artemis --version", + ); + }); + + it("counts only `device` lines, so offline/emulator noise is not a fleet", async () => { + const { deps } = scripted({ + ...healthy(), + "adb devices": { code: 0, stdout: "List of devices attached\nemulator-5554\toffline\n" }, + }); + const result = await createPreflight(deps).run(INPUT); + expect(failedId(result)).toEqual(["adb-devices"]); + }); + + it("a missing uv binary fails both the uv and the install checks", async () => { + const { deps } = scripted({ + ...healthy(), + "uv --version": { code: 1, errorMessage: "ENOENT: spawn uv ENOENT" }, + "uv tool list": { code: 1, errorMessage: "ENOENT: spawn uv ENOENT" }, + }); + const result = await createPreflight(deps).run(INPUT); + expect(failedId(result)).toEqual(["uv", "artemis-installed"]); + }); + + it("fails the helper check when neither install form answers", async () => { + const { deps } = scripted({ + ...healthy(), + "artemis helper status": { code: 1, errorMessage: "ENOENT: spawn artemis ENOENT" }, + "uv run --project /repo --no-sync artemis helper status": { code: 1, stderr: "No module named artemis" }, + }); + const result = await createPreflight(deps).run(INPUT); + expect(failedId(result)).toEqual(["helper"]); + expect(preflightErrorText(result)).toContain("neither form answered"); + }); + + it("accepts the helper through the project venv when no PATH binary exists (G77)", async () => { + const { deps } = scripted({ + ...healthy(), + "artemis helper status": { code: 1, errorMessage: "ENOENT: spawn artemis ENOENT" }, + "uv run --project /repo --no-sync artemis helper status": { code: 0, stdout: "helper: installed (v3)" }, + }); + const result = await createPreflight(deps).run(INPUT); + expect(result.ok).toBe(true); + }); + + it("asks the helper about the configured device when the fleet is ambiguous (G79)", async () => { + const { calls, deps } = scripted({ + ...healthy(), + "artemis helper status": { code: 1, stderr: "Several devices are ready; pass --serial, or --all" }, + "artemis helper status --serial 127.0.0.1:16384": { code: 0, stdout: "helper: installed (v3)" }, + }); + const result = await createPreflight(deps).run({ ...INPUT, defaultDeviceSerial: "127.0.0.1:16384" }); + expect(failedId(result)).toEqual([]); + expect(calls.map((command) => command.join(" "))).toContain("artemis helper status --serial 127.0.0.1:16384"); + }); + + it("passes --serial to the project-venv form too (G79)", async () => { + const { calls, deps } = scripted({ + ...healthy(), + "artemis helper status --serial em-1": { code: 1, errorMessage: "ENOENT: spawn artemis ENOENT" }, + "uv run --project /repo --no-sync artemis helper status --serial em-1": { code: 0, stdout: "helper: installed (v3)" }, + }); + const result = await createPreflight(deps).run({ ...INPUT, defaultDeviceSerial: "em-1" }); + expect(result.ok).toBe(true); + expect(calls.map((command) => command.join(" "))).toContain( + "uv run --project /repo --no-sync artemis helper status --serial em-1", + ); + }); + + it("fails the helper check with its own fix, not the adb one", async () => { + const { deps } = scripted({ + ...healthy(), + "artemis helper status": { code: 1, stdout: "", stderr: "helper apk missing" }, + "uv run --project /repo --no-sync artemis helper status": { code: 1, stdout: "", stderr: "helper apk missing" }, + }); + const result = await createPreflight(deps).run(INPUT); + expect(failedId(result)).toEqual(["helper"]); + expect(preflightErrorText(result)).toContain("artemis helper install"); + }); +}); + +describe("preflight: the 60s cache", () => { + it("reuses the first result within the window and re-probes after it", async () => { + const { calls, setNow, deps } = scripted(healthy()); + const runner = createPreflight(deps); + const first = await runner.run(INPUT); + expect(first.ok).toBe(true); + expect(calls).toHaveLength(6); + await runner.run(INPUT); + expect(calls).toHaveLength(6); + setNow(1_000_000 + 60_001); + await runner.run(INPUT); + expect(calls).toHaveLength(12); + }); +}); diff --git a/plugins/dsh-autogamer/tests/readmeLayout.spec.ts b/plugins/dsh-autogamer/tests/readmeLayout.spec.ts new file mode 100644 index 000000000..e3c5904e8 --- /dev/null +++ b/plugins/dsh-autogamer/tests/readmeLayout.spec.ts @@ -0,0 +1,42 @@ +/** + * README's Layout block is a hand-maintained *copy* of the module tree, and a + * copy has nothing to change it when the tree does: by the time G67 added + * `src/observations.ts`, it had already lost `devicePool.ts` (#55), + * `turnLedger.ts` and `observations.ts` itself. These two directions of drift + * are locked here so the list stops being prose that decays (changelogs **#61**; + * scoped to the block and to the shared file walker by **#64**) — scanning the + * whole README would let a stray prose mention stand in for the Layout row, and + * a hand-rolled two-directory listing would quietly stop covering a module that + * lands in a new subdirectory. + */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { typescriptFiles } from "./support/srcFiles.js"; + +const SRC = fileURLToPath(new URL("../src", import.meta.url)); + +/** The Layout fenced block only - not the prose around it. */ +function layoutBlock(): string { + const readme = readFileSync(fileURLToPath(new URL("../README.md", import.meta.url)), "utf-8"); + const start = readme.indexOf("```", readme.indexOf("## Layout")); + const end = readme.indexOf("```", start + 3); + if (start < 0 || end < 0) throw new Error("README has no Layout fenced block"); + return readme.slice(start, end); +} + +const onDisk = new Set( + [...typescriptFiles(SRC)].map((file) => file.split(/[\\/]/).pop() as string), +); +const layout = layoutBlock(); + +describe("README Layout lists the module tree (changelogs #61)", () => { + it("lists every module under src/", () => { + expect([...onDisk].filter((name) => !layout.includes(name))).toEqual([]); + }); + + it("names no module in the Layout that is not on disk", () => { + const named = new Set([...layout.matchAll(/([A-Za-z0-9_-]+\.ts)/g)].map((m) => m[1])); + expect([...named].filter((name) => !onDisk.has(name))).toEqual([]); + }); +}); diff --git a/plugins/dsh-autogamer/tests/router-errors.spec.ts b/plugins/dsh-autogamer/tests/router-errors.spec.ts new file mode 100644 index 000000000..eae89ea3b --- /dev/null +++ b/plugins/dsh-autogamer/tests/router-errors.spec.ts @@ -0,0 +1,222 @@ +/** + * G34/G38 locks. These drive defineRunDeviceAction directly (not through + * apply()'s defineTool wrapper) on purpose: the DSL validates shapes before + * our code runs, so semantic checks (non-empty, positive, in-range) and the + * internal/task failure split need their own harness with a real gate and + * breaker plus a recording stub client. + */ +import { beforeEach, describe, expect, it } from "vitest"; +import { DeviceBreaker } from "../src/breaker.js"; +import { defaultPool } from "./support/pool.js"; +import { defineRunDeviceAction } from "../src/tools/runDeviceAction.js"; + +const calls: { tool: string; args: Record }[] = []; + +function setup(resolveScreenSize?: () => Promise<{ width: number; height: number } | undefined>) { + calls.length = 0; + const breaker = new DeviceBreaker({ allowedFails: 1, cooldownMs: 30_000 }); + const tool = defineRunDeviceAction({ + pool: defaultPool(), + breaker, + client: { + callTool: async (name: string, args: Record) => { + calls.push({ tool: name, args }); + return "Success"; + }, + } as never, + logger: { info: () => {}, warn: () => {} }, + defaultDeviceKey: "d0", + resolveScreenSize: resolveScreenSize ?? (async () => ({ width: 720, height: 1600 })), + }); + return { tool, breaker }; +} + +function exec(): { signal: AbortSignal } { + return { signal: new AbortController().signal }; +} + +beforeEach(() => { + calls.length = 0; +}); + +describe("router input validation (G34)", () => { + it("rejects empty input_text without touching the device", async () => { + const { tool } = setup(); + await expect( + tool.execute({ action: "input_text", target: { index: 0 }, text: "", reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: text must be a non-empty string/); + expect(calls).toHaveLength(0); + }); + + it("rejects empty and unknown press_key values", async () => { + const { tool } = setup(); + await expect( + tool.execute({ action: "press_key", key: "", reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: key must be a non-empty string/); + await expect( + tool.execute({ action: "press_key", key: "frob", reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: unknown key/); + expect(calls).toHaveLength(0); + }); + + it("rejects out-of-range and non-finite coordinates", async () => { + const { tool } = setup(); + await expect( + tool.execute({ action: "click", target: { coordinate: [2000, 10] }, reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: target\.coordinate must be \[x,y\] with finite numbers in 0-1000/); + await expect( + tool.execute({ action: "click", target: { coordinate: [Number.NaN, 10] }, reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: target\.coordinate/); + expect(calls).toHaveLength(0); + }); + + it("does not fall back to hierarchy search on a malformed coordinate", async () => { + const { tool } = setup(); + await expect( + tool.execute({ action: "click", target: { index: 1, coordinate: [500] }, reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: target\.coordinate/); + expect(calls).toHaveLength(0); + }); + + it("rejects swipe without a full end_coordinate pair", async () => { + const { tool } = setup(); + await expect( + tool.execute( + { action: "swipe", target: { coordinate: [100, 100] }, reason: "t" }, + exec() as never, + ), + ).rejects.toThrow(/^task: end_coordinate/); + expect(calls).toHaveLength(0); + }); + + it("rejects non-positive durations", async () => { + const { tool } = setup(); + await expect( + tool.execute( + { action: "long_press", target: { coordinate: [100, 100] }, duration_ms: 0, reason: "t" }, + exec() as never, + ), + ).rejects.toThrow(/^task: duration_ms must be a positive number/); + await expect( + tool.execute({ action: "wait", duration_ms: -1, reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: duration_ms must be a finite number >= 0/); + expect(calls).toHaveLength(0); + }); + + it("rejects empty package and url", async () => { + const { tool } = setup(); + await expect( + tool.execute({ action: "launch_app", package: "", reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: package must be a non-empty string/); + await expect( + tool.execute({ action: "stop_app", package: "", reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: package must be a non-empty string/); + await expect( + tool.execute({ action: "open_link", url: "", reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: url must be a non-empty string/); + expect(calls).toHaveLength(0); + }); + + it("rejects a swipe without a starting target", async () => { + const { tool } = setup(); + await expect( + tool.execute( + { action: "swipe", end_coordinate: [100, 100], reason: "t" }, + exec() as never, + ), + ).rejects.toThrow(/^task: target\.coordinate/); + expect(calls).toHaveLength(0); + }); + + it("rejects an unknown action without touching the device", async () => { + const { tool } = setup(); + await expect( + tool.execute({ action: "fly", reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: unsupported action/); + expect(calls).toHaveLength(0); + }); + + it("dispatches known press_key mappings", async () => { + const { tool } = setup(); + await tool.execute({ action: "press_key", key: "home", reason: "t" }, exec() as never); + await tool.execute({ action: "press_key", key: "5", reason: "t" }, exec() as never); + await tool.execute({ action: "press_key", key: "KEYCODE_ENTER", reason: "t" }, exec() as never); + expect(calls.map((call) => (call.args as { keycode: string }).keycode)).toEqual([ + "KEYCODE_HOME", + "KEYCODE_5", + "KEYCODE_ENTER", + ]); + }); + + it("reports host cancellation without waiting out the call", async () => { + const tool = defineRunDeviceAction({ + pool: defaultPool(), + breaker: new DeviceBreaker({ allowedFails: 1, cooldownMs: 30_000 }), + client: { + callTool: (_name: string, _args: Record, signal?: AbortSignal) => + new Promise((_, reject) => { + signal?.addEventListener("abort", () => reject(new Error("stub transport aborted")), { + once: true, + }); + }), + } as never, + logger: { info: () => {}, warn: () => {} }, + defaultDeviceKey: "d0", + resolveScreenSize: async () => ({ width: 720, height: 1600 }), + }); + const controller = new AbortController(); + const pending = tool.execute({ action: "back", reason: "t" }, { + signal: controller.signal, + } as never); + await new Promise((resolve) => setTimeout(resolve, 0)); + controller.abort(); + // Without signal forwarding this would hang until the client timeout + // (and trip the 5 s vitest timeout); the abort settles it at once. + await expect(pending).rejects.toThrow(/^task: action cancelled by the user/); + }); + + it("still dispatches a fully valid call", async () => { + const { tool } = setup(); + const value = (await tool.execute({ action: "wait", duration_ms: 1, reason: "t" }, exec() as never)) as Record< + string, + unknown + >; + expect(value).toEqual({ waited_ms: 1 }); + }); +}); + +describe("router internal errors (G38)", () => { + it("reports an unprefixed plugin defect as internal with a do-not-retry signal", async () => { + const { tool } = setup(async () => { + throw new Error("boom (simulated resolver bug)"); + }); + await expect( + tool.execute({ action: "click", target: { coordinate: [500, 500] }, reason: "t" }, exec() as never), + ).rejects.toThrow(/^internal: boom \(simulated resolver bug\).*do not retry/); + expect(calls).toHaveLength(0); + }); + + it("records nothing on the breaker for internal failures", async () => { + const { tool, breaker } = setup(async () => { + throw new Error("boom (simulated resolver bug)"); + }); + await expect( + tool.execute({ action: "click", target: { coordinate: [500, 500] }, reason: "t" }, exec() as never), + ).rejects.toThrow(/^internal:/); + expect(breaker.stateOf("d0")).toBe("closed"); + // The device is not cooling down: the next action still runs. + const value = (await tool.execute({ action: "wait", duration_ms: 1, reason: "t" }, exec() as never)) as Record< + string, + unknown + >; + expect(value).toEqual({ waited_ms: 1 }); + }); + + it("keeps task failures as task (not internal) without opening the breaker", async () => { + const { tool, breaker } = setup(); + await expect( + tool.execute({ action: "press_key", key: "frob", reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: unknown key/); + expect(breaker.stateOf("d0")).toBe("closed"); + }); +}); diff --git a/plugins/dsh-autogamer/tests/rulesInjector.spec.ts b/plugins/dsh-autogamer/tests/rulesInjector.spec.ts new file mode 100644 index 000000000..f002164f7 --- /dev/null +++ b/plugins/dsh-autogamer/tests/rulesInjector.spec.ts @@ -0,0 +1,133 @@ +/** + * Rules-injector unit locks (Phase 5). Filesystem is injected, so no test + * here touches disk: flag handling, path correction, warning paths, and the + * section() call shape live here. Expected paths always go through + * rulesFilePath() itself, so the suite is separator-agnostic (Windows joins + * with backslashes). The live half (the section text reaching model context) + * needs a real session and stays deferred. + */ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + RULES_SECTION_NAME, + RULES_SECTION_ORDER, + rulesFilePath, + setupRulesInjection, +} from "../src/prompt/rulesInjector.js"; + +function harness(options?: { + files?: Record; + readError?: string; +}): { + sections: { name: string; order: number; text: string }[]; + warnings: string[]; + systemPrompt: { section(input: { name: string; order: number; text: string }): unknown }; + deps: { exists(path: string): boolean; read(path: string): string }; +} { + const sections: { name: string; order: number; text: string }[] = []; + const warnings: string[] = []; + return { + sections, + warnings, + systemPrompt: { + section: (input: { name: string; order: number; text: string }) => { + sections.push(input); + return undefined; + }, + }, + deps: { + exists: (path: string) => options?.files !== undefined && path in options.files, + read: (path: string) => { + if (options?.readError) throw new Error(options.readError); + return options?.files?.[path] ?? ""; + }, + }, + }; +} + +describe("rules injector: the guarded path to section()", () => { + it("does nothing when the flag is off, without touching the filesystem", () => { + const probe = harness({ files: { [rulesFilePath("/repo")]: "rules" } }); + let touched = false; + const result = setupRulesInjection( + probe.systemPrompt, + { artemisRepoPath: "/repo", injectRules: false }, + (message) => probe.warnings.push(message), + { + exists: (path) => { + touched = true; + return probe.deps.exists(path); + }, + read: probe.deps.read, + }, + ); + expect(result).toBe("disabled"); + expect(touched).toBe(false); + expect(probe.sections).toHaveLength(0); + }); + + it("warns and skips on an empty repo path", () => { + const probe = harness(); + const result = setupRulesInjection( + probe.systemPrompt, + { artemisRepoPath: " ", injectRules: true }, + (message) => probe.warnings.push(message), + probe.deps, + ); + expect(result).toBe("no-repo"); + expect(probe.warnings.join()).toContain("artemisRepoPath is empty"); + }); + + it("reads mcp_server/rules.md, not the repo root", () => { + expect(rulesFilePath("/repo")).toContain("mcp_server"); + const probe = harness({ files: { [join("/other", "rules.md")]: "root rules" } }); + const result = setupRulesInjection( + probe.systemPrompt, + { artemisRepoPath: "/other", injectRules: true }, + (message) => probe.warnings.push(message), + probe.deps, + ); + expect(result).toBe("missing-file"); + expect(probe.sections).toHaveLength(0); + }); + + it("warns and skips on read errors and empty content", () => { + const unreadable = harness({ + files: { [rulesFilePath("/r")]: "x" }, + readError: "EACCES", + }); + expect( + setupRulesInjection( + unreadable.systemPrompt, + { artemisRepoPath: "/r", injectRules: true }, + (message) => unreadable.warnings.push(message), + unreadable.deps, + ), + ).toBe("read-failed"); + const empty = harness({ files: { [rulesFilePath("/r")]: " \n " } }); + expect( + setupRulesInjection( + empty.systemPrompt, + { artemisRepoPath: "/r", injectRules: true }, + (message) => empty.warnings.push(message), + empty.deps, + ), + ).toBe("empty"); + expect(empty.sections).toHaveLength(0); + }); + + it("contributes the file through section() with the scheme's order", () => { + const probe = harness({ files: { [rulesFilePath("/repo")]: "# testing mindset\n" } }); + const result = setupRulesInjection( + probe.systemPrompt, + { artemisRepoPath: "/repo", injectRules: true }, + (message) => probe.warnings.push(message), + probe.deps, + ); + expect(result).toBe("injected"); + expect(probe.sections).toEqual([ + { name: RULES_SECTION_NAME, order: RULES_SECTION_ORDER, text: "# testing mindset\n" }, + ]); + expect(probe.warnings).toHaveLength(0); + }); +}); diff --git a/plugins/dsh-autogamer/tests/runTask.spec.ts b/plugins/dsh-autogamer/tests/runTask.spec.ts new file mode 100644 index 000000000..e6050547f --- /dev/null +++ b/plugins/dsh-autogamer/tests/runTask.spec.ts @@ -0,0 +1,91 @@ +/** + * `autogamer_run_task` unit locks (Phase 2). Backend and preflight are + * doubles: instruction validation, profile mapping, serial fallback, and the + * immediate (non-blocking) return shape live here. + */ +import { describe, expect, it } from "vitest"; +import type { ArtemisBackend, TaskStartArgs } from "../src/artemis/types.js"; +import type { PreflightResult } from "../src/env/preflight.js"; +import { defineRunTask } from "../src/tools/runTask.js"; + +const HEALTHY: PreflightResult = { ok: true, checks: [], finishedAt: "t" }; +const BROKEN: PreflightResult = { + ok: false, + checks: [{ id: "uv", ok: false, detail: "missing", fix: "install uv" }], + finishedAt: "t", +}; + +function defineWith(overrides?: { + calls?: TaskStartArgs[]; + env?: PreflightResult; + defaultProfile?: "flash" | "pro"; + defaultDeviceSerial?: string; +}): { tool: ReturnType; calls: TaskStartArgs[] } { + const calls: TaskStartArgs[] = overrides?.calls ?? []; + const backend: ArtemisBackend = { + getDeviceState: async () => ({ deviceSerial: "", connected: false }), + startTask: async (args) => { + calls.push(args); + return { taskId: "t-9", profile: args.profile, startedAt: "2026-10-14T00:00:00.000Z" }; + }, + manageTask: async (args) => ({ taskId: args.taskId }), + inspectTrace: async (args) => ({ taskId: args.traceId, steps: [], artifactPaths: [] }), + diagnose: async () => ({ recentCrashes: [], recommendations: [] }), + dispose: async () => undefined, + }; + const tool = defineRunTask({ + backend, + checkEnv: async () => overrides?.env ?? HEALTHY, + defaultProfile: overrides?.defaultProfile ?? "flash", + defaultDeviceSerial: overrides?.defaultDeviceSerial ?? "", + }); + return { tool, calls }; +} + +function exec(): { signal: AbortSignal } { + return { signal: new AbortController().signal }; +} + +describe("run_task: definition", () => { + it("registers under the autogamer_ prefix with the instruction contract", () => { + const { tool } = defineWith(); + expect(tool.name).toBe("autogamer_run_task"); + const parameters = tool.parameters as Record; + expect(parameters.instruction?.required).toBe(true); + expect(typeof tool.description).toBe("string"); + }); +}); + +describe("run_task: execute", () => { + it("refuses an empty or over-long instruction before touching anything", async () => { + const { tool, calls } = defineWith(); + await expect(tool.execute({ instruction: " " }, exec())).rejects.toThrow(/non-empty/); + await expect(tool.execute({ instruction: "x".repeat(2000) }, exec())).rejects.toThrow(/2000/); + expect(calls).toHaveLength(0); + }); + + it("refuses on a failed preflight with the fix attached", async () => { + const { tool, calls } = defineWith({ env: BROKEN }); + await expect(tool.execute({ instruction: "open settings" }, exec())).rejects.toThrow( + /preflight failed at uv.*install uv/, + ); + expect(calls).toHaveLength(0); + }); + + it("maps flash/pro onto the provider models and returns without blocking", async () => { + const { tool, calls } = defineWith({ defaultDeviceSerial: "em-d" }); + const value = (await tool.execute({ instruction: "open settings", profile: "pro" }, exec())) as Record< + string, + unknown + >; + expect(calls).toEqual([{ instruction: "open settings", profile: "Pro", deviceSerial: "em-d" }]); + expect(value).toMatchObject({ task_id: "t-9", status: "running", profile: "pro" }); + expect(typeof value.started_at).toBe("string"); + }); + + it("falls back to the configured default profile and omits an empty serial", async () => { + const { tool, calls } = defineWith({ defaultProfile: "pro", defaultDeviceSerial: "" }); + await tool.execute({ instruction: "x" }, exec()); + expect(calls).toEqual([{ instruction: "x", profile: "Pro" }]); + }); +}); diff --git a/plugins/dsh-autogamer/tests/scrcpy.spec.ts b/plugins/dsh-autogamer/tests/scrcpy.spec.ts new file mode 100644 index 000000000..674c220a0 --- /dev/null +++ b/plugins/dsh-autogamer/tests/scrcpy.spec.ts @@ -0,0 +1,225 @@ +/** + * Mirror manager unit locks (Phase 4). adb/scrcpy never run here: a scripted + * runner records argv in order, the filesystem is a map, and launch is a + * recorded spawn. Device-side command shapes are locked against scrcpy's own + * developer doc (push/forward/app_process + exact-match version + key=value + * options); the stop-path pkill stays marked best-effort in the source. + */ +import { describe, expect, it } from "vitest"; +import { + buildForwardArgs, + buildLaunchArgs, + buildPushArgs, + buildUnforwardArgs, + ScrcpyManager, + type ScrcpyDeps, +} from "../src/scrcpy/server.js"; +import { toAdbInputArgs } from "../src/scrcpy/input.js"; +import type { ExecResult } from "../src/env/preflight.js"; + +function scripted(options?: { + runs?: Record; + files?: Record; + dirs?: Record; + binaries?: Record; +}): { spawned: string[][]; calls: string[][]; deps: ScrcpyDeps } { + const spawned: string[][] = []; + const calls: string[][] = []; + return { + spawned, + calls, + deps: { + run: async (command: string[]) => { + calls.push(command); + return ( + options?.runs?.[command.join(" ")] ?? { code: 0, stdout: "", stderr: "" } + ); + }, + spawnDetached: (command: string[]) => { + spawned.push(command); + }, + exists: (path: string) => options?.files?.[path] ?? false, + listDir: (path: string) => options?.dirs?.[path] ?? [], + which: (binary: string) => options?.binaries?.[binary], + }, + }; +} + +const MIRROR = { maxWidth: 1080, maxFps: 30, bitrateMbps: 4 }; + +describe("mirror: server resolution refuses before it guesses", () => { + it("takes an explicit versioned jar", async () => { + const { deps } = scripted({ files: { "/srv/scrcpy-server-v3.1.jar": true } }); + const manager = new ScrcpyManager({ ...MIRROR, serverPath: "/srv/scrcpy-server-v3.1.jar" }, deps); + expect(await manager.resolve()).toEqual({ jarPath: "/srv/scrcpy-server-v3.1.jar", version: "3.1", source: "explicit" }); + }); + + it("refuses a missing explicit path with the install hint", async () => { + const { deps } = scripted(); + const manager = new ScrcpyManager({ ...MIRROR, serverPath: "/srv/missing.jar" }, deps); + await expect(manager.resolve()).rejects.toThrow(/official source/); + }); + + it("refuses an unversioned jar name: exact match is mandatory", async () => { + const { deps } = scripted({ files: { "/srv/scrcpy-server.jar": true } }); + const manager = new ScrcpyManager({ ...MIRROR, serverPath: "/srv/scrcpy-server.jar" }, deps); + await expect(manager.resolve()).rejects.toThrow(/exact match is mandatory/); + }); + + it("finds the sidecar jar next to the scrcpy binary", async () => { + const { deps } = scripted({ + binaries: { scrcpy: "/opt/scrcpy/scrcpy" }, + dirs: { "/opt/scrcpy": ["scrcpy", "scrcpy-server-v2.5.jar"] }, + }); + const manager = new ScrcpyManager(MIRROR, deps); + expect(await manager.resolve()).toMatchObject({ version: "2.5", source: "sidecar" }); + }); + + it("falls back to the sibling binary version for an unversioned sidecar", async () => { + const { deps } = scripted({ + binaries: { scrcpy: "/opt/scrcpy/scrcpy" }, + dirs: { "/opt/scrcpy": ["scrcpy-server.jar"] }, + runs: { "/opt/scrcpy/scrcpy --version": { code: 0, stdout: "scrcpy 2.5\n", stderr: "" } }, + }); + const manager = new ScrcpyManager(MIRROR, deps); + expect(await manager.resolve()).toMatchObject({ version: "2.5", source: "sidecar" }); + }); + + it("refuses with the install hint when nothing is found", async () => { + const { deps } = scripted(); + await expect(new ScrcpyManager(MIRROR, deps).resolve()).rejects.toThrow(/official source/); + }); +}); + +describe("mirror: documented argv shapes", () => { + it("pushes to the documented device path", () => { + expect(buildPushArgs("/srv/s.jar")).toEqual(["push", "/srv/s.jar", "/data/local/tmp/scrcpy-server.jar"]); + expect(buildPushArgs("/srv/s.jar", "em-1").slice(0, 2)).toEqual(["-s", "em-1"]); + }); + + it("forwards the tunnel port to the abstract socket", () => { + expect(buildForwardArgs(27183)).toEqual(["forward", "tcp:27183", "localabstract:scrcpy"]); + }); + + it("launches with the exact-match version plus the raw-stream option set", () => { + const argv = buildLaunchArgs("3.1", MIRROR); + expect(argv.slice(0, 6)).toEqual([ + "shell", + "CLASSPATH=/data/local/tmp/scrcpy-server.jar", + "app_process", + "/", + "com.genymobile.scrcpy.Server", + "3.1", + ]); + for (const option of [ + "tunnel_forward=true", + "audio=false", + "control=false", + "cleanup=false", + "raw_stream=true", + "max_size=1080", + "max_fps=30", + "video_bit_rate=4000000", + ]) { + expect(argv, `missing server option ${option}`).toContain(option); + } + }); + + it("unforward removes exactly the tunnel that was added", () => { + expect(buildUnforwardArgs(27183)).toEqual(["forward", "--remove", "tcp:27183"]); + }); +}); + +describe("mirror: start/stop order against a fake adb", () => { + it("pushes, forwards, then launches detached — and reuses the handle", async () => { + const { deps, calls, spawned } = scripted({ files: { "/srv/scrcpy-server-v3.1.jar": true } }); + const manager = new ScrcpyManager( + { ...MIRROR, serverPath: "/srv/scrcpy-server-v3.1.jar", serial: "em-1" }, + deps, + ); + const first = await manager.start(); + expect(first).toMatchObject({ port: 27183, version: "3.1" }); + expect(calls.map((call) => call.find((part) => part === "push" || part === "forward"))).toEqual([ + "push", + "forward", + ]); + expect(spawned).toHaveLength(1); + expect(spawned[0]?.join(" ")).toContain("com.genymobile.scrcpy.Server 3.1"); + await manager.start(); + expect(calls).toHaveLength(2); + expect(spawned).toHaveLength(1); + }); + + it("a failed push stops before forward and launch", async () => { + const { deps, calls, spawned } = scripted({ + files: { "/srv/scrcpy-server-v3.1.jar": true }, + runs: { "adb -s em-1 push /srv/scrcpy-server-v3.1.jar /data/local/tmp/scrcpy-server.jar": { code: 1, stdout: "", stderr: "no device" } }, + }); + const manager = new ScrcpyManager( + { ...MIRROR, serverPath: "/srv/scrcpy-server-v3.1.jar", serial: "em-1" }, + deps, + ); + await expect(manager.start()).rejects.toThrow(/^environment: adb push/); + expect(calls).toHaveLength(1); + expect(spawned).toHaveLength(0); + }); + + it("warns instead of throwing when teardown is best-effort", async () => { + const warnings: string[] = []; + const { deps } = scripted({ + files: { "/srv/scrcpy-server-v3.1.jar": true }, + runs: { + "adb forward --remove tcp:27183": { code: 1, stdout: "", stderr: "no such forward" }, + "adb shell pkill -f com.genymobile.scrcpy.Server": { code: 0, stdout: "", stderr: "" }, + }, + }); + const manager = new ScrcpyManager({ ...MIRROR, serverPath: "/srv/scrcpy-server-v3.1.jar" }, deps); + await manager.start(); + await manager.stop((message: string) => warnings.push(message)); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("forward --remove failed"); + }); + + it("stop removes the forward and attempts the kill, then disposes quietly", async () => { + const { deps, calls } = scripted({ files: { "/srv/scrcpy-server-v3.1.jar": true } }); + const manager = new ScrcpyManager({ ...MIRROR, serverPath: "/srv/scrcpy-server-v3.1.jar" }, deps); + await manager.start(); + const before = calls.length; + await manager.stop(); + expect(calls.slice(before).map((call) => call.join(" "))).toEqual([ + "adb forward --remove tcp:27183", + "adb shell pkill -f com.genymobile.scrcpy.Server", + ]); + await manager.stop(); + await manager.dispose(); + expect(calls).toHaveLength(before + 2); + }); +}); + +describe("mirror: touch translation is pure adb input argv", () => { + it("taps round to whole pixels", () => { + expect(toAdbInputArgs({ kind: "tap", x: 100.6, y: 200.2 })).toEqual([ + "shell", + "input", + "tap", + "101", + "200", + ]); + }); + + it("swipes carry an explicit duration, defaulting to 300ms", () => { + expect(toAdbInputArgs({ kind: "swipe", x1: 0, y1: 0, x2: 0, y2: 500 })).toEqual([ + "shell", + "input", + "swipe", + "0", + "0", + "0", + "500", + "300", + ]); + expect( + toAdbInputArgs({ kind: "swipe", x1: 0, y1: 0, x2: 0, y2: 500, durationMs: 120 }), + ).toContain("120"); + }); +}); diff --git a/plugins/dsh-autogamer/tests/support/pool.ts b/plugins/dsh-autogamer/tests/support/pool.ts new file mode 100644 index 000000000..aa58c4953 --- /dev/null +++ b/plugins/dsh-autogamer/tests/support/pool.ts @@ -0,0 +1,31 @@ +/** + * Shared router harness assembly (the "four copies of the same assembly" debt noted in + * `docs/TODO.md` §零): every spec that drives `run_device_action` needs a gate plus a pool over it. + */ +import { DeviceGate } from "../../src/gate.js"; +import { DevicePool } from "../../src/devicePool.js"; + +export interface Harness { + gate: DeviceGate; + pool: DevicePool; +} + +/** + * A pool over a fresh gate with an empty roster -- byte-for-byte the historical single-key + * behaviour, which is what these specs assume unless they are testing scheduling itself. + */ +export function harness(devices: string[] = [], maxConcurrentDevices = 0): Harness { + const gate = new DeviceGate(); + const pool = new DevicePool({ + gate, + defaultDeviceKey: "default", + devices, + maxConcurrentDevices, + }); + return { gate, pool }; +} + +/** Just the pool, for specs that never look at the gate. */ +export function defaultPool(): DevicePool { + return harness().pool; +} diff --git a/plugins/dsh-autogamer/tests/support/srcFiles.ts b/plugins/dsh-autogamer/tests/support/srcFiles.ts new file mode 100644 index 000000000..23df13451 --- /dev/null +++ b/plugins/dsh-autogamer/tests/support/srcFiles.ts @@ -0,0 +1,12 @@ +import { readdirSync } from "node:fs"; +import { join } from "node:path"; + +/** Every TypeScript file under `dir`, so a source-level contract check cannot + * quietly stop covering a file that was added or renamed. */ +export function* typescriptFiles(dir: string): Generator { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) yield* typescriptFiles(full); + else if (entry.name.endsWith(".ts")) yield full; + } +} diff --git a/plugins/dsh-autogamer/tests/validatorLadder.spec.ts b/plugins/dsh-autogamer/tests/validatorLadder.spec.ts new file mode 100644 index 000000000..e070e1d00 --- /dev/null +++ b/plugins/dsh-autogamer/tests/validatorLadder.spec.ts @@ -0,0 +1,500 @@ +/** + * P1-a validator ladder locks. Direct-drives defineRunDeviceAction with a + * scripted stub client (hierarchy payloads, tap outcomes, and effect verdicts + * consumed in order), so every rung is observable without a device. Since G67 a + * `{index}` handle only means something against what the model was last shown, + * so every index-target case observes a hierarchy first (see `observe()`): + * that call is the model's own observation, and the log is cleared after it so + * `hierarchyCalls()` still counts only the ladder's re-fetches: + * pre-execution retries, the bypass fast path, the post-execution coordinate + * retry, pixel guidance + category, the incident counter, and the four + * strictness levels. + */ +import { beforeEach, describe, expect, it } from "vitest"; +import { DeviceBreaker } from "../src/breaker.js"; +import { defaultPool } from "./support/pool.js"; +import { + ActionIncidents, + defineRunDeviceAction, + type ValidatorStrictness, +} from "../src/tools/runDeviceAction.js"; + +function node(text: string, left: number, top: number, right: number, bottom: number) { + return { + index: "0", + text, + "content-desc": "", + "resource-id": "", + class: "android.widget.TextView", + package: "com.android.settings", + clickable: "true", + parsed_bounds: { left, top, right, bottom }, + }; +} + +const WITH_TARGET = JSON.stringify([ + node("亮度", 40, 400, 200, 460), + node("显示", 40, 600, 200, 660), +]); +const WITHOUT_TARGET = JSON.stringify([node("亮度", 40, 400, 200, 460)]); +// 显示 slid 80px down: inside the reach a text label may claim (MAX_HANDLE_SHIFT). +const MOVED_TARGET = JSON.stringify([ + node("亮度", 40, 400, 200, 460), + node("显示", 40, 680, 200, 740), +]); +// Same label 411px away: no text label can claim that is the node it saw, so the +// ladder reports it missing rather than chasing it (G68 ③, upstream +// "prefer disappeared over shifted", precondition_xml.py:392-395). +const MOVED_FAR = JSON.stringify([ + node("亮度", 40, 400, 200, 460), + node("显示", 400, 800, 560, 860), +]); + +const harness = { + calls: [] as { tool: string; args: Record }[], + hierarchies: [] as string[], + taps: [] as string[], + compares: [] as (boolean | undefined)[], +}; + +function setup(strictness?: ValidatorStrictness) { + harness.calls.length = 0; + harness.hierarchies.length = 0; + harness.taps.length = 0; + harness.compares.length = 0; + const tool = defineRunDeviceAction({ + pool: defaultPool(), + breaker: new DeviceBreaker({ allowedFails: 99, cooldownMs: 0 }), + client: { + callTool: async (name: string, args: Record) => { + harness.calls.push({ tool: name, args }); + if (name === "get_ui_hierarchy") { + const text = harness.hierarchies.length > 0 ? harness.hierarchies.shift()! : WITH_TARGET; + return { content: [{ type: "text", text }] }; + } + if (name === "take_screenshot") { + throw new Error("no screenshots in ladder tests (frames come from captureFrame)"); + } + return { content: [{ type: "text", text: harness.taps.length > 0 ? harness.taps.shift()! : "Success" }] }; + }, + } as never, + logger: { info: () => {}, warn: () => {} }, + defaultDeviceKey: "d0", + strictness, + incidents: new ActionIncidents(), + resolveScreenSize: async () => ({ width: 720, height: 1600 }), + captureFrame: async () => "frame", + compareFrames: async () => (harness.compares.length > 0 ? harness.compares.shift() : true), + }); + return tool; +} + +function exec(): { signal: AbortSignal } { + return { signal: new AbortController().signal }; +} + +/** + * G67: give the model its live observation of `payload`, then reset the call + * log so the assertions below count only what the action itself fetched. + */ +async function observe(tool: ReturnType, payload = WITH_TARGET): Promise { + harness.hierarchies.push(payload); + await tool.execute({ action: "get_ui_hierarchy", reason: "t" }, exec() as never); + harness.calls.length = 0; +} + +const GONE = /^task: #1 \("显示"\) from the live hierarchy is no longer on device/; + +function hierarchyCalls(): number { + return harness.calls.filter((call) => call.tool === "get_ui_hierarchy").length; +} + +function tapCalls(): { tool: string; args: Record }[] { + return harness.calls.filter((call) => call.tool === "tap"); +} + +beforeEach(() => { + harness.calls.length = 0; + harness.hierarchies.length = 0; + harness.taps.length = 0; + harness.compares.length = 0; +}); + +describe("validator ladder: pre-execution retries", () => { + it("finds a late-appearing target within 3 fetches", async () => { + const tool = setup(); + await observe(tool); + harness.hierarchies.push(WITHOUT_TARGET, WITH_TARGET); + harness.compares.push(true); + const value = (await tool.execute( + { action: "click", target: { index: 1 }, reason: "t" }, + exec() as never, + )) as Record; + expect(value.effect).toBe("changed"); + expect(hierarchyCalls()).toBe(2); + }); + + it("fails after exactly 3 fetches when the observed target never comes back", async () => { + const tool = setup(); + await observe(tool); + harness.hierarchies.push(WITHOUT_TARGET, WITHOUT_TARGET, WITHOUT_TARGET, WITH_TARGET); + await expect( + tool.execute({ action: "click", target: { index: 1 }, reason: "t" }, exec() as never), + ).rejects.toThrow(GONE); + expect(hierarchyCalls()).toBe(3); + }); + + it("resolves once with strictness off", async () => { + const tool = setup("off"); + await observe(tool); + harness.hierarchies.push(WITHOUT_TARGET, WITH_TARGET); + await expect( + tool.execute({ action: "click", target: { index: 1 }, reason: "t" }, exec() as never), + ).rejects.toThrow(GONE); + expect(hierarchyCalls()).toBe(1); + }); + + it("fails fast on an unreadable hierarchy without retrying", async () => { + const tool = setup(); + await observe(tool); + harness.hierarchies.push("not-json{{{", WITH_TARGET, WITH_TARGET); + await expect( + tool.execute({ action: "click", target: { index: 1 }, reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: hierarchy unreadable/); + expect(hierarchyCalls()).toBe(1); + }); + + it("fails fast without fetching when no target was given", async () => { + const tool = setup(); + await expect( + tool.execute({ action: "click", target: {}, reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: this action needs a target/); + expect(hierarchyCalls()).toBe(0); + }); +}); + +describe("validator ladder: post-execution retry", () => { + it("reruns once with fresh coordinates after an unchanged effect", async () => { + const tool = setup(); + await observe(tool); + harness.hierarchies.push(WITH_TARGET); + harness.compares.push(false, true); + // Re-resolution sees the target at its moved bounds. + harness.hierarchies.push(MOVED_TARGET); + const value = (await tool.execute( + { action: "click", target: { index: 1 }, reason: "t" }, + exec() as never, + )) as Record; + expect(value.effect).toBe("changed"); + const taps = tapCalls(); + expect(taps).toHaveLength(2); + expect(taps[0].args.coordinates).toEqual([120, 630]); + expect(taps[1].args.coordinates).toEqual([120, 710]); + expect(value).not.toHaveProperty("incident"); + }); + + it("does not chase a same-labelled node that moved further than the label can claim", async () => { + const tool = setup(); + await observe(tool); + harness.hierarchies.push(WITH_TARGET); + harness.compares.push(false); + harness.hierarchies.push(MOVED_FAR); + const value = (await tool.execute( + { action: "click", target: { index: 1 }, reason: "t" }, + exec() as never, + )) as Record; + expect(value.effect).toBe("unchanged"); + expect(value.category).toBe("disappeared"); + expect(tapCalls()).toHaveLength(1); + }); + + it("reports disappeared with guidance when the target is gone", async () => { + const tool = setup(); + await observe(tool); + harness.hierarchies.push(WITH_TARGET); + harness.compares.push(false); + harness.hierarchies.push(WITHOUT_TARGET); + const value = (await tool.execute( + { action: "click", target: { index: 1 }, reason: "t" }, + exec() as never, + )) as Record; + expect(value.effect).toBe("unchanged"); + expect(value.category).toBe("disappeared"); + expect(value.pixel_guidance).toContain("Take a screenshot"); + expect(tapCalls()).toHaveLength(1); + }); + + it("does not retry target-less actions", async () => { + const tool = setup(); + harness.compares.push(false); + const value = (await tool.execute( + { action: "launch_app", package: "com.example", reason: "t" }, + exec() as never, + )) as Record; + expect(value.effect).toBe("unchanged"); + expect(value.category).toBe("general"); + expect(value.pixel_guidance).toContain("Take a screenshot"); + expect(harness.calls.filter((call) => call.tool === "launch_app")).toHaveLength(1); + }); + + it("does not retry coordinate-called actions", async () => { + const tool = setup(); + harness.compares.push(false); + const value = (await tool.execute( + { action: "click", target: { coordinate: [500, 500] }, reason: "t" }, + exec() as never, + )) as Record; + expect(value.effect).toBe("unchanged"); + expect(tapCalls()).toHaveLength(1); + }); + + it("returns a bare unchanged with strictness off", async () => { + const tool = setup("off"); + await observe(tool); + harness.hierarchies.push(WITH_TARGET); + harness.compares.push(false); + const value = (await tool.execute( + { action: "click", target: { index: 1 }, reason: "t" }, + exec() as never, + )) as Record; + expect(value.effect).toBe("unchanged"); + expect(tapCalls()).toHaveLength(1); + expect(value).not.toHaveProperty("pixel_guidance"); + expect(value).not.toHaveProperty("incident"); + }); + + it("leaves unknown verdicts alone (fail-open: no ladder, no count)", async () => { + const tool = setup(); + await observe(tool); + harness.hierarchies.push(WITH_TARGET); + harness.compares.push(undefined); + const value = (await tool.execute( + { action: "click", target: { index: 1 }, reason: "t" }, + exec() as never, + )) as Record; + expect(value.effect).toBe("unknown"); + expect(tapCalls()).toHaveLength(1); + expect(value).not.toHaveProperty("pixel_guidance"); + }); +}); + +describe("validator ladder: incident ledger", () => { + async function failThrice(strictness?: ValidatorStrictness): Promise { + const tool = setup(strictness); + const messages: string[] = []; + for (let i = 0; i < 3; i += 1) { + try { + await tool.execute({ action: "press_key", key: "frob", reason: "t" }, exec() as never); + } catch (error) { + messages.push((error as Error).message); + } + } + return messages; + } + + it("attaches an incident on the third consecutive task failure", async () => { + const messages = await failThrice(); + expect(messages[0]).not.toContain("incident"); + expect(messages[1]).not.toContain("incident"); + expect(messages[2]).toMatch(/^task:.*3 consecutive task failures/); + expect(messages[2]).toContain("report_task_status"); + }); + + it("uses milestone wording above final", async () => { + const messages = await failThrice("checkpoints"); + expect(messages[0]).not.toContain("Milestone"); + expect(messages[2]).toContain("Milestone audit"); + const strict = await failThrice("strict"); + expect(strict[2]).toContain("Milestone audit"); + const fin = await failThrice("final"); + expect(fin[2]).not.toContain("Milestone"); + }); + + it("never attaches an incident with strictness off", async () => { + const messages = await failThrice("off"); + expect(messages.join("")).not.toContain("incident"); + }); + + it("resets the count on success", async () => { + const tool = setup(); + for (let i = 0; i < 2; i += 1) { + await expect( + tool.execute({ action: "press_key", key: "frob", reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: unknown key/); + } + await tool.execute({ action: "wait", duration_ms: 1, reason: "t" }, exec() as never); + await expect( + tool.execute({ action: "press_key", key: "frob", reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: unknown key "(?!.*incident)/); + }); + + it("an unknown verdict neither counts nor resets", async () => { + const tool = setup(); + // Observed before the streak: a successful look is a success like any other. + await observe(tool); + for (let i = 0; i < 2; i += 1) { + await expect( + tool.execute({ action: "press_key", key: "frob", reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: unknown key/); + } + harness.hierarchies.push(WITH_TARGET); + harness.compares.push(undefined); + await tool.execute({ action: "click", target: { index: 1 }, reason: "t" }, exec() as never); + await expect( + tool.execute({ action: "press_key", key: "frob", reason: "t" }, exec() as never), + ).rejects.toThrow(/3 consecutive task failures/); + }); +}); + + +describe("validator ladder: incident CLOSED notice (changelogs #62)", () => { + // Upstream keeps an incident open until a LATER success closes it, then renders + // a one-turn notice to settle the original intent (changelogs #62; upstream + // google/artemis carries it as `last_closed_incident` across its graph state, + // validator execution loop, and operator prompt). + async function announce(tool: ReturnType): Promise { + for (let i = 0; i < 3; i += 1) { + await expect( + tool.execute({ action: "press_key", key: "frob", reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: unknown key/); + } + } + + it("announces the closure exactly once, on the success that ends an open incident", async () => { + const tool = setup(); + await announce(tool); + const closed = (await tool.execute( + { action: "wait", duration_ms: 1, reason: "t" }, + exec() as never, + )) as Record; + expect(closed.incident_closed).toEqual({ consecutive_failures: 3 }); + expect(String(closed.closure_request)).toContain("CLOSED"); + expect(String(closed.closure_request)).toContain("already served, still pending, or no longer needed"); + const next = (await tool.execute( + { action: "wait", duration_ms: 1, reason: "t" }, + exec() as never, + )) as Record; + expect(next).not.toHaveProperty("incident_closed"); + expect(next).not.toHaveProperty("closure_request"); + }); + + it("says nothing on a success when no incident was ever announced", async () => { + const tool = setup(); + await expect( + tool.execute({ action: "press_key", key: "frob", reason: "t" }, exec() as never), + ).rejects.toThrow(/^task: unknown key/); + const value = (await tool.execute( + { action: "wait", duration_ms: 1, reason: "t" }, + exec() as never, + )) as Record; + expect(value).not.toHaveProperty("incident_closed"); + }); + + it("closes on a ladder retry that finally moved the screen", async () => { + const tool = setup(); + await observe(tool); + await announce(tool); + harness.hierarchies.push(WITH_TARGET, MOVED_TARGET); + harness.compares.push(false, true); + const value = (await tool.execute( + { action: "click", target: { index: 1 }, reason: "t" }, + exec() as never, + )) as Record; + expect(value.effect).toBe("changed"); + expect(value.incident_closed).toEqual({ consecutive_failures: 3 }); + expect(tapCalls()).toHaveLength(2); + }); +}); + +describe("validator ladder: strictness prompts", () => { + it("adds a self-check to mutating successes only under strict", async () => { + const strict = setup("strict"); + await observe(strict); + harness.hierarchies.push(WITH_TARGET); + harness.compares.push(true); + const strictValue = (await strict.execute( + { action: "click", target: { index: 1 }, reason: "t" }, + exec() as never, + )) as Record; + expect(strictValue.self_check).toContain("Self-check"); + + const fin = setup("final"); + await observe(fin); + harness.hierarchies.push(WITH_TARGET); + harness.compares.push(true); + const finalValue = (await fin.execute( + { action: "click", target: { index: 1 }, reason: "t" }, + exec() as never, + )) as Record; + expect(finalValue).not.toHaveProperty("self_check"); + }); +}); + +describe("validator ladder: unchanged streaks and mid-ladder transport death", () => { + it("accumulates unchanged streaks to an incident", async () => { + const tool = setup("checkpoints"); + await observe(tool); + for (let i = 0; i < 3; i += 1) { + harness.hierarchies.push(WITH_TARGET, WITH_TARGET); + harness.compares.push(false, false); + const value = (await tool.execute( + { action: "click", target: { index: 1 }, reason: "t" }, + exec() as never, + )) as Record; + if (i < 2) { + expect(value).not.toHaveProperty("incident"); + } else { + expect(value.incident).toEqual({ consecutive_failures: 3 }); + expect(value.audit_request).toContain("Milestone audit"); + } + } + }); + + it("classifies a transport death mid-ladder as environment", async () => { + let hierarchies = 0; + const failing = defineRunDeviceAction({ + pool: defaultPool(), + breaker: new DeviceBreaker({ allowedFails: 99, cooldownMs: 0 }), + client: { + callTool: async (name: string) => { + if (name === "get_ui_hierarchy") { + hierarchies += 1; + if (hierarchies >= 2) throw new Error("Connection closed"); + return { content: [{ type: "text", text: WITH_TARGET }] }; + } + return { content: [{ type: "text", text: "Success" }] }; + }, + } as never, + logger: { info: () => {}, warn: () => {} }, + defaultDeviceKey: "d0", + incidents: new ActionIncidents(), + resolveScreenSize: async () => ({ width: 720, height: 1600 }), + captureFrame: async () => "frame", + compareFrames: async () => false, + }); + // The first fetch is the model's observation; the ladder's re-fetch dies. + await failing.execute({ action: "get_ui_hierarchy", reason: "t" }, exec() as never); + await expect( + failing.execute({ action: "click", target: { index: 1 }, reason: "t" }, exec() as never), + ).rejects.toThrow(/^environment: action transport failed/); + }); + + it("classifies a screenshot-branch transport death as environment", async () => { + const failing = defineRunDeviceAction({ + pool: defaultPool(), + breaker: new DeviceBreaker({ allowedFails: 99, cooldownMs: 0 }), + client: { + callTool: async () => { + throw new Error("Connection closed"); + }, + } as never, + logger: { info: () => {}, warn: () => {} }, + defaultDeviceKey: "d0", + incidents: new ActionIncidents(), + resolveScreenSize: async () => ({ width: 720, height: 1600 }), + }); + await expect( + failing.execute({ action: "take_screenshot", reason: "t" }, exec() as never), + ).rejects.toThrow(/^environment: action transport failed/); + }); +}); diff --git a/plugins/dsh-autogamer/tsconfig.json b/plugins/dsh-autogamer/tsconfig.json new file mode 100644 index 000000000..7092b7aed --- /dev/null +++ b/plugins/dsh-autogamer/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/plugins/dsh-autogamer/vitest.config.ts b/plugins/dsh-autogamer/vitest.config.ts new file mode 100644 index 000000000..649a293ef --- /dev/null +++ b/plugins/dsh-autogamer/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.spec.ts"], + environment: "node", + }, +}); diff --git a/pyproject.toml b/pyproject.toml index e5cd095fe..486c49c65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,8 +24,6 @@ authors = [ requires-python = ">=3.12" dependencies = [ - # Remote Python SDK (kept as a zero-dependency workspace package) - "artemis-client>=0.1.0", # Orchestration & Framework "langgraph>=1.0.2,<2.0.0", "langchain>=1.0.0", @@ -89,7 +87,6 @@ dev = [ [project.scripts] artemis = "artemis.interfaces.cli.main:cli" -artemis-admin = "apps.admin_console.server:main" [build-system] @@ -98,11 +95,23 @@ artemis-admin = "apps.admin_console.server:main" requires = ["setuptools>=61.0", "Cython"] build-backend = "setuptools.build_meta" -[tool.uv.workspace] -members = ["packages/artemis-client"] - -[tool.uv.sources] -artemis-client = { workspace = true } +# The contracted tree keeps exactly two importable top-level packages. Without +# this, setuptools flat-layout discovery trips over leftover untracked dirs +# (notably apps/, whose node_modules were never cleaned) and editable installs +# fail with "Multiple top-level packages discovered". +# `third_party` is a namespace package (no __init__.py): mapping it keeps +# `third_party.mobile_use.*` imports working under installed console scripts +# (`uv run artemis mcp …`). The old tree achieved the same through a lazy +# meta-path finder installed by artemis/__init__.py (`_legacy_imports`, +# dropped in the P4 rewrite along with its module); explicit namespace +# mapping replaces that machinery. Without it, imports only resolve when the +# repo root happens to sit on sys.path (`python -m`, pytest), and the CI +# fixture step (`uv run artemis mcp --type mock`) dies with +# "ModuleNotFoundError: No module named 'third_party'". +[tool.setuptools.packages.find] +where = ["."] +include = ["artemis*", "third_party*"] +namespaces = true [tool.ruff] # Exclude a variety of commonly ignored directories. @@ -183,19 +192,15 @@ skip-magic-trailing-comma = false line-ending = "auto" [tool.pytest.ini_options] -# Add project root and apps to Python path for clean absolute imports +# Add project root to Python path for clean absolute imports pythonpath = [ ".", - "apps", - "apps/admin_console", ] # Specify test discovery patterns testpaths = [ "tests/unit", - "tests/tools", - "packages/artemis-client/tests", ] python_files = [ "*.test.py", diff --git a/pyright-core.json b/pyright-core.json index 3ae152e75..c776d72a4 100644 --- a/pyright-core.json +++ b/pyright-core.json @@ -2,16 +2,10 @@ "venvPath": ".", "venv": ".venv", "include": [ - "artemis/graph/state.py", - "artemis/graph/visibility.py", "artemis/runtime/adb_endpoint.py", "artemis/runtime/process_probe.py", "artemis/mcp/action_types.py", - "artemis/data_engine/models.py", - "artemis/agents/explorer/native_runner.py", - "artemis/agents/explorer/perception_tools.py", - "mcp_server/utils/device_utils.py", - "packages/artemis-client/src/artemis_client" + "artemis/data_engine/models.py" ], "exclude": [".venv/**"] } diff --git a/scripts/check_dependency_sources.py b/scripts/check_dependency_sources.py index 64f1709a3..4b49d3ca6 100644 --- a/scripts/check_dependency_sources.py +++ b/scripts/check_dependency_sources.py @@ -18,7 +18,6 @@ Uses only the Python 3.12+ standard library so CI can run it before uv sync. """ -import json import sys import tomllib from pathlib import Path @@ -36,9 +35,12 @@ def check_url(label: str, url: str, host: str) -> None: errors.append(f"{label}: expected an HTTPS URL on {host}") lock = tomllib.loads((ROOT / "uv.lock").read_text(encoding="utf-8")) + # Only workspace member left is the root project itself: every other + # package must come from the public PyPI registry (P4 contraction deleted + # packages/artemis-client; the old console's package-lock.json went with + # apps/showcase_ui_v2, so the npm half of this check is gone with it). workspace_sources = { "artemis": {"editable": "."}, - "artemis-client": {"editable": "packages/artemis-client"}, } for package in lock["package"]: label = f"uv.lock: {package['name']}=={package['version']}" @@ -52,15 +54,10 @@ def check_url(label: str, url: str, host: str) -> None: for artifact in artifacts: check_url(f"{label}: artifact", artifact.get("url", ""), "files.pythonhosted.org") - npm_lock = json.loads((ROOT / "apps/showcase_ui/package-lock.json").read_text(encoding="utf-8")) - for name, package in npm_lock["packages"].items(): - if "resolved" in package: - check_url(f"package-lock.json: {name}", package["resolved"], "registry.npmjs.org") - if errors: print("Dependency source check failed:\n" + "\n".join(errors), file=sys.stderr) return 1 - print("Dependency sources use public PyPI and npm registries.") + print("Dependency sources use the public PyPI registry.") return 0 diff --git a/scripts/check_doc_registry.py b/scripts/check_doc_registry.py new file mode 100644 index 000000000..7e6692858 --- /dev/null +++ b/scripts/check_doc_registry.py @@ -0,0 +1,314 @@ +"""Numbered-reference gate for the migration docs. + +Why this exists: the docs cross-reference each other by bare ids (D7, S11, G30, +R13). A mechanical sweep found 14 gap ids referenced with no findable row and +three ids (G8/G10/G11) carrying different meanings in docs/migration/inventory +than in docs/TODO.md. docs/migration/registry.md is the declared authority; this +script proves it stays true. + +Judgments (each fails loudly, none of them prints-and-forgets): + 1. registry lists exactly the ids that TODO.md / CHANGELOGS.md / plugin-contract-rules.md define + 2. every id referenced anywhere in the tracked markdown corpus exists in registry + 3. ids that inventory/* defines with a different meaning are all listed as clashes + 4. the "closed" ids are individual registry rows, not folded back into one line + 5. paths and scripts registry.md points at actually exist + 6. no doc restates an id range that means "the whole set" (D1-Dn / S1-Sn / G1-Gn / + R1-Rn) - only the authority (registry.md), the archive (CHANGELOGS.md, which + quotes pre-fix text) and the heading that DEFINES the sequence may say so + +Usage: + python -X utf8 scripts/check_doc_registry.py + python -X utf8 scripts/check_doc_registry.py --selftest +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +TODO = ROOT / "docs" / "TODO.md" +CHANGELOGS = ROOT / "docs" / "CHANGELOGS.md" +RULES = ROOT / "docs" / "migration" / "plugin-contract-rules.md" +REGISTRY = ROOT / "docs" / "migration" / "registry.md" +INVENTORY_GLOB = "docs/migration/inventory" + +KINDS = ("D", "S", "G", "R") +# inventory-side ids that registry.md already declares as clashing / reused +CLASHABLE = {"G8", "G9", "G10", "G11", "G12"} + +# A range that STARTS at 1 reads as "the whole set", so it is a copy of a derived +# fact: it goes wrong the moment anything is added. Only the authority itself may +# hold it, plus the archive that quotes pre-fix wording verbatim, plus the heading +# that defines the sequence. +TOTAL_RANGE = re.compile(r"\b([DSGR])1-\1?(\d+)") + + +def total_range_claims(files: list[Path]) -> list[tuple[str, int, str]]: + """Every 'the whole set' range claim outside the authority (headings excepted).""" + hits: list[tuple[str, int, str]] = [] + for path in files: + if path in (REGISTRY, CHANGELOGS): + continue + for no, line in enumerate(read(path).split("\n"), start=1): + if line.startswith("#"): + # the section that DEFINES the sequence may name its own range + continue + for m in TOTAL_RANGE.finditer(line): + rel = path.relative_to(ROOT).as_posix() + hits.append((rel, no, m.group(0))) + return hits + + +def tracked_markdown() -> list[Path]: + """Use git ls-files as the scope; rglob would drag in node_modules.""" + out = subprocess.run( + ["git", "-C", str(ROOT), "ls-files", "*.md"], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + files = [] + for rel in out.stdout.splitlines(): + p = ROOT / rel.strip().replace("\\", "/") + if p.exists(): + files.append(p) + return files + + +def read(path: Path) -> str: + return path.read_text(encoding="utf-8", errors="replace") + + +def defined_in_todo() -> dict[str, set[str]]: + text = read(TODO) + got = {k: set() for k in KINDS} + got["D"] |= {f"D{m}" for m in re.findall(r"^\| D(\d+) \|", text, re.M)} + got["S"] |= {f"S{m}" for m in re.findall(r"^\*\*S(\d+)\b", text, re.M)} + got["G"] |= {f"G{m}" for m in re.findall(r"^\| G(\d+) \|", text, re.M)} + # ids folded into the "| 已关闭 | ... |" row (kept for the legacy shape) + for row in re.findall(r"^\| 已关闭 \|(.*)\|$", text, re.M): + got["G"] |= {f"G{m}" for m in re.findall(r"G(\d+)", row)} + # closed gaps now live in CHANGELOGS.md's 已关闭差距史 table: one row per id + if CHANGELOGS.exists(): + log = read(CHANGELOGS) + got["G"] |= {f"G{m}" for m in re.findall(r"^\| G(\d+) \|", log, re.M)} + return got + + +def defined_in_rules() -> set[str]: + return {f"R{m}" for m in re.findall(r"^## R(\d+)", read(RULES), re.M)} + + +def registry_ids() -> set[str]: + ids: set[str] = set() + for row in re.findall(r"^\| ([DSGR]\d+) \|", read(REGISTRY), re.M): + ids.add(row) + return ids + + +def referenced_ids(files: list[Path]) -> dict[str, set[str]]: + """Every id mentioned in the corpus, expanding ranges like R1-R19.""" + where: dict[str, set[str]] = {} + for path in files: + text = read(path) + for kind in KINDS: + for m in re.findall(rf"\b{kind}(\d+)\b", text): + where.setdefault(f"{kind}{m}", set()).add(path.name) + for lo, hi in re.findall(rf"{kind}(\d+)[-–]{kind}?(\d+)", text): + for i in range(int(lo), int(hi) + 1): + where.setdefault(f"{kind}{i}", set()).add(path.name) + return where + + +def inventory_side_ids() -> set[str]: + """Ids inventory/* uses its own numbering for (``**G10 · …**`` or bullets).""" + found: set[str] = set() + for path in sorted((ROOT / INVENTORY_GLOB).glob("*.md")): + text = read(path) + found |= {f"G{m}" for m in re.findall(r"\*\*G(\d+) ·", text)} + return found + + +def referenced_paths_in_registry() -> list[str]: + """Every file path registry.md points at, whether or not it exists.""" + text = read(REGISTRY) + cited = set(re.findall(r"scripts/[\w.\-/]+\.py", text)) + cited |= set(re.findall(r"tests/fixtures/[\w.\-/]+\.json", text)) + cited |= set(re.findall(r"(?:docs|plugins|artemis)/[\w.\-/]+\.(?:md|json|ts)", text)) + return sorted(cited) + + +def resolve(rel: str) -> bool: + """A cited path resolves if it exists at the root or inside the plugin.""" + base = rel.replace("\\", "/").lstrip("/") + candidates = [ROOT / base, ROOT / "plugins" / "dsh-autogamer" / base] + if not base.startswith(("docs/", "plugins/", "scripts/")): + candidates.insert(0, ROOT / "docs" / base) + return any(c.exists() for c in candidates) + + +def check() -> list[str]: + problems: list[str] = [] + files = tracked_markdown() + todo_ids = defined_in_todo() + rules_ids = defined_in_rules() + reg = registry_ids() + + authoritative = set().union(*todo_ids.values()) | rules_ids + + # 1. registry must list exactly the authoritative ids + missing_in_registry = sorted(authoritative - reg) + invented_in_registry = sorted(reg - authoritative) + for ident in missing_in_registry: + problems.append(f"registry.md is missing a row for {ident} (defined in todo/rules)") + for ident in invented_in_registry: + problems.append(f"registry.md lists {ident} but nothing defines it any more") + + # 2. no dangling references anywhere + for ident, sources in sorted(referenced_ids(files).items()): + if ident not in reg: + problems.append(f"dangling reference {ident} used in {sorted(sources)}") + + # 3. inventory's private numbering must be declared as a clash + undeclared = sorted(inventory_side_ids() - CLASHABLE) + for ident in undeclared: + problems.append( + f"inventory/* re-defines {ident} with its own meaning; " + "add it to the clash table in registry.md or rename the reference" + ) + if "编号冲突" not in read(REGISTRY): + problems.append("registry.md lost its clash table section") + + # 4. closed ids stay as rows + closed = { + ident for ident in todo_ids["G"] if re.search(rf"^\| {ident} \|", read(TODO), re.M) is None + } + for ident in sorted(closed): + row = re.search(rf"^\| {ident} \|", read(REGISTRY), re.M) + if row is None: + problems.append( + f"{ident} is only folded into TODO.md's 已关闭 row and has no registry row" + ) + + # 5. pointers inside registry.md must resolve + for rel in referenced_paths_in_registry(): + if not resolve(rel): + problems.append(f"registry.md points at {rel} which does not exist") + + # 6. no second copy of "the whole set" + claims = total_range_claims(files) + for name, no, text in claims: + problems.append( + f"{name}:{no} restates the total id range {text}; cite registry.md instead " + "- a copied range is wrong the moment one id is added" + ) + + print( + f"corpus: {len(files)} md files; authoritative ids: {len(authoritative)} " + f"(D{len(todo_ids['D'])} S{len(todo_ids['S'])} G{len(todo_ids['G'])} R{len(rules_ids)}); " + f"registry rows: {len(reg)}; total-range restatements outside the authority: {len(claims)}" + ) + return problems + + +def report(problems: list[str]) -> None: + if problems: + for p in problems: + print("PROBLEM:", p) + raise SystemExit(f"FAIL: {len(problems)} doc-reference problem(s)") + print("OK: every numbered reference resolves and registry matches the sources") + + +def selftest() -> None: + """Inject defects, then restore byte-for-byte. + + write_text() would re-line-end the file on Windows, so every mutation here + goes through bytes and the restore check compares bytes too. + """ + report(check()) + fired = 0 + registry_bytes = REGISTRY.read_bytes() + original = registry_bytes.decode("utf-8") + # drop a whole class of rows: registry must then report them missing + gutted = "\n".join(ln for ln in original.split("\n") if not re.match(r"^\| R\d+ \|", ln)) + REGISTRY.write_bytes(gutted.encode("utf-8")) + try: + problems = check() + caught = [p for p in problems if "missing a row for R" in p] + if not caught: + raise SystemExit("FAIL: ruler is blind to deleted R rows") + print(f"CAUGHT [deleted R rows]: {caught[0]}") + fired += 1 + finally: + REGISTRY.write_bytes(registry_bytes) + if REGISTRY.read_bytes() != registry_bytes: + raise SystemExit("FAIL: selftest did not restore registry.md byte-for-byte") + print("OK: registry.md restored byte-for-byte after selftest") + # invent a dangling id in a doc and expect it to be reported. + # The ghost id is DERIVED (max registered G + 1), never a literal: a hardcoded + # one rots the moment that number gets defined, and then the mutation silently + # stops firing - which is the exact failure class rule R27 warns about. + todo_bytes = TODO.read_bytes() + todo = todo_bytes.decode("utf-8") + known = {int(m) for m in re.findall(r"^\| G(\d+) \|", original, re.M)} + ghost = f"G{(max(known) + 1) if known else 9999}" + tampered = re.sub(r"\bG\d+\b", ghost, todo, count=1) + if tampered == todo: + raise SystemExit("FAIL: selftest anchor not found in TODO.md") + if ghost in original: + raise SystemExit(f"FAIL: ghost id {ghost} is not actually dangling") + TODO.write_bytes(tampered.encode("utf-8")) + try: + problems = check() + caught = [p for p in problems if ghost in p] + if not caught: + raise SystemExit(f"FAIL: ruler is blind to a renamed/dangling id ({ghost})") + print(f"CAUGHT [dangling id]: {caught[0]}") + fired += 1 + finally: + TODO.write_bytes(todo_bytes) + if TODO.read_bytes() != todo_bytes: + raise SystemExit("FAIL: selftest did not restore TODO.md byte-for-byte") + print("OK: TODO.md restored byte-for-byte after selftest") + # copy a "the whole set" range into a doc body: only registry.md may say that. + # Anchor on whatever gap row exists, else a decision row (TODO may carry no + # open gap rows at all after the 2026-10-14 clearing); the range literal is + # built from the registry's current D max so the anchor cannot rot again. + gap_row = next((ln for ln in todo.split("\n") if re.match(r"^\| G\d+ \|", ln)), None) + if gap_row is None: + gap_row = next((ln for ln in todo.split("\n") if re.match(r"^\| D\d+ \|", ln)), None) + if gap_row is None: + raise SystemExit( + "FAIL: no gap or decision row to inject the range defect into - the tables moved" + ) + max_d = max(int(i[1:]) for i in registry_ids() if i.startswith("D")) + copied = gap_row.rstrip().rstrip("|") + f" 完整口径见 D1-D{max_d} |\n" + TODO.write_bytes(todo.replace(gap_row, copied, 1).encode("utf-8")) + try: + problems = check() + caught = [p for p in problems if "restates the total id range" in p] + if not caught: + raise SystemExit("FAIL: ruler is blind to a copied total id range") + print(f"CAUGHT [total-range restatement]: {caught[0]}") + fired += 1 + finally: + TODO.write_bytes(todo_bytes) + if TODO.read_bytes() != todo_bytes: + raise SystemExit("FAIL: selftest did not restore TODO.md byte-for-byte (range defect)") + print(f"SELFTEST: reference gate fires on all {fired} injected defects") + + +def main() -> None: + import sys + + if "--selftest" in sys.argv: + selftest() + return + report(check()) + + +if __name__ == "__main__": + main() diff --git a/scripts/check_doc_tables.py b/scripts/check_doc_tables.py new file mode 100644 index 000000000..93e08c409 --- /dev/null +++ b/scripts/check_doc_tables.py @@ -0,0 +1,389 @@ +"""Markdown table ruler for the docs that carry numbered decisions. + +Groups every CONTIGUOUS block of `|` lines as one table (a section may legally +hold several tables, which the first version of this script mistook for a +broken table). Per table it proves: + + - row 1 is a header and row 2 is a separator, otherwise the block is a + fragment split off a real table by a blank line + - every row closes with a pipe + - every row has exactly as many cells as that table's own header + - no table spills past its `## ` section + - decision ids D1..Dn are contiguous, match the range the heading claims, and + (in TODO.md only) appear exactly once; gap ids likewise + +A second, different duty lives here too: docs/migration/README.md's 体量 table is a +COPY of derived facts (line counts, KB) - hand-copied, it goes stale on every edit +(it did, twice: changelogs #30). So the ruler verifies each row against the real file +and `--refresh-sizes` rewrites the column instead of a human typing numbers again. +Sizes are counted on CR-stripped bytes, so one commit measures the same number on a +CRLF and an LF checkout (changelogs #45: the table was refreshed on a CRLF worktree +and went stale when those files were rewritten as LF). + +Usage: + python -X utf8 scripts/check_doc_tables.py [file ...] + python -X utf8 scripts/check_doc_tables.py --selftest + python -X utf8 scripts/check_doc_tables.py --refresh-sizes +""" + +import re +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEFAULTS = [ + ROOT / "AGENTS.md", + ROOT / "docs" / "TODO.md", + ROOT / "docs" / "CHANGELOGS.md", + ROOT / "docs" / "migration" / "README.md", + ROOT / "docs" / "migration" / "registry.md", + ROOT / "docs" / "migration" / "glossary.md", + ROOT / "docs" / "migration" / "upstream-plugin-forms.md", + ROOT / "docs" / "migration" / "plugin-contract-rules.md", + ROOT / "docs" / "migration" / "ts-fullstack-spec.md", +] +SEPARATOR = re.compile(r"^\|[\s:|-]+\|?\s*$") + + +def blocks(lines): + """Yield (start_index, rows) for each contiguous run of table lines.""" + start = None + rows = [] + for i, ln in enumerate(lines): + if ln.startswith("|"): + if start is None: + start = i + rows.append((i, ln)) + elif start is not None: + yield start, rows + start, rows = None, [] + if start is not None: + yield start, rows + + +def heading_of(lines, index): + heads = [ln for ln in lines[:index] if ln.startswith("## ")] + return heads[-1].strip() if heads else "(no ## heading)" + + +def next_heading_at(lines, end_row): + for i in range(end_row + 1, len(lines)): + if lines[i].startswith("## "): + return i + return len(lines) + + +def check(text, label, unique_ids=False): + lines = text.split("\n") + problems = [] + tables = 0 + all_decision_ids = [] + for start, rows in blocks(lines): + if len(rows) < 2: + problems.append( + f"{label}: table-like block at line {start + 1} has no separator row " + "(a blank line probably split a real table)" + ) + continue + tables += 1 + header = rows[0][1] + expected = len(header.strip().strip("|").split("|")) + if not SEPARATOR.match(rows[1][1].strip()): + problems.append(f"{label}: block at line {start + 1} row 2 is not a separator row") + if not header.rstrip().endswith("|"): + problems.append(f"{label}: header at line {start + 1} does not close with a pipe") + end_row = rows[-1][0] + for pos, ln in rows: + if not ln.rstrip().endswith("|"): + problems.append(f"{label}: line {pos + 1} does not close with a pipe") + cells = ln.strip().strip("|").split("|") + if len(cells) != expected: + problems.append( + f"{label}: line {pos + 1} has {len(cells)} cells, " + f"header at line {start + 1} declares {expected}" + ) + heading = heading_of(lines, start) + if next_heading_at(lines, start) <= end_row: + problems.append(f"{label}: block starting line {start + 1} spills past its section") + ids = [ln.strip().strip("|").split("|")[0].strip() for _, ln in rows] + decisions = [i for i in ids if re.fullmatch(r"D\d+", i)] + gaps = [i for i in ids if re.fullmatch(r"G\d+", i)] + all_decision_ids += decisions + if decisions: + nums = sorted(int(i[1:]) for i in decisions) + claimed = re.search(r"D1-D(\d+)", heading) + if claimed and int(claimed.group(1)) != max(nums): + problems.append( + f"{label} [{heading}]: heading claims D1-D{claimed.group(1)} " + f"but rows reach D{max(nums)}" + ) + if gaps: + print( + f" [{heading or label}] {len(gaps)} gap rows: {sorted(int(g[1:]) for g in gaps)}" + ) + if unique_ids: + for pool, kind in ((decisions, "decision"), (gaps, "gap")): + dupes = sorted({i for i in pool if pool.count(i) > 1}) + if dupes: + problems.append(f"{label}: duplicate {kind} ids in one table: {dupes}") + print(f"{label}: {tables} table block(s) checked") + if tables == 0: + problems.append(f"{label}: no tables found at all - ruler proved nothing") + return problems + + +def report(problems, label): + if problems: + for p in problems: + print("PROBLEM:", p) + raise SystemExit(f"FAIL: {label}") + print(f"OK: {label}") + + +def expect_caught(problems, label): + global FIRED + if problems: + FIRED += 1 + print(f"CAUGHT [{label}]: {problems[0]}") + else: + raise SystemExit(f"FAIL: ruler is blind to [{label}]") + + +FIRED = 0 + + +def inject(text, anchor, replacement, probe): + out = text.replace(anchor, replacement, 1) + if out == text: + raise SystemExit(f"FAIL: anchor for injection ({probe}) not found - the text moved") + return out + + +def read(path): + return Path(path).read_text(encoding="utf-8") + + +def unique_flag_for(path): + return Path(path).name == "TODO.md" + + +# --- the derived-fact table in docs/migration/README.md (体量/实测) ------------- + +DOC_README = ROOT / "docs" / "migration" / "README.md" +SIZE_HEADER = "| 文档 | 体量(实测)" +SIZE_CELL = re.compile(r"^(\d+) 行 / ([\d.]+) KB$") + + +def size_rows(lines): + """Row indexes of the 体量 table, or None when the table is not there.""" + head = next((i for i, ln in enumerate(lines) if ln.startswith(SIZE_HEADER)), None) + if head is None: + return None + rows = [] + i = head + 2 + while i < len(lines) and lines[i].startswith("|"): + rows.append(i) + i += 1 + return rows + + +def row_targets(cell, base): + """Resolve a row's doc cell to the file(s) its size must come from. + + Convention (docs/migration/README.md §三 note): the first column is written + RELATIVE TO THE REPO ROOT. The doc's own directory is kept as a fallback so a + bare sibling filename still resolves, but an unresolvable cell is a failure - + silently skipping it is how a copied number goes unwatched again. + """ + if "inventory/01" in cell: + return sorted((ROOT / "docs" / "migration" / "inventory").glob("*.md")) or None + text = cell.replace("`", "").strip() + for cand in ((ROOT / text).resolve(), (base / text).resolve()): + if cand.is_file(): + return [cand] + return None + + +def measured(paths): + total_lines = total_bytes = 0 + for p in paths: + # Worktree bytes, not blob bytes: with core.autocrlf the same content lands as + # CRLF or LF depending on what last wrote it, so un-normalized sizes drift per OS. + raw = p.read_bytes().replace(b"\r\n", b"\n") + total_lines += raw.count(b"\n") + total_bytes += len(raw) + return total_lines, f"{total_bytes / 1024:.1f}" + + +def check_sizes(text, label): + lines = text.split("\n") + base = Path(label).parent + rows = size_rows(lines) + if rows is None: + return [f"{label}: the 体量 table is gone - the size gate would prove nothing"], 0 + problems = [] + for idx in rows: + cells = [c.strip() for c in lines[idx].strip().strip("|").split("|")] + targets = row_targets(cells[0], base) + if targets is None: + problems.append(f"{label}: 体量 row line {idx + 1} ({cells[0]}) resolves to no file") + continue + want_lines, want_kb = measured(targets) + m = SIZE_CELL.match(cells[1]) + if not m: + problems.append( + f"{label}: 体量 row line {idx + 1}: cell '{cells[1]}' is not 'N 行 / X KB'" + ) + continue + if int(m.group(1)) != want_lines or m.group(2) != want_kb: + names = "+".join(p.name for p in targets) + problems.append( + f"{label}: 体量 row {names} says {m.group(1)} 行 / {m.group(2)} KB, " + f"measured {want_lines} 行 / {want_kb} KB" + ) + print(f"{label}: {len(rows)} size row(s) checked") + return problems, len(rows) + + +def refresh_sizes(): + path = DOC_README + original = path.read_bytes() + text = original.decode("utf-8") + lines = text.split("\n") + rows = size_rows(lines) + if rows is None: + raise SystemExit(f"FAIL: {path} has no 体量 table to refresh") + base = path.parent + changed = 0 + for idx in rows: + cells = [c.strip() for c in lines[idx].strip().strip("|").split("|")] + targets = row_targets(cells[0], base) + if targets is None: + raise SystemExit(f"FAIL: 体量 row line {idx + 1} ({cells[0]}) resolves to no file") + want_lines, want_kb = measured(targets) + cells[1] = f"{want_lines} 行 / {want_kb} KB" + rebuilt = "| " + " | ".join(cells) + " |" + if rebuilt != lines[idx]: + lines[idx] = rebuilt + changed += 1 + path.write_bytes("\n".join(lines).encode("utf-8")) + print(f"OK: sizes refreshed - {changed} of {len(rows)} row(s) rewritten") + + +def selftest(): + # (i) invariant, not an injected defect: identical content must measure identically + # whatever line endings the checkout happens to have written it with. It runs + # FIRST because every check below reads the real docs, and an un-normalized + # measurement makes those go red and exit before this one is ever reached. + # The body is 200 lines on purpose - KB is printed to one decimal (102.4 bytes a + # step), so a short sample measures the same either way (changelogs #45). + body = b"line\n" * 200 + with tempfile.TemporaryDirectory() as tmp: + lf = Path(tmp) / "lf.md" + crlf = Path(tmp) / "crlf.md" + lf.write_bytes(body) + crlf.write_bytes(body.replace(b"\n", b"\r\n")) + if measured([lf]) != measured([crlf]): + raise SystemExit( + f"FAIL: 体量 tracks the checkout's line endings " + f"({measured([lf])} vs {measured([crlf])})" + ) + if measured([lf]) != (200, "1.0"): + raise SystemExit(f"FAIL: normalized measurement moved: {measured([lf])}") + print("OK [i]: a CRLF checkout measures the same as an LF one") + for path in DEFAULTS: + report(check(read(path), path, unique_ids=unique_flag_for(path)), path) + todo_path = next(p for p in DEFAULTS if unique_flag_for(p)) + good = read(todo_path) + uniq = unique_flag_for(todo_path) + expect_caught( + check(inject(good, "2026-10-10 用户裁定", "2026-10-10|裁定", "a"), "inject a", uniq), + "bare pipe inside a cell", + ) + # Anchor on whatever gap row currently exists; when TODO carries no open gap + # rows at all (2026-10-14 clearing), fall back to a decision row - the probe + # is "a blank line splitting a table", which any table row serves. + gap_row = next((ln for ln in good.split("\n") if re.match(r"^\| G\d+ \|", ln)), None) + if gap_row is None: + gap_row = next((ln for ln in good.split("\n") if re.match(r"^\| D\d+ \|", ln)), None) + if gap_row is None: + raise SystemExit("FAIL: no gap or decision row to inject into - the tables moved") + expect_caught( + check(inject(good, gap_row, "\n" + gap_row, "b"), "inject b", uniq), + "blank line splitting a table", + ) + lines = good.split("\n") + hit = next((i for i, ln in enumerate(lines) if ln.startswith("| D16 |")), None) + if hit is None: + raise SystemExit("FAIL: anchor for injection (c) not found") + lines[hit] = lines[hit].rstrip().rstrip("|") + expect_caught(check("\n".join(lines), "inject c", uniq), "row not closing with a pipe") + expect_caught( + check(inject(good, "| D15 |", "| D14 |", "d"), "inject d", uniq), + "duplicate decision id", + ) + # Anchor on the decision-range heading itself: read the claimed max and + # under-claim it by 4. A hardcoded range rots the moment a decision is added + # (it did - "D1-D17" survived the D18 registration and this selftest failed + # loudly on a clean tree, which is the point of it, but the anchor must move + # with the heading instead of pinning a stale number). + dhead = next((ln for ln in good.split("\n") if re.match(r"^## .*D1-D(\d+)", ln)), None) + if dhead is None: + raise SystemExit("FAIL: anchor for injection (e) not found - no D-range heading") + claimed_max = int(re.search(r"D1-D(\d+)", dhead).group(1)) + under = dhead.replace(f"D1-D{claimed_max}", f"D1-D{claimed_max - 4}") + expect_caught( + check(inject(good, dhead, under, "e"), "inject e", uniq), + "heading under-claiming the decision range", + ) + # (f) a separator row must exist, so a header-only fragment is reported + expect_caught( + check(inject(good, "| D1 |", "| DX |\n", "f"), "inject f", uniq), + "headerless or separatorless fragment", + ) + # (g) a size row that no longer matches the real file must be reported, and the + # anchor is taken from the table itself so the selftest cannot rot by date + readme_text = read(DOC_README) + rlines = readme_text.split("\n") + rows = size_rows(rlines) + if not rows: + raise SystemExit("FAIL: no 体量 rows to inject into - the size table moved") + report(check_sizes(readme_text, DOC_README)[0], f"{DOC_README} (size table, clean)") + first = rows[0] + cells = [c.strip() for c in rlines[first].strip().strip("|").split("|")] + stale = "\n".join( + ln.replace(cells[1], "999 行 / 1.0 KB", 1) if i == first else ln + for i, ln in enumerate(rlines) + ) + expect_caught( + check_sizes(stale, DOC_README)[0], + "体量 copy out of date with the real file", + ) + # (h) a missing size table must not read as "nothing to check" + expect_caught( + check_sizes("\n".join(ln for ln in rlines if not ln.startswith(SIZE_HEADER)), DOC_README)[ + 0 + ], + "体量 table missing entirely", + ) + print(f"SELFTEST: ruler fires on all {FIRED} injected defects") + + +def main(): + if "--selftest" in sys.argv: + selftest() + return + if "--refresh-sizes" in sys.argv: + refresh_sizes() + return + paths = [Path(a) for a in sys.argv[1:] if not a.startswith("--")] or DEFAULTS + for path in paths: + report(check(read(path), path, unique_ids=unique_flag_for(path)), path) + if DOC_README in paths: + problems, _ = check_sizes(read(DOC_README), DOC_README) + report(problems, f"{DOC_README} (size table)") + + +if __name__ == "__main__": + main() diff --git a/scripts/install_deps.ps1 b/scripts/install_deps.ps1 deleted file mode 100644 index b6fc71681..000000000 --- a/scripts/install_deps.ps1 +++ /dev/null @@ -1,520 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -<# -.SYNOPSIS - Artemis Smart Multi-Platform Dependency Installer for Windows (PowerShell). -#> - -[CmdletBinding()] -param( - [switch]$Launch = $false, - [switch]$Open = $false -) - -$ErrorActionPreference = "Continue" -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 - -# Ensure modern TLS protocols are enabled for downloads (GitHub releases, nodejs.org, Google repositories) -try { - [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12 -} catch {} - -Write-Host "======================================================" -ForegroundColor Cyan -Write-Host " Artemis - Windows Smart Installer " -ForegroundColor Cyan -Write-Host "======================================================" -ForegroundColor Cyan -Write-Host "" - -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$RootDir = Split-Path -Parent $ScriptDir -Set-Location $RootDir - -function Update-EnvironmentPath { - $standardDirs = @( - "$env:LOCALAPPDATA\Microsoft\WinGet\Links", - "$env:LOCALAPPDATA\Programs\node", - "$env:USERPROFILE\.local\share\node", - "$env:LOCALAPPDATA\Programs\scrcpy", - "$env:USERPROFILE\.local\share\scrcpy", - "$env:LOCALAPPDATA\Programs\platform-tools", - "$env:USERPROFILE\.local\share\platform-tools", - "$env:LOCALAPPDATA\Programs\uv", - "$env:LOCALAPPDATA\uv", - "$env:APPDATA\uv", - "$env:USERPROFILE\.cargo\bin", - "$env:USERPROFILE\.local\bin", - "$env:USERPROFILE\scoop\shims", - "C:\ProgramData\chocolatey\bin", - "$env:LOCALAPPDATA\Android\Sdk\platform-tools", - "$env:LOCALAPPDATA\Android\android-sdk\platform-tools", - "$env:ProgramFiles\Android\platform-tools", - "${env:ProgramFiles(x86)}\Android\android-sdk\platform-tools", - "$env:ProgramFiles\nodejs", - "${env:ProgramFiles(x86)}\nodejs", - "$env:APPDATA\npm" - ) - if ($env:CARGO_HOME) { $standardDirs = @("$env:CARGO_HOME\bin") + $standardDirs } - if ($env:ANDROID_HOME) { $standardDirs += "$env:ANDROID_HOME\platform-tools" } - if ($env:ANDROID_SDK_ROOT) { $standardDirs += "$env:ANDROID_SDK_ROOT\platform-tools" } - if ($env:NVM_SYMLINK) { $standardDirs = @($env:NVM_SYMLINK) + $standardDirs } - if ($env:NVM_HOME) { $standardDirs = @($env:NVM_HOME) + $standardDirs } - - $regPath = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") - $currentPaths = ($env:PATH -split ";") + ($regPath -split ";") + $standardDirs | Where-Object { [string]::IsNullOrWhiteSpace($_) -eq $false -and (Test-Path $_) } | Select-Object -Unique - $env:PATH = $currentPaths -join ";" - - # Ensure user-space portable tools take absolute priority over system-level outdated versions - $portableNode = "$env:LOCALAPPDATA\Programs\node" - if (Test-Path "$portableNode\node.exe") { - $env:PATH = "$portableNode;$env:PATH" - } - $portableScrcpy = "$env:LOCALAPPDATA\Programs\scrcpy" - if (Test-Path "$portableScrcpy\scrcpy.exe") { - $env:PATH = "$portableScrcpy;$env:PATH" - } - $portablePt = "$env:LOCALAPPDATA\Programs\platform-tools" - if (Test-Path "$portablePt\adb.exe") { - $env:PATH = "$portablePt;$env:PATH" - } -} - -function Start-LocalAdbServer { - if (Test-CommandExists "adb") { - try { - $origSocket = $env:ADB_SERVER_SOCKET - Remove-Item Env:\ADB_SERVER_SOCKET -ErrorAction SilentlyContinue - & adb start-server 2>$null | Out-Null - if ($origSocket) { $env:ADB_SERVER_SOCKET = $origSocket } - } catch {} - } -} - -function Test-CommandExists { - param([string]$Command) - $res = Get-Command $Command -ErrorAction SilentlyContinue - return ($null -ne $res) -} - -function Invoke-DownloadFile { - param( - [Parameter(Mandatory=$true)][string]$Uri, - [Parameter(Mandatory=$true)][string]$OutFile, - [int]$TimeoutSec = 45 - ) - if (Test-Path $OutFile) { - Remove-Item -Path $OutFile -Force -ErrorAction SilentlyContinue - } - - # 1. Try Windows built-in curl.exe first (available in Win 10 1803+ and Win 11) - if (Test-CommandExists "curl.exe") { - try { - & curl.exe -f -sSL --connect-timeout 5 --max-time $TimeoutSec "$Uri" -o "$OutFile" 2>$null - if ($LASTEXITCODE -eq 0 -and (Test-Path $OutFile) -and ((Get-Item $OutFile).Length -gt 0)) { - return $true - } - } catch {} - if (Test-Path $OutFile) { - Remove-Item -Path $OutFile -Force -ErrorAction SilentlyContinue - } - } - - # 2. Fallback to Invoke-WebRequest (suppressing progress bar to eliminate PowerShell buffer hangs) - try { - $prevProgress = $ProgressPreference - $ProgressPreference = 'SilentlyContinue' - Invoke-WebRequest -Uri $Uri -OutFile $OutFile -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop - $ProgressPreference = $prevProgress - if ((Test-Path $OutFile) -and ((Get-Item $OutFile).Length -gt 0)) { - return $true - } - } catch { - $ProgressPreference = $prevProgress - } - - if (Test-Path $OutFile) { - Remove-Item -Path $OutFile -Force -ErrorAction SilentlyContinue - } - return $false -} - -function Install-PortablePlatformTools { - $ptDir = "$env:LOCALAPPDATA\Programs\platform-tools" - if (Test-Path "$ptDir\adb.exe") { - $env:PATH = "$ptDir;$env:PATH" - return $true - } - Write-Host " [INFO] Installing Android platform-tools (adb) in user space..." -ForegroundColor Cyan - try { - $zipPath = "$env:TEMP\platform-tools-windows.zip" - if (Invoke-DownloadFile -Uri "https://dl.google.com/android/repository/platform-tools-latest-windows.zip" -OutFile $zipPath -TimeoutSec 60) { - New-Item -ItemType Directory -Path "$env:LOCALAPPDATA\Programs" -Force | Out-Null - Expand-Archive -Path $zipPath -DestinationPath "$env:LOCALAPPDATA\Programs" -Force - Remove-Item $zipPath -Force -ErrorAction SilentlyContinue - if (Test-Path "$ptDir\adb.exe") { - $env:PATH = "$ptDir;$env:PATH" - Write-Host " [OK] adb installed in user space." -ForegroundColor Green - return $true - } - } - } catch { - Write-Host " [WARN] Failed to install portable platform-tools: $_" -ForegroundColor DarkYellow - } - return $false -} - -function Install-PortableScrcpy { - $scrcpyDir = "$env:LOCALAPPDATA\Programs\scrcpy" - if (Test-Path "$scrcpyDir\scrcpy.exe") { - $env:PATH = "$scrcpyDir;$env:PATH" - return $true - } - # Check if nested from an earlier run and heal - $nested = Get-ChildItem -Path $scrcpyDir -Directory -Filter "scrcpy*" -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($nested -and (Test-Path "$($nested.FullName)\scrcpy.exe")) { - Copy-Item -Path "$($nested.FullName)\*" -Destination $scrcpyDir -Recurse -Force - Remove-Item -Path $nested.FullName -Recurse -Force -ErrorAction SilentlyContinue - if (Test-Path "$scrcpyDir\scrcpy.exe") { - $env:PATH = "$scrcpyDir;$env:PATH" - return $true - } - } - - Write-Host " [INFO] Installing portable scrcpy in user space..." -ForegroundColor Cyan - try { - $zipPath = "$env:TEMP\scrcpy-win64.zip" - if (Invoke-DownloadFile -Uri "https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-win64-v4.1.zip" -OutFile $zipPath -TimeoutSec 60) { - $extractDir = "$env:TEMP\scrcpy_extract" - if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue } - New-Item -ItemType Directory -Path $extractDir -Force | Out-Null - Expand-Archive -Path $zipPath -DestinationPath $extractDir -Force - $extractedFolder = Get-ChildItem -Path $extractDir -Directory | Select-Object -First 1 - if ($extractedFolder) { - if (-not (Test-Path $scrcpyDir)) { - New-Item -ItemType Directory -Path $scrcpyDir -Force | Out-Null - } - Copy-Item -Path "$($extractedFolder.FullName)\*" -Destination $scrcpyDir -Recurse -Force - } - Remove-Item $zipPath -Force -ErrorAction SilentlyContinue - Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue - if (Test-Path "$scrcpyDir\scrcpy.exe") { - $env:PATH = "$scrcpyDir;$env:PATH" - Write-Host " [OK] Portable scrcpy installed in user space." -ForegroundColor Green - return $true - } - } - } catch { - Write-Host " [WARN] Failed to install portable scrcpy: $_" -ForegroundColor DarkYellow - } - return $false -} - -function Test-NodeCompatible { - if (-not (Test-CommandExists "node") -or (-not (Test-CommandExists "npm") -and -not (Test-CommandExists "npm.cmd"))) { return $false } - try { - $verRaw = (& node -v 2>$null) - if (-not $verRaw) { return $false } - $verStr = $verRaw.ToString().Trim().TrimStart('v') - $parts = $verStr.Split('.') - if ($parts.Count -ge 2) { - $major = [int]$parts[0] - $minor = [int]$parts[1] - if ($major -ge 26) { return $true } - if ($major -eq 24 -and $minor -ge 15) { return $true } - if ($major -eq 22 -and $minor -ge 22) { return $true } - } - } catch { - return $false - } - return $false -} - -function Install-PortableNode { - $nodeDir = "$env:LOCALAPPDATA\Programs\node" - if (Test-Path "$nodeDir\node.exe") { - $env:PATH = "$nodeDir;$env:PATH" - if (Test-NodeCompatible) { - return $true - } - } - # Check if nested from an earlier run and heal - $nested = Get-ChildItem -Path $nodeDir -Directory -Filter "node-*" -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($nested -and (Test-Path "$($nested.FullName)\node.exe")) { - Copy-Item -Path "$($nested.FullName)\*" -Destination $nodeDir -Recurse -Force - Remove-Item -Path $nested.FullName -Recurse -Force -ErrorAction SilentlyContinue - if (Test-Path "$nodeDir\node.exe") { - $env:PATH = "$nodeDir;$env:PATH" - if (Test-NodeCompatible) { - return $true - } - } - } - - Write-Host " [INFO] Installing portable Node.js LTS (v22.23.2) in user space..." -ForegroundColor Cyan - try { - $nodeVer = "v22.23.2" - $arch = if ([System.Environment]::Is64BitOperatingSystem) { - if ($env:PROCESSOR_ARCHITECTURE -match "ARM64") { "arm64" } else { "x64" } - } else { "x64" } - $zipPath = "$env:TEMP\node-$nodeVer-win-$arch.zip" - if (Invoke-DownloadFile -Uri "https://nodejs.org/dist/$nodeVer/node-$nodeVer-win-$arch.zip" -OutFile $zipPath -TimeoutSec 60) { - $extractDir = "$env:TEMP\node_extract" - if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue } - New-Item -ItemType Directory -Path $extractDir -Force | Out-Null - Expand-Archive -Path $zipPath -DestinationPath $extractDir -Force - $extractedFolder = Get-ChildItem -Path $extractDir -Directory | Select-Object -First 1 - if ($extractedFolder) { - if (-not (Test-Path $nodeDir)) { - New-Item -ItemType Directory -Path $nodeDir -Force | Out-Null - } - Copy-Item -Path "$($extractedFolder.FullName)\*" -Destination $nodeDir -Recurse -Force - } - Remove-Item $zipPath -Force -ErrorAction SilentlyContinue - Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue - if (Test-Path "$nodeDir\node.exe") { - $env:PATH = "$nodeDir;$env:PATH" - Write-Host " [OK] Portable Node.js $nodeVer installed in user space." -ForegroundColor Green - return $true - } - } - } catch { - Write-Host " [WARN] Failed to install portable Node.js: $_" -ForegroundColor DarkYellow - } - return $false -} - -# 0. Initial PATH refresh -Update-EnvironmentPath - -# 1. Check System Dependencies (ADB, FFmpeg, scrcpy) -Write-Host "1. Checking System Toolchains (ADB, FFmpeg, scrcpy)..." -ForegroundColor Yellow -$missingTools = @() - -if (-not (Test-CommandExists "adb")) { $missingTools += "adb" } -if (-not (Test-CommandExists "ffmpeg")) { $missingTools += "ffmpeg" } -if (-not (Test-CommandExists "scrcpy")) { $missingTools += "scrcpy" } - -if ($missingTools.Count -eq 0) { - Write-Host " [OK] All system toolchains are already installed (ADB, FFmpeg, scrcpy)." -ForegroundColor Green - Start-LocalAdbServer -} else { - Write-Host " ! Missing toolchains: $($missingTools -join ', ')" -ForegroundColor DarkYellow - $useWinGet = $false - if (Test-CommandExists "winget") { - if ([Console]::IsInputRedirected -eq $false) { - $ans = Read-Host " Install system packages via WinGet? [Y/n]" - if ($ans -eq "" -or $ans -match "^[Yy]") { - $useWinGet = $true - } - } else { - $useWinGet = $true - } - } - - if ($useWinGet) { - Write-Host " Detected WinGet. Installing packages..." -ForegroundColor Cyan - if ($missingTools -contains "adb") { - Write-Host " [INFO] Installing Google.PlatformTools (ADB)..." -ForegroundColor Cyan - winget install --id Google.PlatformTools -e --accept-source-agreements --accept-package-agreements --silent 2>$null | Out-Null - } - if ($missingTools -contains "ffmpeg") { - Write-Host " [INFO] Installing Gyan.FFmpeg..." -ForegroundColor Cyan - winget install --id Gyan.FFmpeg -e --accept-source-agreements --accept-package-agreements --silent 2>$null | Out-Null - } - if ($missingTools -contains "scrcpy") { - Write-Host " [INFO] Installing Genymobile.scrcpy..." -ForegroundColor Cyan - winget install --id Genymobile.scrcpy -e --accept-source-agreements --accept-package-agreements --silent 2>$null | Out-Null - } - Update-EnvironmentPath - } elseif (Test-CommandExists "choco") { - Write-Host " Detected Chocolatey. Installing packages..." -ForegroundColor Cyan - choco install ($missingTools -join " ") -y | Out-Null - Update-EnvironmentPath - } elseif (Test-CommandExists "scoop") { - Write-Host " Detected Scoop. Installing packages..." -ForegroundColor Cyan - scoop install ($missingTools -join " ") | Out-Null - Update-EnvironmentPath - } - - # Zero-admin user-space fallback for adb and scrcpy if still missing - if (-not (Test-CommandExists "adb")) { - Install-PortablePlatformTools - } - if (-not (Test-CommandExists "scrcpy")) { - Install-PortableScrcpy - } - - # Ensure local ADB daemon is warm & listening - Start-LocalAdbServer -} - -# 2. Check and Install uv (Fast Python Package Manager) -Write-Host "`n2. Checking Python Package Manager (uv)..." -ForegroundColor Yellow -if (-not (Test-CommandExists "uv")) { - Write-Host " uv not found. Installing Astral uv..." -ForegroundColor Cyan - powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" - Update-EnvironmentPath -} - -if (Test-CommandExists "uv") { - $uvVer = uv --version - Write-Host " [OK] uv is ready ($uvVer)" -ForegroundColor Green -} else { - Write-Host " [FAIL] Failed to detect uv in PATH." -ForegroundColor Red - Exit 1 -} - -# 3. Setup Python Runtime & Sync Dependencies -Write-Host "`n3. Configuring Python Runtime & Syncing Dependencies..." -ForegroundColor Yellow -Write-Host " [INFO] Running uv sync (auto-provisioning Python >=3.12 & dependencies)..." -ForegroundColor Cyan -uv sync -Write-Host " [OK] Dependencies synced successfully." -ForegroundColor Green - -# 4. Check .env Configuration -Write-Host "`n4. Checking Environment Configuration (.env)..." -ForegroundColor Yellow -if (-not (Test-Path ".env")) { - if (Test-Path ".env.example") { - Copy-Item ".env.example" ".env" - Write-Host " [OK] Created .env template from .env.example." -ForegroundColor Green - } else { - New-Item -ItemType File -Path ".env" | Out-Null - Write-Host " [OK] Created empty .env file." -ForegroundColor Green - } -} else { - Write-Host " [OK] .env configuration file exists." -ForegroundColor Green -} - -# 5. Check and Build Showcase UI (Angular) -Write-Host "`n5. Checking Showcase UI Build (Angular)..." -ForegroundColor Yellow -$ShowcaseIndex = "$RootDir\apps\showcase_ui\dist\frontend\browser\index.html" -$ShowcaseIndexAlt1 = "$RootDir\apps\showcase_ui\dist\browser\index.html" -$ShowcaseIndexAlt2 = "$RootDir\apps\showcase_ui\dist\index.html" -if ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Path $ShowcaseIndexAlt1)) -and (-not (Test-Path $ShowcaseIndexAlt2))) { - if (-not (Test-NodeCompatible)) { - if (Test-CommandExists "node") { - $curVer = (& node -v 2>$null) - Write-Host " [WARN] Detected Node.js $curVer, but Angular CLI requires Node.js >= v22.22.0." -ForegroundColor Yellow - } else { - Write-Host " [WARN] Node.js/npm not found (required for Showcase UI)." -ForegroundColor Cyan - } - - # 1. Try nvm if available on Windows - if (Test-CommandExists "nvm") { - Write-Host " [INFO] Installing Node.js 22 LTS via nvm..." -ForegroundColor Cyan - & nvm install 22.23.2 | Out-Null - & nvm use 22.23.2 | Out-Null - Update-EnvironmentPath - } - - # 2. Try WinGet - if (-not (Test-NodeCompatible)) { - $useWinGetNode = $false - if (Test-CommandExists "winget") { - if ([Console]::IsInputRedirected -eq $false) { - $ans = Read-Host " Install/Upgrade Node.js via WinGet (may require Administrator)? [Y/n]" - if ($ans -eq "" -or $ans -match "^[Yy]") { - $useWinGetNode = $true - } - } else { - $useWinGetNode = $true - } - } - - if ($useWinGetNode) { - Write-Host " [INFO] Installing/Upgrading Node.js LTS via WinGet..." -ForegroundColor Cyan - winget install --id OpenJS.NodeJS.LTS -e --accept-source-agreements --accept-package-agreements --silent 2>$null | Out-Null - Update-EnvironmentPath - } - } - - # 3. Zero-admin user-space fallback for Node.js if still not compatible - if (-not (Test-NodeCompatible)) { - Install-PortableNode - } - } - - if (Test-NodeCompatible) { - $nodeVer = (& node -v 2>$null) - Write-Host " [OK] Node.js $nodeVer is ready." -ForegroundColor Green - Write-Host " [INFO] Compiling Angular Showcase UI..." -ForegroundColor Cyan - Push-Location "$RootDir\apps\showcase_ui" - try { - $npmExec = if (Test-CommandExists "npm.cmd") { "npm.cmd" } else { "npm" } - Write-Host " [INFO] Installing frontend npm dependencies..." -ForegroundColor Cyan - & $npmExec install --no-audit --no-fund --loglevel=error - if ($LASTEXITCODE -ne 0) { - Write-Host " [WARN] npm install returned exit code $LASTEXITCODE. Trying silent install..." -ForegroundColor DarkYellow - & $npmExec install --silent - } - $cliNodeVersion = "$RootDir\apps\showcase_ui\node_modules\@angular\cli\src\utilities\node-version.js" - if (Test-Path $cliNodeVersion) { - (Get-Content $cliNodeVersion) -replace '22\.22\.3', '22.22.0' | Set-Content $cliNodeVersion - } - Write-Host " [INFO] Building Angular frontend application..." -ForegroundColor Cyan - & $npmExec run build - if (Test-Path $ShowcaseIndex) { - Write-Host " [OK] Showcase UI compiled successfully." -ForegroundColor Green - } elseif (Test-Path $ShowcaseIndexAlt1) { - Write-Host " [OK] Showcase UI compiled successfully." -ForegroundColor Green - } else { - Write-Host " [WARN] Showcase UI build finished but index.html was not found in expected dist directory." -ForegroundColor DarkYellow - } - } catch { - Write-Host " [WARN] Failed to build Showcase UI: $_" -ForegroundColor DarkYellow - } finally { - Pop-Location - } - } else { - Write-Host " [WARN] Could not configure compatible Node.js (>= 22.22.0). Showcase UI will show fallback notice on launch." -ForegroundColor DarkYellow - } -} else { - Write-Host " [OK] Showcase UI build already exists." -ForegroundColor Green -} - -# 6. Toolchain Readiness Summary -Write-Host "`n6. Toolchain Readiness Summary:" -ForegroundColor Yellow -$tools = @("adb", "ffmpeg", "scrcpy", "uv", "npm") -foreach ($t in $tools) { - $hasCmd = Test-CommandExists $t - if (-not $hasCmd -and $t -eq "npm") { - $hasCmd = Test-CommandExists "npm.cmd" - } - if ($hasCmd) { - $targetName = if ($t -eq "npm" -and -not (Test-CommandExists "npm") -and (Test-CommandExists "npm.cmd")) { "npm.cmd" } else { $t } - $p = (Get-Command $targetName).Source - if ($t -eq "npm" -and (Test-CommandExists "node")) { - $nVer = (& node -v 2>$null) - Write-Host " [OK] $t ($nVer) -> $p" -ForegroundColor Green - } else { - Write-Host " [OK] $t -> $p" -ForegroundColor Green - } - } else { - Write-Host " [--] $t -> Not found in PATH (Fallbacks active)" -ForegroundColor DarkYellow - } -} - -Write-Host "" -Write-Host "======================================================" -ForegroundColor Cyan -Write-Host " Artemis Environment Ready! " -ForegroundColor Green -Write-Host "======================================================" -ForegroundColor Cyan - -if ($Launch -or $Open) { - Start-LocalAdbServer - Write-Host "[INFO] Launching Showcase UI..." -ForegroundColor Green - uv run python -m artemis ui --open -} else { - Write-Host "To start the Showcase UI and interactive onboarding:" -ForegroundColor White - Write-Host " -> .\start.bat (or: uv run python -m artemis ui)" -ForegroundColor Cyan - Write-Host "" -} - diff --git a/scripts/install_deps.sh b/scripts/install_deps.sh deleted file mode 100755 index 553f6d8aa..000000000 --- a/scripts/install_deps.sh +++ /dev/null @@ -1,533 +0,0 @@ -#!/usr/bin/env bash -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -set -euo pipefail - -# Visual styling -BOLD='\033[1m' -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -CYAN='\033[0;36m' -DIM='\033[2m' -NC='\033[0m' - -AUTO_LAUNCH=false -for arg in "$@"; do - case "${arg}" in - --launch|-l|--open) - AUTO_LAUNCH=true - ;; - esac -done - -echo -e "${BOLD}${CYAN}======================================================${NC}" -echo -e "${BOLD}${CYAN} 🚀 Artemis - Smart Multi-Platform Installer ${NC}" -echo -e "${BOLD}${CYAN}======================================================${NC}" -echo "" - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" -cd "${ROOT_DIR}" - -# Normalize PATH with standard platform binary paths -STANDARD_PATHS=( - "/opt/homebrew/bin" - "/opt/homebrew/sbin" - "/usr/local/bin" - "/usr/local/sbin" - "${HOME}/.local/bin" - "${HOME}/.local/share/node/bin" - "${HOME}/.local/share/platform-tools" - "${HOME}/.local/share/scrcpy" - "${HOME}/.cargo/bin" - "${HOME}/Library/Android/sdk/platform-tools" - "${HOME}/Android/Sdk/platform-tools" -) -for p in "${STANDARD_PATHS[@]}"; do - if [ -d "${p}" ] && [[ ":${PATH}:" != *":${p}:"* ]]; then - export PATH="${p}:${PATH}" - fi -done - -OS_TYPE="$(uname -s)" -ARCH_TYPE="$(uname -m)" - -# Helper to check command availability -has_cmd() { - command -v "$1" >/dev/null 2>&1 -} - -# Sudo credential and choice state tracking -SUDO_AUTHENTICATED=false -SUDO_DECLINED=false - -# Helper to check, prompt for, or skip administrator (sudo) privileges gracefully -request_sudo() { - [ "$(id -u)" -eq 0 ] && return 0 - if ! has_cmd sudo; then - return 1 - fi - # If already authenticated or passwordless sudo works - if [ "${SUDO_AUTHENTICATED}" = true ] || sudo -n true >/dev/null 2>&1; then - SUDO_AUTHENTICATED=true - return 0 - fi - # If previously declined or failed in this session, don't nag again - if [ "${SUDO_DECLINED}" = true ]; then - return 1 - fi - # If interactive terminal, ask user whether to enter sudo password - if [ -t 0 ]; then - local action="${1:-install system packages}" - echo -e " ${CYAN}🔐 Administrator (sudo) privileges can be used to ${action}.${NC}" - local reply="" - read -r -p " Enter sudo password now? [Y/n]: " reply - reply=${reply:-Y} - if [[ "${reply}" =~ ^[Yy]$ ]]; then - if sudo -v; then - SUDO_AUTHENTICATED=true - echo -e " ${GREEN}✔ Sudo authenticated successfully.${NC}" - return 0 - else - echo -e " ${YELLOW}⚠ Sudo authentication failed or cancelled. Using user-space fallback.${NC}" - SUDO_DECLINED=true - return 1 - fi - else - echo -e " ${YELLOW}⏭️ Skipped sudo. Using user-space fallback.${NC}" - SUDO_DECLINED=true - return 1 - fi - fi - return 1 -} - -# Helper to check if Node.js & npm meet Angular CLI 22 requirement (>= 22.22.0, >= 24.15.0, or >= 26.0.0) -is_node_compatible() { - if ! has_cmd node || ! has_cmd npm; then - return 1 - fi - local node_ver - node_ver="$(node -v 2>/dev/null | tr -d 'v')" - [ -z "${node_ver}" ] && return 1 - local major minor - major="$(echo "${node_ver}" | cut -d. -f1)" - minor="$(echo "${node_ver}" | cut -d. -f2)" - if [ "${major}" -ge 26 ] 2>/dev/null; then - return 0 - elif [ "${major}" -ge 24 ] 2>/dev/null; then - [ "${minor}" -ge 15 ] 2>/dev/null && return 0 - elif [ "${major}" -ge 22 ] 2>/dev/null; then - [ "${minor}" -ge 22 ] 2>/dev/null && return 0 - fi - return 1 -} - -echo -e "${BOLD}1. Detecting Operating System & Environment...${NC}" -echo -e " Platform: ${BLUE}${OS_TYPE}${NC} (${ARCH_TYPE})" -echo -e " Root Dir: ${DIM}${ROOT_DIR}${NC}" - -# Function to install system toolchains (ADB, FFmpeg, scrcpy) -install_system_packages() { - echo -e "\n${BOLD}2. Checking System Toolchains (ADB, FFmpeg, scrcpy)...${NC}" - - local need_adb=false - local need_ffmpeg=false - local need_scrcpy=false - - if ! has_cmd adb; then - for candidate in \ - "${ANDROID_HOME:-}/platform-tools" \ - "${ANDROID_SDK_ROOT:-}/platform-tools" \ - "${HOME}/Library/Android/sdk/platform-tools" \ - "${HOME}/Android/Sdk/platform-tools" \ - "${HOME}/.local/share/platform-tools" \ - "/opt/homebrew/bin" \ - "/usr/local/bin"; do - if [ -n "${candidate}" ] && [ -x "${candidate}/adb" ]; then - export PATH="${candidate}:${PATH}" - break - fi - done - fi - - if ! has_cmd adb; then need_adb=true; fi - if ! has_cmd ffmpeg; then need_ffmpeg=true; fi - if ! has_cmd scrcpy; then need_scrcpy=true; fi - - if [ "${need_adb}" = false ] && [ "${need_ffmpeg}" = false ] && [ "${need_scrcpy}" = false ]; then - echo -e " ${GREEN}✓ All core system tools are already installed.${NC}" - if has_cmd adb; then - (unset ADB_SERVER_SOCKET; adb start-server >/dev/null 2>&1 || true) - fi - return 0 - fi - - echo -e " ${YELLOW}! Missing toolchains detected:${NC}" - [ "${need_adb}" = true ] && echo -e " • [ADB] Android Debug Bridge" - [ "${need_ffmpeg}" = true ] && echo -e " • [FFmpeg] Video encoding/trimming framework" - [ "${need_scrcpy}" = true ] && echo -e " • [scrcpy] Real-time Android screen mirroring" - - echo -e " Attempting automated installation..." - - if [ "${OS_TYPE}" = "Darwin" ]; then - # Ensure Homebrew environment variables to prevent hanging - export HOMEBREW_NO_AUTO_UPDATE=1 - export HOMEBREW_NO_INSTALL_CLEANUP=1 - export HOMEBREW_NO_ENV_HINTS=1 - - if ! has_cmd brew; then - if [ -x "/opt/homebrew/bin/brew" ]; then - eval "$(/opt/homebrew/bin/brew shellenv)" - elif [ -x "/usr/local/bin/brew" ]; then - eval "$(/usr/local/bin/brew shellenv)" - fi - fi - - if has_cmd brew; then - echo -e " ${CYAN}Detected Homebrew ($(brew --version | head -n1)). Installing missing components...${NC}" - if [ "${need_adb}" = true ]; then - echo -e " 📦 Installing ${BOLD}android-platform-tools${NC}..." - brew install --cask android-platform-tools || brew install android-platform-tools || true - fi - if [ "${need_ffmpeg}" = true ]; then - echo -e " 📦 Installing ${BOLD}ffmpeg${NC}..." - brew install ffmpeg || true - fi - if [ "${need_scrcpy}" = true ]; then - echo -e " 📦 Installing ${BOLD}scrcpy${NC}..." - brew install scrcpy || true - fi - else - echo -e " ${YELLOW}⚠ Homebrew not found. Please install Homebrew or install tools manually:${NC}" - echo -e " /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"" - echo -e " brew install --cask android-platform-tools && brew install ffmpeg scrcpy" - fi - - elif [ "${OS_TYPE}" = "Linux" ]; then - local PKGS=() - [ "${need_adb}" = true ] && PKGS+=("adb") - [ "${need_ffmpeg}" = true ] && PKGS+=("ffmpeg") - [ "${need_scrcpy}" = true ] && PKGS+=("scrcpy") - - if request_sudo "install missing system components (${PKGS[*]})"; then - local SUDO_PREFIX="" - if [ "$(id -u)" -ne 0 ]; then - SUDO_PREFIX="sudo" - fi - - if has_cmd apt-get; then - echo -e " ${CYAN}Detected Debian/Ubuntu (apt-get). Installing missing packages...${NC}" - DEBIAN_FRONTEND=noninteractive ${SUDO_PREFIX} apt-get -o DPkg::Lock::Timeout=3 -o Acquire::http::Timeout=3 -o Acquire::https::Timeout=3 install -y "${PKGS[@]}" 2>/dev/null || true - elif has_cmd dnf; then - echo -e " ${CYAN}Detected Fedora/RHEL (dnf). Installing packages...${NC}" - local DNF_PKGS=() - [ "${need_adb}" = true ] && DNF_PKGS+=("android-tools") - [ "${need_ffmpeg}" = true ] && DNF_PKGS+=("ffmpeg") - [ "${need_scrcpy}" = true ] && DNF_PKGS+=("scrcpy") - ${SUDO_PREFIX} dnf install -y "${DNF_PKGS[@]}" 2>/dev/null || true - elif has_cmd pacman; then - echo -e " ${CYAN}Detected Arch Linux (pacman). Installing packages...${NC}" - local PAC_PKGS=() - [ "${need_adb}" = true ] && PAC_PKGS+=("android-tools") - [ "${need_ffmpeg}" = true ] && PAC_PKGS+=("ffmpeg") - [ "${need_scrcpy}" = true ] && PAC_PKGS+=("scrcpy") - ${SUDO_PREFIX} pacman -S --noconfirm "${PAC_PKGS[@]}" 2>/dev/null || true - fi - fi - - # Fallback: if scrcpy is still missing, install official precompiled portable scrcpy in user space - if ! has_cmd scrcpy; then - local SCRCPY_ARCH="" - case "${ARCH_TYPE}" in - x86_64|amd64) SCRCPY_ARCH="x86_64" ;; - aarch64|arm64) SCRCPY_ARCH="aarch64" ;; - esac - if [ -n "${SCRCPY_ARCH}" ]; then - local SCRCPY_DIR="${HOME}/.local/share/scrcpy" - if [ ! -x "${SCRCPY_DIR}/scrcpy" ]; then - echo -e " ${CYAN}📦 Installing portable scrcpy in user space (~/.local)...${NC}" - mkdir -p "${SCRCPY_DIR}" "${HOME}/.local/bin" - local SCRCPY_URL="https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-linux-${SCRCPY_ARCH}-v4.1.tar.gz" - if curl -fsSL --connect-timeout 5 --max-time 30 "${SCRCPY_URL}" | tar -xz -C "${SCRCPY_DIR}" --strip-components=1 2>/dev/null; then - ln -sf "${SCRCPY_DIR}/scrcpy" "${HOME}/.local/bin/scrcpy" - export PATH="${SCRCPY_DIR}:${PATH}" - echo -e " ${GREEN}✓ scrcpy installed in user space.${NC}" - fi - else - ln -sf "${SCRCPY_DIR}/scrcpy" "${HOME}/.local/bin/scrcpy" - export PATH="${SCRCPY_DIR}:${PATH}" - fi - fi - fi - - # User-space fallback for adb if missing and no root privileges - if ! has_cmd adb; then - if [[ "${ARCH_TYPE}" = "x86_64" || "${ARCH_TYPE}" = "amd64" ]]; then - local PT_DIR="${HOME}/.local/share/platform-tools" - if [ ! -x "${PT_DIR}/adb" ]; then - echo -e " ${CYAN}📦 Installing Android platform-tools (adb) in user space...${NC}" - local TEMP_ZIP="/tmp/platform-tools-$$.zip" - if curl -fsSL --connect-timeout 5 --max-time 30 "https://dl.google.com/android/repository/platform-tools-latest-linux.zip" -o "${TEMP_ZIP}" 2>/dev/null; then - mkdir -p "${HOME}/.local/share" "${HOME}/.local/bin" - if has_cmd unzip; then - unzip -q -o "${TEMP_ZIP}" -d "${HOME}/.local/share" 2>/dev/null || true - else - python3 -m zipfile -e "${TEMP_ZIP}" "${HOME}/.local/share" 2>/dev/null || true - fi - rm -f "${TEMP_ZIP}" - fi - fi - if [ -x "${PT_DIR}/adb" ]; then - ln -sf "${PT_DIR}/adb" "${HOME}/.local/bin/adb" - export PATH="${PT_DIR}:${PATH}" - echo -e " ${GREEN}✓ adb installed in user space.${NC}" - fi - fi - fi - fi - - # Ensure local ADB daemon is warm & listening - if has_cmd adb; then - (unset ADB_SERVER_SOCKET; adb start-server >/dev/null 2>&1 || true) - fi -} - -# Function to ensure uv is installed and ready -ensure_uv() { - echo -e "\n${BOLD}3. Checking Python Package Manager (uv)...${NC}" - if ! has_cmd uv; then - echo -e " ${YELLOW}uv not found. Installing Astral uv (ultra-fast Python package manager)...${NC}" - curl -LsSf https://astral.sh/uv/install.sh | sh - export PATH="${HOME}/.local/bin:${HOME}/.cargo/bin:${PATH}" - fi - - # Explicitly persist PATH to shell configuration files if not present - local export_line='export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"' - for rc in "${HOME}/.bashrc" "${HOME}/.zshrc" "${HOME}/.profile"; do - if [ -f "${rc}" ]; then - if ! grep -qs 'HOME/\.local/bin' "${rc}"; then - echo -e "\n# Added by Artemis installer\n${export_line}" >> "${rc}" - fi - fi - done - - # If /usr/local/bin is writable or sudo is available, try to create a symlink to make it available immediately - local uv_bin="" - if [ -x "${HOME}/.local/bin/uv" ]; then - uv_bin="${HOME}/.local/bin/uv" - elif command -v uv >/dev/null 2>&1; then - uv_bin="$(command -v uv)" - fi - - if [ -n "${uv_bin}" ]; then - if [ -w "/usr/local/bin" ]; then - ln -sf "${uv_bin}" /usr/local/bin/uv 2>/dev/null || true - [ -x "${HOME}/.local/bin/uvx" ] && ln -sf "${HOME}/.local/bin/uvx" /usr/local/bin/uvx 2>/dev/null || true - elif has_cmd sudo; then - sudo -n ln -sf "${uv_bin}" /usr/local/bin/uv 2>/dev/null || true - [ -x "${HOME}/.local/bin/uvx" ] && sudo -n ln -sf "${HOME}/.local/bin/uvx" /usr/local/bin/uvx 2>/dev/null || true - fi - fi - - if has_cmd uv; then - echo -e " ${GREEN}✓ uv is ready ($(uv --version))${NC}" - else - echo -e " ${RED}✗ Failed to find uv in PATH. Please add ~/.local/bin to your PATH.${NC}" - exit 1 - fi -} - -# Function to setup Python runtime and sync dependencies -setup_python_env() { - echo -e "\n${BOLD}4. Configuring Python Runtime & Syncing Dependencies...${NC}" - echo -e " ${BLUE}📦 Running uv sync (auto-provisioning Python >=3.12 & dependencies)...${NC}" - uv sync - echo -e " ${GREEN}✓ Python runtime and dependencies synced successfully.${NC}" -} - -# Function to initialize .env configuration -setup_env_file() { - echo -e "\n${BOLD}5. Checking Environment Configuration (.env)...${NC}" - if [ ! -f ".env" ]; then - if [ -f ".env.example" ]; then - cp .env.example .env - echo -e " ${GREEN}✓ Created .env template from .env.example.${NC}" - else - touch .env - echo -e " ${GREEN}✓ Created empty .env file.${NC}" - fi - else - echo -e " ${GREEN}✓ .env configuration file exists.${NC}" - fi -} - -# Function to check and build Showcase UI -setup_showcase_ui() { - echo -e "\n${BOLD}6. Checking Showcase UI Build (Angular)...${NC}" - local SHOWCASE_INDEX="${ROOT_DIR}/apps/showcase_ui/dist/frontend/browser/index.html" - local SHOWCASE_INDEX_ALT1="${ROOT_DIR}/apps/showcase_ui/dist/browser/index.html" - local SHOWCASE_INDEX_ALT2="${ROOT_DIR}/apps/showcase_ui/dist/index.html" - if [ ! -f "${SHOWCASE_INDEX}" ] && [ ! -f "${SHOWCASE_INDEX_ALT1}" ] && [ ! -f "${SHOWCASE_INDEX_ALT2}" ]; then - # Try loading nvm if available in user environment - export NVM_DIR="${HOME}/.nvm" - if [ -s "${NVM_DIR}/nvm.sh" ]; then - # shellcheck disable=SC1090,SC1091 - . "${NVM_DIR}/nvm.sh" 2>/dev/null || true - fi - if [ -d "${HOME}/.local/share/node/bin" ] && [[ ":${PATH}:" != *":${HOME}/.local/share/node/bin:"* ]]; then - export PATH="${HOME}/.local/share/node/bin:${PATH}" - fi - - # Check if nvm already has a compatible Node version installed - if ! is_node_compatible && (command -v nvm >/dev/null 2>&1 || type nvm >/dev/null 2>&1); then - nvm use 22 >/dev/null 2>&1 || true - fi - - if ! is_node_compatible; then - if has_cmd node; then - local current_node="$(node -v 2>/dev/null)" - echo -e " ${YELLOW}⚡ Detected Node.js ${current_node}, but Angular CLI requires Node.js >= v22.22.0. Upgrading Node.js...${NC}" - else - echo -e " ${YELLOW}⚡ Node.js/npm not found. Auto-installing Node.js 22 LTS for Showcase UI compilation...${NC}" - fi - - # 1. Try installing Node 22 via nvm if available - if command -v nvm >/dev/null 2>&1 || type nvm >/dev/null 2>&1; then - echo -e " ${CYAN}📦 Installing Node.js 22 LTS via nvm...${NC}" - nvm install 22 >/dev/null 2>&1 || true - nvm use 22 >/dev/null 2>&1 || true - fi - - # 2. Try brew on macOS - if ! is_node_compatible && [ "${OS_TYPE}" = "Darwin" ] && has_cmd brew; then - brew install node >/dev/null 2>&1 || brew upgrade node >/dev/null 2>&1 || true - fi - - # 3. Try Linux package managers with sudo / root - if ! is_node_compatible && [ "${OS_TYPE}" = "Linux" ]; then - if request_sudo "install or upgrade Node.js to >= 22.22.0"; then - local SUDO_PREFIX="" - if [ "$(id -u)" -ne 0 ]; then SUDO_PREFIX="sudo"; fi - if has_cmd apt-get; then - echo -e " ${CYAN}📦 Configuring NodeSource Node.js 22 LTS repository...${NC}" - curl -fsSL --connect-timeout 5 --max-time 30 https://deb.nodesource.com/setup_22.x | ${SUDO_PREFIX} bash - >/dev/null 2>&1 || true - ${SUDO_PREFIX} apt-get -o DPkg::Lock::Timeout=5 install -y -qq nodejs 2>/dev/null || true - elif has_cmd dnf; then - curl -fsSL --connect-timeout 5 --max-time 30 https://rpm.nodesource.com/setup_22.x | ${SUDO_PREFIX} bash - >/dev/null 2>&1 || true - ${SUDO_PREFIX} dnf install -y nodejs || true - elif has_cmd pacman; then - ${SUDO_PREFIX} pacman -S --noconfirm nodejs npm || true - fi - fi - fi - - # 4. Standalone portable Node.js LTS (zero-admin user space fallback, guaranteed compatible) - if ! is_node_compatible; then - local ARCH="$(uname -m)" - local NODE_ARCH="" - case "${ARCH}" in - x86_64|amd64) NODE_ARCH="x64" ;; - aarch64|arm64) NODE_ARCH="arm64" ;; - esac - local OS_SYS="$(uname -s | tr '[:upper:]' '[:lower:]')" - if [ -n "${NODE_ARCH}" ] && { [ "${OS_SYS}" = "linux" ] || [ "${OS_SYS}" = "darwin" ]; }; then - local NODE_VER="v22.23.2" - local NODE_DIR="${HOME}/.local/share/node" - echo -e " ${CYAN}📦 Installing portable Node.js ${NODE_VER} in user space (~/.local)...${NC}" - rm -rf "${NODE_DIR}" - mkdir -p "${NODE_DIR}" "${HOME}/.local/bin" - if curl -fsSL --connect-timeout 5 --max-time 60 "https://nodejs.org/dist/${NODE_VER}/node-${NODE_VER}-${OS_SYS}-${NODE_ARCH}.tar.gz" | tar -xz -C "${NODE_DIR}" --strip-components=1 2>/dev/null; then - ln -sf "${NODE_DIR}/bin/node" "${HOME}/.local/bin/node" - ln -sf "${NODE_DIR}/bin/npm" "${HOME}/.local/bin/npm" - ln -sf "${NODE_DIR}/bin/npx" "${HOME}/.local/bin/npx" - export PATH="${NODE_DIR}/bin:${HOME}/.local/bin:${PATH}" - hash -r 2>/dev/null || true - echo -e " ${GREEN}✓ Portable Node.js ${NODE_VER} installed in user space.${NC}" - fi - fi - fi - fi - - if is_node_compatible; then - echo -e " ${GREEN}✓ Node.js $(node -v 2>/dev/null) and npm $(npm -v 2>/dev/null) ready.${NC}" - echo -e " ${YELLOW}🎨 Building Angular Showcase UI...${NC}" - ( - cd "${ROOT_DIR}/apps/showcase_ui" - npm install --silent - CLI_NODE_VERSION="${ROOT_DIR}/apps/showcase_ui/node_modules/@angular/cli/src/utilities/node-version.js" - if [ -f "${CLI_NODE_VERSION}" ]; then - if [ "${OS_TYPE}" = "Darwin" ]; then - sed -i '' 's/22\.22\.3/22.22.0/g' "${CLI_NODE_VERSION}" 2>/dev/null || true - else - sed -i 's/22\.22\.3/22.22.0/g' "${CLI_NODE_VERSION}" 2>/dev/null || true - fi - fi - npm run build - ) - echo -e " ${GREEN}✓ Showcase UI compiled successfully.${NC}" - else - echo -e " ${YELLOW}⚠ Could not configure compatible Node.js (>= 22.22.0). Showcase UI will show fallback notice on launch.${NC}" - fi - else - echo -e " ${GREEN}✓ Showcase UI build already exists.${NC}" - fi -} - -# Function to display system readiness report -verify_readiness() { - echo -e "\n${BOLD}7. Toolchain Readiness Summary:${NC}" - - local tools=("adb" "ffmpeg" "scrcpy" "uv" "python3" "npm") - for t in "${tools[@]}"; do - if has_cmd "$t"; then - local loc - loc="$(command -v "$t")" - if [ "$t" = "npm" ] && has_cmd node; then - local n_ver - n_ver="$(node -v 2>/dev/null || echo '')" - echo -e " ${GREEN}✔ ${t}${NC} (${n_ver}) -> ${DIM}${loc}${NC}" - else - echo -e " ${GREEN}✔ ${t}${NC} -> ${DIM}${loc}${NC}" - fi - else - echo -e " ${YELLOW}○ ${t}${NC} -> ${YELLOW}Not found in PATH (Fallbacks active)${NC}" - fi - done -} - -# Run setup workflow -install_system_packages -ensure_uv -setup_python_env -setup_env_file -setup_showcase_ui -verify_readiness - -echo -e "\n${BOLD}${CYAN}======================================================${NC}" -echo -e "${BOLD}${GREEN} ✨ Artemis Environment Ready! ${NC}" -echo -e "${BOLD}${CYAN}======================================================${NC}" - -if [ "${AUTO_LAUNCH}" = true ]; then - echo -e "\n${GREEN}🚀 Auto-launching Showcase UI...${NC}" - exec uv run artemis ui --open -else - echo -e "To launch the unified UI & live device onboarding:" - echo -e " 👉 ${BOLD}${CYAN}./start.sh${NC} (Recommended - automatically sets up environment)" - echo -e " 👉 ${BOLD}source ~/.bashrc && uv run artemis ui${NC} (or open a new terminal tab)" - echo "" -fi diff --git a/scripts/real_device_video_acceptance.py b/scripts/real_device_video_acceptance.py deleted file mode 100644 index 4df14cfef..000000000 --- a/scripts/real_device_video_acceptance.py +++ /dev/null @@ -1,483 +0,0 @@ -#!/usr/bin/env python3 -"""Run a deterministic, real-device acceptance test for the video analyzer. - -The script intentionally bypasses the autonomous planner. It uses the same -ARTEMIS device driver, recorder, controller, configured video model, and -persistent blackboard as production, while keeping the UI ground truth fully -deterministic and independently observable through UIAutomator. - -Usage: - python scripts/real_device_video_acceptance.py --device-id SERIAL -""" - -from __future__ import annotations - -import argparse -import asyncio -from dataclasses import asdict, dataclass -from datetime import datetime -import json -import os -from pathlib import Path -import re -import sys -import time -from typing import Any - -from adbutils import AdbClient - -PROJECT_ROOT = Path(__file__).resolve().parents[1] -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) - -from artemis.agents.video_analyzer.video_analyzer import VideoAnalyzer -from artemis.config import initialize_llm_config, settings -from artemis.context import ArtemisContext, DeviceContext, DevicePlatform, ExecutionSetup -from artemis.controllers.controller_factory import get_controller -from artemis.runtime import DeviceExecutionLock -from artemis.sdk.builders import Builders -from artemis.sdk.types import AgentProfile -from third_party.mobile_use.clients.ui_automator_client import UIAutomatorClient - - -CALCULATOR_PACKAGE = "com.google.android.calculator" -RESOURCE_PREFIX = f"{CALCULATOR_PACKAGE}:id/" - -# Raw-device coordinates verified on a 1080x2424 Pixel. They are only used if -# dynamic resource-id lookup fails, and are scaled to the actual screen size. -REFERENCE_SIZE = (1080, 2424) -FALLBACK_COORDINATES = { - "clear": (140, 1190), - "digit_1": (140, 1950), - "digit_2": (406, 1950), - "digit_3": (674, 1950), - "digit_4": (140, 1696), - "op_add": (940, 1950), - "digit_7": (140, 1442), - "digit_8": (406, 1442), - "op_mul": (940, 1442), - "eq": (940, 2202), - "history_toggle_button": (95, 262), - "drag_handle_view_for_history": (540, 678), -} - - -@dataclass -class GroundTruthEvent: - name: str - relative_time_seconds: float - observed_formula: str - observed_result: str - history_state: str - - -def _element_value(element: dict[str, Any] | None) -> str: - if not element: - return "" - return str(element.get("text") or element.get("content-desc") or "").strip() - - -def _compact(value: str) -> str: - return ( - re.sub(r"[\s`*_\[\](){}:;,.,。:;]", "", value.lower()) - .replace("×", "x") - .replace("*", "x") - .replace("=", "=") - .replace("=", "=") - ) - - -def _contains_any(value: str, candidates: tuple[str, ...]) -> bool: - return any(candidate in value for candidate in candidates) - - -def _grade_answer(kind: str, answer: str) -> tuple[bool, list[str]]: - compact = _compact(answer) - failures: list[str] = [] - - if kind in {"timeline", "calculations"}: - if "12+34" not in compact: - failures.append("missing 12+34") - if "46" not in compact: - failures.append("missing result 46") - if not _contains_any(compact, ("7x8", "7·8")): - failures.append("missing 7×8") - if "56" not in compact: - failures.append("missing result 56") - - if kind in {"timeline", "history"}: - if not _contains_any(compact, ("历史", "history")): - failures.append("missing history panel") - if not _contains_any(compact, ("打开", "开启", "open", "shown", "visible")): - failures.append("missing history-open transition") - if not _contains_any(compact, ("关闭", "close", "closed", "hidden", "返回")): - failures.append("missing history-close transition") - - if answer.startswith(("PARTIAL VIDEO ANALYSIS", "All sub-agent chunks failed", "Error:")): - failures.append("analysis was partial or failed") - return not failures, failures - - -async def _find_by_resource(controller, resource_name: str) -> dict[str, Any] | None: - data = await controller.driver.get_screen_data(skip_settling=True) - expected = RESOURCE_PREFIX + resource_name - for element in data.ui_elements or []: - resource_id = str(element.get("resource_id") or element.get("resource-id") or "") - if resource_id == expected or resource_id.endswith("/" + resource_name): - return element - return None - - -async def _tap(controller, resource_name: str, *, settle: float = 0.35) -> str: - result = await controller.tap_element(resource_id=RESOURCE_PREFIX + resource_name) - method = "resource-id" - if result.error: - if resource_name not in FALLBACK_COORDINATES: - raise RuntimeError(f"Dynamic locator failed for {resource_name}: {result.error}") - width, height = controller.driver.screen_size - ref_x, ref_y = FALLBACK_COORDINATES[resource_name] - x = round(ref_x * width / REFERENCE_SIZE[0]) - y = round(ref_y * height / REFERENCE_SIZE[1]) - fallback = await controller.tap_at(x, y) - if fallback.error: - raise RuntimeError( - f"Dynamic and coordinate locators failed for {resource_name}: " - f"{result.error}; {fallback.error}" - ) - method = f"coordinate-fallback({x},{y})" - await asyncio.sleep(settle) - return method - - -async def _observe(controller) -> tuple[str, str, str, list[str]]: - data = await controller.driver.get_screen_data(skip_settling=True) - formula = "" - result = "" - history_state = "" - visible_values: list[str] = [] - for element in data.ui_elements or []: - resource_id = str(element.get("resource_id") or element.get("resource-id") or "") - value = _element_value(element) - if value: - visible_values.append(value) - if resource_id.endswith("/formula"): - formula = value - elif resource_id.endswith("/result_preview") or resource_id.endswith("/result_final"): - result = value - elif "history_toggle_button" in resource_id: - history_state = value - return formula, result, history_state, visible_values - - -def _assert_calculation( - formula: str, - result: str, - expression: str, - expected: str, - *, - require_expression: bool = True, -) -> None: - observed = _compact(f"{formula} {result}") - normalized_expression = _compact(expression) - expression_missing = require_expression and normalized_expression not in observed - if expression_missing or expected not in observed: - raise AssertionError( - f"Expected {expression}={expected}; observed formula={formula!r}, result={result!r}" - ) - - -async def _run_ui_sequence( - controller, recording_started: float -) -> tuple[list[GroundTruthEvent], dict[str, str]]: - device = controller.driver.device - await asyncio.to_thread( - device.shell, - f"monkey -p {CALCULATOR_PACKAGE} -c android.intent.category.LAUNCHER 1", - ) - await asyncio.sleep(1.0) - - locator_methods: dict[str, str] = {} - events: list[GroundTruthEvent] = [] - - # Calculator preserves its history drawer state across launches. Normalize - # the starting layout so the deterministic sequence never inherits a - # shifted keypad from a previous run. - _, _, initial_history_state, _ = await _observe(controller) - if _contains_any(_compact(initial_history_state), ("已开启", "open", "on")): - locator_methods["normalize_history_closed"] = await _tap( - controller, "history_toggle_button", settle=0.7 - ) - _, _, normalized_history_state, _ = await _observe(controller) - if not _contains_any(_compact(normalized_history_state), ("已关闭", "close", "off")): - raise AssertionError( - f"Could not normalize history drawer; state={normalized_history_state!r}" - ) - - locator_methods["clear_1"] = await _tap(controller, "clear") - for resource_name in ("digit_1", "digit_2", "op_add", "digit_3", "digit_4"): - locator_methods[resource_name + "_first"] = await _tap(controller, resource_name) - formula_before_equals, preview, _, _ = await _observe(controller) - _assert_calculation(formula_before_equals, preview, "12+34", "46") - locator_methods["eq_first"] = await _tap(controller, "eq") - formula, result, history_state, _ = await _observe(controller) - _assert_calculation(formula, result, "12+34", "46", require_expression=False) - events.append( - GroundTruthEvent( - "12+34=46", - round(time.monotonic() - recording_started, 2), - formula_before_equals, - result, - history_state, - ) - ) - - locator_methods["clear_2"] = await _tap(controller, "clear") - for resource_name in ("digit_7", "op_mul", "digit_8"): - locator_methods[resource_name + "_second"] = await _tap(controller, resource_name) - formula_before_equals, preview, _, _ = await _observe(controller) - _assert_calculation(formula_before_equals, preview, "7x8", "56") - locator_methods["eq_second"] = await _tap(controller, "eq") - formula, result, history_state, _ = await _observe(controller) - _assert_calculation(formula, result, "7x8", "56", require_expression=False) - events.append( - GroundTruthEvent( - "7×8=56", - round(time.monotonic() - recording_started, 2), - formula_before_equals, - result, - history_state, - ) - ) - - locator_methods["history_open"] = await _tap(controller, "history_toggle_button", settle=0.7) - formula, result, history_state, visible = await _observe(controller) - if not _contains_any(_compact(history_state), ("已开启", "open", "on")): - raise AssertionError(f"History panel did not open; state={history_state!r}") - # The compact history drawer only exposes the newest item. Expand it so - # both independently verified calculations become simultaneously visible - # evidence for the video model. - locator_methods["history_expand"] = await _tap( - controller, "drag_handle_view_for_history", settle=0.7 - ) - formula, result, history_state, visible = await _observe(controller) - history_text = _compact(" ".join(visible)) - for expected in ("12+34", "46", "7x8", "56"): - if expected not in history_text: - raise AssertionError(f"History is missing {expected}; visible={visible!r}") - events.append( - GroundTruthEvent( - "history_open", - round(time.monotonic() - recording_started, 2), - formula, - result, - history_state, - ) - ) - - locator_methods["history_close"] = await _tap(controller, "history_toggle_button", settle=0.7) - formula, result, history_state, _ = await _observe(controller) - if not _contains_any(_compact(history_state), ("已关闭", "close", "off")): - raise AssertionError(f"History panel did not close; state={history_state!r}") - _assert_calculation(formula, result, "7x8", "56", require_expression=False) - events.append( - GroundTruthEvent( - "history_close", - round(time.monotonic() - recording_started, 2), - formula, - result, - history_state, - ) - ) - await asyncio.sleep(1.0) - return events, locator_methods - - -async def _analyze(analyzer: VideoAnalyzer, end_time: float, repeats: int) -> list[dict[str, Any]]: - queries = { - "timeline": ( - "List four facts from the recording in chronological order with visible evidence and relative timestamps: " - "First complete 12+34=46; then complete 7×8=56; subsequently open the history panel; finally close the history panel and return to the main screen showing 56. " - "Do not guess based on prompts, only report what is actually visible in the video." - ), - "calculations": ( - "Only verify the two calculations actually entered in the recording and their final visible results. Must explicitly write the complete expressions 12+34=46 and 7×8=56, " - "and report them in order of occurrence; if any is not visible, clearly state that it is missing." - ), - "history": ( - "Only verify state changes of the history panel: whether it was opened after completing both calculations, whether it displayed both calculation records, " - "and whether it was subsequently closed returning to the calculator main screen. Please explicitly use 'open' and 'close' to describe the two transitions." - ), - } - results: list[dict[str, Any]] = [] - for kind, query in queries.items(): - first_started = time.monotonic() - first = await analyzer.exec_spawn_sub_agent(0.0, end_time, query) - first_elapsed = round(time.monotonic() - first_started, 2) - passed, failures = _grade_answer(kind, first) - item: dict[str, Any] = { - "kind": kind, - "query": query, - "first_result": first, - "first_elapsed_seconds": first_elapsed, - "accuracy_passed": passed, - "accuracy_failures": failures, - "cache_replays": [], - } - for _ in range(repeats): - replay_started = time.monotonic() - replay = await analyzer.exec_spawn_sub_agent(0.0, end_time, query) - replay_elapsed = round(time.monotonic() - replay_started, 3) - replay_passed, replay_failures = _grade_answer(kind, replay) - item["cache_replays"].append( - { - "result": replay, - "elapsed_seconds": replay_elapsed, - "cache_hit": replay.startswith("CACHED VIDEO ANALYSIS:"), - "accuracy_passed": replay_passed, - "accuracy_failures": replay_failures, - } - ) - results.append(item) - return results - - -async def run(args: argparse.Namespace) -> dict[str, Any]: - output_dir = Path(args.output_dir).resolve() - output_dir.mkdir(parents=True, exist_ok=True) - llm_config = initialize_llm_config() - profile = AgentProfile(name="acceptance", llm_config=llm_config) - agent_config = Builders.AgentConfig.with_default_profile(profile).build() - adb_client = AdbClient(host=settings.ADB_HOST or "127.0.0.1", port=settings.ADB_PORT or 5037) - device = adb_client.device(serial=args.device_id) - width, height = await asyncio.to_thread(device.window_size) - ui_client = UIAutomatorClient(device_id=args.device_id) - ctx = ArtemisContext( - device=DeviceContext( - host_platform="WINDOWS" if os.name == "nt" else "LINUX", - mobile_platform=DevicePlatform.ANDROID, - device_id=args.device_id, - device_width=width, - device_height=height, - ), - llm_config=llm_config, - agent_config=agent_config, - adb_client=adb_client, - ui_adb_client=ui_client, - execution_setup=ExecutionSetup( - traces_path=output_dir.parent, - trace_name=output_dir.name, - video_recording_tools_enabled=True, - ), - ) - - lock = DeviceExecutionLock(args.device_id, description="real-device video acceptance") - await asyncio.to_thread(lock.acquire, timeout=args.device_lock_timeout) - controller = get_controller(ctx) - recording_stopped = False - started_wall = datetime.now().astimezone().isoformat() - report: dict[str, Any] = { - "device_id": args.device_id, - "started_at": started_wall, - "output_dir": str(output_dir), - "status": "failed", - } - try: - await asyncio.to_thread(ui_client.connect) - recording = await controller.start_video_recording(output_dir=output_dir) - if not recording.success: - raise RuntimeError(recording.message) - recording_clock = time.monotonic() - events, locator_methods = await _run_ui_sequence(controller, recording_clock) - end_time = round(max(0.5, time.monotonic() - recording_clock - 0.55), 1) - - analyzer = VideoAnalyzer(ctx) - analyses = await _analyze(analyzer, end_time, args.cache_replays) - stop_result = await controller.stop_video_recording() - recording_stopped = True - - accuracy_checks = [item["accuracy_passed"] for item in analyses] - cache_checks = [ - replay["cache_hit"] and replay["accuracy_passed"] - for item in analyses - for replay in item["cache_replays"] - ] - report.update( - { - "ground_truth": [asdict(event) for event in events], - "locator_methods": locator_methods, - "analysis_interval": [0.0, end_time], - "analyses": analyses, - "blackboard_metrics": analyzer.blackboard.metrics(), - "recording": { - "success": stop_result.success, - "message": stop_result.message, - "video_path": str(stop_result.video_path) if stop_result.video_path else None, - "video_id": str(stop_result.video_id) if stop_result.video_id else None, - "generation": stop_result.generation, - }, - "accuracy_pass_rate": ( - sum(accuracy_checks) / len(accuracy_checks) if accuracy_checks else 0.0 - ), - "cache_replay_pass_rate": ( - sum(cache_checks) / len(cache_checks) if cache_checks else 0.0 - ), - } - ) - passed = bool( - stop_result.success - and accuracy_checks - and all(accuracy_checks) - and cache_checks - and all(cache_checks) - ) - report["status"] = "passed" if passed else "failed" - except Exception as error: - report["error"] = f"{type(error).__name__}: {error}" - raise - finally: - if not recording_stopped: - try: - stop_result = await controller.stop_video_recording() - report["emergency_recording_stop"] = { - "success": stop_result.success, - "message": stop_result.message, - "video_path": str(stop_result.video_path) if stop_result.video_path else None, - } - except Exception as stop_error: - report["emergency_recording_stop_error"] = str(stop_error) - try: - await asyncio.to_thread(ui_client.disconnect) - finally: - await asyncio.to_thread(lock.release) - report["finished_at"] = datetime.now().astimezone().isoformat() - report_path = output_dir / "acceptance-report.json" - report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") - print(json.dumps(report, ensure_ascii=False, indent=2), flush=True) - - return report - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--device-id", required=True) - parser.add_argument("--cache-replays", type=int, default=2) - parser.add_argument("--device-lock-timeout", type=float, default=30.0) - parser.add_argument( - "--output-dir", - default=str( - PROJECT_ROOT - / "traces" - / f"real_device_video_acceptance_{datetime.now().strftime('%Y%m%d_%H%M%S')}" - ), - ) - args = parser.parse_args() - try: - report = asyncio.run(run(args)) - except Exception as error: - print(f"Acceptance test failed: {type(error).__name__}: {error}", file=sys.stderr) - return 1 - return 0 if report.get("status") == "passed" else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/run_edge_case_part.py b/scripts/run_edge_case_part.py deleted file mode 100644 index aec2de60a..000000000 --- a/scripts/run_edge_case_part.py +++ /dev/null @@ -1,327 +0,0 @@ -"""Interactive Edge-Case Test Suite with Clear Step-by-Step Guidance. - -Supports running each edge-case part independently or all together. -Usage: - python scripts/run_edge_case_part.py --part 1 - python scripts/run_edge_case_part.py --part 2 - python scripts/run_edge_case_part.py --part 3 - python scripts/run_edge_case_part.py --part 4 - python scripts/run_edge_case_part.py --part all -""" - -import argparse -import asyncio -import os -import sys -import time -import requests - -WORKSPACE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -if WORKSPACE_ROOT not in sys.path: - sys.path.insert(0, WORKSPACE_ROOT) - -from artemis.interfaces.sdk.client import ArtemisClient -from artemis.runtime.device_lock import DeviceExecutionLock -from artemis.toolchain import ensure_toolchain_in_path - -ensure_toolchain_in_path() - -from artemis.runtime import device_pool - - -def _resolve_devices() -> tuple[str, str]: - all_devs = device_pool.list_devices() - real_dev = next((d for d in all_devs if not d.is_emulator and d.state == "device"), None) - emu_dev = next((d for d in all_devs if d.is_emulator and d.state == "device"), None) - phone = os.environ.get("ARTEMIS_PHONE_SERIAL") or ( - real_dev.serial if real_dev else "63191FDKX00062" - ) - emu = os.environ.get("ARTEMIS_EMULATOR_SERIAL") or ( - emu_dev.serial if emu_dev else "emulator-5554" - ) - return phone, emu - - -PHONE_SERIAL, EMULATOR_SERIAL = _resolve_devices() -API_STATUS_URL = "http://127.0.0.1:8000/api/status" - - -def query_api(): - try: - r = requests.get(API_STATUS_URL, timeout=3).json() - return r.get("active_tasks", []), r.get("queue", []) - except Exception: - return [], [] - - -def print_status_snapshot(label: str): - active, queue = query_api() - print(f"\n[{label}] Web Console Live Snapshot (/api/status):") - print(f" ⚡ Running Tasks ({len(active)}):") - for a in active: - dev = a.get("device_id") or a.get("device_serial") - print(f" - [Device: {dev}] {a.get('goal')} (PID: {a.get('pid')})") - print(f" ⏳ Pending Tasks ({len(queue)}):") - for q in queue: - dev = q.get("device_serial") or q.get("device_id") - print(f" - [Device: {dev}] {q.get('goal')} [status={q.get('status')}]") - print(f" 📊 Total Active Queue (Running + Pending) = {len(active) + len(queue)}\n") - - -async def run_part_1(): - print("=" * 80) - print("[Part 1: Single Device Serial Queueing & Automatic Relay]") - print("=" * 80) - print( - "🎯 Goal: Launch 2 tasks on the same physical device; verify FIFO queueing, Web Active Queue count, and relay." - ) - print("📱 Target Device: Physical Pixel 11 Pro (63191FDKX00062)") - print("👀 Please observe:") - print( - " 1. Web Console (http://127.0.0.1:8000): Active Queue becomes 2 (1 Running blue badge, 1 Pending yellow badge)" - ) - print( - " 2. Device Screen: Task 1A opens Settings first; upon completion, Task 1B automatically takes over to open Clock" - ) - print( - " 3. Web Console: After 1A finishes, 1B switches to Running, and queue eventually clears to zero" - ) - print("-" * 80) - - DeviceExecutionLock.cleanup_stale_locks() - client = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="per_device") - - async def task_1a(): - print("🚀 [Task 1A] Launching: Open Settings on physical device...") - res = await client.run( - "Use manage_app to launch Settings app and report task status completed", - profile="flash", - ) - print(f"✅ [Task 1A] Completed: status={res.status}") - return res - - async def task_1b(): - await asyncio.sleep(0.4) - print( - "🚀 [Task 1B] Launching: Open Clock on physical device -> should immediately enter queue..." - ) - res = await client.run( - "Use manage_app to launch Clock app and report task status completed", profile="flash" - ) - print(f"✅ [Task 1B] Completed: status={res.status}") - return res - - t1 = asyncio.create_task(task_1a()) - t2 = asyncio.create_task(task_1b()) - - await asyncio.sleep(1.2) - print_status_snapshot("Part 1 Running") - - await asyncio.gather(t1, t2) - print( - "🎉 Part 1 Success: Task 1B automatically relayed and completed after Task 1A finished!\n" - ) - - -async def run_part_2(): - print("=" * 80) - print("[Part 2: Multi-Device True Parallel Execution]") - print("=" * 80) - print( - "🎯 Goal: Cross-device true concurrency; both devices run independently at the same time without blocking." - ) - print(f"📱 Target Device 1: Physical Pixel 11 Pro ({PHONE_SERIAL})") - print(f"💻 Target Device 2: Android Emulator ({EMULATOR_SERIAL})") - print("👀 Please observe:") - print( - " 1. Dual screens: Physical device and emulator operate simultaneously without sequential waiting!" - ) - print( - " 2. Web Console (http://127.0.0.1:8000): Active Tasks shows 2 (both with blue Running badges)" - ) - print(" 3. Queue count: Queue is 0, each device displays its own Device Serial") - print("-" * 80) - - DeviceExecutionLock.cleanup_stale_locks() - client_phone = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="per_device") - client_emu = ArtemisClient(device_serial=EMULATOR_SERIAL, concurrency_mode="per_device") - - start_t = time.time() - - async def p_task(): - print(f"🚀 [Phone Task] Launching: Open Settings on {PHONE_SERIAL}...") - res = await client_phone.run( - "Use manage_app to launch Settings app and report task status completed", - profile="flash", - ) - print(f"✅ [Phone Task] Finished in {time.time() - start_t:.2f}s") - return res - - async def e_task(): - print(f"🚀 [Emulator Task] Launching: Open Settings on {EMULATOR_SERIAL}...") - res = await client_emu.run( - "Use manage_app to launch Settings app and report task status completed", - profile="flash", - ) - print(f"✅ [Emulator Task] Finished in {time.time() - start_t:.2f}s") - return res - - tp = asyncio.create_task(p_task()) - te = asyncio.create_task(e_task()) - - await asyncio.sleep(1.2) - print_status_snapshot("Part 2 Running (Multi-Device Concurrency)") - - await asyncio.gather(tp, te) - print( - f"🎉 Part 2 Success: Both devices completed tasks concurrently in {time.time() - start_t:.2f}s!\n" - ) - - -async def run_part_3(): - print("=" * 80) - print("[Part 3: Global Concurrency Limit Across Devices]") - print("=" * 80) - print( - "🎯 Goal: Configure global concurrency mode (global, limit 1); even targeting different devices, tasks must queue globally." - ) - print(f"📱 Device 1: Physical Pixel 11 Pro ({PHONE_SERIAL})") - print(f"💻 Device 2: Android Emulator ({EMULATOR_SERIAL})") - print("👀 Please observe:") - print( - " 1. Emulator screen: Even though emulator is completely idle, it waits until the phone task completes!" - ) - print( - " 2. Web Console: Phone task is Running (blue badge), emulator task is Pending (yellow badge)!" - ) - print(" 3. Once phone finishes, emulator instantly activates and begins execution!") - print("-" * 80) - - DeviceExecutionLock.cleanup_stale_locks() - client_phone_g = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="global") - client_emu_g = ArtemisClient(device_serial=EMULATOR_SERIAL, concurrency_mode="global") - - async def task_3a(): - print(f"🚀 [Task 3A - Phone] Global mode starting: Executing on {PHONE_SERIAL}...") - res = await client_phone_g.run( - "Use manage_app to launch Settings app and report task status completed", - profile="flash", - ) - print("✅ [Task 3A - Phone] Finished") - return res - - async def task_3b(): - await asyncio.sleep(0.4) - print( - f"🚀 [Task 3B - Emulator] Global mode starting: Targeting {EMULATOR_SERIAL} (should queue due to global limit)..." - ) - res = await client_emu_g.run( - "Use manage_app to launch Settings app and report task status completed", - profile="flash", - ) - print("✅ [Task 3B - Emulator] Finished") - return res - - t3a = asyncio.create_task(task_3a()) - t3b = asyncio.create_task(task_3b()) - - await asyncio.sleep(1.2) - print_status_snapshot("Part 3 Running (Global Limit Cross-Device Queueing)") - - await asyncio.gather(t3a, t3b) - print( - "🎉 Part 3 Success: Global mutex accurately queued and relayed tasks across physical phone and emulator!\n" - ) - - -async def run_part_4(): - print("=" * 80) - print("[Part 4: Multi-Device Asymmetric Mixed Queueing]") - print("=" * 80) - print("🎯 Goal: Assign 2 tasks to physical phone, 1 task to emulator.") - print( - " Expected: Phone 4A and Emulator 4C run concurrently; Phone 4B queues in dedicated phone queue; 4B relays immediately on phone after 4A." - ) - print("👀 Please observe:") - print( - " 1. Web Console: Active Tasks = 2 (Phone 4A + Emulator 4C), Queue = 1 (Phone 4B), Total = 3!" - ) - print( - " 2. Screens: Phone and emulator operate concurrently first; Phone 4B executes on phone immediately after 4A finishes!" - ) - print("-" * 80) - - DeviceExecutionLock.cleanup_stale_locks() - client_phone = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="per_device") - client_emu = ArtemisClient(device_serial=EMULATOR_SERIAL, concurrency_mode="per_device") - - async def task_4a(): - print("🚀 [Task 4A - Phone] Started...") - return await client_phone.run( - "Use manage_app to launch Settings app and report task status completed", - profile="flash", - ) - - async def task_4b(): - await asyncio.sleep(0.4) - print("🚀 [Task 4B - Phone] Started (Queued behind 4A)...") - return await client_phone.run( - "Use manage_app to launch Clock app and report task status completed", profile="flash" - ) - - async def task_4c(): - print("🚀 [Task 4C - Emulator] Started concurrently with 4A...") - return await client_emu.run( - "Use manage_app to launch Settings app and report task status completed", - profile="flash", - ) - - t4a = asyncio.create_task(task_4a()) - t4b = asyncio.create_task(task_4b()) - t4c = asyncio.create_task(task_4c()) - - await asyncio.sleep(1.2) - print_status_snapshot("Part 4 Running (Mixed Queueing)") - - await asyncio.gather(t4a, t4b, t4c) - print("Part 4 passed: cross-device concurrency and single-device queueing.\n") - - -async def main(): - global PHONE_SERIAL, EMULATOR_SERIAL - parser = argparse.ArgumentParser(description="Artemis Concurrency & Queue Edge-Cases Runner") - parser.add_argument( - "--part", - choices=["1", "2", "3", "4", "all"], - default="1", - help="Which part to run (1, 2, 3, 4, or all)", - ) - parser.add_argument("--phone", default=None, help="Target physical phone serial number") - parser.add_argument("--emulator", default=None, help="Target Android emulator serial number") - args = parser.parse_args() - - if args.phone: - PHONE_SERIAL = args.phone - if args.emulator: - EMULATOR_SERIAL = args.emulator - - if args.part == "1": - await run_part_1() - elif args.part == "2": - await run_part_2() - elif args.part == "3": - await run_part_3() - elif args.part == "4": - await run_part_4() - elif args.part == "all": - await run_part_1() - await asyncio.sleep(1) - await run_part_2() - await asyncio.sleep(1) - await run_part_3() - await asyncio.sleep(1) - await run_part_4() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/scripts/run_ui.py b/scripts/run_ui.py deleted file mode 100755 index 837eadb31..000000000 --- a/scripts/run_ui.py +++ /dev/null @@ -1,242 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Artemis UI Quick Launcher (run_ui.py) - -Fastest and simplest way to launch the Artemis Web UI across all platforms: -- Automatically detects if the UI is already running and opens the browser directly. -- Uses uv or an existing virtualenv. -- Zero-configuration one-click start. -""" - -import argparse -import os -from pathlib import Path -import socket -import subprocess -import sys -import urllib.request -import webbrowser - -ROOT_DIR = Path(__file__).resolve().parent.parent - - -def is_port_in_use(port: int, host: str = "127.0.0.1") -> bool: - """Check whether a TCP port is in use.""" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.settimeout(0.5) - return s.connect_ex((host, port)) == 0 - - -def is_artemis_ui_responsive(url: str, timeout: float = 1.0) -> bool: - """Check if Artemis UI server is responding on given URL.""" - try: - req = urllib.request.Request(url, headers={"User-Agent": "Artemis-QuickLauncher/1.0"}) - with urllib.request.urlopen(req, timeout=timeout) as resp: - return resp.status in (200, 304) - except Exception: - return False - - -def find_python_executable() -> str: - """Find the best Python executable (.venv or system).""" - # 1. Check local .venv - venv_py = ( - ROOT_DIR - / ".venv" - / ("Scripts" if os.name == "nt" else "bin") - / ("python.exe" if os.name == "nt" else "python") - ) - if venv_py.is_file() and os.access(venv_py, os.X_OK if os.name != "nt" else os.F_OK): - return str(venv_py) - - # 2. Return current sys.executable - return sys.executable - - -def main() -> None: - parser = argparse.ArgumentParser( - description="✨ One-click launcher for Artemis Mobile Agent UI", - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - ) - parser.add_argument("-p", "--port", type=int, default=8000, help="Port to run UI on") - parser.add_argument( - "-H", - "--host", - type=str, - default="127.0.0.1", - help="Host address to bind (loopback by default; use a tunnel for remote access)", - ) - parser.add_argument( - "-r", - "--restart", - action="store_true", - help="Restart the server by terminating any existing instance on the port first", - ) - parser.add_argument( - "-s", - "--stop", - action="store_true", - help="Stop running Artemis server on the specified port", - ) - parser.add_argument( - "--status", - action="store_true", - help="Display status of running Artemis server on the specified port", - ) - args, unknown = parser.parse_known_args() - - # Ensure project root is on sys.path for lifecycle utility imports - if str(ROOT_DIR) not in sys.path: - sys.path.insert(0, str(ROOT_DIR)) - - from artemis.runtime.server_lifecycle import get_server_status, stop_server - - def _arm_session_reconciler() -> None: - # Entry-layer assembly: importing the admin console session repository - # registers its orphan-session reconciler with the runtime, so a forced - # stop below can mark sessions whose worker processes were killed. - try: - import apps.admin_console.database.repositories.session_repository # noqa: F401 - except Exception: - pass - - if args.status: - st = get_server_status(args.port) - if st["running"]: - pid_info = f" (PID: {', '.join(map(str, st['pids']))})" if st["pids"] else "" - print(f"\033[1;32m● Artemis Server is RUNNING on port {args.port}{pid_info}\033[0m") - print(f" URL: {st['url']}") - else: - print(f"\033[1;30m○ Artemis Server is STOPPED (Port {args.port} is free)\033[0m") - return - - if args.stop: - print(f"\033[1;33m🛑 Stopping Artemis server on port {args.port}...\033[0m") - _arm_session_reconciler() - ok, msg, pids = stop_server(args.port) - print(f"\033[1;32m✓ {msg}\033[0m") - return - - ui_url = f"http://localhost:{args.port}" - admin_url = f"http://localhost:{args.port}/admin" - - print("\033[1;36m" + "=" * 56 + "\033[0m") - print("\033[1;36m ✨ Artemis Autonomous Mobile Agent UI \033[0m") - print("\033[1;36m" + "=" * 56 + "\033[0m\n") - - # Check if restart requested - if args.restart: - print(f"\033[1;33m🔄 Restart requested. Recycling port {args.port}...\033[0m") - _arm_session_reconciler() - ok, msg, pids = stop_server(args.port) - if pids: - print(f"\033[1;32m✓ {msg}\033[0m\n") - else: - print(f"Port {args.port} is clear.\n") - - # Check if server is already running - elif is_port_in_use(args.port): - if is_artemis_ui_responsive(ui_url): - print(f"\033[1;32m✓ Artemis UI is already running at:\033[0m \033[1;36m{ui_url}\033[0m") - print(f"\033[1;35m🛠️ Admin Console:\033[0m \033[1;36m{admin_url}\033[0m\n") - - # In interactive terminal, allow user to restart or open browser - if sys.stdin.isatty(): - print("Server is active in another session/terminal.") - print(" [1] Open browser (default)") - print(" [2] Restart server (stop existing and start here)") - print(" [3] Stop server") - try: - choice = input("Select [1-3, default 1]: ").strip() - except (KeyboardInterrupt, EOFError): - choice = "1" - - if choice == "2": - print(f"\n\033[1;33m🔄 Restarting Artemis server on port {args.port}...\033[0m") - _arm_session_reconciler() - stop_server(args.port) - elif choice == "3": - print(f"\n\033[1;33m🛑 Stopping Artemis server on port {args.port}...\033[0m") - _arm_session_reconciler() - ok, msg, _ = stop_server(args.port) - print(f"\033[1;32m✓ {msg}\033[0m") - return - else: - if not args.no_open: - print("🌐 Opening browser...") - webbrowser.open(ui_url) - return - else: - if not args.no_open: - print("🌐 Opening browser...") - webbrowser.open(ui_url) - return - else: - print(f"\033[1;33m⚠️ Port {args.port} is in use by another process.\033[0m") - print("Attempting to connect or start on specified port...") - - # Choose execution engine: uv run artemis ui > direct python - has_uv = ( - subprocess.run( - ["which", "uv"] if os.name != "nt" else ["where", "uv"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ).returncode - == 0 - ) - - cmd = [] - if has_uv and (ROOT_DIR / "pyproject.toml").exists(): - # Launch via `python -m artemis` (not the `artemis` console-script shim) so the - # long-running server never locks .venv/Scripts/artemis.exe against reinstalls. - cmd = [ - "uv", - "run", - "python", - "-m", - "artemis", - "ui", - "--port", - str(args.port), - "--host", - args.host, - ] - if args.no_open: - cmd.append("--no-open") - if args.reload: - cmd.append("--reload") - else: - python_bin = find_python_executable() - # Set PYTHONPATH so apps and artemis are importable - env = os.environ.copy() - env["PYTHONPATH"] = ( - f"{ROOT_DIR}{os.pathsep}{ROOT_DIR / 'apps'}{os.pathsep}{env.get('PYTHONPATH', '')}" - ) - cmd = [python_bin, "-m", "artemis", "ui", "--port", str(args.port), "--host", args.host] - if args.no_open: - cmd.append("--no-open") - if args.reload: - cmd.append("--reload") - - try: - # Run the server - subprocess.run(cmd, cwd=str(ROOT_DIR)) - except KeyboardInterrupt: - print("\n\033[1;33m🛑 Artemis UI server stopped.\033[0m") - - -if __name__ == "__main__": - main() diff --git a/scripts/spike_reset.py b/scripts/spike_reset.py new file mode 100644 index 000000000..62857d611 --- /dev/null +++ b/scripts/spike_reset.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Spike reset and verdict scaffolding for the DSH device quality runs (G43). + +Uses only the Python 3.12+ standard library. Every reset goes through the UI +or platform commands that were verified live; the `settings put ui_night_mode` +path is deliberately absent (it does not take effect on the MuMu ROM, and +`settings get` still reads 2 while dark). Achievement is judged from +screenshots by a human or the model -- this script never judges pixels, it +only produces deterministically reset states plus a review checklist. + +Live runs need an emulator (absent on machines without adb); `--dry-run` +prints every adb invocation without touching a device, so CI stays green. +""" + +import argparse +import subprocess +import sys +from pathlib import Path + +# Verbatim goal texts from the D9 ruling table (one new DSH session per task). +TASKS = { + "T1": "Open Settings, go to Display", + "T2": "Open Settings, turn on the dark theme", +} + + +def run_adb(serial: str, *args: str, dry_run: bool = False) -> str: + cmd = ["adb", "-s", serial, *args] + if dry_run: + print("$ " + " ".join(cmd)) + return "" + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + raise RuntimeError(f"adb failed: {' '.join(cmd)}\n{proc.stderr.strip()}") + return proc.stdout.strip() + + +def reset_home(serial: str, dry_run: bool = False) -> None: + """Back to the launcher and prove it via the focused window.""" + run_adb( + serial, + "shell", + "am", + "start", + "-a", + "android.intent.action.MAIN", + "-c", + "android.intent.category.HOME", + dry_run=dry_run, + ) + focus = run_adb(serial, "shell", "dumpsys", "window", dry_run=dry_run) + if not dry_run and "LawnchairLauncher" not in focus: + raise RuntimeError("home reset failed: mCurrentFocus is not LawnchairLauncher") + print("home: launcher focused") + + +def reset_light(serial: str, dry_run: bool = False) -> None: + """Light theme through the platform command (settings put does not work).""" + run_adb(serial, "shell", "cmd", "uimode", "night", "no", dry_run=dry_run) + print("theme: reset to light (verify against the next screenshot)") + + +def shot(serial: str, dest: str, dry_run: bool = False) -> None: + """Pull one screenshot; the verdict is always made from the image.""" + cmd = ["adb", "-s", serial, "exec-out", "screencap", "-p"] + if dry_run: + print("$ " + " ".join(cmd) + f" > {dest}") + return + proc = subprocess.run(cmd, capture_output=True) + if proc.returncode != 0 or not proc.stdout: + raise RuntimeError(f"screencap failed: {proc.stderr.decode('utf-8', 'replace')}") + Path(dest).write_bytes(proc.stdout) + print(f"shot: {dest}") + + +def oracle(task: str, serial: str, dry_run: bool = False) -> None: + """Print the旁证 (never the verdict): settings value + review checklist.""" + setting = run_adb( + serial, "shell", "settings", "get", "secure", "ui_night_mode", dry_run=dry_run + ) + print(f"oracle[{task}]: settings ui_night_mode = {setting} (旁证 only, not the verdict)") + if task == "T1": + print("checklist[T1]: screenshot shows the Display page, light theme") + elif task == "T2": + print("checklist[T2]: screenshot shows the display switch ON and a dark device") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--serial", default=None, help="ADB serial (default: $ADB_DEVICE_SERIAL)") + parser.add_argument( + "--task", + choices=sorted(TASKS), + default="T1", + help="spike task; goal texts: " + + "; ".join(f"{name}: {goal}" for name, goal in TASKS.items()), + ) + parser.add_argument("--out-dir", default="spike", help="where to drop screenshots") + parser.add_argument( + "--dry-run", action="store_true", help="print adb invocations without running them" + ) + args = parser.parse_args(argv) + + serial = args.serial + if not serial: + import os + + serial = os.environ.get("ADB_DEVICE_SERIAL", "") + if not serial and not args.dry_run: + print("no device serial: pass --serial or set ADB_DEVICE_SERIAL", file=sys.stderr) + return 2 + + try: + reset_home(serial or "", dry_run=args.dry_run) + reset_light(serial or "", dry_run=args.dry_run) + shot( + serial or "", + str(Path(args.out_dir) / f"{args.task}-reset.png"), + dry_run=args.dry_run, + ) + oracle(args.task, serial or "", dry_run=args.dry_run) + except RuntimeError as error: + print(f"FAILED: {error}", file=sys.stderr) + return 2 + print("reset complete: judge the task from screenshots, never from settings") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/start.ps1 b/scripts/start.ps1 deleted file mode 100644 index 1dd832af6..000000000 --- a/scripts/start.ps1 +++ /dev/null @@ -1,500 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -[CmdletBinding()] -param( - [int]$Port = 8000, - [switch]$NoOpen = $false -) - -$ErrorActionPreference = "Continue" -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 - -# Ensure modern TLS protocols are enabled for downloads (GitHub releases, nodejs.org, Google repositories) -try { - [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12 -} catch {} - -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$RootDir = Split-Path -Parent $ScriptDir -Set-Location $RootDir - -Write-Host "======================================================" -ForegroundColor Cyan -Write-Host " Artemis Autonomous Mobile Agent UI " -ForegroundColor Cyan -Write-Host "======================================================" -ForegroundColor Cyan -Write-Host "" - -function Update-EnvironmentPath { - $standardDirs = @( - "$env:LOCALAPPDATA\Microsoft\WinGet\Links", - "$env:LOCALAPPDATA\Programs\node", - "$env:USERPROFILE\.local\share\node", - "$env:LOCALAPPDATA\Programs\scrcpy", - "$env:USERPROFILE\.local\share\scrcpy", - "$env:LOCALAPPDATA\Programs\platform-tools", - "$env:USERPROFILE\.local\share\platform-tools", - "$env:LOCALAPPDATA\Programs\uv", - "$env:LOCALAPPDATA\uv", - "$env:APPDATA\uv", - "$env:USERPROFILE\.cargo\bin", - "$env:USERPROFILE\.local\bin", - "$env:USERPROFILE\scoop\shims", - "C:\ProgramData\chocolatey\bin", - "$env:LOCALAPPDATA\Android\Sdk\platform-tools", - "$env:LOCALAPPDATA\Android\android-sdk\platform-tools", - "$env:ProgramFiles\Android\platform-tools", - "${env:ProgramFiles(x86)}\Android\android-sdk\platform-tools", - "$env:ProgramFiles\nodejs", - "${env:ProgramFiles(x86)}\nodejs", - "$env:APPDATA\npm" - ) - if ($env:CARGO_HOME) { $standardDirs = @("$env:CARGO_HOME\bin") + $standardDirs } - if ($env:ANDROID_HOME) { $standardDirs += "$env:ANDROID_HOME\platform-tools" } - if ($env:ANDROID_SDK_ROOT) { $standardDirs += "$env:ANDROID_SDK_ROOT\platform-tools" } - if ($env:NVM_SYMLINK) { $standardDirs = @($env:NVM_SYMLINK) + $standardDirs } - if ($env:NVM_HOME) { $standardDirs = @($env:NVM_HOME) + $standardDirs } - - $regPath = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") - $currentPaths = ($env:PATH -split ";") + ($regPath -split ";") + $standardDirs | Where-Object { [string]::IsNullOrWhiteSpace($_) -eq $false -and (Test-Path $_) } | Select-Object -Unique - $env:PATH = $currentPaths -join ";" - - # Ensure user-space portable tools take absolute priority over system-level outdated versions - $portableNode = "$env:LOCALAPPDATA\Programs\node" - if (Test-Path "$portableNode\node.exe") { - $env:PATH = "$portableNode;$env:PATH" - } - $portableScrcpy = "$env:LOCALAPPDATA\Programs\scrcpy" - if (Test-Path "$portableScrcpy\scrcpy.exe") { - $env:PATH = "$portableScrcpy;$env:PATH" - } - $portablePt = "$env:LOCALAPPDATA\Programs\platform-tools" - if (Test-Path "$portablePt\adb.exe") { - $env:PATH = "$portablePt;$env:PATH" - } -} - -function Start-LocalAdbServer { - if (Test-CommandExists "adb") { - try { - $origSocket = $env:ADB_SERVER_SOCKET - Remove-Item Env:\ADB_SERVER_SOCKET -ErrorAction SilentlyContinue - & adb start-server 2>$null | Out-Null - if ($origSocket) { $env:ADB_SERVER_SOCKET = $origSocket } - } catch {} - } -} - -function Test-CommandExists { - param([string]$Command) - $res = Get-Command $Command -ErrorAction SilentlyContinue - return ($null -ne $res) -} - -function Invoke-DownloadFile { - param( - [Parameter(Mandatory=$true)][string]$Uri, - [Parameter(Mandatory=$true)][string]$OutFile, - [int]$TimeoutSec = 45 - ) - if (Test-Path $OutFile) { - Remove-Item -Path $OutFile -Force -ErrorAction SilentlyContinue - } - - # 1. Try Windows built-in curl.exe first (available in Win 10 1803+ and Win 11) - if (Test-CommandExists "curl.exe") { - try { - & curl.exe -f -sSL --connect-timeout 5 --max-time $TimeoutSec "$Uri" -o "$OutFile" 2>$null - if ($LASTEXITCODE -eq 0 -and (Test-Path $OutFile) -and ((Get-Item $OutFile).Length -gt 0)) { - return $true - } - } catch {} - if (Test-Path $OutFile) { - Remove-Item -Path $OutFile -Force -ErrorAction SilentlyContinue - } - } - - # 2. Fallback to Invoke-WebRequest (suppressing progress bar to eliminate PowerShell buffer hangs) - try { - $prevProgress = $ProgressPreference - $ProgressPreference = 'SilentlyContinue' - Invoke-WebRequest -Uri $Uri -OutFile $OutFile -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop - $ProgressPreference = $prevProgress - if ((Test-Path $OutFile) -and ((Get-Item $OutFile).Length -gt 0)) { - return $true - } - } catch { - $ProgressPreference = $prevProgress - } - - if (Test-Path $OutFile) { - Remove-Item -Path $OutFile -Force -ErrorAction SilentlyContinue - } - return $false -} - -function Install-PortablePlatformTools { - $ptDir = "$env:LOCALAPPDATA\Programs\platform-tools" - if (Test-Path "$ptDir\adb.exe") { - $env:PATH = "$ptDir;$env:PATH" - return $true - } - Write-Host " [INFO] Installing Android platform-tools (adb) in user space..." -ForegroundColor Cyan - try { - $zipPath = "$env:TEMP\platform-tools-windows.zip" - if (Invoke-DownloadFile -Uri "https://dl.google.com/android/repository/platform-tools-latest-windows.zip" -OutFile $zipPath -TimeoutSec 60) { - New-Item -ItemType Directory -Path "$env:LOCALAPPDATA\Programs" -Force | Out-Null - Expand-Archive -Path $zipPath -DestinationPath "$env:LOCALAPPDATA\Programs" -Force - Remove-Item $zipPath -Force -ErrorAction SilentlyContinue - if (Test-Path "$ptDir\adb.exe") { - $env:PATH = "$ptDir;$env:PATH" - Write-Host " [OK] adb installed in user space." -ForegroundColor Green - return $true - } - } - } catch { - Write-Host " [WARN] Failed to install portable platform-tools: $_" -ForegroundColor DarkYellow - } - return $false -} - -function Install-PortableScrcpy { - $scrcpyDir = "$env:LOCALAPPDATA\Programs\scrcpy" - if (Test-Path "$scrcpyDir\scrcpy.exe") { - $env:PATH = "$scrcpyDir;$env:PATH" - return $true - } - # Check if nested from an earlier run and heal - $nested = Get-ChildItem -Path $scrcpyDir -Directory -Filter "scrcpy*" -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($nested -and (Test-Path "$($nested.FullName)\scrcpy.exe")) { - Copy-Item -Path "$($nested.FullName)\*" -Destination $scrcpyDir -Recurse -Force - Remove-Item -Path $nested.FullName -Recurse -Force -ErrorAction SilentlyContinue - if (Test-Path "$scrcpyDir\scrcpy.exe") { - $env:PATH = "$scrcpyDir;$env:PATH" - return $true - } - } - - Write-Host " [INFO] Installing portable scrcpy in user space..." -ForegroundColor Cyan - try { - $zipPath = "$env:TEMP\scrcpy-win64.zip" - if (Invoke-DownloadFile -Uri "https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-win64-v4.1.zip" -OutFile $zipPath -TimeoutSec 60) { - $extractDir = "$env:TEMP\scrcpy_extract" - if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue } - New-Item -ItemType Directory -Path $extractDir -Force | Out-Null - Expand-Archive -Path $zipPath -DestinationPath $extractDir -Force - $extractedFolder = Get-ChildItem -Path $extractDir -Directory | Select-Object -First 1 - if ($extractedFolder) { - if (-not (Test-Path $scrcpyDir)) { - New-Item -ItemType Directory -Path $scrcpyDir -Force | Out-Null - } - Copy-Item -Path "$($extractedFolder.FullName)\*" -Destination $scrcpyDir -Recurse -Force - } - Remove-Item $zipPath -Force -ErrorAction SilentlyContinue - Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue - if (Test-Path "$scrcpyDir\scrcpy.exe") { - $env:PATH = "$scrcpyDir;$env:PATH" - Write-Host " [OK] Portable scrcpy installed in user space." -ForegroundColor Green - return $true - } - } - } catch { - Write-Host " [WARN] Failed to install portable scrcpy: $_" -ForegroundColor DarkYellow - } - return $false -} - -function Test-NodeCompatible { - if (-not (Test-CommandExists "node") -or (-not (Test-CommandExists "npm") -and -not (Test-CommandExists "npm.cmd"))) { return $false } - try { - $verRaw = (& node -v 2>$null) - if (-not $verRaw) { return $false } - $verStr = $verRaw.ToString().Trim().TrimStart('v') - $parts = $verStr.Split('.') - if ($parts.Count -ge 2) { - $major = [int]$parts[0] - $minor = [int]$parts[1] - if ($major -ge 26) { return $true } - if ($major -eq 24 -and $minor -ge 15) { return $true } - if ($major -eq 22 -and $minor -ge 22) { return $true } - } - } catch { - return $false - } - return $false -} - -function Install-PortableNode { - $nodeDir = "$env:LOCALAPPDATA\Programs\node" - if (Test-Path "$nodeDir\node.exe") { - $env:PATH = "$nodeDir;$env:PATH" - if (Test-NodeCompatible) { - return $true - } - } - # Check if nested from an earlier run and heal - $nested = Get-ChildItem -Path $nodeDir -Directory -Filter "node-*" -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($nested -and (Test-Path "$($nested.FullName)\node.exe")) { - Copy-Item -Path "$($nested.FullName)\*" -Destination $nodeDir -Recurse -Force - Remove-Item -Path $nested.FullName -Recurse -Force -ErrorAction SilentlyContinue - if (Test-Path "$nodeDir\node.exe") { - $env:PATH = "$nodeDir;$env:PATH" - if (Test-NodeCompatible) { - return $true - } - } - } - - Write-Host " [INFO] Installing portable Node.js LTS (v22.23.2) in user space..." -ForegroundColor Cyan - try { - $nodeVer = "v22.23.2" - $arch = if ([System.Environment]::Is64BitOperatingSystem) { - if ($env:PROCESSOR_ARCHITECTURE -match "ARM64") { "arm64" } else { "x64" } - } else { "x64" } - $zipPath = "$env:TEMP\node-$nodeVer-win-$arch.zip" - if (Invoke-DownloadFile -Uri "https://nodejs.org/dist/$nodeVer/node-$nodeVer-win-$arch.zip" -OutFile $zipPath -TimeoutSec 60) { - $extractDir = "$env:TEMP\node_extract" - if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue } - New-Item -ItemType Directory -Path $extractDir -Force | Out-Null - Expand-Archive -Path $zipPath -DestinationPath $extractDir -Force - $extractedFolder = Get-ChildItem -Path $extractDir -Directory | Select-Object -First 1 - if ($extractedFolder) { - if (-not (Test-Path $nodeDir)) { - New-Item -ItemType Directory -Path $nodeDir -Force | Out-Null - } - Copy-Item -Path "$($extractedFolder.FullName)\*" -Destination $nodeDir -Recurse -Force - } - Remove-Item $zipPath -Force -ErrorAction SilentlyContinue - Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue - if (Test-Path "$nodeDir\node.exe") { - $env:PATH = "$nodeDir;$env:PATH" - Write-Host " [OK] Portable Node.js $nodeVer installed in user space." -ForegroundColor Green - return $true - } - } - } catch { - Write-Host " [WARN] Failed to install portable Node.js: $_" -ForegroundColor DarkYellow - } - return $false -} - -# 1. Refresh PATH with standard toolchain paths -Update-EnvironmentPath - -# 2. Check or install uv -if (-not (Test-CommandExists "uv")) { - Write-Host " [INFO] uv not found. Installing Astral uv..." -ForegroundColor Yellow - try { - Invoke-Expression (Invoke-RestMethod -Uri "https://astral.sh/uv/install.ps1") - } catch { - powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" - } - Update-EnvironmentPath -} - -if (-not (Test-CommandExists "uv")) { - Write-Host " [ERROR] Astral 'uv' package manager could not be installed or found in PATH." -ForegroundColor Red - Write-Host " Please install uv manually from https://astral.sh/ or run:" -ForegroundColor Yellow - Write-Host " powershell -c ""irm https://astral.sh/uv/install.ps1 | iex""" -ForegroundColor Cyan - exit 1 -} - -# 3. Check and try installing missing system toolchains (ADB, FFmpeg, scrcpy) -$missingCore = @() -if (-not (Test-CommandExists "adb")) { $missingCore += "adb" } -if (-not (Test-CommandExists "ffmpeg")) { $missingCore += "ffmpeg" } -if (-not (Test-CommandExists "scrcpy")) { $missingCore += "scrcpy" } - -if ($missingCore.Count -gt 0) { - $useWinGet = $false - if (Test-CommandExists "winget") { - if ([Console]::IsInputRedirected -eq $false) { - Write-Host " [INFO] Missing components detected: $($missingCore -join ', ')." -ForegroundColor Cyan - $ans = Read-Host " Install system packages via WinGet? [Y/n]" - if ($ans -eq "" -or $ans -match "^[Yy]") { - $useWinGet = $true - } - } else { - $useWinGet = $true - } - } - - if ($useWinGet) { - Write-Host " [INFO] Auto-installing missing components ($($missingCore -join ', '))..." -ForegroundColor DarkYellow - if ($missingCore -contains "adb") { - winget install --id Google.PlatformTools -e --accept-source-agreements --accept-package-agreements --silent 2>$null | Out-Null - } - if ($missingCore -contains "ffmpeg") { - winget install --id Gyan.FFmpeg -e --accept-source-agreements --accept-package-agreements --silent 2>$null | Out-Null - } - if ($missingCore -contains "scrcpy") { - winget install --id Genymobile.scrcpy -e --accept-source-agreements --accept-package-agreements --silent 2>$null | Out-Null - } - Update-EnvironmentPath - } - - # Zero-admin user-space fallback for adb and scrcpy if still missing - if (-not (Test-CommandExists "adb")) { - Install-PortablePlatformTools - } - if (-not (Test-CommandExists "scrcpy")) { - Install-PortableScrcpy - } -} - -# Ensure local ADB daemon is warm & listening so probes do not hit 'Connection refused' -Start-LocalAdbServer - -# 4. Check or initialize .env -if (-not (Test-Path ".env")) { - if (Test-Path ".env.example") { - Copy-Item ".env.example" ".env" - Write-Host " [OK] Initialized .env configuration file." -ForegroundColor Green - } else { - New-Item -ItemType File -Path ".env" | Out-Null - } -} - -# 5. Synchronize dependencies -Write-Host " [INFO] Synchronizing Python runtime and project dependencies..." -ForegroundColor Cyan -uv sync --quiet - -# 6. Check and build Showcase UI if not already compiled -$ShowcaseIndex = "$RootDir\apps\showcase_ui\dist\frontend\browser\index.html" -$ShowcaseIndexAlt1 = "$RootDir\apps\showcase_ui\dist\browser\index.html" -$ShowcaseIndexAlt2 = "$RootDir\apps\showcase_ui\dist\index.html" -if ((-not (Test-Path $ShowcaseIndex)) -and (-not (Test-Path $ShowcaseIndexAlt1)) -and (-not (Test-Path $ShowcaseIndexAlt2))) { - if (-not (Test-NodeCompatible)) { - if (Test-CommandExists "node") { - $curVer = (& node -v 2>$null) - Write-Host " [INFO] Detected Node.js $curVer, but Angular CLI requires Node.js >= v22.22.0." -ForegroundColor Yellow - } else { - Write-Host " [INFO] Node.js/npm not found (required for Showcase UI)." -ForegroundColor Cyan - } - - # 1. Try nvm if available on Windows - if (Test-CommandExists "nvm") { - Write-Host " [INFO] Installing Node.js 22 LTS via nvm..." -ForegroundColor Cyan - & nvm install 22.23.2 | Out-Null - & nvm use 22.23.2 | Out-Null - Update-EnvironmentPath - } - - # 2. Try WinGet - if (-not (Test-NodeCompatible)) { - $useWinGetNode = $false - if (Test-CommandExists "winget") { - if ([Console]::IsInputRedirected -eq $false) { - Write-Host " [INFO] Node.js >= v22.22.0 required for Showcase UI." -ForegroundColor Cyan - $ans = Read-Host " Install/Upgrade Node.js via WinGet (may require Administrator)? [Y/n]" - if ($ans -eq "" -or $ans -match "^[Yy]") { - $useWinGetNode = $true - } - } else { - $useWinGetNode = $true - } - } - - if ($useWinGetNode) { - Write-Host " [INFO] Installing/Upgrading Node.js LTS via WinGet..." -ForegroundColor Yellow - winget install --id OpenJS.NodeJS.LTS -e --accept-source-agreements --accept-package-agreements --silent 2>$null | Out-Null - Update-EnvironmentPath - } - } - - # 3. Zero-admin user-space fallback for Node.js if still not compatible - if (-not (Test-NodeCompatible)) { - Install-PortableNode - } - } - - if (Test-NodeCompatible) { - $nodeVer = (& node -v 2>$null) - Write-Host " [OK] Node.js $nodeVer is ready." -ForegroundColor Green - Write-Host " [INFO] Showcase UI build not found. Compiling Angular Showcase UI..." -ForegroundColor Yellow - Push-Location "$RootDir\apps\showcase_ui" - try { - $npmExec = if (Test-CommandExists "npm.cmd") { "npm.cmd" } else { "npm" } - Write-Host " [INFO] Installing frontend npm dependencies..." -ForegroundColor Cyan - & $npmExec install --no-audit --no-fund --loglevel=error - if ($LASTEXITCODE -ne 0) { - Write-Host " [WARN] npm install returned exit code $LASTEXITCODE. Trying silent install..." -ForegroundColor DarkYellow - & $npmExec install --silent - } - $cliNodeVersion = "$RootDir\apps\showcase_ui\node_modules\@angular\cli\src\utilities\node-version.js" - if (Test-Path $cliNodeVersion) { - (Get-Content $cliNodeVersion) -replace '22\.22\.3', '22.22.0' | Set-Content $cliNodeVersion - } - Write-Host " [INFO] Building Angular frontend application..." -ForegroundColor Cyan - & $npmExec run build - if (Test-Path $ShowcaseIndex) { - Write-Host " [OK] Showcase UI compiled successfully." -ForegroundColor Green - } elseif (Test-Path $ShowcaseIndexAlt1) { - Write-Host " [OK] Showcase UI compiled successfully." -ForegroundColor Green - } else { - Write-Host " [WARN] Showcase UI build finished but index.html was not found in expected dist directory." -ForegroundColor DarkYellow - } - } catch { - Write-Host " [WARN] Failed to build Showcase UI: $_" -ForegroundColor DarkYellow - } finally { - Pop-Location - } - } else { - Write-Host " [WARN] Node.js >= v22.22.0 not found. Showcase UI will show fallback notice on launch." -ForegroundColor DarkYellow - } -} - -# 7. Optionally configure MCP for detected AI IDEs -Write-Host "" -Write-Host " [INFO] Would you like to configure ARTEMIS MCP & testing rules for your AI IDEs?" -ForegroundColor Cyan -Write-Host " (Supported: Antigravity, Cursor, Claude Code/Desktop, Codex, OpenClaw, Windsurf)" -$installMcp = "Y" -if ([Console]::IsInputRedirected -eq $false) { - $response = Read-Host " Install MCP configuration & rules now? [Y/n]" - if ($response -ne "") { - $installMcp = $response - } -} else { - $installMcp = "N" -} - -if ($installMcp -match "^[Yy]") { - Write-Host " Installing MCP server configuration & testing rules..." -ForegroundColor Cyan - uv run python -m artemis mcp --install all - if ($LASTEXITCODE -eq 0) { - Write-Host " [OK] MCP configuration and rules installed successfully." -ForegroundColor Green - Write-Host " [TIP] You can update or re-install anytime with: uv run artemis mcp --install all" -ForegroundColor Cyan - } else { - Write-Host " [WARN] MCP installation failed. Review the error above and retry with: uv run artemis mcp --install all" -ForegroundColor Yellow - } -} else { - Write-Host " [SKIP] You can install MCP anytime later with: uv run artemis mcp --install all" -ForegroundColor Yellow -} -Write-Host "" - -# 8. Launch unified Showcase UI & open browser -# Ensure local ADB daemon is warm & listening -Start-LocalAdbServer - -# Launch via `python -m artemis` (not the `artemis.exe` console-script shim) so the -# long-running server never locks .venv\Scripts\artemis.exe against `uv sync` reinstalls. -Write-Host " [INFO] Launching Artemis Showcase UI & Admin Console..." -ForegroundColor Green -if ($NoOpen) { - uv run python -m artemis ui --port $Port --no-open -} else { - uv run python -m artemis ui --port $Port --open -} - -if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE -} diff --git a/scripts/test_all_edge_cases.py b/scripts/test_all_edge_cases.py deleted file mode 100644 index 746707525..000000000 --- a/scripts/test_all_edge_cases.py +++ /dev/null @@ -1,269 +0,0 @@ -"""Comprehensive Edge-Case Test Suite for Artemis Multi-Device Concurrency & Unified Queue. - -Covers: -1. Single Device FIFO Serial Queueing & Relay -2. Multi-Device True Parallel Execution -3. Global Concurrency Serialization across Distinct Devices -4. Mixed Multi-Device Queueing -""" - -import asyncio -import os -import sys -import time -import requests - -# Ensure workspace in path -WORKSPACE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -if WORKSPACE_ROOT not in sys.path: - sys.path.insert(0, WORKSPACE_ROOT) - -from artemis.interfaces.sdk.client import ArtemisClient -from artemis.runtime.device_lock import DeviceExecutionLock -from artemis.toolchain import ensure_toolchain_in_path - -ensure_toolchain_in_path() - -from artemis.runtime import device_pool - - -def _resolve_devices() -> tuple[str, str]: - all_devs = device_pool.list_devices() - real_dev = next((d for d in all_devs if not d.is_emulator and d.state == "device"), None) - emu_dev = next((d for d in all_devs if d.is_emulator and d.state == "device"), None) - phone = os.environ.get("ARTEMIS_PHONE_SERIAL") or ( - real_dev.serial if real_dev else "63191FDKX00062" - ) - emu = os.environ.get("ARTEMIS_EMULATOR_SERIAL") or ( - emu_dev.serial if emu_dev else "emulator-5554" - ) - return phone, emu - - -PHONE_SERIAL, EMULATOR_SERIAL = _resolve_devices() -API_STATUS_URL = "http://127.0.0.1:8000/api/status" - - -def query_api_status(): - try: - res = requests.get(API_STATUS_URL, timeout=3).json() - active = res.get("active_tasks", []) - queue = res.get("queue", []) - return active, queue - except Exception as e: - return [], [] - - -def print_status_snapshot(stage_name: str): - active, queue = query_api_status() - print(f"\n[{stage_name}] Live Snapshot from Web Console (/api/status):") - print(f" ⚡ Running Tasks ({len(active)}):") - for a in active: - print(f" - Device: {a.get('device_id')} | Goal: {a.get('goal')} (PID: {a.get('pid')})") - print(f" ⏳ Queued Tasks ({len(queue)}):") - for q in queue: - dev = q.get("device_serial") or q.get("device_id") - print(f" - Device: {dev} | Goal: {q.get('goal')} [status={q.get('status')}]") - print(f" 📊 Total in Active Queue (Running + Pending) = {len(active) + len(queue)}\n") - - -async def stage_1_single_device_queueing(): - print("\n" + "=" * 80) - print("STAGE 1: Single Device FIFO Queueing & Relay") - print(f"Target Device: {PHONE_SERIAL} (Pixel 11 Pro)") - print("=" * 80) - - client_phone = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="per_device") - - t1_started = False - t2_queued = False - - async def task_1a(): - print(f"🚀 [Task 1A] Launching on {PHONE_SERIAL}: Press HOME and open Settings...") - res = await client_phone.run("Press HOME key and open Settings", profile="flash") - print(f"✅ [Task 1A] Finished with status: {res.status}") - return res - - async def task_1b(): - # Small delay so 1A is definitely acquired first - await asyncio.sleep(0.4) - print( - f"🚀 [Task 1B] Launching on {PHONE_SERIAL}: Press HOME and open Clock (Should queue!)..." - ) - res = await client_phone.run("Press HOME key and open Clock", profile="flash") - print(f"✅ [Task 1B] Finished with status: {res.status}") - return res - - # Run both simultaneously - task_a_coro = asyncio.create_task(task_1a()) - task_b_coro = asyncio.create_task(task_1b()) - - # Wait 1.5s and inspect status while 1A is running and 1B is queued - await asyncio.sleep(1.2) - print_status_snapshot("STAGE 1 In-Progress") - active, queue = query_api_status() - assert len(active) >= 1, "Expected Task 1A to be actively running!" - # 1B should be in the queue or 1A just finished - print(f"✓ Verified: Task 1A is running on {PHONE_SERIAL}") - - res_a, res_b = await asyncio.gather(task_a_coro, task_b_coro) - print("✓ Stage 1 Complete: Both tasks executed in order and completed successfully!") - - -async def stage_2_multi_device_parallel(): - print("\n" + "=" * 80) - print("STAGE 2: Multi-Device True Parallel Execution") - print(f"Devices: {PHONE_SERIAL} (Pixel 11 Pro) AND {EMULATOR_SERIAL} (Emulator)") - print("Concurrency Mode: per_device (Should run simultaneously!)") - print("=" * 80) - - client_phone = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="per_device") - client_emu = ArtemisClient(device_serial=EMULATOR_SERIAL, concurrency_mode="per_device") - - start_time = time.time() - - async def task_phone(): - print(f"📱 [Phone Task] Running on {PHONE_SERIAL}: Open Settings...") - res = await client_phone.run("Press HOME key and open Settings", profile="flash") - print(f"✅ [Phone Task] Completed in {time.time() - start_time:.2f}s") - return res - - async def task_emu(): - print(f"💻 [Emulator Task] Running on {EMULATOR_SERIAL}: Open Settings...") - res = await client_emu.run("Press HOME key and open Settings", profile="flash") - print(f"✅ [Emulator Task] Completed in {time.time() - start_time:.2f}s") - return res - - p_task = asyncio.create_task(task_phone()) - e_task = asyncio.create_task(task_emu()) - - # Check during run - await asyncio.sleep(1.2) - print_status_snapshot("STAGE 2 Parallel In-Progress") - active, queue = query_api_status() - print(f"Active devices count: {len(active)}") - active_devices = {a.get("device_id") for a in active} - print(f"Active devices: {active_devices}") - - await asyncio.gather(p_task, e_task) - total_duration = time.time() - start_time - print(f"✓ Stage 2 Complete: Both devices completed parallel tasks in {total_duration:.2f}s!") - - -async def stage_3_global_concurrency_mode(): - print("\n" + "=" * 80) - print("STAGE 3: Global Concurrency Mode Serialization") - print("Concurrency Mode: global (max 1 task system-wide even across different devices)") - print("=" * 80) - - client_phone_global = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="global") - client_emu_global = ArtemisClient(device_serial=EMULATOR_SERIAL, concurrency_mode="global") - - async def task_phone_g(): - print(f"📱 [Task 3A - Phone] Acquiring global lock on {PHONE_SERIAL}...") - res = await client_phone_global.run("Press HOME key and open Settings", profile="flash") - print("✅ [Task 3A - Phone] Finished.") - return res - - async def task_emu_g(): - await asyncio.sleep(0.4) - print( - f"💻 [Task 3B - Emulator] Requesting run on {EMULATOR_SERIAL} under global mode (Should queue!)..." - ) - res = await client_emu_global.run("Press HOME key and open Settings", profile="flash") - print("✅ [Task 3B - Emulator] Finished.") - return res - - p_task = asyncio.create_task(task_phone_g()) - e_task = asyncio.create_task(task_emu_g()) - - await asyncio.sleep(1.2) - print_status_snapshot("STAGE 3 Global Mode In-Progress") - active, queue = query_api_status() - print(f"Active count: {len(active)}, Queue count: {len(queue)}") - - await asyncio.gather(p_task, e_task) - print( - "✓ Stage 3 Complete: Global lock correctly serialized execution across physical phone and emulator!" - ) - - -async def stage_4_mixed_queue_and_parallel(): - print("\n" + "=" * 80) - print("STAGE 4: Mixed Multi-Device Queueing") - print("Scenario: Phone gets 2 tasks (4A, 4B), Emulator gets 1 task (4C).") - print("Expectation: 4A (Phone) and 4C (Emulator) run concurrently; 4B waits for Phone.") - print("=" * 80) - - client_phone = ArtemisClient(device_serial=PHONE_SERIAL, concurrency_mode="per_device") - client_emu = ArtemisClient(device_serial=EMULATOR_SERIAL, concurrency_mode="per_device") - - async def task_4a(): - print("📱 [Task 4A - Phone] Running...") - return await client_phone.run("Press HOME key and open Settings", profile="flash") - - async def task_4b(): - await asyncio.sleep(0.4) - print("📱 [Task 4B - Phone] Queued behind 4A...") - return await client_phone.run("Press HOME key and open Clock", profile="flash") - - async def task_4c(): - print("💻 [Task 4C - Emulator] Running in parallel with 4A...") - return await client_emu.run("Press HOME key and open Settings", profile="flash") - - t4a = asyncio.create_task(task_4a()) - t4b = asyncio.create_task(task_4b()) - t4c = asyncio.create_task(task_4c()) - - await asyncio.sleep(1.2) - print_status_snapshot("STAGE 4 Mixed Multi-Device In-Progress") - - await asyncio.gather(t4a, t4b, t4c) - print("✓ Stage 4 Complete: 4A and 4C ran in parallel, 4B queued and picked up phone relay!") - - -async def main(): - import argparse - - global PHONE_SERIAL, EMULATOR_SERIAL - - parser = argparse.ArgumentParser(description="ARTEMIS All Edge-Cases Comprehensive Test Suite") - parser.add_argument("--phone", default=None, help="Target physical phone serial number") - parser.add_argument("--emulator", default=None, help="Target Android emulator serial number") - args = parser.parse_args() - - if args.phone: - PHONE_SERIAL = args.phone - if args.emulator: - EMULATOR_SERIAL = args.emulator - - print("\n" + "#" * 80) - print("# ARTEMIS ALL EDGE-CASES COMPREHENSIVE CONCURRENCY & QUEUE TEST SUITE") - print(f"# Devices: Real Phone ({PHONE_SERIAL}) + Android Emulator ({EMULATOR_SERIAL})") - print("# Web Console API: http://127.0.0.1:8000/api/status") - print("#" * 80) - - # Initial cleanup - DeviceExecutionLock.cleanup_stale_locks() - print_status_snapshot("Initial System State") - - # Run all 4 stages - await stage_1_single_device_queueing() - await asyncio.sleep(1) - - await stage_2_multi_device_parallel() - await asyncio.sleep(1) - - await stage_3_global_concurrency_mode() - await asyncio.sleep(1) - - await stage_4_mixed_queue_and_parallel() - - print("\n" + "=" * 80) - print("🎉 ALL EDGE-CASE STAGES COMPLETED SUCCESSFULLY!") - print("=" * 80) - print_status_snapshot("Final System State") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/scripts/verify_concurrency_and_devices.py b/scripts/verify_concurrency_and_devices.py deleted file mode 100644 index ef88d0194..000000000 --- a/scripts/verify_concurrency_and_devices.py +++ /dev/null @@ -1,385 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2026 Google LLC -"""Live experiment demonstrating Artemis concurrency modes and device targeting. - -Covers: -1. Single-device queueing and relay (FIFO mutual exclusion on the same device). -2. Multi-device parallel execution (per_device concurrency mode across real device & emulator). -3. Cross-device global serialization (global concurrency mode restricting all devices to 1 task). -4. Full parameter coverage (device_serial, set_device, Task objects, list_devices, get_idle_devices). -""" - -import asyncio -import os -import sys -import time -from datetime import datetime - -# Add project root to sys.path -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from artemis import ArtemisClient, ConcurrencyMode, Task -from artemis.runtime import DeviceBusyError, device_pool - - -def print_header(title: str): - print("\n" + "=" * 76) - print(f" {title}") - print("=" * 76) - - -def now_str() -> str: - return datetime.now().strftime("%H:%M:%S.%f")[:-3] - - -async def run_experiment_1_single_device_relay(real_serial: str): - """Experiment 1: Single device FIFO queueing & relay.""" - print_header("[Experiment 1] Single-Device Task Queueing & Relay (FIFO Relay on Single Device)") - print(f"Target Device: {real_serial} (Physical Pixel 11 Pro)") - - client = ArtemisClient( - device_serial=real_serial, default_profile="flash", concurrency_mode="per_device" - ) - print( - f"Client Init: device_serial='{client.device_serial}', concurrency_mode='{client.concurrency_mode}'" - ) - - task_1_launched = 0.0 - task_1_start = 0.0 - task_1_end = 0.0 - - task_2_launched = 0.0 - task_2_start = 0.0 - task_2_end = 0.0 - - async def execute_task_1(): - nonlocal task_1_launched, task_1_start, task_1_end - task_1_launched = time.time() - print(f"[{now_str()}] ▶ [Task 1] Submitted for scheduling (target device: {real_serial})") - task_1_start = time.time() - res = await client.run( - "Open Settings and check battery level", - device_serial=real_serial, - max_turns=3, - ) - task_1_end = time.time() - print( - f"[{now_str()}] ◀ [Task 1] Execution completed! Status={res.status}, " - f"Trace={res.trace_id[:8]}..., Elapsed={task_1_end - task_1_start:.3f}s" - ) - return res - - async def execute_task_2(): - nonlocal task_2_launched, task_2_start, task_2_end - task_2_launched = time.time() - print( - f"[{now_str()}] ▶ [Task 2] Submitted concurrently (detects device occupied by Task 1, entering FIFO queue...)" - ) - - task_2_start = time.time() - res = await client.run( - "Return to home screen and open Clock", - device_serial=real_serial, - max_turns=3, - ) - task_2_end = time.time() - print( - f"[{now_str()}] ◀ [Task 2] Relayed execution completed! Status={res.status}, " - f"Trace={res.trace_id[:8]}..., Elapsed={task_2_end - task_2_start:.3f}s" - ) - return res - - # Launch Task 1 first, then Task 2 40ms later while Task 1 is holding the lock - t_coro_1 = asyncio.create_task(execute_task_1()) - await asyncio.sleep(0.04) - t_coro_2 = asyncio.create_task(execute_task_2()) - res1, res2 = await asyncio.gather(t_coro_1, t_coro_2) - - print("\n[Timing & Relay Analysis]:") - print( - f" Task 1 runtime interval: [{task_1_start:.3f}s ~ {task_1_end:.3f}s] (elapsed {task_1_end - task_1_start:.3f}s)" - ) - print(f" Task 2 submission time: {task_2_launched:.3f}s") - print(f" Task 2 actual start: {task_2_start:.3f}s (waited for Task 1 lock release)") - print(f" Task 2 end time: {task_2_end:.3f}s (elapsed {task_2_end - task_2_start:.3f}s)") - - assert task_2_end > task_1_end, "Task 2 did not complete after Task 1!" - assert task_2_end - task_1_start >= (task_1_end - task_1_start) + 0.1, ( - "Tasks failed to execute in sequential queue!" - ) - print( - " ✅ Verification successful: Task 2 queued properly and relayed immediately after Task 1 released the lock!" - ) - - -async def run_experiment_2_multi_device_parallel(real_serial: str, emu_serial: str): - """Experiment 2: Multi-device parallel execution under per_device concurrency.""" - print_header("[Experiment 2] Multi-Device Parallel Execution Test") - print(f"Device A (Physical): {real_serial}") - print(f"Device B (Emulator): {emu_serial}") - - client_a = ArtemisClient( - device_serial=real_serial, default_profile="flash", concurrency_mode="per_device" - ) - client_b = ArtemisClient( - device_serial=emu_serial, default_profile="flash", concurrency_mode="per_device" - ) - - a_start, a_end = 0.0, 0.0 - b_start, b_end = 0.0, 0.0 - - async def run_on_device_a(): - nonlocal a_start, a_end - print(f"[{now_str()}] ▶ [Device A Task] Starting on {real_serial}") - a_start = time.time() - res = await client_a.run("Test operation A on physical device", max_turns=3) - a_end = time.time() - print( - f"[{now_str()}] ◀ [Device A Task] Completed! Elapsed: {a_end - a_start:.3f}s, Device: {res.device_id}" - ) - return res - - async def run_on_device_b(): - nonlocal b_start, b_end - print(f"[{now_str()}] ▶ [Device B Task] Starting on {emu_serial}") - b_start = time.time() - res = await client_b.run("Test operation B on emulator", max_turns=3) - b_end = time.time() - print( - f"[{now_str()}] ◀ [Device B Task] Completed! Elapsed: {b_end - b_start:.3f}s, Device: {res.device_id}" - ) - return res - - t_start = time.time() - task_a = asyncio.create_task(run_on_device_a()) - task_b = asyncio.create_task(run_on_device_b()) - res_a, res_b = await asyncio.gather(task_a, task_b) - t_total = time.time() - t_start - - print("\n[Dual-Device Concurrency Analysis]:") - print( - f" Device A execution interval: {a_start:.3f}s ~ {a_end:.3f}s (elapsed {a_end - a_start:.3f}s)" - ) - print( - f" Device B execution interval: {b_start:.3f}s ~ {b_end:.3f}s (elapsed {b_end - b_start:.3f}s)" - ) - print( - f" Total concurrent duration: {t_total:.3f}s (sum of sequential durations: {(a_end - a_start) + (b_end - b_start):.3f}s)" - ) - - # Overlap verification - overlap = min(a_end, b_end) - max(a_start, b_start) - print(f" Parallel overlap duration: {overlap:.3f}s") - assert overlap > 0.0, "Dual devices failed to run concurrently with overlap!" - assert t_total < ((a_end - a_start) + (b_end - b_start) * 0.95), ( - "Total duration should be less than sum of both tasks!" - ) - print(" ✅ Verification successful: Tasks on both devices ran concurrently without blocking!") - - -async def run_experiment_3_global_concurrency_serialization(real_serial: str, emu_serial: str): - """Experiment 3: Global concurrency serialization across devices.""" - print_header("[Experiment 3] Global Concurrency Mode Test") - print( - "Mode description: concurrency_mode='global', entire system allows only 1 task across all devices." - ) - - client_a = ArtemisClient( - device_serial=real_serial, default_profile="flash", concurrency_mode="global" - ) - client_b = ArtemisClient( - device_serial=emu_serial, default_profile="flash", concurrency_mode="global" - ) - - # 3.1 Test non-blocking mode rejection - print("\n[Subtest 3.1: Non-blocking rejection limit]") - from artemis.runtime import DeviceExecutionLock - - lock_a = DeviceExecutionLock(real_serial, "Placeholder test lock", concurrency_mode="global") - await asyncio.to_thread(lock_a.acquire) - try: - print(f"[{now_str()}] Device A ({real_serial}) holds the global lock...") - try: - print(f"[{now_str()}] Device B ({emu_serial}) attempts run with blocking=False...") - await client_b.run("Emulator non-blocking task", blocking=False) - print(" ❌ Exception: Was not intercepted by global lock!") - except DeviceBusyError as exc: - print(f" ✅ Correctly intercepted with DeviceBusyError: {exc}") - finally: - await asyncio.to_thread(lock_a.release) - - # 3.2 Test cross-device queueing and relay - print("\n[Subtest 3.2: Cross-device global queueing & relay]") - t1_submitted, t1_end = 0.0, 0.0 - t2_submitted, t2_end = 0.0, 0.0 - - async def run_global_task_1(): - nonlocal t1_submitted, t1_end - t1_submitted = time.time() - print(f"[{now_str()}] ▶ [Global Task 1] Started on physical device ({real_serial})...") - res = await client_a.run("Global task 1", max_turns=3) - t1_end = time.time() - print(f"[{now_str()}] ◀ [Global Task 1] Completed! Elapsed: {t1_end - t1_submitted:.3f}s") - return res - - async def run_global_task_2(): - nonlocal t2_submitted, t2_end - # Submit task 2 shortly after task 1 - await asyncio.sleep(0.04) - t2_submitted = time.time() - print( - f"[{now_str()}] ▶ [Global Task 2] Submitted to emulator ({emu_serial}), entering queue due to global limit..." - ) - res = await client_b.run("Global task 2", max_turns=3) - t2_end = time.time() - print( - f"[{now_str()}] ◀ [Global Task 2] Relayed completion! Total time (including queue): {t2_end - t2_submitted:.3f}s" - ) - return res - - res1, res2 = await asyncio.gather(run_global_task_1(), run_global_task_2()) - print( - f" Global Task 1 interval: {t1_submitted:.3f}s ~ {t1_end:.3f}s (elapsed {t1_end - t1_submitted:.3f}s)" - ) - print(f" Global Task 2 submit time: {t2_submitted:.3f}s") - print( - f" Global Task 2 end time: {t2_end:.3f}s (waited for Task 1 completion before executing)" - ) - assert t2_end > t1_end, "Global Task 2 did not complete after Global Task 1!" - assert (t2_end - t1_end) >= 0.08, ( - "Global Task 2 did not execute sequentially after Task 1 released global lock!" - ) - print( - " ✅ Verification successful: Under global mode, strictly 1 task executed sequentially across different devices!" - ) - - -async def run_experiment_4_parameters_and_utilities(real_serial: str, emu_serial: str): - """Experiment 4: Comprehensive SDK parameter and utility testing.""" - print_header("[Experiment 4] SDK Parameter & Utility Comprehensive Test") - - client = ArtemisClient() - print(f"1. Default idle device selection: client.device_serial = '{client.device_serial}'") - - # Chain switch device - client.set_device(real_serial) - print( - f"2. Chain switch to physical device: client.set_device('{real_serial}') -> '{client.device_serial}'" - ) - assert client.device_serial == real_serial - - # Attribute switch - client.device_serial = emu_serial - print( - f"3. Property assignment to emulator: client.device_serial = '{emu_serial}' -> '{client.device_id}'" - ) - assert client.device_id == emu_serial - - # Dynamically change concurrency mode - client.set_concurrency_mode(ConcurrencyMode.GLOBAL) - print( - f"4. Chain change concurrency mode: client.concurrency_mode = '{client.concurrency_mode}'" - ) - assert client.concurrency_mode == "global" - client.concurrency_mode = "per_device" - - # Query all available devices - print("5. client.list_devices() status:") - devices = client.list_devices() - for d in devices: - print( - f" • [{d.serial}] State: {d.state}, Model: {d.model}, IsEmulator: {d.is_emulator}, Busy: {d.is_busy}" - ) - - # Query idle devices - idle = client.get_idle_devices() - print(f"6. client.get_idle_devices(): {[d.serial for d in idle]}") - - # Use Task structured object with run_task - print("7. Calling client.run_task() with structured Task entity:") - task_obj = Task( - goal="Verify physical device call via Task object", - profile="flash", - device_serial=real_serial, - concurrency_mode="per_device", - max_turns=3, - ) - result = await client.run_task(task_obj) - print( - f" Task result: Status={result.status}, Device={result.device_id}, Trace={result.trace_id[:8]}..." - ) - assert result.device_id == real_serial - - # Use stream_run for live event stream - print("8. Using client.stream_run() for live event stream:") - events_received = 0 - async for ev in client.stream_run( - "Stream operation test", - device_serial=emu_serial, - profile="flash", - max_turns=3, - ): - events_received += 1 - print(f" • [{ev.event_type}] payload={ev.payload}") - assert events_received >= 2 - print(f" ✅ Stream events captured successfully! Captured {events_received} events.") - - -async def main(): - # Detect devices - devices = device_pool.list_devices() - print("=" * 76) - print("[ARTEMIS Python SDK Concurrency & Device Targeting Experiment]") - print(f"Detected connected devices count: {len(devices)}") - for d in devices: - print( - f" • Serial: {d.serial} | Model: {d.model} | State: {d.state} | IsEmulator: {d.is_emulator}" - ) - print("=" * 76) - - import argparse - - parser = argparse.ArgumentParser( - description="Live experiment demonstrating Artemis concurrency modes and device targeting." - ) - parser.add_argument( - "--phone", - default=os.environ.get("ARTEMIS_PHONE_SERIAL"), - help="Target physical phone serial number", - ) - parser.add_argument( - "--emulator", - default=os.environ.get("ARTEMIS_EMULATOR_SERIAL"), - help="Target Android emulator serial number", - ) - args, _ = parser.parse_known_args() - - # Auto-detect connected devices if not explicitly provided - real_dev = next((d for d in devices if not d.is_emulator and d.state == "device"), None) - emu_dev = next((d for d in devices if d.is_emulator and d.state == "device"), None) - - real_serial = args.phone or (real_dev.serial if real_dev else "63191FDKX00062") - emu_serial = args.emulator or (emu_dev.serial if emu_dev else "emulator-5554") - - # Verify both devices are available - serials = [d.serial for d in devices] - if real_serial not in serials or emu_serial not in serials: - print( - f"Error: Both physical device '{real_serial}' and emulator '{emu_serial}' must be connected for full experiment." - ) - print(f"Currently online devices: {serials}") - return - - # Run experiments in sequence - await run_experiment_1_single_device_relay(real_serial) - await run_experiment_2_multi_device_parallel(real_serial, emu_serial) - await run_experiment_3_global_concurrency_serialization(real_serial, emu_serial) - await run_experiment_4_parameters_and_utilities(real_serial, emu_serial) - - print_header( - "🎉 All experiments completed successfully! All concurrency and device targeting verified!" - ) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/setup.py b/setup.py deleted file mode 100644 index 054260cbc..000000000 --- a/setup.py +++ /dev/null @@ -1,163 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -from pathlib import Path -import shutil - -from setuptools import Extension, find_namespace_packages, setup -from setuptools.command.build_py import build_py as _build_py -from setuptools.command.sdist import sdist as _sdist - -USE_CYTHON = os.environ.get("USE_CYTHON", "0") == "1" - - -def _showcase_dist(source_root: Path) -> Path | None: - """Locate a complete Angular browser build in a source or sdist tree.""" - base_dist = source_root / "apps" / "showcase_ui" / "dist" - candidates = ( - base_dist / "frontend" / "browser", - base_dist / "browser", - base_dist / "frontend", - base_dist, - source_root / "artemis" / "resources" / "showcase_ui", - ) - return next((path for path in candidates if (path / "index.html").is_file()), None) - - -def _copy_release_resources(source_root: Path, resource_root: Path) -> None: - """Stage immutable runtime resources into a wheel or source distribution.""" - config_candidates = ( - source_root / "config" / "artemis.jsonc", - source_root / "artemis" / "resources" / "config" / "artemis.jsonc", - ) - config_source = next((path for path in config_candidates if path.is_file()), None) - if config_source is None: - raise RuntimeError("Cannot build Artemis: config/artemis.jsonc is missing.") - - showcase_source = _showcase_dist(source_root) - if showcase_source is None: - raise RuntimeError( - "Cannot build an Artemis distribution without the Showcase UI. " - "Run `npm ci --prefix apps/showcase_ui` and " - "`npm run build --prefix apps/showcase_ui` first." - ) - config_target = resource_root / "config" - config_target.mkdir(parents=True, exist_ok=True) - shutil.copy2(config_source, config_target / "artemis.jsonc") - - showcase_target = resource_root / "showcase_ui" - if showcase_target.exists(): - shutil.rmtree(showcase_target) - shutil.copytree(showcase_source, showcase_target) - - -class build_py(_build_py): - """Build Python modules, then stage config and precompiled web assets.""" - - def run(self) -> None: - super().run() - # Editable installs use the checkout's config and frontend assets. - if self.editable_mode: - return - # Older builds may have copied frontend sources and node_modules here. - excluded_showcase = Path(self.build_lib) / "apps" / "showcase_ui" - if excluded_showcase.exists(): - shutil.rmtree(excluded_showcase) - _copy_release_resources( - Path.cwd(), - Path(self.build_lib) / "artemis" / "resources", - ) - - -class sdist(_sdist): - """Make sdists self-contained so wheels can be built from their archive.""" - - def make_release_tree(self, base_dir: str, files: list[str]) -> None: - # Old egg-info manifests may include Python packages from node_modules. - filtered_files = [ - path for path in files if not Path(path).as_posix().startswith("apps/showcase_ui/") - ] - super().make_release_tree(base_dir, filtered_files) - # Protect local rebuilds from stale egg-info/SOURCES.txt manifests - # generated before package discovery was narrowed. - excluded_showcase = Path(base_dir) / "apps" / "showcase_ui" - if excluded_showcase.exists(): - shutil.rmtree(excluded_showcase) - _copy_release_resources( - Path.cwd(), - Path(base_dir) / "artemis" / "resources", - ) - - -ext_modules = [] - -if USE_CYTHON: - from Cython.Build import cythonize - - print(f"USE_CYTHON: {USE_CYTHON}") - - extensions = [] - src_dir = Path("artemis") - - # Recursively find all .py files inside the artemis/ directory for optional acceleration - for py_file in src_dir.rglob("*.py"): - rel_path = py_file.relative_to(src_dir) - mod_parts = ("artemis",) + rel_path.with_suffix("").parts - module_name = ".".join(mod_parts) - - if module_name in ( - "artemis.__init__", - "artemis._legacy_imports", - "artemis.main", - "artemis.mcp.adb_server", - "artemis.mcp.xml_search_server", - ): - continue - - extensions.append(Extension(module_name, [str(py_file)])) - - ext_modules = cythonize( - extensions, - compiler_directives={"language_level": "3"}, - build_dir="build/cythonized", - ) - -setup( - # Name and version come from pyproject.toml. - packages=find_namespace_packages( - include=[ - "artemis", - "artemis.*", - "mcp_server", - "mcp_server.*", - "apps", - "apps.admin_console", - "apps.admin_console.*", - "third_party.*", - ], - ), - package_data={ - "artemis": ["**/*.json", "**/*.md"], - "artemis.resources": ["config/*.jsonc", "showcase_ui/*", "showcase_ui/**/*"], - "apps.admin_console": ["index.html"], - # third_party has no __init__.py (namespace package), so it does not clash - # with other distributions that also ship a third_party directory. - "third_party.mobile_use": ["LICENSE", "NOTICE", "METADATA", "**/*.md"], - }, - include_package_data=False, - ext_modules=ext_modules, - exclude_package_data={"": ["*.c", "*.pyx", "*.pxd", "*.cpp"]}, - cmdclass={"build_py": build_py, "sdist": sdist}, -) diff --git a/start.bat b/start.bat deleted file mode 100644 index d527b27bf..000000000 --- a/start.bat +++ /dev/null @@ -1,35 +0,0 @@ -@echo off -rem Copyright 2026 Google LLC -rem -rem Licensed under the Apache License, Version 2.0 (the "License"); -rem you may not use this file except in compliance with the License. -rem You may obtain a copy of the License at -rem -rem http://www.apache.org/licenses/LICENSE-2.0 -rem -rem Unless required by applicable law or agreed to in writing, software -rem distributed under the License is distributed on an "AS IS" BASIS, -rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -rem See the License for the specific language governing permissions and -rem limitations under the License. - -chcp 65001 >nul 2>&1 -setlocal EnableDelayedExpansion -title Artemis Mobile Agent UI - -cd /d "%~dp0" - -echo ====================================================== -echo Artemis Autonomous Mobile Agent UI -echo ====================================================== -echo. - -rem Run the PowerShell bootstrap with execution policy bypass -powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\start.ps1" %* - -if %ERRORLEVEL% NEQ 0 ( - echo. - echo An error occurred while starting Artemis. - pause -) - diff --git a/start.sh b/start.sh deleted file mode 100755 index e288f329d..000000000 --- a/start.sh +++ /dev/null @@ -1,423 +0,0 @@ -#!/usr/bin/env bash -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -set -euo pipefail - -BOLD='\033[1m' -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -CYAN='\033[0;36m' -NC='\033[0m' - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "${SCRIPT_DIR}" - -echo -e "${BOLD}${CYAN}======================================================${NC}" -echo -e "${BOLD}${CYAN} ✨ Artemis Autonomous Mobile Agent UI ${NC}" -echo -e "${BOLD}${CYAN}======================================================${NC}" -echo "" - -# 1. Normalize PATH with standard toolchain and package manager paths -STANDARD_PATHS=( - "/opt/homebrew/bin" - "/opt/homebrew/sbin" - "/usr/local/bin" - "/usr/local/sbin" - "${HOME}/.local/bin" - "${HOME}/.local/share/node/bin" - "${HOME}/.local/share/platform-tools" - "${HOME}/.local/share/scrcpy" - "${HOME}/.cargo/bin" - "${HOME}/Library/Android/sdk/platform-tools" - "${HOME}/Android/Sdk/platform-tools" -) -for p in "${STANDARD_PATHS[@]}"; do - if [ -d "${p}" ] && [[ ":${PATH}:" != *":${p}:"* ]]; then - export PATH="${p}:${PATH}" - fi -done - -# Sudo credential and choice state tracking -SUDO_AUTHENTICATED=false -SUDO_DECLINED=false - -# Helper to check, prompt for, or skip administrator (sudo) privileges gracefully -request_sudo() { - [ "$(id -u)" -eq 0 ] && return 0 - if ! command -v sudo >/dev/null 2>&1; then - return 1 - fi - # If already authenticated or passwordless sudo works - if [ "${SUDO_AUTHENTICATED}" = true ] || sudo -n true >/dev/null 2>&1; then - SUDO_AUTHENTICATED=true - return 0 - fi - # If previously declined or failed in this session, don't nag again - if [ "${SUDO_DECLINED}" = true ]; then - return 1 - fi - # If interactive terminal, ask user whether to enter sudo password - if [ -t 0 ]; then - local action="${1:-install system packages}" - echo -e " ${CYAN}🔐 Administrator (sudo) privileges can be used to ${action}.${NC}" - local reply="" - read -r -p " Enter sudo password now? [Y/n]: " reply - reply=${reply:-Y} - if [[ "${reply}" =~ ^[Yy]$ ]]; then - if sudo -v; then - SUDO_AUTHENTICATED=true - echo -e " ${GREEN}✔ Sudo authenticated successfully.${NC}" - return 0 - else - echo -e " ${YELLOW}⚠ Sudo authentication failed or cancelled. Using user-space fallback.${NC}" - SUDO_DECLINED=true - return 1 - fi - else - echo -e " ${YELLOW}⏭️ Skipped sudo. Using user-space fallback.${NC}" - SUDO_DECLINED=true - return 1 - fi - fi - return 1 -} - -# Helper to check if Node.js & npm meet Angular CLI 22 requirement (>= 22.22.0, >= 24.15.0, or >= 26.0.0) -is_node_compatible() { - if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then - return 1 - fi - local node_ver - node_ver="$(node -v 2>/dev/null | tr -d 'v')" - [ -z "${node_ver}" ] && return 1 - local major minor - major="$(echo "${node_ver}" | cut -d. -f1)" - minor="$(echo "${node_ver}" | cut -d. -f2)" - if [ "${major}" -ge 26 ] 2>/dev/null; then - return 0 - elif [ "${major}" -ge 24 ] 2>/dev/null; then - [ "${minor}" -ge 15 ] 2>/dev/null && return 0 - elif [ "${major}" -ge 22 ] 2>/dev/null; then - [ "${minor}" -ge 22 ] 2>/dev/null && return 0 - fi - return 1 -} - -# 2. Check or install uv (Fast Python package manager) -if ! command -v uv >/dev/null 2>&1; then - echo -e "${YELLOW}⚡ uv not found. Installing Astral uv...${NC}" - curl -LsSf https://astral.sh/uv/install.sh | sh - export PATH="${HOME}/.local/bin:${HOME}/.cargo/bin:${PATH}" -fi - -# 3. Check and auto-install missing system toolchains (ADB, FFmpeg, scrcpy) -# Discover standard Android SDK / user-space locations if adb not in PATH -if ! command -v adb >/dev/null 2>&1; then - for candidate in \ - "${ANDROID_HOME:-}/platform-tools" \ - "${ANDROID_SDK_ROOT:-}/platform-tools" \ - "${HOME}/Library/Android/sdk/platform-tools" \ - "${HOME}/Android/Sdk/platform-tools" \ - "${HOME}/.local/share/platform-tools" \ - "/opt/homebrew/bin" \ - "/usr/local/bin"; do - if [ -n "${candidate}" ] && [ -x "${candidate}/adb" ]; then - export PATH="${candidate}:${PATH}" - break - fi - done -fi - -MISSING_CORE=() -if ! command -v adb >/dev/null 2>&1; then MISSING_CORE+=("adb"); fi -if ! command -v ffmpeg >/dev/null 2>&1; then MISSING_CORE+=("ffmpeg"); fi -if ! command -v scrcpy >/dev/null 2>&1; then MISSING_CORE+=("scrcpy"); fi - -if [ ${#MISSING_CORE[@]} -gt 0 ]; then - OS_NAME="$(uname -s)" - if [ "${OS_NAME}" = "Darwin" ]; then - export HOMEBREW_NO_AUTO_UPDATE=1 - export HOMEBREW_NO_INSTALL_CLEANUP=1 - export HOMEBREW_NO_ENV_HINTS=1 - - if ! command -v brew >/dev/null 2>&1; then - if [ -x "/opt/homebrew/bin/brew" ]; then - eval "$(/opt/homebrew/bin/brew shellenv)" - elif [ -x "/usr/local/bin/brew" ]; then - eval "$(/usr/local/bin/brew shellenv)" - fi - fi - - if command -v brew >/dev/null 2>&1; then - echo -e " ${YELLOW}⚡ Auto-installing missing components (${MISSING_CORE[*]})...${NC}" - if ! command -v adb >/dev/null 2>&1; then - brew install --cask android-platform-tools >/dev/null 2>&1 || brew install android-platform-tools >/dev/null 2>&1 || true - fi - if ! command -v ffmpeg >/dev/null 2>&1; then - brew install ffmpeg >/dev/null 2>&1 || true - fi - if ! command -v scrcpy >/dev/null 2>&1; then - brew install scrcpy >/dev/null 2>&1 || true - fi - fi - elif [ "${OS_NAME}" = "Linux" ]; then - if request_sudo "install missing system components (${MISSING_CORE[*]})"; then - SUDO_PREFIX="" - if [ "$(id -u)" -ne 0 ]; then SUDO_PREFIX="sudo"; fi - if command -v apt-get >/dev/null 2>&1; then - echo -e " ${CYAN}📦 Installing missing packages (${MISSING_CORE[*]}) via apt-get...${NC}" - DEBIAN_FRONTEND=noninteractive ${SUDO_PREFIX} apt-get -o DPkg::Lock::Timeout=3 -o Acquire::http::Timeout=3 -o Acquire::https::Timeout=3 install -y -qq "${MISSING_CORE[@]}" 2>/dev/null || true - elif command -v dnf >/dev/null 2>&1; then - ${SUDO_PREFIX} dnf install -y "${MISSING_CORE[@]}" 2>/dev/null || true - elif command -v pacman >/dev/null 2>&1; then - ${SUDO_PREFIX} pacman -S --noconfirm "${MISSING_CORE[@]}" 2>/dev/null || true - fi - fi - - # Fallback: if scrcpy is still missing, install official precompiled portable scrcpy in user space - if ! command -v scrcpy >/dev/null 2>&1; then - ARCH="$(uname -m)" - SCRCPY_ARCH="" - case "${ARCH}" in - x86_64|amd64) SCRCPY_ARCH="x86_64" ;; - aarch64|arm64) SCRCPY_ARCH="aarch64" ;; - esac - if [ -n "${SCRCPY_ARCH}" ]; then - SCRCPY_DIR="${HOME}/.local/share/scrcpy" - if [ ! -x "${SCRCPY_DIR}/scrcpy" ]; then - echo -e " ${CYAN}📦 Installing portable scrcpy in user space (~/.local)...${NC}" - mkdir -p "${SCRCPY_DIR}" "${HOME}/.local/bin" - SCRCPY_URL="https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-linux-${SCRCPY_ARCH}-v4.1.tar.gz" - if curl -fsSL --connect-timeout 5 --max-time 30 "${SCRCPY_URL}" | tar -xz -C "${SCRCPY_DIR}" --strip-components=1 2>/dev/null; then - ln -sf "${SCRCPY_DIR}/scrcpy" "${HOME}/.local/bin/scrcpy" - export PATH="${SCRCPY_DIR}:${PATH}" - echo -e " ${GREEN}✓ scrcpy installed in user space.${NC}" - fi - else - ln -sf "${SCRCPY_DIR}/scrcpy" "${HOME}/.local/bin/scrcpy" - export PATH="${SCRCPY_DIR}:${PATH}" - fi - fi - fi - - # Fallback: if adb is missing on Linux without root/sudo, install platform-tools in user space - if ! command -v adb >/dev/null 2>&1; then - ARCH="$(uname -m)" - if [[ "${ARCH}" = "x86_64" || "${ARCH}" = "amd64" ]]; then - PT_DIR="${HOME}/.local/share/platform-tools" - if [ ! -x "${PT_DIR}/adb" ]; then - echo -e " ${CYAN}📦 Installing Android platform-tools (adb) in user space...${NC}" - TEMP_ZIP="/tmp/platform-tools-$$.zip" - if curl -fsSL --connect-timeout 5 --max-time 30 "https://dl.google.com/android/repository/platform-tools-latest-linux.zip" -o "${TEMP_ZIP}" 2>/dev/null; then - mkdir -p "${HOME}/.local/share" "${HOME}/.local/bin" - if command -v unzip >/dev/null 2>&1; then - unzip -q -o "${TEMP_ZIP}" -d "${HOME}/.local/share" 2>/dev/null || true - else - python3 -m zipfile -e "${TEMP_ZIP}" "${HOME}/.local/share" 2>/dev/null || true - fi - rm -f "${TEMP_ZIP}" - fi - fi - if [ -x "${PT_DIR}/adb" ]; then - ln -sf "${PT_DIR}/adb" "${HOME}/.local/bin/adb" - export PATH="${PT_DIR}:${PATH}" - echo -e " ${GREEN}✓ adb installed in user space.${NC}" - fi - fi - fi - fi -fi - -# Ensure local ADB daemon is warm & listening so probes do not hit 'Connection refused' -if command -v adb >/dev/null 2>&1; then - (unset ADB_SERVER_SOCKET; adb start-server >/dev/null 2>&1 || true) -fi - -# 4. Check or initialize .env configuration file -if [ ! -f ".env" ]; then - if [ -f ".env.example" ]; then - cp .env.example .env - echo -e " ${GREEN}✓ Initialized .env configuration file.${NC}" - else - touch .env - fi -fi - -# 5. Synchronize Python runtime & dependencies -echo -e " ${BLUE}📦 Synchronizing Python runtime and project dependencies...${NC}" -uv sync --quiet - -# 6. Check and build Showcase UI if not already compiled -SHOWCASE_INDEX="${SCRIPT_DIR}/apps/showcase_ui/dist/frontend/browser/index.html" -SHOWCASE_INDEX_ALT1="${SCRIPT_DIR}/apps/showcase_ui/dist/browser/index.html" -SHOWCASE_INDEX_ALT2="${SCRIPT_DIR}/apps/showcase_ui/dist/index.html" -if [ ! -f "${SHOWCASE_INDEX}" ] && [ ! -f "${SHOWCASE_INDEX_ALT1}" ] && [ ! -f "${SHOWCASE_INDEX_ALT2}" ]; then - # First: Try loading existing nvm or user-installed node in environment - export NVM_DIR="${HOME}/.nvm" - if [ -s "${NVM_DIR}/nvm.sh" ]; then - # shellcheck disable=SC1090,SC1091 - . "${NVM_DIR}/nvm.sh" 2>/dev/null || true - fi - if [ -d "${HOME}/.local/share/node/bin" ] && [[ ":${PATH}:" != *":${HOME}/.local/share/node/bin:"* ]]; then - export PATH="${HOME}/.local/share/node/bin:${PATH}" - fi - - # Check if nvm already has a compatible Node version installed - if ! is_node_compatible && (command -v nvm >/dev/null 2>&1 || type nvm >/dev/null 2>&1); then - nvm use 22 >/dev/null 2>&1 || true - fi - - if ! is_node_compatible; then - OS_NAME="$(uname -s)" - if command -v node >/dev/null 2>&1; then - echo -e " ${YELLOW}⚡ Detected Node.js $(node -v 2>/dev/null), but Angular CLI requires Node.js >= v22.22.0. Upgrading Node.js...${NC}" - else - echo -e " ${YELLOW}⚡ Node.js/npm not found. Auto-installing Node.js 22 LTS for Showcase UI compilation...${NC}" - fi - - # 1. Try installing Node 22 via nvm if available - if command -v nvm >/dev/null 2>&1 || type nvm >/dev/null 2>&1; then - echo -e " ${CYAN}📦 Installing Node.js 22 LTS via nvm...${NC}" - nvm install 22 >/dev/null 2>&1 || true - nvm use 22 >/dev/null 2>&1 || true - fi - - # 2. Try brew on macOS - if ! is_node_compatible && [ "${OS_NAME}" = "Darwin" ] && command -v brew >/dev/null 2>&1; then - brew install node >/dev/null 2>&1 || brew upgrade node >/dev/null 2>&1 || true - fi - - # 3. Try Linux package managers with sudo / root - if ! is_node_compatible && [ "${OS_NAME}" = "Linux" ]; then - if request_sudo "install or upgrade Node.js to >= 22.22.0"; then - SUDO_PREFIX="" - if [ "$(id -u)" -ne 0 ]; then SUDO_PREFIX="sudo"; fi - if command -v apt-get >/dev/null 2>&1; then - echo -e " ${CYAN}📦 Configuring NodeSource Node.js 22 LTS repository...${NC}" - curl -fsSL https://deb.nodesource.com/setup_22.x | ${SUDO_PREFIX} bash - >/dev/null 2>&1 || true - ${SUDO_PREFIX} apt-get -o DPkg::Lock::Timeout=5 install -y -qq nodejs 2>/dev/null || true - elif command -v dnf >/dev/null 2>&1; then - curl -fsSL https://rpm.nodesource.com/setup_22.x | ${SUDO_PREFIX} bash - >/dev/null 2>&1 || true - ${SUDO_PREFIX} dnf install -y nodejs || true - elif command -v pacman >/dev/null 2>&1; then - ${SUDO_PREFIX} pacman -S --noconfirm nodejs npm || true - fi - fi - fi - - # 4. Standalone portable Node.js LTS (zero-admin user space fallback, guaranteed compatible) - if ! is_node_compatible; then - ARCH="$(uname -m)" - NODE_ARCH="" - case "${ARCH}" in - x86_64|amd64) NODE_ARCH="x64" ;; - aarch64|arm64) NODE_ARCH="arm64" ;; - esac - OS_SYS="$(uname -s | tr '[:upper:]' '[:lower:]')" - if [ -n "${NODE_ARCH}" ] && { [ "${OS_SYS}" = "linux" ] || [ "${OS_SYS}" = "darwin" ]; }; then - NODE_VER="v22.23.2" - NODE_DIR="${HOME}/.local/share/node" - echo -e " ${CYAN}📦 Installing portable Node.js ${NODE_VER} in user space (~/.local)...${NC}" - rm -rf "${NODE_DIR}" - mkdir -p "${NODE_DIR}" "${HOME}/.local/bin" - if curl -fsSL --connect-timeout 5 --max-time 60 "https://nodejs.org/dist/${NODE_VER}/node-${NODE_VER}-${OS_SYS}-${NODE_ARCH}.tar.gz" | tar -xz -C "${NODE_DIR}" --strip-components=1 2>/dev/null; then - ln -sf "${NODE_DIR}/bin/node" "${HOME}/.local/bin/node" - ln -sf "${NODE_DIR}/bin/npm" "${HOME}/.local/bin/npm" - ln -sf "${NODE_DIR}/bin/npx" "${HOME}/.local/bin/npx" - export PATH="${NODE_DIR}/bin:${HOME}/.local/bin:${PATH}" - hash -r 2>/dev/null || true - echo -e " ${GREEN}✓ Portable Node.js ${NODE_VER} installed in user space.${NC}" - fi - fi - fi - fi - - if is_node_compatible; then - echo -e " ${GREEN}✓ Node.js $(node -v 2>/dev/null) and npm $(npm -v 2>/dev/null) ready.${NC}" - echo -e " ${YELLOW}🎨 Showcase UI build not found. Compiling Angular Showcase UI...${NC}" - ( - cd "${SCRIPT_DIR}/apps/showcase_ui" - npm install --silent - # Auto-patch Angular CLI node version constraint if running on Node 22.22.x (e.g. Cloudtop / Debian) - CLI_NODE_VERSION="${SCRIPT_DIR}/apps/showcase_ui/node_modules/@angular/cli/src/utilities/node-version.js" - if [ -f "${CLI_NODE_VERSION}" ]; then - if [ "$(uname -s)" = "Darwin" ]; then - sed -i '' 's/22\.22\.3/22.22.0/g' "${CLI_NODE_VERSION}" 2>/dev/null || true - else - sed -i 's/22\.22\.3/22.22.0/g' "${CLI_NODE_VERSION}" 2>/dev/null || true - fi - fi - npm run build - ) - else - echo -e " ${YELLOW}⚠ Could not configure compatible Node.js (>= 22.22.0). Showcase UI will serve fallback build notice.${NC}" - fi -fi - -# 7. Optionally configure MCP for detected AI IDEs -echo "" -echo -e " ${CYAN}🔌 Would you like to configure ARTEMIS MCP & testing rules for your AI IDEs?${NC}" -echo -e " (Supported: Antigravity, Cursor, Claude Code/Desktop, Codex, OpenClaw, Windsurf)" -if [ -t 0 ]; then - read -r -p " Install MCP configuration & rules now? [Y/n]: " INSTALL_MCP - INSTALL_MCP=${INSTALL_MCP:-Y} -else - INSTALL_MCP="n" -fi - -if [[ "${INSTALL_MCP}" =~ ^[Yy]$ ]]; then - echo -e " ${CYAN}Installing MCP server configuration & testing rules...${NC}" - if uv run artemis mcp --install all; then - echo -e " ${GREEN}✔ MCP configuration and rules installed successfully.${NC}" - echo -e " ${CYAN}💡 Tip: You can update or re-install anytime with: ${BOLD}uv run artemis mcp --install all${NC}" - else - echo -e " ${YELLOW}⚠ MCP installation failed. Review the error above and retry with: ${BOLD}uv run artemis mcp --install all${NC}" - fi -else - echo -e " ${YELLOW}⏭️ Skipped. You can install MCP anytime later with: ${BOLD}uv run artemis mcp --install all${NC}" -fi -echo "" - -# 8. Detect environment & launch unified Showcase UI -IS_REMOTE=false -if [ -n "${SSH_CONNECTION:-}" ] || [ -n "${SSH_CLIENT:-}" ] || [ -n "${SSH_TTY:-}" ] || [ -z "${DISPLAY:-}" ]; then - IS_REMOTE=true -fi - -echo -e " ${GREEN}🚀 Launching Artemis Showcase UI & Admin Console...${NC}" - -OPEN_FLAG="--open" -if [ "${IS_REMOTE}" = true ]; then - HOSTNAME_STR="$(hostname 2>/dev/null || echo 'cloud-host')" - USER_STR="$(whoami 2>/dev/null || echo 'user')" - echo -e " ${CYAN}☁️ Cloud / Remote environment detected:${NC}" - echo -e " • Access locally via SSH tunnel: ${BOLD}ssh -L 8000:localhost:8000 ${USER_STR}@${HOSTNAME_STR}${NC}" - echo -e " • Or access via Cloudtop / VS Code / Cursor Port Forwarding (Port 8000)" - if [ -z "${DISPLAY:-}" ]; then - echo -e " • Headless session detected (browser auto-open disabled)." - OPEN_FLAG="--no-open" - fi - echo "" -fi - -# Ensure local ADB daemon is active and listening before launching UI -if command -v adb >/dev/null 2>&1; then - (unset ADB_SERVER_SOCKET; adb start-server >/dev/null 2>&1 || true) -fi - -# Launch via `python -m artemis` (not the `artemis` console-script shim) so the -# long-running server never pins .venv/Scripts/artemis[.exe] against reinstalls. -exec uv run python -m artemis ui "${OPEN_FLAG}" "$@" diff --git a/tests/e2e/play_youtube.py b/tests/e2e/play_youtube.py deleted file mode 100644 index 266f897a7..000000000 --- a/tests/e2e/play_youtube.py +++ /dev/null @@ -1,326 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import re -import subprocess -import sys -import time -import xml.etree.ElementTree as ET - - -def run_cmd(cmd): - """Runs a local shell command and returns stdout, stderr.""" - result = subprocess.run(cmd, shell=True, capture_output=True, text=True) - return result.stdout.strip(), result.stderr.strip() - - -def adb_cmd(cmd): - """Runs an adb command.""" - return run_cmd(f"adb {cmd}") - - -def dump_ui(): - """Dumps the UI hierarchy and returns it as an XML string.""" - # Try to dump to a specified path - out, err = adb_cmd("shell uiautomator dump /data/local/tmp/uidump.xml") - path = "/data/local/tmp/uidump.xml" - - if "dumped to" not in out and "dumped to" not in err: - # Try default dump - out, err = adb_cmd("shell uiautomator dump") - match = re.search(r"dumped to: (.*)", out + "\n" + err) - if match: - path = match.group(1).strip() - else: - # Fallback common path - path = "/sdcard/window_dump.xml" - - local_path = "temp_uidump.xml" - # Ensure we remove local temp file if it exists - if os.path.exists(local_path): - os.remove(local_path) - - out, err = adb_cmd(f"pull {path} {local_path}") - - if os.path.exists(local_path): - with open(local_path, encoding="utf-8") as f: - content = f.read() - os.remove(local_path) - return content - else: - print(f"Failed to pull UI dump from {path}. Out: {out}, Err: {err}") - return None - - -def parse_bounds(bounds_str): - """Parses bounds string '[left,top][right,bottom]' into [left, top, right, bottom].""" - m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds_str) - if m: - return [int(x) for x in m.groups()] - return None - - -def get_center(bounds): - """Returns the center (x, y) of the bounds.""" - return (bounds[0] + bounds[2]) // 2, (bounds[1] + bounds[3]) // 2 - - -def find_nodes(xml_str, filter_fn): - """Finds all nodes in the XML that match the filter function.""" - try: - root = ET.fromstring(xml_str) - except Exception as e: - print(f"Failed to parse XML: {e}") - return [] - - matched = [] - for node in root.iter("node"): - attrib = node.attrib - if filter_fn(attrib): - matched.append(attrib) - return matched - - -def tap_element(element, description="element"): - bounds_str = element.get("bounds") - if bounds_str: - bounds = parse_bounds(bounds_str) - if bounds: - x, y = get_center(bounds) - print(f"Tapping {description} at [{x}, {y}]") - adb_cmd(f"shell input tap {x} {y}") - return True - return False - - -def bypass_chrome_welcome(timeout=15): - """Bypasses Chrome welcome/sync screens if they appear.""" - print("Checking for Chrome welcome/sync screens...") - start_time = time.time() - while time.time() - start_time < timeout: - xml = dump_ui() - if not xml: - time.sleep(1) - continue - - # Check for "Accept & continue" - accept_nodes = find_nodes( - xml, - lambda a: ( - a.get("text") == "Accept & continue" or "terms_accept" in a.get("resource-id", "") - ), - ) - if accept_nodes: - print("Found 'Accept & continue' screen.") - tap_element(accept_nodes[0], "'Accept & continue' button") - time.sleep(2) - continue - - # Check for "No thanks" or "Don't turn on" (Sync promo) - no_thanks_nodes = find_nodes( - xml, - lambda a: ( - a.get("text") in ["No thanks", "Don't turn on"] - or "negative_button" in a.get("resource-id", "") - ), - ) - if no_thanks_nodes: - print("Found Sync/Promo screen.") - tap_element(no_thanks_nodes[0], "'No thanks' button") - time.sleep(2) - continue - - # Check if we are already on a webpage or home page (e.g., search bar is visible, or youtube content) - # If we don't see welcome screens anymore, we might be good. - # Let's check if we see "Search or type URL" or similar Chrome home elements, or YouTube. - youtube_nodes = find_nodes( - xml, - lambda a: ( - "youtube" in a.get("text", "").lower() - or "youtube" in a.get("content-desc", "").lower() - ), - ) - chrome_home = find_nodes( - xml, - lambda a: "search or type web address" in a.get("text", "").lower(), - ) - - if youtube_nodes or chrome_home: - print("Chrome seems to be in main browser view.") - break - - print("Waiting for welcome screens to resolve or main view to appear...") - time.sleep(1) - - -def play_first_video(timeout=20): - """Finds and clicks the first video on the YouTube page.""" - print("Waiting for YouTube to load and finding a video...") - start_time = time.time() - while time.time() - start_time < timeout: - xml = dump_ui() - if not xml: - time.sleep(1) - continue - - # Look for video elements. - # Heuristic: clickable elements with content-desc containing "views" and "ago" - # or elements that look like video titles. - video_nodes = [] - - # Try finding by content-desc (common for video cards) - video_nodes = find_nodes( - xml, - lambda a: a.get("clickable") == "true" and "views" in a.get("content-desc", "").lower(), - ) - - # If not found, try finding any node with "views" and "ago" in text/desc and click it - if not video_nodes: - video_nodes = find_nodes( - xml, - lambda a: ( - ( - "views" in a.get("content-desc", "").lower() - and "ago" in a.get("content-desc", "").lower() - ) - or ("views" in a.get("text", "").lower() and "ago" in a.get("text", "").lower()) - ), - ) - - # If still not found, maybe we are on a cookie consent page? - # Check for "Before you continue to YouTube" or "Reject all" / "Accept all" - consent_nodes = find_nodes( - xml, - lambda a: a.get("text") in ["Reject all", "Accept all", "I agree"], - ) - if consent_nodes: - print("Found cookie consent banner. Clicking 'Accept all' or 'I agree'...") - # Prefer Accept/Agree - agree_node = [n for n in consent_nodes if n.get("text") in ["Accept all", "I agree"]] - node_to_click = agree_node[0] if agree_node else consent_nodes[0] - tap_element(node_to_click, "Consent button") - time.sleep(3) - continue - - if video_nodes: - print(f"Found {len(video_nodes)} potential video elements.") - # Click the first one - first_video = video_nodes[0] - desc = first_video.get("content-desc", first_video.get("text", "video")) - print(f"Selecting video: {desc[:50]}...") - if tap_element(first_video, "first video"): - return True - - print("Still waiting for videos to load...") - time.sleep(2) - - print("Failed to find a video to play.") - return False - - -def verify_playback(timeout=15): - """Verifies that the video is playing.""" - print("Verifying playback...") - start_time = time.time() - while time.time() - start_time < timeout: - xml = dump_ui() - if not xml: - time.sleep(1) - continue - - # Check for video player element - player_nodes = find_nodes( - xml, - lambda a: ( - "video player" in a.get("text", "").lower() - or "video player" in a.get("content-desc", "").lower() - ), - ) - if player_nodes: - print("Video player detected on screen.") - - # Check if it's paused or if we need to click play - # Sometimes there is a "Play video" button if it didn't auto-play - play_button = find_nodes( - xml, - lambda a: a.get("text") == "Play" or a.get("content-desc") == "Play video", - ) - if play_button: - print("Video is paused. Clicking play...") - tap_element(play_button[0], "Play button") - time.sleep(2) - else: - print("Video seems to be playing (no play button blocking it, player active).") - - # Check for "TAP TO UNMUTE" - unmute_button = find_nodes(xml, lambda a: "unmute" in a.get("text", "").lower()) - if unmute_button: - print("Optional: Clicking unmute...") - tap_element(unmute_button[0], "Unmute button") - - return True - - print("Waiting for video player to appear...") - time.sleep(2) - return False - - -def main(): - # Ensure a device is connected - out, err = run_cmd("adb devices") - devices = [ - line.split()[0] - for line in out.splitlines()[1:] - if line.strip() and not line.startswith("*") - ] - if not devices: - print("No ADB devices found. Please connect a device and try again.") - sys.exit(1) - print(f"Using device: {devices[0]}") - - # Optional: Clear Chrome to test clean run (highly recommended for testing the script itself) - # To enable, uncomment the line below: - # print("Clearing Chrome data for a clean test...") - # adb_cmd("shell pm clear com.android.chrome") - # time.sleep(2) - - # Start Chrome and navigate to YouTube - url = "https://m.youtube.com" - print(f"Starting Chrome and navigating to {url}...") - # -d specifies the data URI - # -n specifies the component - adb_cmd(f"shell am start -n com.android.chrome/com.google.android.apps.chrome.Main -d {url}") - - # Wait for app to launch - time.sleep(3) - - # Step 1: Bypass welcome screens - bypass_chrome_welcome() - - # Step 2: Play the first video - if play_first_video(): - # Step 3: Verify it plays - time.sleep(3) # Wait for transition - if verify_playback(): - print("SUCCESS: Chrome opened, navigated to YouTube, and video playback started!") - else: - print("FAILURE: Video player not detected or failed to play.") - else: - print("FAILURE: Could not find or click a video.") - - -if __name__ == "__main__": - main() diff --git a/tests/e2e/test_multi_port_end_to_end.py b/tests/e2e/test_multi_port_end_to_end.py deleted file mode 100644 index 7c624e9af..000000000 --- a/tests/e2e/test_multi_port_end_to_end.py +++ /dev/null @@ -1,294 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Comprehensive End-to-End Test for Artemis Multi-Port System. - -Verifies end-to-end functionality across all entry points and ports: -1. Configuration & Default Model (gemini-3.5-flash-lite) -2. Daemon Server API & Multi-Device Discovery (/api/devices, /api/status) -3. Multi-Device Detection (Physical phone + Android Emulator) -4. MCP Server Tools (mobile_get_device_state, mobile_run_task, mobile_manage_task) -5. Python SDK (ArtemisClient with device targeting & concurrency modes) -6. CLI Entry Points (artemis run, batch, status) -7. Device Execution Lock Isolation (Multi-device concurrent execution) -""" - -import asyncio -import json -import os -import subprocess -import sys -import time -import urllib.request -from pathlib import Path -import pytest - -from artemis.config import ( - DEFAULT_MODEL, - ROOT_DIR, - Settings, - settings, - get_config_path, -) -from artemis.runtime.daemon_client import ( - ensure_daemon_running, - get_daemon_session, - get_daemon_status, - is_daemon_running, - submit_task_to_daemon, - stop_task_on_daemon, -) -from artemis.runtime.device_lock import DeviceExecutionLock -from artemis.runtime.device_pool import device_pool -from artemis.interfaces.sdk.client import ArtemisClient, ConcurrencyMode -from mcp_server.tools.device_state import mobile_get_device_state -from mcp_server.tools.task_runner import mobile_run_task -from mcp_server.tools.task_manager import mobile_manage_task -from artemis.runtime import trace_store - - -@pytest.fixture(scope="module", autouse=True) -def ensure_daemon(): - """Ensure Artemis Daemon is running for E2E tests.""" - ok, _base_url = ensure_daemon_running(wait_ready=True) - assert ok, "Artemis Daemon failed to start" - yield - - -def test_01_default_model_configured(): - """Verify default model configuration loads properly across config and router.""" - assert DEFAULT_MODEL is not None - cfg_path = get_config_path("artemis.jsonc") - assert cfg_path.exists() - content = cfg_path.read_text(encoding="utf-8") - assert "default" in content - - # Check Settings pydantic model - s = Settings() - assert s.ARTEMIS_DEFAULT_MODEL is not None - - -def test_02_daemon_device_discovery_sees_phone_and_emulator(): - """Verify Daemon API /api/devices detects both the physical phone and emulator.""" - status = get_daemon_status() - assert status is not None - assert status.get("status") in ("idle", "running", "ok") - if "model_info" in status and status["model_info"]: - assert status["model_info"]["id"] is not None - - req = urllib.request.Request("http://127.0.0.1:8000/api/devices") - with urllib.request.urlopen(req, timeout=5.0) as resp: - assert resp.status == 200 - data = json.loads(resp.read().decode("utf-8")) - devices = data.get("devices", []) - serials = [d["serial"] for d in devices] - - # Must detect both devices - assert any(s.startswith("emulator") for s in serials), f"Emulator not found in {serials}" - assert any(not s.startswith("emulator") for s in serials), ( - f"Physical phone not found in {serials}" - ) - - # Check is_emulator flag classification - for d in devices: - if d["serial"].startswith("emulator"): - assert d["is_emulator"] is True - else: - assert d["is_emulator"] is False - - -@pytest.mark.asyncio -async def test_03_mcp_device_state_inspects_both_devices(): - """Verify MCP mobile_get_device_state reads element hierarchies from both devices.""" - devices = device_pool.list_devices() - emulator_serial = next((d.serial for d in devices if d.is_emulator), None) - phone_serial = next((d.serial for d in devices if not d.is_emulator), None) - - assert emulator_serial, f"No emulator connected in {devices}" - assert phone_serial, f"No physical phone connected in {devices}" - - # Inspect emulator state - emu_state = await mobile_get_device_state(view_type="hierarchy", device_serial=emulator_serial) - assert isinstance(emu_state, str) - assert len(emu_state) > 0 - assert "Bounds:" in emu_state or "Text:" in emu_state or len(emu_state.strip()) > 0 - - # Inspect physical phone state - phone_state = await mobile_get_device_state(view_type="hierarchy", device_serial=phone_serial) - assert isinstance(phone_state, str) - assert len(phone_state) > 0 - assert "Bounds:" in phone_state or "Text:" in phone_state or len(phone_state.strip()) > 0 - - -def test_04_mcp_run_and_manage_task_via_daemon(): - """Verify MCP mobile_run_task dispatches to Daemon and mobile_manage_task tracks it.""" - devices = device_pool.list_devices() - emulator_serial = next((d.serial for d in devices if d.is_emulator), None) - assert emulator_serial, "No emulator connected" - - res = mobile_run_task( - task_desc="E2E Test task from MCP port", - conversation_id="e2e-conv-mcp-1", - model="Flash", - device_serial=emulator_serial, - ) - assert "trace_id" in res - trace_id = res["trace_id"] - assert res["status"] in ("started", "queued", "running") - - # Verify tracking state via mobile_manage_task - status_res = mobile_manage_task(action="status", trace_id=trace_id) - assert status_res["trace_id"] == trace_id - assert "status" in status_res - assert status_res.get("device_serial") == emulator_serial - - # Cleanup: stop the test task on Daemon and release locks - stop_task_on_daemon(session_id=trace_id) - time.sleep(1.5) - DeviceExecutionLock.cleanup_stale_locks() - - -def test_05_sdk_client_targeting_and_concurrency(): - """Verify Python SDK ArtemisClient correctly targets specific devices.""" - devices = device_pool.list_devices() - emulator_serial = next((d.serial for d in devices if d.is_emulator), None) - phone_serial = next((d.serial for d in devices if not d.is_emulator), None) - - assert emulator_serial, "Emulator serial required" - assert phone_serial, "Phone serial required" - - client_emu = ArtemisClient(device_id=emulator_serial, concurrency_mode="per_device") - assert client_emu.device_serial == emulator_serial - assert client_emu.concurrency_mode == "per_device" - - client_phone = ArtemisClient(device_id=phone_serial, concurrency_mode="per_device") - assert client_phone.device_serial == phone_serial - - # Verify multi-device lock independence. acquire() returns None and raises - # DeviceBusyError on failure, so success means "no exception + we own it". - DeviceExecutionLock.cleanup_stale_locks() - lock_emu = DeviceExecutionLock( - device_id=emulator_serial, - description="e2e lock emulator", - concurrency_mode=ConcurrencyMode.PER_DEVICE, - ) - lock_phone = DeviceExecutionLock( - device_id=phone_serial, - description="e2e lock phone", - concurrency_mode=ConcurrencyMode.PER_DEVICE, - ) - - def _owned_by(lock: DeviceExecutionLock) -> bool: - owner = DeviceExecutionLock.get_active_owner(lock.device_id, lock.lock_scope) - return owner is not None and owner.pid == os.getpid() and owner.token == lock.token - - # Both locks can be held simultaneously because device serials differ. - try: - lock_emu.acquire(timeout=5.0) - assert _owned_by(lock_emu), "Emulator lock not held after acquire()" - lock_phone.acquire(timeout=5.0) - assert _owned_by(lock_phone), ( - "Failed to hold phone lock concurrently (multi-device parallel mode)" - ) - assert _owned_by(lock_emu), "Emulator lock lost while acquiring phone lock" - finally: - lock_phone.release() - lock_emu.release() - assert not _owned_by(lock_emu) - assert not _owned_by(lock_phone) - - -def _run_cli(*args: str) -> subprocess.CompletedProcess: - # A wide, colorless console keeps Rich from wrapping or styling option names. - env = {**os.environ, "COLUMNS": "200", "NO_COLOR": "1", "TERM": "dumb"} - return subprocess.run( - [sys.executable, "-m", "artemis.main", *args], - capture_output=True, - text=True, - env=env, - timeout=120, - ) - - -def test_06_cli_daemon_and_standalone_options(): - """Verify CLI commands properly expose and route through Daemon or standalone.""" - # artemis status renders the Daemon status panel ("● ONLINE / RUNNING"). - res = _run_cli("status") - assert res.returncode == 0, res.stdout + res.stderr - assert "online / running" in res.stdout.lower(), res.stdout - - # artemis run --help exposes --standalone and --device - res_run = _run_cli("run", "--help") - assert res_run.returncode == 0, res_run.stdout + res_run.stderr - assert "--standalone" in res_run.stdout - assert "--device" in res_run.stdout - - # artemis batch --help exposes --standalone - res_batch = _run_cli("batch", "--help") - assert res_batch.returncode == 0, res_batch.stdout + res_batch.stderr - assert "--standalone" in res_batch.stdout - - -def _scheduler_session_ids() -> set[str]: - status = get_daemon_status() or {} - return { - str(item.get("session_id")) - for key in ("active_tasks", "queue") - for item in status.get(key) or [] - if isinstance(item, dict) - } - - -def test_07_daemon_session_details_api(): - """Verify Daemon GET /api/sessions/{session_id} API endpoint.""" - goal = "E2E session query test: open Android Settings and do not change anything." - target_serial = os.environ.get("ARTEMIS_TEST_DEVICE_SERIAL") or next( - (d.serial for d in device_pool.list_devices() if d.is_emulator), None - ) - assert target_serial, "No target device for the session details test" - - res = submit_task_to_daemon( - goal=goal, - profile="flash", - device_serial=target_serial, - ingress="e2e_test", - ) - assert res is not None, "Daemon rejected or did not answer /api/run" - # /api/run returns {"status", "tasks": [{"session_id", ...}], "enqueued_count", ...}. - assert res.get("status") in ("started", "queued"), res - assert res.get("enqueued_count") == 1, res - tasks = res.get("tasks") or [] - assert len(tasks) == 1 and tasks[0].get("session_id"), res - session_id = str(tasks[0]["session_id"]) - - try: - # The session row is persisted once the worker picks the task up. - session_data = None - deadline = time.monotonic() + 90 - while time.monotonic() < deadline: - session_data = get_daemon_session(session_id) - if session_data is not None: - break - time.sleep(1.0) - assert session_data is not None, f"Session {session_id} never became queryable" - assert session_data["session_id"] == session_id - assert session_data["initial_goal"] == goal - finally: - # Clean up: stop the task and wait until it no longer owns a scheduler slot. - stop_task_on_daemon(session_id=session_id) - deadline = time.monotonic() + 30 - while time.monotonic() < deadline and session_id in _scheduler_session_ids(): - time.sleep(0.5) - DeviceExecutionLock.cleanup_stale_locks() - assert session_id not in _scheduler_session_ids(), "Stopped task still owns a slot" diff --git a/tests/fixtures/action_surfaces/adb_server_manifest.json b/tests/fixtures/action_surfaces/adb_server_manifest.json index 119d75ef8..2ab8f30b5 100644 --- a/tests/fixtures/action_surfaces/adb_server_manifest.json +++ b/tests/fixtures/action_surfaces/adb_server_manifest.json @@ -1,35 +1,63 @@ { - "tap": { - "description": "Taps on the screen at coordinates.\n\n'coordinates' is a list [x, y].\n'times' is the number of consecutive clicks (default 1).\n'delay_ms' is the delay in milliseconds between consecutive clicks (default\n100).\n", + "back": { + "description": "Simulates pressing the system back button.", + "inputSchema": { + "properties": {}, + "title": "backArguments", + "type": "object" + } + }, + "compare_ui_frames": { + "description": "Reports whether the screen visibly changed between two captured frames.\n\nPure computation on base64 frames the caller already holds -- nothing is\ncaptured here, so the caller keeps ownership of when to look. The verdict\nrules (status/nav bar masking, the full-screen change ratio, the widened ROI\ncheck for taps, and assuming \"changed\" when a frame cannot be decoded) are\nthe same ones the Validator uses, from artemis.utils.image_diff.\n\nReturns a JSON string such as '{\"changed\": true}', or 'Error: ...'.\n", "inputSchema": { "properties": { - "coordinates": { - "items": { - "type": "integer" - }, - "title": "Coordinates", - "type": "array" + "action": { + "default": "tap", + "title": "Action", + "type": "string" }, - "times": { - "default": 1, - "title": "Times", - "type": "integer" + "after_b64": { + "title": "After B64", + "type": "string" }, - "delay_ms": { - "default": 100, - "title": "Delay Ms", - "type": "integer" + "before_b64": { + "title": "Before B64", + "type": "string" + }, + "coordinates": { + "anyOf": [ + { + "items": { + "type": "integer" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Coordinates" } }, "required": [ - "coordinates" + "before_b64", + "after_b64" ], - "title": "tapArguments", + "title": "compare_ui_framesArguments", "type": "object" } }, - "long_press_on": { - "description": "Long presses on the screen at coordinates.\n\n'coordinates' is a list [x, y].\n'duration' is in milliseconds (default 1000).\n", + "erase_one_char": { + "description": "Erases a single character (simulates Backspace).", + "inputSchema": { + "properties": {}, + "title": "erase_one_charArguments", + "type": "object" + } + }, + "focus_and_clear_text": { + "description": "Focuses on a UI element at coordinates and clears its text content.\n\n'coordinates' is a list [x, y] to tap first to gain focus.\n", "inputSchema": { "properties": { "coordinates": { @@ -38,24 +66,24 @@ }, "title": "Coordinates", "type": "array" - }, - "duration": { - "default": 1000, - "title": "Duration", - "type": "integer" } }, "required": [ "coordinates" ], - "title": "long_press_onArguments", + "title": "focus_and_clear_textArguments", "type": "object" } }, - "swipe": { - "description": "Swipes from start coordinates to end coordinates.\n\n'coordinates' is a list [start_x, start_y, end_x, end_y].\n'duration' is in milliseconds (default 400).\n\nSet duration >= 1000 to drag-and-drop. Drag slightly past the target\nposition to trigger reordering.\n", + "focus_and_input_text": { + "description": "Focuses on a UI element at coordinates and inputs text.\n\n'coordinates' is a list [x, y] to tap first to gain focus.\n'clear_before_input' if True, clears all existing text before typing. If False, appends text at the end of existing content.\n'text' supports multi-line content with '\\n'.\n", "inputSchema": { "properties": { + "clear_before_input": { + "default": false, + "title": "Clear Before Input", + "type": "boolean" + }, "coordinates": { "items": { "type": "integer" @@ -63,24 +91,24 @@ "title": "Coordinates", "type": "array" }, - "duration": { - "default": 400, - "title": "Duration", - "type": "integer" + "text": { + "title": "Text", + "type": "string" } }, "required": [ - "coordinates" + "coordinates", + "text" ], - "title": "swipeArguments", + "title": "focus_and_input_textArguments", "type": "object" } }, - "back": { - "description": "Simulates pressing the system back button.", + "get_ui_hierarchy": { + "description": "Retrieves the current UI elements hierarchy from the device.", "inputSchema": { "properties": {}, - "title": "backArguments", + "title": "get_ui_hierarchyArguments", "type": "object" } }, @@ -100,19 +128,41 @@ "type": "object" } }, - "stop_app": { - "description": "Force stops an application by its Android package name.", + "list_devices": { + "description": "Lists attached Android devices and their readiness, touching none of them.\n\nRead-only discovery for callers that schedule work across machines (the plugin's device pool and\nsubmission admission, G3/G28/G64). Enumeration is the runtime pool's own, so snapshot caching\nand timeout behaviour stay in one place.\n\nThe result keeps **\"could not ask adb\"** distinct from **\"adb answered: no devices\"**:\n`enumerated` is false only when the question itself failed. Collapsing those two would turn a\ntransient adb blip into an empty fleet, which is why `try_list_devices_async` exists upstream.\n\n`include_lock_state` defaults to **false** because the answer costs 1-2 `dumpsys` calls per\ndevice (Keyguard is not part of `adb devices`); ask for it only when you gate a dispatch on it.\nEach device then carries `is_locked`: true locked, false unlocked, **null unknown** -- and\nunknown is treated as not-admissible by the submission gate, so it must stay distinguishable\nfrom false. Without the flag the key is simply absent, meaning \"not asked\", never \"unlocked\".\n\nReturns a JSON string such as '{\"enumerated\": true, \"devices\": [{\"serial\": \"emulator-5554\",\n\"state\": \"device\", \"is_busy\": false, \"is_locked\": null}]}'.\n", "inputSchema": { "properties": { - "package_name": { - "title": "Package Name", - "type": "string" + "include_lock_state": { + "default": false, + "title": "Include Lock State", + "type": "boolean" + } + }, + "title": "list_devicesArguments", + "type": "object" + } + }, + "long_press_on": { + "description": "Long presses on the screen at coordinates.\n\n'coordinates' is a list [x, y].\n'duration' is in milliseconds (default 1000).\n", + "inputSchema": { + "properties": { + "coordinates": { + "items": { + "type": "integer" + }, + "title": "Coordinates", + "type": "array" + }, + "duration": { + "default": 1000, + "title": "Duration", + "type": "integer" } }, "required": [ - "package_name" + "coordinates" ], - "title": "stop_appArguments", + "title": "long_press_onArguments", "type": "object" } }, @@ -132,75 +182,59 @@ "type": "object" } }, - "focus_and_input_text": { - "description": "Focuses on a UI element at coordinates and inputs text.\n\n'coordinates' is a list [x, y] to tap first to gain focus.\n'clear_before_input' if True, clears all existing text before typing. If False, appends text at the end of existing content.\n'text' supports multi-line content with '\\n'.\n", + "press_key": { + "description": "Presses a specific Android key event (e.g., KEYCODE_ENTER, KEYCODE_HOME).", "inputSchema": { "properties": { - "coordinates": { - "items": { - "type": "integer" - }, - "title": "Coordinates", - "type": "array" - }, - "text": { - "title": "Text", + "keycode": { + "title": "Keycode", "type": "string" - }, - "clear_before_input": { - "default": false, - "title": "Clear Before Input", - "type": "boolean" } }, "required": [ - "coordinates", - "text" + "keycode" ], - "title": "focus_and_input_textArguments", + "title": "press_keyArguments", "type": "object" } }, - "focus_and_clear_text": { - "description": "Focuses on a UI element at coordinates and clears its text content.\n\n'coordinates' is a list [x, y] to tap first to gain focus.\n", + "stop_app": { + "description": "Force stops an application by its Android package name.", "inputSchema": { "properties": { - "coordinates": { - "items": { - "type": "integer" - }, - "title": "Coordinates", - "type": "array" + "package_name": { + "title": "Package Name", + "type": "string" } }, "required": [ - "coordinates" + "package_name" ], - "title": "focus_and_clear_textArguments", - "type": "object" - } - }, - "erase_one_char": { - "description": "Erases a single character (simulates Backspace).", - "inputSchema": { - "properties": {}, - "title": "erase_one_charArguments", + "title": "stop_appArguments", "type": "object" } }, - "press_key": { - "description": "Presses a specific Android key event (e.g., KEYCODE_ENTER, KEYCODE_HOME).", + "swipe": { + "description": "Swipes from start coordinates to end coordinates.\n\n'coordinates' is a list [start_x, start_y, end_x, end_y].\n'duration' is in milliseconds (default 400).\n\nSet duration >= 1000 to drag-and-drop. Drag slightly past the target\nposition to trigger reordering.\n", "inputSchema": { "properties": { - "keycode": { - "title": "Keycode", - "type": "string" + "coordinates": { + "items": { + "type": "integer" + }, + "title": "Coordinates", + "type": "array" + }, + "duration": { + "default": 400, + "title": "Duration", + "type": "integer" } }, "required": [ - "keycode" + "coordinates" ], - "title": "press_keyArguments", + "title": "swipeArguments", "type": "object" } }, @@ -212,11 +246,32 @@ "type": "object" } }, - "get_ui_hierarchy": { - "description": "Retrieves the current UI elements hierarchy from the device.", + "tap": { + "description": "Taps on the screen at coordinates.\n\n'coordinates' is a list [x, y].\n'times' is the number of consecutive clicks (default 1).\n'delay_ms' is the delay in milliseconds between consecutive clicks (default\n100).\n", "inputSchema": { - "properties": {}, - "title": "get_ui_hierarchyArguments", + "properties": { + "coordinates": { + "items": { + "type": "integer" + }, + "title": "Coordinates", + "type": "array" + }, + "delay_ms": { + "default": 100, + "title": "Delay Ms", + "type": "integer" + }, + "times": { + "default": 1, + "title": "Times", + "type": "integer" + } + }, + "required": [ + "coordinates" + ], + "title": "tapArguments", "type": "object" } } diff --git a/tests/integration/agents/explorer/test_explorer_all_tools/helpers.py b/tests/integration/agents/explorer/test_explorer_all_tools/helpers.py deleted file mode 100644 index 0927a3599..000000000 --- a/tests/integration/agents/explorer/test_explorer_all_tools/helpers.py +++ /dev/null @@ -1,68 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Helper utilities for Explorer integration tests.""" - -from pathlib import Path -from PIL import Image -from unittest.mock import MagicMock -from artemis.context import ArtemisContext -from artemis.graph.state import State - - -def create_mock_context() -> MagicMock: - """Creates a mock ArtemisContext suitable for Explorer tests.""" - ctx = MagicMock(spec=ArtemisContext) - ctx.device = MagicMock() - ctx.device.device_width = 1080 - ctx.device.device_height = 2400 - ctx.llm_config = MagicMock() - ctx.agent_config = MagicMock() - ctx.agent_config.denylisted_tools = {"explorer": []} - ctx.data_engine = None - ctx.adb_client = None - return ctx - - -def create_mock_state() -> MagicMock: - """Creates a mock State suitable for Explorer tests.""" - state = MagicMock(spec=State) - state.operator_raw_data = {"width": 1080, "height": 2400} - state.latest_ui_hierarchy = [] - state.latest_screenshot = None - return state - - -def get_or_create_test_screenshot(preferred_path: Path | None = None) -> Path: - """Finds an existing test screenshot or generates a valid 1080x2400 mock JPEG.""" - if preferred_path and preferred_path.exists() and preferred_path.stat().st_size > 0: - return preferred_path - - # Search known repo screenshot paths - candidate_paths = [ - Path(__file__).parent / "input_screenshot_test_explorer_all_tools_sequential_mocked.jpg", - Path(__file__).parent / "input_screenshot_test_explorer_ask_vision_coder_tool.jpg", - Path(__file__).parents[3] / "tools" / "inputs" / "screenshot.jpg", - ] - for p in candidate_paths: - if p.exists() and p.stat().st_size > 0: - return p - - # If none found, generate a mock RGB image - out_path = ( - Path(__file__).parent / "input_screenshot_test_explorer_all_tools_sequential_mocked.jpg" - ) - img = Image.new("RGB", (1080, 2400), color=(73, 109, 137)) - img.save(out_path, format="JPEG") - return out_path diff --git a/tests/integration/agents/explorer/test_explorer_all_tools/input_screenshot_test_explorer_all_tools_sequential_mocked.jpg b/tests/integration/agents/explorer/test_explorer_all_tools/input_screenshot_test_explorer_all_tools_sequential_mocked.jpg deleted file mode 100644 index 9b41affa3..000000000 Binary files a/tests/integration/agents/explorer/test_explorer_all_tools/input_screenshot_test_explorer_all_tools_sequential_mocked.jpg and /dev/null differ diff --git a/tests/integration/agents/explorer/test_explorer_all_tools/input_screenshot_test_explorer_ask_vision_coder_tool.jpg b/tests/integration/agents/explorer/test_explorer_all_tools/input_screenshot_test_explorer_ask_vision_coder_tool.jpg deleted file mode 100644 index 9b41affa3..000000000 Binary files a/tests/integration/agents/explorer/test_explorer_all_tools/input_screenshot_test_explorer_ask_vision_coder_tool.jpg and /dev/null differ diff --git a/tests/integration/agents/explorer/test_explorer_all_tools/run_live_tools_test.py b/tests/integration/agents/explorer/test_explorer_all_tools/run_live_tools_test.py deleted file mode 100644 index 52aa51515..000000000 --- a/tests/integration/agents/explorer/test_explorer_all_tools/run_live_tools_test.py +++ /dev/null @@ -1,280 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -import json -import logging -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch - -from artemis.agents.explorer.explorer import Explorer -from tests.integration.agents.explorer.test_explorer_all_tools.helpers import ( - create_mock_context, - create_mock_state, -) -from tests.integration.agents.explorer.test_explorer_all_tools.seed_mock_image import ( - seed_database, -) - -# Set up logging to stdout -logging.basicConfig(level=logging.INFO, format="%(message)s") -logger = logging.getLogger("live_tools_test") - - -def extract_and_print_tool_results(contents, set_idx, logger): - logger.info("\n======================================================================") - logger.info(f" RESULTS FOR INPUT TEST SET {set_idx}") - logger.info("======================================================================") - - for content in contents: - if content.role != "tool": - continue - for part in content.parts: - if part.function_response: - name = part.function_response.name - response_data = part.function_response.response - - # Extract result text - tool_text = None - if hasattr(response_data, "get"): - tool_text = response_data.get("result") - elif hasattr(response_data, "fields"): - tool_text = response_data.fields.get("result") - else: - tool_text = getattr(response_data, "result", None) - - logger.info(f"\n🛠️ Tool: '{name}'") - logger.info("--------------------------------------------------") - logger.info(f"Output Result:\n{tool_text}") - - if part.inline_data and part.inline_data.mime_type == "image/jpeg": - logger.info( - f"📷 Visual Data: Attached {len(part.inline_data.data)}" - " bytes of annotated JPEG screenshot" - ) - - -async def run_single_set(set_idx, query, search_query, labels, coords_nx, coords_ny, candidates): - import os - - os.environ["GOOGLE_API_KEY"] = "dummy_api_key" - - mock_ctx = create_mock_context() - mock_ctx.llm_config.utils = MagicMock() - mock_ctx.llm_config.utils.object_detector = MagicMock() - mock_ctx.llm_config.utils.object_detector.model = "gemini-2.5-flash" - mock_ctx.llm_config.utils.object_detector.timeout = 10.0 - - mock_state = create_mock_state() - - screenshot_path = ( - Path(__file__).resolve().parent - / "input_screenshot_test_explorer_all_tools_sequential_mocked.jpg" - ) - - # Multi-turn sequential mock client - # Turn 1: get_ocr_list - mock_fc_ocr = MagicMock() - mock_fc_ocr.name = "get_ocr_list" - mock_fc_ocr.args = {} - mock_resp_ocr = MagicMock() - mock_resp_ocr.function_calls = [mock_fc_ocr] - - # Turn 2: search_xml_ocr with query - mock_fc_search = MagicMock() - mock_fc_search.name = "search_xml_ocr" - mock_fc_search.args = {"query": search_query} - mock_resp_search = MagicMock() - mock_resp_search.function_calls = [mock_fc_search] - - # Turn 3: detect_objects - mock_fc_detect = MagicMock() - mock_fc_detect.name = "detect_objects" - mock_fc_detect.args = {"labels": labels, "target_image_id": "img_0"} - mock_resp_detect = MagicMock() - mock_resp_detect.function_calls = [mock_fc_detect] - - # Turn 4: search_xml_ocr with coordinates - mock_fc_coords = MagicMock() - mock_fc_coords.name = "search_xml_ocr" - mock_fc_coords.args = {"nx": coords_nx, "ny": coords_ny} - mock_resp_coords = MagicMock() - mock_resp_coords.function_calls = [mock_fc_coords] - - # Turn 5: submit_answer - mock_fc_submit = MagicMock() - mock_fc_submit.name = "submit_answer" - mock_fc_submit.args = { - "candidates": candidates, - "fallback_message": f"Successfully verified test set {set_idx}", - } - mock_resp_submit = MagicMock() - mock_resp_submit.function_calls = [mock_fc_submit] - - mock_client = MagicMock() - - # Mock client.aio.models.generate_content side effects for ReAct turns - mock_client.aio.models.generate_content = AsyncMock( - side_effect=[ - mock_resp_ocr, - mock_resp_search, - mock_resp_detect, - mock_resp_coords, - mock_resp_submit, - ] - ) - - # Mock the internal model call for _detect_single_label inside detect_objects - mock_detect_response = MagicMock() - y_norm, x_norm = 0, 0 - if "gear icon" in labels: - y_norm, x_norm = 300, 200 - elif "dashboard icon" in labels: - y_norm, x_norm = 500, 500 - elif "magnifying glass icon" in labels: - y_norm, x_norm = 140, 875 - elif "avatar icon" in labels: - y_norm, x_norm = 825, 375 - elif "arrow back icon" in labels: - y_norm, x_norm = 75, 100 - - mock_detect_response.text = json.dumps([{"point": [y_norm, x_norm]}]) - - mock_detector_client = MagicMock() - mock_detector_client.aio.models.generate_content = AsyncMock(return_value=mock_detect_response) - - with ( - patch( - "google.genai.Client", - side_effect=[mock_client, mock_detector_client], - ), - ): - explorer = Explorer(mock_ctx) - await explorer.run( - query=query, - context_feedback="", - screenshot_path=str(screenshot_path), - state=mock_state, - ) - - call_args_list = mock_client.aio.models.generate_content.call_args_list - contents = call_args_list[4].kwargs.get("contents") or call_args_list[4].args[0] - extract_and_print_tool_results(contents, set_idx, logger) - - -async def main(): - import shutil - from artemis.config import settings - - # Define outputs directory - outputs_dir = Path(__file__).resolve().parent / "outputs" - if outputs_dir.exists(): - shutil.rmtree(outputs_dir) - outputs_dir.mkdir(parents=True, exist_ok=True) - - # Redirect traces output to outputs/ and only keep the latest run - settings.TRACES_PATH = outputs_dir - - # 1. Seed the SQLite DB record first - seed_database() - - # 2. Define the 5 groups of input tests - test_sets = [ - { - "query": "Verify Settings button", - "search_query": "Settings", - "labels": ["gear icon"], - "coords_nx": (185), # Matches pixel center [200, 225] on [1080, 2400] scale - "coords_ny": 93, - "candidates": [ - { - "label": "S1", - "coords": [185, 93], - "description": "Settings Button", - } - ], - }, - { - "query": "Verify Dashboard button", - "search_query": "Dashboard", - "labels": ["dashboard icon"], - "coords_nx": (462), # Matches pixel center [500, 500] on [1080, 2400] scale - "coords_ny": 208, - "candidates": [ - { - "label": "S1", - "coords": [462, 208], - "description": "Dashboard Button", - } - ], - }, - { - "query": "Verify Search bar", - "search_query": "Search", - "labels": ["magnifying glass icon"], - "coords_nx": (810), # Matches pixel center [875, 140] on [1080, 2400] scale - "coords_ny": 58, - "candidates": [ - { - "label": "S1", - "coords": [810, 58], - "description": "Search bar", - } - ], - }, - { - "query": "Verify Profile icon", - "search_query": "Profile", - "labels": ["avatar icon"], - "coords_nx": (347), # Matches pixel center [375, 825] on [1080, 2400] scale - "coords_ny": 343, - "candidates": [ - { - "label": "S1", - "coords": [347, 343], - "description": "Profile icon", - } - ], - }, - { - "query": "Verify Back button", - "search_query": "Back", - "labels": ["arrow back icon"], - "coords_nx": (92), # Matches pixel center [100, 75] on [1080, 2400] scale - "coords_ny": 31, - "candidates": [ - { - "label": "S1", - "coords": [92, 31], - "description": "Back button", - } - ], - }, - ] - - # 3. Run all 5 sets sequentially - for i, test in enumerate(test_sets, 1): - await run_single_set( - set_idx=i, - query=test["query"], - search_query=test["search_query"], - labels=test["labels"], - coords_nx=test["coords_nx"], - coords_ny=test["coords_ny"], - candidates=test["candidates"], - ) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/tests/integration/agents/explorer/test_explorer_all_tools/seed_mock_image.py b/tests/integration/agents/explorer/test_explorer_all_tools/seed_mock_image.py deleted file mode 100644 index 86b860c28..000000000 --- a/tests/integration/agents/explorer/test_explorer_all_tools/seed_mock_image.py +++ /dev/null @@ -1,162 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import hashlib -import json -from pathlib import Path -import sqlite3 - - -def seed_database(): - db_path = Path(__file__).resolve().parents[5] / "traces" / "data_engine.db" - screenshot_path = ( - Path(__file__).resolve().parent - / "input_screenshot_test_explorer_all_tools_sequential_mocked.jpg" - ) - - if not screenshot_path.exists(): - # Try fallback to tests/screenshots/pixel_phone_mockup.png - source_path = ( - Path(__file__).resolve().parents[5] / "tests" / "screenshots" / "pixel_phone_mockup.png" - ) - import shutil - - shutil.copy(source_path, screenshot_path) - print(f"Copied screenshot from {source_path} to {screenshot_path}") - - # Compute SHA-256 hash - sha256_hash = hashlib.sha256() - with open(screenshot_path, "rb") as f: - for byte_block in iter(lambda: f.read(4096), b""): - sha256_hash.update(byte_block) - computed_hash = sha256_hash.hexdigest() - print(f"Computed screenshot hash: {computed_hash}") - - # Bounding elements - # 1. Settings [100, 200][300, 250] -> center pixel [200, 225] -> normalized [185, 93] - # 2. Dashboard [400, 400][600, 600] -> center pixel [500, 500] -> normalized [462, 208] - # 3. Search [800, 100][950, 180] -> center pixel [875, 140] -> normalized [810, 58] - # 4. Profile [300, 800][450, 850] -> center pixel [375, 825] -> normalized [347, 343] - # 5. Back [50, 50][150, 100] -> center pixel [100, 75] -> normalized [92, 31] - - ocr_result = [ - {"text": "Screen Dashboard Title", "position": []}, - { - "text": "Settings", - "position": [ - {"x": 100, "y": 200}, - {"x": 300, "y": 200}, - {"x": 300, "y": 250}, - {"x": 100, "y": 250}, - ], - }, - { - "text": "Dashboard", - "position": [ - {"x": 400, "y": 400}, - {"x": 600, "y": 400}, - {"x": 600, "y": 600}, - {"x": 400, "y": 600}, - ], - }, - { - "text": "Search", - "position": [ - {"x": 800, "y": 100}, - {"x": 950, "y": 100}, - {"x": 950, "y": 180}, - {"x": 800, "y": 180}, - ], - }, - { - "text": "Profile", - "position": [ - {"x": 300, "y": 800}, - {"x": 450, "y": 800}, - {"x": 450, "y": 850}, - {"x": 300, "y": 850}, - ], - }, - { - "text": "Back", - "position": [ - {"x": 50, "y": 50}, - {"x": 150, "y": 50}, - {"x": 150, "y": 100}, - {"x": 50, "y": 100}, - ], - }, - ] - - ui_tree = [ - {"class": "android.widget.FrameLayout", "bounds": "[0,0][1080,2400]"}, - { - "class": "android.widget.Button", - "text": "Settings", - "resource-id": "com.example.app:id/settings", - "clickable": "true", - "bounds": "[100,200][300,250]", - }, - { - "class": "android.widget.Button", - "text": "Dashboard", - "resource-id": "com.example.app:id/dashboard", - "clickable": "true", - "bounds": "[400,400][600,600]", - }, - { - "class": "android.widget.Button", - "text": "Search", - "resource-id": "com.example.app:id/search", - "clickable": "true", - "bounds": "[800,100][950,180]", - }, - { - "class": "android.widget.Button", - "text": "Profile", - "resource-id": "com.example.app:id/profile", - "clickable": "true", - "bounds": "[300,800][450,850]", - }, - { - "class": "android.widget.Button", - "text": "Back", - "resource-id": "com.example.app:id/back", - "clickable": "true", - "bounds": "[50,50][150,100]", - }, - ] - - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - cursor.execute( - "INSERT OR REPLACE INTO images (image_name, timestamp, ocr_result," - " ui_tree, extra_metadata) VALUES (?, ?, ?, ?, ?)", - ( - computed_hash, - 1717398000.0, - json.dumps(ocr_result), - json.dumps(ui_tree), - json.dumps({}), - ), - ) - - conn.commit() - conn.close() - print(f"Successfully inserted mock image record {computed_hash} into database at {db_path}") - - -if __name__ == "__main__": - seed_database() diff --git a/tests/integration/agents/explorer/test_explorer_all_tools/test_coordinate_normalization_swap.py b/tests/integration/agents/explorer/test_explorer_all_tools/test_coordinate_normalization_swap.py deleted file mode 100644 index ea3cad2da..000000000 --- a/tests/integration/agents/explorer/test_explorer_all_tools/test_coordinate_normalization_swap.py +++ /dev/null @@ -1,62 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import AsyncMock, patch -from artemis.agents.object_detector.object_detector import _run_object_detection -import pytest - - -@pytest.mark.asyncio -async def test_coordinate_normalization_swap(): - """Verify that _run_object_detection correctly swaps coordinates from [y, x] to [x, y].""" - - # 1. Define the mock output returned by the VLM task (VLM native format: [y_norm, x_norm]) - mock_vlm_output = [ - { - "label": "settings icon", - "point": [800, 200], # [y, x] -> y = 800, x = 200 - } - ] - - # 2. Patch dependencies to run in a pure offline sandbox - with ( - patch( - "artemis.agents.object_detector.object_detector._detect_single_label", - new_callable=AsyncMock, - return_value=mock_vlm_output, - ), - patch("artemis.agents.object_detector.object_detector.get_llm"), - ): - # Mock context setup - mock_ctx = AsyncMock() - mock_ctx.llm_config.utils.object_detector = None - - # 3. Execute the function under test - json_output = await _run_object_detection( - ctx=mock_ctx, - image_bytes=b"fake_image_bytes", - queries=["settings icon"], - templates=["find the {label}"], - ) - - # 4. Verify that the coordinates were successfully swapped - result = json_output - detected_items = result["detected"] - - assert len(detected_items) == 1 - assert detected_items[0]["label"] == "settings icon" - - # CRITICAL ASSERTION: - # The coordinate MUST be swapped from [800, 200] (y, x) to [200, 800] (x, y) - assert detected_items[0]["point"] == [200, 800] diff --git a/tests/integration/agents/explorer/test_explorer_all_tools/test_explorer_all_tools.py b/tests/integration/agents/explorer/test_explorer_all_tools/test_explorer_all_tools.py deleted file mode 100644 index 17682c265..000000000 --- a/tests/integration/agents/explorer/test_explorer_all_tools/test_explorer_all_tools.py +++ /dev/null @@ -1,323 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -import json -import logging -from pathlib import Path -import shutil -import sys -from unittest.mock import AsyncMock, MagicMock, patch -from artemis.agents.explorer.explorer import Explorer -from tests.integration.agents.explorer.test_explorer_all_tools.helpers import ( - create_mock_context, - create_mock_state, - get_or_create_test_screenshot, -) -import pytest - - -def _verify_all_tools_responses(contents: list, logger: logging.Logger): - """Parses the multi-turn conversational history payload and maps tool outputs.""" - logger.info("=== VERIFYING TOOL RESPONSES IN CONVERSATION HISTORY ===") - tool_responses = {} - for content in contents: - for part in content.parts: - if part.function_response: - name = part.function_response.name - response_data = part.function_response.response - - # Extract result text - tool_text = None - if hasattr(response_data, "get"): - tool_text = response_data.get("result") - elif hasattr(response_data, "fields"): - tool_text = response_data.fields.get("result") - else: - tool_text = getattr(response_data, "result", None) - - if name not in tool_responses: - tool_responses[name] = [] - tool_responses[name].append({"text": tool_text, "has_image": False}) - logger.info(f"Found tool response for '{name}':\n{tool_text}") - - if part.inline_data and part.inline_data.mime_type == "image/jpeg": - # The image byte block corresponds to the preceding function response - if tool_responses: - last_tool = list(tool_responses.keys())[-1] - tool_responses[last_tool][-1]["has_image"] = True - tool_responses[last_tool][-1]["img_bytes"] = part.inline_data.data - logger.info( - f" -> Attached {len(part.inline_data.data)} bytes of" - f" JPEG inline visual content to '{last_tool}'" - ) - - return tool_responses - - -@pytest.mark.asyncio -async def test_explorer_all_tools_sequential_mocked(): - """Verify that the Explorer ReAct loop correctly sequences, dispatches, processes, and - - annotates screenshot outputs for all available visual tools: - 1. get_ocr_list - 2. search_xml_ocr (query) - 3. detect_objects - 4. search_xml_ocr (coordinates) - 5. submit_answer - """ - test_name = sys._getframe().f_code.co_name - - # Set up standard log file saved on the same level as test file - log_file = Path(__file__).parent / f"log_{test_name}.log" - logger = logging.getLogger(test_name) - logger.setLevel(logging.INFO) - handler = logging.FileHandler(log_file, mode="w", encoding="utf-8") - logger.addHandler(handler) - - # Attach the main agent logger to capture internal step logs - agent_logger = logging.getLogger("artemis.agents.explorer") - agent_logger.setLevel(logging.INFO) - agent_logger.addHandler(handler) - - logger.info(f"Starting comprehensive test: {test_name}") - - # Define outputs directory and redirect settings to ensure only the latest run is saved - outputs_dir = Path(__file__).resolve().parent / "outputs" - if outputs_dir.exists(): - shutil.rmtree(outputs_dir) - outputs_dir.mkdir(parents=True, exist_ok=True) - - from artemis.config import settings - - settings.TRACES_PATH = outputs_dir - - mock_ctx = create_mock_context() - mock_state = create_mock_state() - - logger.info( - f"Mock context: device={mock_ctx.device.device_width}x{mock_ctx.device.device_height}" - ) - - # Load and verify screenshot - input_screenshot = get_or_create_test_screenshot() - logger.info(f"Visual screenshot loaded: {input_screenshot}") - - # ========================================================================= - # 1. Mock LLM Multi-Turn Responses (Sequentially calling each tool) - # ========================================================================= - - # Turn 1: call get_ocr_list - mock_fc_ocr = MagicMock() - mock_fc_ocr.name = "get_ocr_list" - mock_fc_ocr.args = {} - mock_resp_ocr = MagicMock() - mock_resp_ocr.function_calls = [mock_fc_ocr] - - # Turn 2: call ask_perception_tool - mock_fc_search = MagicMock() - mock_fc_search.name = "ask_perception_tool" - mock_fc_search.args = { - "search_query": "Settings", - "nx": 500, - "ny": 500, - "detect_queries": ["gear icon"], - } - mock_resp_search = MagicMock() - mock_resp_search.function_calls = [mock_fc_search] - - # Turn 3: call detect_objects - mock_fc_detect = MagicMock() - mock_fc_detect.name = "detect_objects" - mock_fc_detect.args = {"queries": ["gear icon"], "target_image_id": "img_0"} - mock_resp_detect = MagicMock() - mock_resp_detect.function_calls = [mock_fc_detect] - - # Turn 4: call inspect_region - mock_fc_coords = MagicMock() - mock_fc_coords.name = "inspect_region" - mock_fc_coords.args = { - "x_min": 100, - "y_min": 100, - "x_max": 300, - "y_max": 300, - "zoom_factor": 2.0, - } - mock_resp_coords = MagicMock() - mock_resp_coords.function_calls = [mock_fc_coords] - - # Turn 5: call submit_answer to complete loop - mock_fc_submit = MagicMock() - mock_fc_submit.name = "submit_answer" - mock_fc_submit.args = { - "candidates": [ - { - "label": "T1", - "coords": [462, 208], - "description": "Dashboard Button", - }, - { - "label": "S1", - "coords": [462, 208], - "description": "Settings Button", - }, - {"label": "D1", "coords": [200, 300], "description": "gear icon"}, - ], - "fallback_message": ("Successfully verified all visual controls sequentially."), - } - mock_resp_submit = MagicMock() - mock_resp_submit.function_calls = [mock_fc_submit] - - mock_client = MagicMock() - mock_client.aio.models.generate_content = AsyncMock( - side_effect=[ - mock_resp_ocr, - mock_resp_search, - mock_resp_detect, - mock_resp_coords, - mock_resp_submit, - ] - ) - - mock_ctx._genai_client = mock_client - mock_ctx.llm_config.explorer = None - - # ========================================================================= - # 2. Mock Downstream Tool Logic Dependencies - # ========================================================================= - - # Mock SQLite StorageManager for get_ocr_list - mock_storage = MagicMock() - mock_record = MagicMock() - mock_record.ocr_result = [ - {"text": "screen description text", "position": []}, - { - "text": "Dashboard Button", - "position": [ - {"x": 400, "y": 400}, - {"x": 600, "y": 400}, - {"x": 600, "y": 600}, - {"x": 400, "y": 600}, - ], - }, - ] - mock_storage.get_image.return_value = mock_record - - # The UI tree of the record feeds the in-memory ScreenIndex that backs the - # text search and the coordinate audit (no Data Engine query per tool). - mock_record.ui_tree = [ - { - "text": "Settings Button", - "bounds": "[400,400][600,600]", - "class": "android.widget.Button", - "clickable": "true", - } - ] - - # Mock downstream object detection pipeline (_run_object_detection) - mock_detect = AsyncMock( - return_value={"detected": [{"label": "gear icon", "point": [200, 300]}]} - ) - - logger.info("Starting Explorer.run execution loop with sequential tool mocks...") - - with ( - patch( - "artemis.agents.explorer.explorer.genai.Client", - return_value=mock_client, - ), - patch( - "artemis.agents.explorer.run_setup.StorageManager", - return_value=mock_storage, - ), - # get_ocr_list is only exposed when an OCR backend is configured. - patch("artemis.agents.explorer.explorer.is_ocr_configured", return_value=True), - patch("artemis.agents.explorer.run_setup.is_ocr_configured", return_value=True), - patch("artemis.agents.explorer.perception_tools._run_object_detection", mock_detect), - ): - explorer = Explorer(mock_ctx) - result = await explorer.run( - query="Test all tools", - context_feedback="", - screenshot_path=str(input_screenshot), - state=mock_state, - version="ultra", - enable_caching=False, - ) - - # Verify ReAct loop went through all 5 turns - assert mock_client.aio.models.generate_content.call_count == 5 - logger.info("Successfully completed all 5 ReAct turns.") - - # Retrieve final submission outcome - outcome_data = json.loads(result) - assert "candidates" in outcome_data - assert ( - outcome_data["fallback_message"] - == "Successfully verified all visual controls sequentially." - ) - logger.info("Submission response verified successfully.") - - # Extract all tool outputs from history to assert correctness - call_args_list = mock_client.aio.models.generate_content.call_args_list - # The fifth call receives the accumulated tool history - contents = call_args_list[4].kwargs.get("contents") or call_args_list[4].args[0] - - tool_responses = _verify_all_tools_responses(contents, logger) - - # ASSERTIONS FOR EACH TOOL INDIVIDUALLY - - # Verify each tool execution record in explorer.trace_history - assert len(explorer.trace_history) >= 4 - # 1. Verify get_ocr_list - ocr_trace = explorer.trace_history[0]["tool_calls"][0] - assert ocr_trace["name"] == "get_ocr_list" - assert "[O" in ocr_trace["response"]["text"] - assert "[462,208]" in ocr_trace["response"]["text"] - assert ocr_trace["response"]["image_path"] is not None - - # 2. Verify ask_perception_tool - search_trace = explorer.trace_history[1]["tool_calls"][0] - assert search_trace["name"] == "ask_perception_tool" - assert "Text Search Results are" in search_trace["response"]["text"] - assert len(search_trace["response"]["image_paths"]) > 0 - - # 3. Verify detect_objects - detect_trace = explorer.trace_history[2]["tool_calls"][0] - assert detect_trace["name"] == "detect_objects" - assert "[D" in detect_trace["response"]["text"] - assert detect_trace["response"]["image_path"] is not None - - # 4. Verify inspect_region - coords_trace = explorer.trace_history[3]["tool_calls"][0] - assert coords_trace["name"] == "inspect_region" - assert coords_trace["response"]["image_path"] is not None - # The coordinate audit at normalized [500, 500] (pixel [540, 1200]) is - # answered from the in-memory index: the UI-tree button covers that point. - perception_trace = explorer.trace_history[1]["tool_calls"][0] - assert perception_trace["name"] == "ask_perception_tool" - assert "Settings Button" in perception_trace["response"]["text"] - - logger.info( - "All assertions passed! The entire suite of explorer tools is fully functional." - ) - - # Clean up logging handlers - agent_logger.removeHandler(handler) - logger.removeHandler(handler) - handler.close() - - -if __name__ == "__main__": - asyncio.run(test_explorer_all_tools_sequential_mocked()) diff --git a/tests/integration/agents/explorer/test_explorer_all_tools/test_explorer_ask_image_processor.py b/tests/integration/agents/explorer/test_explorer_all_tools/test_explorer_ask_image_processor.py deleted file mode 100644 index ce7ef00f4..000000000 --- a/tests/integration/agents/explorer/test_explorer_all_tools/test_explorer_ask_image_processor.py +++ /dev/null @@ -1,372 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -import json -import logging -from pathlib import Path -import sys -from unittest.mock import AsyncMock, MagicMock, patch -from artemis.agents.explorer.explorer import Explorer -from tests.integration.agents.explorer.test_explorer_all_tools.helpers import ( - create_mock_context, - create_mock_state, - get_or_create_test_screenshot, -) -from PIL import Image -import pytest - - -def _verify_tool_responses(contents: list, logger: logging.Logger): - """Parses the multi-turn conversational history payload and maps tool outputs.""" - logger.info("=== VERIFYING TOOL RESPONSES IN CONVERSATION HISTORY ===") - tool_responses = {} - for content in contents: - for part in content.parts: - if part.function_response: - name = part.function_response.name - response_data = part.function_response.response - - # Extract result text - tool_text = None - if hasattr(response_data, "get"): - tool_text = response_data.get("result") - elif hasattr(response_data, "fields"): - tool_text = response_data.fields.get("result") - else: - tool_text = getattr(response_data, "result", None) - - if name not in tool_responses: - tool_responses[name] = [] - tool_responses[name].append({"text": tool_text, "has_image": False}) - logger.info(f"Found tool response for '{name}':\n{tool_text}") - - if part.inline_data and part.inline_data.mime_type == "image/jpeg": - if tool_responses: - last_tool = list(tool_responses.keys())[-1] - tool_responses[last_tool][-1]["has_image"] = True - tool_responses[last_tool][-1]["img_bytes"] = part.inline_data.data - logger.info( - f" -> Attached {len(part.inline_data.data)} bytes of" - f" JPEG inline visual content to '{last_tool}'" - ) - - return tool_responses - - -@pytest.mark.asyncio -async def test_explorer_ask_image_processor_tool(): - """Verify that the Explorer ReAct loop correctly dispatches, runs, processes, and - - handles the output image/transform details for the 'ask_image_processor' - tool. - The test ends immediately after the Vision Coder responds (in the second - turn). - """ - test_name = sys._getframe().f_code.co_name - - # Set up standard log file saved on the same level as test file - log_file = Path(__file__).parent / f"log_{test_name}.log" - logger = logging.getLogger(test_name) - logger.setLevel(logging.INFO) - handler = logging.FileHandler(log_file, mode="w", encoding="utf-8") - logger.addHandler(handler) - - # Attach the main agent logger to capture internal step logs - agent_logger = logging.getLogger("artemis.agents.explorer") - agent_logger.setLevel(logging.INFO) - agent_logger.addHandler(handler) - - logger.info(f"Starting test: {test_name}") - - # Define outputs directory and redirect settings to ensure only the latest run is saved - outputs_dir = Path(__file__).resolve().parent / "outputs" - if not outputs_dir.exists(): - outputs_dir.mkdir(parents=True, exist_ok=True) - - from artemis.config import settings - - settings.TRACES_PATH = outputs_dir - - mock_ctx = create_mock_context() - mock_state = create_mock_state() - - logger.info( - f"Mock context: device={mock_ctx.device.device_width}x{mock_ctx.device.device_height}" - ) - - # Load and verify screenshot - input_screenshot = get_or_create_test_screenshot() - logger.info(f"Visual screenshot loaded: {input_screenshot}") - - # ========================================================================= - # 1. Mock LLM Multi-Turn Responses (ask_image_processor -> submit_answer) - # ========================================================================= - - # Turn 1: call ask_image_processor - mock_fc_coder = MagicMock() - mock_fc_coder.name = "ask_image_processor" - mock_fc_coder.args = { - "instruction": ( - "Crop the top-right area, convert to HSV, resize, then convert to grayscale." - ), - "target_image_id": "img_0", - } - mock_resp_coder = MagicMock() - mock_resp_coder.function_calls = [mock_fc_coder] - - # Turn 2: call submit_answer to complete loop immediately after vision coder responds - mock_fc_submit = MagicMock() - mock_fc_submit.name = "submit_answer" - mock_fc_submit.args = { - "candidates": [], - "fallback_message": ("Successfully processed the image via Vision Coder."), - } - mock_resp_submit = MagicMock() - mock_resp_submit.function_calls = [mock_fc_submit] - - mock_client = MagicMock() - mock_client.aio.models.generate_content = AsyncMock( - side_effect=[mock_resp_coder, mock_resp_submit] - ) - - mock_ctx._genai_client = mock_client - mock_ctx.llm_config.explorer = None - - # ========================================================================= - # 2. Mock ImageProcessor agent - # ========================================================================= - - # Create the mock output image path - mock_new_image_path = outputs_dir / f"mock_image_processor_output_{test_name}.jpg" - # Create a small dummy image of size 100x100 using PIL so that cv2.imread inside explorer reads these dimensions - img = Image.new("RGB", (100, 100), color="red") - img.save(mock_new_image_path) - - # Also write a transform JSON file for the new image - transform_path = Path(str(mock_new_image_path) + ".transform.json") - mock_transform_data = { - "offset_x": 108.0, - "offset_y": 240.0, - "scale_x": 0.5, - "scale_y": 0.5, - } - with open(transform_path, "w") as f: - json.dump(mock_transform_data, f) - - # We mock ImageProcessor class itself - mock_image_processor_instance = MagicMock() - mock_image_processor_instance.run = AsyncMock( - return_value={ - "outputs": [ - { - "image_id": "img_1", - "path": str(mock_new_image_path), - "transform": { - "offset_x": 108.0, - "offset_y": 240.0, - "scale_x": 0.5, - "scale_y": 0.5, - }, - "annotations": {"V1": [50, 100], "V2": [180, 240]}, - } - ], - "summary": "Cropped and highlighted the red button", - } - ) - - logger.info("Starting Explorer.run execution loop with ask_image_processor...") - - with ( - patch( - "artemis.agents.explorer.explorer.genai.Client", - return_value=mock_client, - ), - patch( - "artemis.agents.explorer.perception_tools.ImageProcessor", - return_value=mock_image_processor_instance, - ), - ): - explorer = Explorer(mock_ctx) - - result = await explorer.run( - query=("Click on the red button in the top-right corner of the screen."), - context_feedback="", - screenshot_path=str(input_screenshot), - state=mock_state, - version="ultra", - ) - - # Verify initial image pool has img_0 - assert "img_0" in explorer.image_pool - assert explorer.image_pool["img_0"]["path"] == str(input_screenshot) - - # Verify ReAct loop went through exactly 2 turns - assert mock_client.aio.models.generate_content.call_count == 2 - logger.info("Successfully completed both ReAct turns.") - - # Verify ImageProcessor run was called with correct parameters - mock_image_processor_instance.run.assert_called_once_with( - "Crop the top-right area, convert to HSV, resize, then convert to grayscale.", - str(input_screenshot), - ) - logger.info("Verified ImageProcessor was invoked with correct parameters.") - - # Verify new image is registered in explorer's image_pool - assert "img_1" in explorer.image_pool - img_1_info = explorer.image_pool["img_1"] - assert img_1_info["path"] == str(mock_new_image_path) - - # Verify transform calculations: - # Since parent (img_0) transform is {"offset_x": 0.0, "offset_y": 0.0, "scale_x": 1.0, "scale_y": 1.0} - # and child transform is {"offset_x": 108.0, "offset_y": 240.0, "scale_x": 0.5, "scale_y": 0.5} - assert img_1_info["transform"]["scale_x"] == 0.5 - assert img_1_info["transform"]["scale_y"] == 0.5 - assert img_1_info["transform"]["offset_x"] == 108.0 - assert img_1_info["transform"]["offset_y"] == 240.0 - assert "Cropped and highlighted the red button" in img_1_info["description"] - logger.info("Verified image_pool registrations and coordinate transforms.") - - # Retrieve final submission outcome - outcome_data = json.loads(result) - assert ( - outcome_data["fallback_message"] == "Successfully processed the image via Vision Coder." - ) - logger.info("Submission response verified successfully.") - - # Extract all tool outputs from history to assert correctness - call_args_list = mock_client.aio.models.generate_content.call_args_list - # The second call receives the accumulated tool history - contents = call_args_list[1].kwargs.get("contents") or call_args_list[1].args[0] - - tool_responses = _verify_tool_responses(contents, logger) - - # Assertions for ask_image_processor tool response (Turn 1 output) - assert "ask_image_processor" in tool_responses - coder_resp = tool_responses["ask_image_processor"][0] - assert "Image Processor completed successfully" in coder_resp["text"] - assert "img_1" in coder_resp["text"] - assert "Annotations (wrt original screenshot):" in coder_resp["text"] - assert "img_1:" in coder_resp["text"] - assert "- [V1]: [208, 440]" in coder_resp["text"] - assert "- [V2]: [468, 720]" in coder_resp["text"] - assert coder_resp["has_image"] - assert len(coder_resp["img_bytes"]) > 0 - logger.info( - "Verified tool response contains the response text, annotations" - " list, and the attached image bytes." - ) - - # Clean up dummy files (Commented out temporarily so you can see the images!) - # if mock_new_image_path.exists(): - # mock_new_image_path.unlink() - # if transform_path.exists(): - # transform_path.unlink() - # if input_screenshot.exists(): - # input_screenshot.unlink() - - # Clean up logging handlers - agent_logger.removeHandler(handler) - logger.removeHandler(handler) - handler.close() - - -if __name__ == "__main__": - asyncio.run(test_explorer_ask_image_processor_tool()) - - -def test_cv_canvas_annotations(tmp_path): - import numpy as np - import cv2 - from artemis.utils.cv_canvas import ImageCanvas - - # Create a dummy image - img_dir = tmp_path / "images" - img_dir.mkdir() - img_path = img_dir / "img_0.jpg" - img_data = np.zeros((100, 100, 3), dtype=np.uint8) - cv2.imwrite(str(img_path), img_data) - - # Initialize transforms registry - registry_path = img_dir / "intermediate_transforms.json" - registry_data = { - "img_0": { - "image_id": "img_0", - "path": str(img_path.resolve()), - "transform": { - "offset_x": 0.0, - "offset_y": 0.0, - "scale_x": 1.0, - "scale_y": 1.0, - }, - "annotations": {}, - } - } - with open(registry_path, "w") as f: - json.dump(registry_data, f) - - canvas = ImageCanvas("img_0", str(img_dir)) - assert canvas.annotations == {} - - # Test _get_resolution and initial width/height - assert canvas._get_resolution() == (100, 100) - assert canvas.width == 100 - assert canvas.height == 100 - - # Test normalized_to_pixel_coords - assert canvas.normalized_to_pixel_coords(500, 500) == (50, 50) - assert canvas.normalized_to_pixel_coords(0, 0) == (0, 0) - assert canvas.normalized_to_pixel_coords(1000, 1000) == (99, 99) - - # Draw dot - canvas.draw_dot(10, 20, 1) - assert canvas.annotations == {"V1": [10, 20]} - - # Crop - canvas.crop(5, 5, 20, 20) - assert canvas.width == 20 - assert canvas.height == 20 - assert canvas._get_resolution() == (20, 20) - assert canvas.normalized_to_pixel_coords(500, 500) == (10, 10) - assert canvas.annotations == {"V1": [5, 15]} - - # Out of bounds test - canvas.draw_dot(2, 2, 2) - assert canvas.annotations == {"V1": [5, 15], "V2": [2, 2]} - - # Crop again to discard V1 - canvas.crop(1, 1, 5, 5) - assert canvas.width == 5 - assert canvas.height == 5 - assert canvas.normalized_to_pixel_coords(500, 500) == (2, 2) - # V1 was at [5, 15], after crop(1, 1), it becomes [4, 14], which is out of bounds of 5x5. - # V2 was at [2, 2], after crop(1, 1), it becomes [1, 1], which is inside 5x5. - assert canvas.annotations == {"V2": [1, 1]} - - # Resize by factor - canvas.resize_by_factor(2.0) - assert canvas.width == 10 - assert canvas.height == 10 - assert canvas._get_resolution() == (10, 10) - assert canvas.normalized_to_pixel_coords(500, 500) == (5, 5) - assert canvas.annotations == {"V2": [2, 2]} - - # Save and verify registry - canvas.save(final=True) - with open(registry_path) as f: - saved_registry = json.load(f) - - assert "img_1" in saved_registry - assert saved_registry["img_1"]["annotations"] == {"V2": [2, 2]} - assert saved_registry["img_1"]["is_output"] is True diff --git a/tests/integration/agents/explorer/test_explorer_all_tools/test_explorer_ask_image_processor_jupyter.py b/tests/integration/agents/explorer/test_explorer_all_tools/test_explorer_ask_image_processor_jupyter.py deleted file mode 100644 index de61d2a17..000000000 --- a/tests/integration/agents/explorer/test_explorer_all_tools/test_explorer_ask_image_processor_jupyter.py +++ /dev/null @@ -1,268 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -import faulthandler -import json -import logging -from pathlib import Path -import shutil -import time -from unittest.mock import AsyncMock, MagicMock, patch - -from langchain_core.messages import AIMessage -import pytest - -from artemis.agents.image_processor.image_processor import ImageProcessor -from artemis.config import settings -from artemis.utils.python_executor import PythonExecutor -from tests.integration.agents.explorer.test_explorer_all_tools.helpers import ( - create_mock_context, - get_or_create_test_screenshot, -) - -# Setup logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger("test_explorer_ask_image_processor_jupyter") - -# Hard ceiling for the whole test. Kernel startup, one cell and shutdown take a -# few seconds even on a saturated host; if anything blocks past this point, -# faulthandler prints every thread's stack to stderr and terminates the process -# instead of leaving the run hanging. -TEST_WALL_CLOCK_LIMIT_S = 300 - - -@pytest.mark.asyncio -async def test_explorer_ask_image_processor_jupyter(): - test_name = "test_explorer_ask_image_processor_jupyter" - started_at = time.monotonic() - faulthandler.dump_traceback_later(TEST_WALL_CLOCK_LIMIT_S, exit=True) - original_traces_path = settings.TRACES_PATH - try: - await _run_scenario(test_name) - finally: - settings.TRACES_PATH = original_traces_path - faulthandler.cancel_dump_traceback_later() - logger.info(f"Test wall-clock time: {time.monotonic() - started_at:.1f}s") - - -async def _run_scenario(test_name: str) -> None: - # 1. Setup outputs directory - outputs_dir = Path(__file__).resolve().parent / "outputs" / test_name - outputs_dir.mkdir(parents=True, exist_ok=True) - - # Redirect traces path to the new outputs directory - settings.TRACES_PATH = outputs_dir - - # 2. Prepare the input image - src_image_path = get_or_create_test_screenshot() - input_screenshot = outputs_dir / "input_screenshot.jpg" - shutil.copy2(src_image_path, input_screenshot) - logger.info(f"Copied input screenshot to: {input_screenshot}") - - # 3. Formulate the instruction with the injected G-Logo coordinate prior - instruction = ( - 'The user wants to find "the red patch above the green patch". Let\'s' - " analyze the screen layout. \nIn the dock / app icons, we have:\n-" - " Play Store (9): colorful triangle (has green, blue, yellow, red)\n-" - " Gmail (10): M icon (red, yellow, green, blue)\n- Photos (11):" - " pinwheel (red, yellow, green, blue)\n- Chrome (16): circular logo" - " with green on the bottom, red on top, yellow on the side, blue" - " center.\nWait, let's look at the Photos icon (11) or Chrome icon (16)" - " or Gmail (10) or Google search bar logo (21) or Play Store" - " (9).\nLet's zoom into Chrome (16), Gmail (10), Photos (11), Play" - " Store (9), Google logo (21) to analyze the relative positions of red" - " patches and green patches.\nThe G-Logo is exactly at pixel" - " coordinates [2173, 107, 2241, 174].\nLet's crop these icons to find" - ' which icon has a "red patch above the green patch" or is it a' - " specific patch?\nWait!\nLet's look at Chrome (16):\nRed is at the" - " top, green is at the bottom/left. Red is directly above green? Yes," - " the top slice of Chrome is red, and the bottom slice is green.\nLet's" - " look at Photos (11):\nIt has four petals. The top petal is red? No," - " top petal is red/pink, bottom is green? Let's check.\nLet's look at" - " Gmail (10):\nThe red is the M shape, green is at the bottom" - " right?\nLet's write Python code to crop these areas and output the" - " crop to see clearly.\nSpecifically, let's crop:\n- Play Store (bounds" - " around 9)\n- Gmail (bounds around 10)\n- Photos (bounds around 11)\n-" - " Chrome (bounds around 16)\n- Google widget logo (bounds around" - " 21)\nWe will save a visualization of these cropped regions side by" - " side or separately so we can examine the colors." - ) - - # 4. Intercept Python executions to record them for the Jupyter Notebook - executed_turns = [] - original_execute = PythonExecutor.execute - - def wrapped_execute(self, code: str) -> str: - logger.info(f"Intercepting Python code execution. Code:\n{code}\n") - result = original_execute(self, code) - logger.info(f"Execution result:\n{result}\n") - executed_turns.append({"code": code, "output": result}) - return result - - # The code runs in a separate kernel process, which does not see this - # test's settings.TRACES_PATH override, so the intermediates location is - # passed in as a literal path. - image_processor_dir = outputs_dir / "images" / "image_processor" - sample_code = f""" -from artemis.utils.cv_canvas import ImageCanvas -import glob -from pathlib import Path - -dirs = sorted(glob.glob(str(Path({str(image_processor_dir)!r}) / "intermediates_*"))) -intermediate_dir = dirs[-1] - -canvas = ImageCanvas("img_0", intermediate_dir) -canvas.crop(10, 10, 50, 50) -canvas.save(final=True) -print("saved crop from", intermediate_dir) -""" - - mock_llm = MagicMock() - mock_bound = MagicMock() - mock_bound.ainvoke = AsyncMock( - side_effect=[ - AIMessage( - content="", - tool_calls=[ - { - "name": "execute_python", - "args": {"code": sample_code}, - "id": "call_1", - } - ], - ), - AIMessage( - content="", - tool_calls=[ - { - "name": "submit_result", - "args": {"summary": "Cropped target G-Logo region."}, - "id": "call_2", - } - ], - ), - ] - ) - mock_llm.bind_tools.return_value = mock_bound - - mock_ctx = create_mock_context() - image_processor = ImageProcessor(mock_ctx) - - # Run with patched executor and mock LLM - logger.info("Running Image Processor with G-Logo prior...") - with ( - patch.object(PythonExecutor, "execute", wrapped_execute), - patch( - "artemis.agents.image_processor.image_processor.get_llm", - return_value=mock_llm, - ), - ): - result = await image_processor.run(instruction, str(input_screenshot)) - - logger.info(f"Image Processor finished. Result: {result}") - - # 5. Compile and save the Jupyter Notebook (.ipynb) - notebook_cells = [] - # Add a markdown introduction cell - notebook_cells.append( - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Image Processor Execution Trace\n", - f"**Task**: {instruction[:150]}...\n", - "**Input Image**: `input_screenshot.jpg`", - ], - } - ) - - for turn_idx, turn in enumerate(executed_turns): - # Add code cell - notebook_cells.append( - { - "cell_type": "code", - "execution_count": turn_idx + 1, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [line + "\n" for line in turn["output"].split("\n")], - } - ], - "source": [line + "\n" for line in turn["code"].split("\n")], - } - ) - - # If a result was submitted, add a final markdown cell - outputs = result.get("outputs", []) - if outputs: - final_image_path = outputs[-1]["path"] - notebook_cells.append( - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# 🎉 Execution Completed Successfully\n", - f"**Summary**: {result.get('summary')}\n", - f"**Output Image**: `{Path(final_image_path).name}`", - ], - } - ) - # Copy final image to output directory root for easy access - shutil.copy2(final_image_path, outputs_dir / "final_output.jpg") - - notebook_json = { - "cells": notebook_cells, - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3", - } - }, - "nbformat": 4, - "nbformat_minor": 2, - } - - notebook_path = outputs_dir / "vision_coder_execution.ipynb" - with open(notebook_path, "w", encoding="utf-8") as f: - json.dump(notebook_json, f, indent=2) - - logger.info(f"Saved compiled Jupyter notebook to: {notebook_path}") - - # 6. Assertions - assert "error" not in result, f"Image Processor failed: {result.get('error')}" - assert len(executed_turns) == 1, "Exactly one execute_python turn was expected" - cell_output = executed_turns[0]["output"] - assert "Traceback" not in cell_output, f"Kernel raised while cropping:\n{cell_output}" - assert "Error]" not in cell_output, f"Executor reported an error:\n{cell_output}" - assert "saved crop from" in cell_output, f"Unexpected cell output:\n{cell_output}" - assert "outputs" in result, "Image Processor did not return outputs" - assert len(result["outputs"]) > 0, "Image Processor did not return any output images" - final_entry = result["outputs"][-1] - assert final_entry.get("is_output") is True, ( - "Final image was not produced by the executed code (fell back to the input image)" - ) - assert final_entry["image_id"] != "img_0", ( - "Final image must be the cropped result, not the input" - ) - assert Path(final_entry["path"]).is_file(), f"Cropped image missing: {final_entry['path']}" - assert result.get("summary") == "Cropped target G-Logo region." - logger.info("Test completed successfully! All assertions passed.") - - -if __name__ == "__main__": - asyncio.run(test_explorer_ask_image_processor_jupyter()) diff --git a/tests/integration/agents/explorer/test_explorer_all_tools/test_object_detector_concurrency.py b/tests/integration/agents/explorer/test_explorer_all_tools/test_object_detector_concurrency.py deleted file mode 100644 index 735f2da63..000000000 --- a/tests/integration/agents/explorer/test_explorer_all_tools/test_object_detector_concurrency.py +++ /dev/null @@ -1,130 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -import time -from unittest.mock import AsyncMock, MagicMock, patch - -from artemis.agents.object_detector.object_detector import _run_object_detection -from tests.integration.agents.explorer.test_explorer_all_tools.helpers import create_mock_context -import pytest - - -@pytest.mark.asyncio -async def test_object_detector_concurrency_and_throttling(tmp_path): - """Verify that object detector requests run in parallel and are correctly throttled by the semaphore.""" - # Create a dummy image file for the test - dummy_image_path = tmp_path / "dummy.jpg" - dummy_image_path.write_bytes(b"dummy image data") - - # Setup mock context - mock_ctx = create_mock_context() - - # Configure the object detector LLM config explicitly to avoid MagicMocks for timeout and model - od_cfg = MagicMock() - od_cfg.model = "gemini-robotics-er-early-access" - od_cfg.fix_model = "gemini-2.5-flash" - od_cfg.timeout = 10.0 - mock_ctx.llm_config.utils.object_detector = od_cfg - - # Track active requests - active_requests = 0 - max_concurrent_requests = 0 - request_lock = asyncio.Lock() - from langchain_core.messages import AIMessage - - mock_llm = MagicMock() - mock_llm.ainvoke = AsyncMock(side_effect=lambda *args, **kwargs: mock_generate_content()) - - async def mock_generate_content(*args, **kwargs): - nonlocal active_requests, max_concurrent_requests - async with request_lock: - active_requests += 1 - if active_requests > max_concurrent_requests: - max_concurrent_requests = active_requests - - try: - await asyncio.sleep(0.1) - finally: - async with request_lock: - active_requests -= 1 - - return AIMessage(content='[{"point": [500, 500]}]') - - mock_llm.ainvoke = AsyncMock(side_effect=mock_generate_content) - - with patch( - "artemis.agents.object_detector.object_detector.get_llm", - return_value=mock_llm, - ): - # --- TEST 1: Concurrency Assertion (5 labels) --- - # 5 labels should run in parallel, completing in ~0.1s (much faster than a sequential 0.5s) - # Reset trackers - active_requests = 0 - max_concurrent_requests = 0 - - start_time = time.time() - labels_5 = [f"label_{i}" for i in range(5)] - templates = ["Find {labels_str}"] - - result_5 = await _run_object_detection( - ctx=mock_ctx, - image_path=str(dummy_image_path), - queries=labels_5, - templates=templates, - ) - elapsed_5 = time.time() - start_time - - # Concurrency assertions - assert max_concurrent_requests == 5, ( - f"Expected 5 concurrent requests, got {max_concurrent_requests}" - ) - assert elapsed_5 < 0.3, f"Expected execution to take < 0.3s, but took {elapsed_5:.3f}s" - - # Verify result structure and swapped coordinates [y, x] -> [x, y] - parsed_result_5 = result_5 - assert len(parsed_result_5["detected"]) == 5 - for item in parsed_result_5["detected"]: - assert item["point"] == [500, 500] - - # --- TEST 2: Throttling Assertion (10 labels) --- - # 10 labels should be throttled by the semaphore limit of 6. - # It should run in two batches, completing in ~0.2s (much faster than a sequential 1.0s). - # Reset trackers - active_requests = 0 - max_concurrent_requests = 0 - - start_time = time.time() - labels_10 = [f"label_{i}" for i in range(10)] - - result_10 = await _run_object_detection( - ctx=mock_ctx, - image_path=str(dummy_image_path), - queries=labels_10, - templates=templates, - ) - elapsed_10 = time.time() - start_time - - # Throttling assertions - assert max_concurrent_requests == 6, ( - f"Expected max concurrent requests to be capped at 6, got {max_concurrent_requests}" - ) - assert elapsed_10 < 0.5, ( - f"Expected execution to take < 0.5s (two batches of 0.1s), but took {elapsed_10:.3f}s" - ) - - parsed_result_10 = result_10 - assert len(parsed_result_10["detected"]) == 10 - for item in parsed_result_10["detected"]: - assert item["point"] == [500, 500] diff --git a/tests/integration/test_artemis_login.py b/tests/integration/test_artemis_login.py deleted file mode 100644 index 7eabf4a39..000000000 --- a/tests/integration/test_artemis_login.py +++ /dev/null @@ -1,88 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -import select -import sys -import time - -import pytest - -pytestmark = [pytest.mark.integration, pytest.mark.manual] - -if sys.platform == "win32": - pytest.skip("PTY-based SSH login test requires a POSIX host", allow_module_level=True) - -import pty - - -@pytest.mark.skip(reason="Manual integration test requiring remote host") -def test_artemis_ssh_login(): - """Manual test to verify SSH login to remote Artemis instance.""" - run_artemis_ssh_login() - - -def run_artemis_ssh_login(): - host = os.getenv("ARTEMIS_SSH_TEST_HOST", "127.0.0.1") - user = os.getenv("ARTEMIS_SSH_TEST_USER", "artemis") - password = os.getenv("ARTEMIS_SSH_TEST_PASSWORD", "password") - - cmd = [ - "ssh", - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/dev/null", - "-o", - "PreferredAuthentications=password,keyboard-interactive", - f"{user}@{host}", - ( - "echo '=================================================='; echo '🚀 BINGO!! PURE" - " PASSWORD LOGIN VICTORY 🚀'; echo" - " '=================================================='; whoami; hostname; uptime" - ), - ] - - master, slave = pty.openpty() - pid = os.fork() - if pid == 0: - os.close(master) - os.execvp(cmd[0], cmd) - else: - os.close(slave) - output = b"" - start = time.time() - password_sent = False - while time.time() - start < 15: - r, _, _ = select.select([master], [], [], 0.3) - if master in r: - try: - data = os.read(master, 2048) - if not data: - break - output += data - sys.stdout.buffer.write(data) - sys.stdout.flush() - if (b"Password:" in data or b"password:" in data) and not password_sent: - time.sleep(0.2) - os.write(master, f"{password}\n".encode()) - password_sent = True - except OSError: - break - os.close(master) - os.waitpid(pid, 0) - - -if __name__ == "__main__": - run_artemis_ssh_login() diff --git a/tests/integration/test_cloud_gateway.py b/tests/integration/test_cloud_gateway.py deleted file mode 100644 index 86d643841..000000000 --- a/tests/integration/test_cloud_gateway.py +++ /dev/null @@ -1,132 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -""" -Unit and Integration Tests for GCP Cloud Brain Outer Server Gateway. - -Tests multi-tenant authentication, device registration, device mutex locking, -workspace path resolution, RESTful /api/v1/* endpoints, and SSE event streaming. -""" - -import pytest -from fastapi.testclient import TestClient - -pytestmark = [pytest.mark.integration, pytest.mark.cloud] -pytest.importorskip( - "cloud_service", - reason="optional cloud_service package is not installed", -) - -from cloud_service.gateway.auth import TokenAuthProvider -from cloud_service.gateway.manager import ConnectionManager - -try: - from cloud_service.server import app -except ImportError: - from apps.cloud_service.server import app - - -client = TestClient(app) - - -def test_token_auth_provider(): - auth = TokenAuthProvider() - # Test valid tenant name as Bearer token - assert auth.authenticate("Bearer alice") == "alice" - assert auth.authenticate("Bearer bob") == "bob" - assert auth.authenticate("alice-token") == "alice" - # Test invalid token - assert auth.authenticate("Bearer invalid-token-1234") is None - assert auth.authenticate("invalid-token-1234") is None - - -def test_connection_manager_device_lifecycle(): - mgr = ConnectionManager() - device = mgr.register_device("alice", "pixel8-test-001", model="Pixel 8") - assert device.tenant == "alice" - assert device.device_serial == "pixel8-test-001" - assert device.status == "ONLINE" - - devices = mgr.list_tenant_devices("alice") - assert len(devices) == 1 - assert devices[0].device_serial == "pixel8-test-001" - - # Test unregister - assert mgr.unregister_device("alice", "pixel8-test-001") is True - assert len(mgr.list_tenant_devices("alice")) == 0 - assert mgr.unregister_device("alice", "non-existent") is False - - -def test_workspace_dir_resolution(): - mgr = ConnectionManager() - ws_path = mgr.get_workspace_dir("alice", "session_test_001") - assert "alice" in str(ws_path) - assert "session_test_001" in str(ws_path) - assert ws_path.exists() - - -def test_api_v1_tenant_profile(): - headers = {"Authorization": "Bearer alice"} - resp = client.get("/api/v1/tenants/me", headers=headers) - assert resp.status_code == 200 - data = resp.json() - assert data["tenant_id"] == "alice" - assert "max_concurrent_sessions" in data - - -def test_api_v1_unauthorized(): - resp = client.get("/api/v1/tenants/me") - assert resp.status_code == 401 - resp2 = client.get("/api/v1/tenants/me", headers={"Authorization": "Bearer bad-token"}) - assert resp2.status_code in (401, 403) - - -def test_api_v1_device_register_and_list(): - headers = {"Authorization": "Bearer alice"} - reg_payload = { - "device_serial": "pixel8-api-001", - "model": "Pixel 8 Pro", - "android_version": "14", - } - resp = client.post("/api/v1/devices/register", json=reg_payload, headers=headers) - assert resp.status_code == 200 - data = resp.json().get("device", resp.json()) - assert data["device_serial"] == "pixel8-api-001" - assert data["model"] == "Pixel 8 Pro" - - # List devices - list_resp = client.get("/api/v1/devices", headers=headers) - assert list_resp.status_code == 200 - devices = list_resp.json()["devices"] - assert any(d["device_serial"] == "pixel8-api-001" for d in devices) - - # Unregister device - del_resp = client.delete("/api/v1/devices/pixel8-api-001", headers=headers) - assert del_resp.status_code == 200 - assert del_resp.json()["status"] == "unregistered" - - -@pytest.mark.asyncio -async def test_device_mutex_lock(): - mgr = ConnectionManager() - mgr.register_device("alice", "pixel8-mutex-001") - lock = mgr.get_device_lock("pixel8-mutex-001") - - # Simulate device busy by acquiring lock - await lock.acquire() - try: - with pytest.raises(RuntimeError, match="currently BUSY"): - await mgr.spawn_user_session("alice", "pixel8-mutex-001", "test goal") - finally: - lock.release() diff --git a/tests/integration/test_flash_standalone_news.py b/tests/integration/test_flash_standalone_news.py deleted file mode 100644 index ca9aed89a..000000000 --- a/tests/integration/test_flash_standalone_news.py +++ /dev/null @@ -1,58 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -import logging -import os -import sys -from dotenv import load_dotenv - -# Ensure we can import from project -sys.path.append(os.path.dirname(__file__)) - -from artemis.sdk import Agent -from artemis.sdk.builders import Builders -from artemis.sdk.types import AgentProfile - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger("test_flash_standalone") - - -async def main(): - load_dotenv() - - # 1. Initialize Agent to set up device and connection pools - logger.info("Initializing Agent and connecting to device...") - profile = AgentProfile(name="default", from_file="llm-config.override.jsonc") - config = Builders.AgentConfig.with_default_profile(profile).build() - - agent = Agent(config=config) - await agent.init() - - # 2. Run task using 'flash' profile - logger.info("Starting Flash runner task execution...") - try: - result = await agent.run_task( - goal="Open USA Today in Chrome browser and view the USA news", - profile="flash", - ) - logger.info(f"Flash runner task execution finished! Result: {result}") - except Exception: - logger.exception("Error during Flash task execution:") - finally: - await agent.clean() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/tests/integration/test_flash_standalone_youtube.py b/tests/integration/test_flash_standalone_youtube.py deleted file mode 100644 index e34f9f232..000000000 --- a/tests/integration/test_flash_standalone_youtube.py +++ /dev/null @@ -1,57 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -import logging -import os -import sys -from dotenv import load_dotenv - -# Ensure we can import from project -sys.path.append(os.path.dirname(__file__)) - -from artemis.sdk import Agent -from artemis.sdk.builders import Builders -from artemis.sdk.types import AgentProfile - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger("test_flash_standalone") - - -async def main(): - load_dotenv() - - # 1. Initialize Agent to set up device and connection pools - logger.info("Initializing Agent and connecting to device...") - profile = AgentProfile(name="default", from_file="llm-config.override.jsonc") - config = Builders.AgentConfig.with_default_profile(profile).build() - - agent = Agent(config=config) - await agent.init() - - # 2. Run task using 'flash' profile - logger.info("Starting Flash runner task execution...") - try: - result = await agent.run_task( - goal="Play a video in full screen on YouTube in Chrome", profile="flash" - ) - logger.info(f"Flash runner task execution finished! Result: {result}") - except Exception: - logger.exception("Error during Flash task execution:") - finally: - await agent.clean() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/tests/integration/test_ime.py b/tests/integration/test_ime.py deleted file mode 100644 index 9b097ab20..000000000 --- a/tests/integration/test_ime.py +++ /dev/null @@ -1,35 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import time -import uiautomator2 as u2 - - -def test(): - device = u2.connect("emulator-5554") - print("Testing send_keys with immediate IME switch off") - device.set_fastinput_ime(True) - device.send_keys("Test 1") - device.set_fastinput_ime(False) - - time.sleep(2) - print("Testing send_keys with delay before IME switch off") - device.set_fastinput_ime(True) - device.send_keys("Test 2") - time.sleep(1.0) - device.set_fastinput_ime(False) - - -if __name__ == "__main__": - test() diff --git a/tests/integration/test_physical_actions.py b/tests/integration/test_physical_actions.py deleted file mode 100644 index 3ae74d0c0..000000000 --- a/tests/integration/test_physical_actions.py +++ /dev/null @@ -1,103 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -from pathlib import Path -import sys - -# Add root to sys.path -root_dir = Path(__file__).parent.parent -if str(root_dir) not in sys.path: - sys.path.insert(0, str(root_dir)) - -from adbutils import AdbClient -from artemis.context import DeviceContext, DevicePlatform, ArtemisContext -from artemis.controllers.unified_controller import UnifiedMobileController -from third_party.mobile_use.clients.ui_automator_client import UIAutomatorClient - - -import pytest - -pytestmark = [pytest.mark.integration, pytest.mark.android] - - -@pytest.mark.asyncio -async def test_actions(): - print("=========================================") - print(" Physical Action Direct Validation Testbench") - print("=========================================") - adb = AdbClient(host="localhost", port=5037) - devices = adb.device_list() - if not devices: - pytest.skip("No active Android emulator or physical device detected") - device_id = devices[0].serial - print(f"✅ Target device locked: {device_id}") - - ui_client = UIAutomatorClient(device_id=device_id) - print("🔄 Connecting to UIAutomator2 and syncing screen properties...") - try: - ui_data = ui_client.get_screen_data() - w, h = ui_data.width, ui_data.height - print(f"✅ Screen sync ready, physical resolution: {w}x{h}") - except Exception as e: - print(f"❌ Screen properties sync failed: {e}") - return - - ctx = ArtemisContext( - trace_id="physical-test-bench", - device=DeviceContext( - host_platform="LINUX", - mobile_platform=DevicePlatform.ANDROID, - device_id=device_id, - device_width=w, - device_height=h, - ), - adb_client=adb, - ui_adb_client=ui_client, - ) - - ctrl = UnifiedMobileController(ctx) - - print("\n[Task 1] Extract Live UI Hierarchy...") - try: - elems = await ctrl.get_ui_elements() - print(f"✅ Parsed UI element count: {len(elems)}") - except Exception as e: - print(f"❌ Failed to extract UI hierarchy: {e}") - - print("\n[Task 2] Capture Live Screenshot...") - try: - shot = await ctrl.take_screenshot() - print(f"✅ Screenshot captured, Base64 payload length: {len(shot)}") - except Exception as e: - print(f"❌ Failed to capture screenshot: {e}") - - print("\n[Task 3] Simulate Drag/Swipe Down...") - try: - # Swipe up from bottom to top - err = await ctrl.swipe_coords(w // 2, int(h * 0.8), w // 2, int(h * 0.2), duration=400) - if err: - print(f"❌ Swipe rejected by controller: {err}") - else: - print("✅ Swipe command injected successfully!") - except Exception as e: - print(f"❌ Swipe execution exception: {e}") - - print("\n🧹 Safely releasing connection and driver handles...") - await ctrl.cleanup() - print("🎉 Direct physical validation tests completed successfully!") - - -if __name__ == "__main__": - asyncio.run(test_actions()) diff --git a/tests/integration/test_remote_adb.py b/tests/integration/test_remote_adb.py deleted file mode 100644 index 513bc5903..000000000 --- a/tests/integration/test_remote_adb.py +++ /dev/null @@ -1,175 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -""" -Unit tests for RemoteAdbDevice, RemoteAdbClient, and platform specific commands parity. -""" - -from unittest.mock import MagicMock - -import pytest - -pytestmark = [pytest.mark.integration, pytest.mark.cloud] -pytest.importorskip( - "cloud_service", - reason="optional cloud_service package is not installed", -) - -from cloud_service.virtualization.remote_adb import ( - RemoteAdbDevice, - RemoteAdbClient, -) - -from artemis.context import ArtemisContext, DeviceContext, DevicePlatform -from third_party.mobile_use.controllers.platform_specific_commands_controller import ( - get_adb_device, - list_packages, - get_current_foreground_package, - get_device_date, -) - - -def test_remote_adb_device_shell(): - mock_bridge = MagicMock() - mock_bridge.execute_sync.return_value = { - "status": "SUCCESS", - "output": "package:/data/app/com.android.chrome.apk=com.android.chrome\npackage:/data/app/com.android.settings.apk=com.android.settings", - } - - device = RemoteAdbDevice(serial="emulator-5554", bridge=mock_bridge) - output = device.shell("pm list packages -f") - - assert "com.android.chrome" in output - mock_bridge.execute_sync.assert_called_once_with("shell", {"command": "pm list packages -f"}) - - -def test_remote_adb_device_current_app(): - mock_bridge = MagicMock() - mock_bridge.execute_sync.return_value = { - "status": "SUCCESS", - "package": "com.android.chrome", - "activity": "com.google.android.apps.chrome.Main", - } - - device = RemoteAdbDevice(serial="emulator-5554", bridge=mock_bridge) - app = device.current_app() - - assert app is not None - assert app.package == "com.android.chrome" - assert app.activity == "com.google.android.apps.chrome.Main" - - -def test_remote_adb_device_window_size(): - mock_bridge = MagicMock() - mock_bridge.execute_sync.return_value = {"status": "SUCCESS", "width": 1440, "height": 3120} - - device = RemoteAdbDevice(serial="emulator-5554", bridge=mock_bridge) - size = device.window_size() - - assert size == (1440, 3120) - - -def test_platform_commands_parity_with_remote_device(monkeypatch): - monkeypatch.setenv("ARTEMIS_CLOUD_MODE", "1") - monkeypatch.setenv("ARTEMIS_CLOUD_SESSION_ID", "session_parity_001") - monkeypatch.setenv("ADB_DEVICE_SERIAL", "emulator-5554") - - mock_bridge = MagicMock() - mock_bridge.execute_sync.side_effect = [ - # 1. date - {"status": "SUCCESS", "output": "Thu Aug 6 12:00:00 UTC 2026\n"}, - # 2. pm list packages - { - "status": "SUCCESS", - "output": "package:/data/app/com.example.app1.apk=com.example.app1\npackage:/data/app/com.example.app2.apk=com.example.app2", - }, - # 3. current_app - {"status": "SUCCESS", "package": "com.example.app1", "activity": ".MainActivity"}, - ] - - client = RemoteAdbClient(bridge=mock_bridge) - device = client.device("emulator-5554") - - ctx = ArtemisContext( - device=DeviceContext( - host_platform="LINUX", - mobile_platform=DevicePlatform.ANDROID, - device_id="emulator-5554", - device_width=1080, - device_height=2400, - ), - adb_client=client, - ) - - dev = get_adb_device(ctx) - assert dev.serial == "emulator-5554" - - # Verify real dynamic date - date_str = get_device_date(ctx) - assert "2026" in date_str - - # Verify real dynamic package listing (Zero hardcoding) - pkgs = list_packages(ctx) - assert pkgs == "com.example.app1\ncom.example.app2" - - # Verify real dynamic foreground package detection (Zero retry loop) - fg_pkg = get_current_foreground_package(ctx) - assert fg_pkg == "com.example.app1" - - -def test_remote_uiautomator_client_parity(): - """Verify RemoteUIAutomatorClient correctly returns structured UIAutomatorScreenData.""" - from cloud_service.virtualization.remote_adb import RemoteUIAutomatorClient - - mock_bridge = MagicMock() - mock_bridge.execute_sync.return_value = { - "status": "SUCCESS", - "base64": "fake_b64", - "hierarchy_xml": "", - "elements": [{"text": "Search", "bounds": "[100,200][300,400]"}], - "width": 1080, - "height": 2400, - } - - client = RemoteUIAutomatorClient(bridge=mock_bridge) - b64, xml, elements = client.get_screen_data() - - assert b64 == "fake_b64" - assert " pytest tests/integration/test_thin_sdk_live.py -""" - -from __future__ import annotations - -import os -import asyncio -import shutil -import subprocess -import time -import uuid - -import pytest - -from artemis_client import ArtemisClient, TaskRejectedError - - -def _live_client() -> tuple[ArtemisClient, str]: - serial = os.environ.get("ARTEMIS_TEST_DEVICE_SERIAL") - if not serial: - pytest.skip("ARTEMIS_TEST_DEVICE_SERIAL is required for the live SDK test") - return ( - ArtemisClient( - os.environ.get("ARTEMIS_BASE_URL", "http://127.0.0.1:8000"), - device_serial=serial, - default_profile="flash", - poll_interval=0.5, - ), - serial, - ) - - -def _device_prop(serial: str, prop: str) -> str: - adb = shutil.which("adb") - if not adb: - pytest.skip("adb is required to read the target device's properties") - result = subprocess.run( - [adb, "-s", serial, "shell", "getprop", prop], - capture_output=True, - text=True, - timeout=30, - ) - value = result.stdout.strip() - if result.returncode != 0 or not value: - pytest.skip(f"Could not read {prop} from {serial}: {result.stderr.strip()}") - return value - - -@pytest.mark.android -@pytest.mark.asyncio -async def test_thin_sdk_remote_contract_on_real_device(): - client, serial = _live_client() - - readiness = await client.health() - assert readiness - - capabilities = await client.capabilities() - assert capabilities.supports("tasks.submit") - - devices = await client.list_devices() - assert any(device.serial == serial and device.state == "device" for device in devices) - - # Verify against the target device's own values so the goal is achievable on - # any phone or emulator rather than one hardcoded model. - model = _device_prop(serial, "ro.product.model") - android_version = _device_prop(serial, "ro.build.version.release") - goal = ( - "Open Android Settings, navigate to About phone (About emulated device on " - f"emulators), and verify that the visible model is {model} and the Android " - f"version is {android_version}. Do not change any setting. Stop after both " - "values are observed." - ) - - task_id = str(uuid.uuid4()) - handle = await client.submit( - goal, - task_id=task_id, - locked_app_package="com.android.settings", - ) - assert handle.task_id == task_id - - finished = False - try: - duplicate = await client.submit( - goal, - task_id=task_id, - locked_app_package="com.android.settings", - ) - assert duplicate.task_id == task_id - - result = await client.wait_for_task(task_id, timeout=600) - finished = True - assert result.succeeded, result.error or result.raw - finally: - if not finished: - await client.stop(task_id) - - cleanup_deadline = time.monotonic() + 30 - while time.monotonic() < cleanup_deadline: - scheduler = await client.health() - if scheduler.get("status") == "idle" and not scheduler.get("active_tasks"): - break - await asyncio.sleep(0.5) - else: - pytest.fail("Artemis scheduler did not release the device after task completion") - - -@pytest.mark.android -@pytest.mark.asyncio -async def test_thin_sdk_rejects_unknown_real_device_serial(): - client, _ = _live_client() - - with pytest.raises(TaskRejectedError): - await client.submit( - "This task must never reach a device", - device_serial="artemis-nonexistent-device", - ) - - -@pytest.mark.android -@pytest.mark.asyncio -async def test_thin_sdk_can_cancel_remote_task_and_release_device(): - client, _ = _live_client() - task_id = str(uuid.uuid4()) - await client.submit( - "Open Android Settings and inspect the top-level categories without changing anything.", - task_id=task_id, - locked_app_package="com.android.settings", - ) - - assert await client.stop(task_id) - - cleanup_deadline = time.monotonic() + 30 - while time.monotonic() < cleanup_deadline: - scheduler = await client.health() - active_ids = { - str(item.get("session_id")) - for item in scheduler.get("active_tasks", []) - if isinstance(item, dict) - } - queued_ids = { - str(item.get("session_id")) - for item in scheduler.get("queue", []) - if isinstance(item, dict) - } - if task_id not in active_ids | queued_ids: - break - await asyncio.sleep(0.5) - else: - pytest.fail("Cancelled task still owns a scheduler slot or device lock") diff --git a/tests/tools/checker/test_checker_tools.py b/tests/tools/checker/test_checker_tools.py deleted file mode 100644 index 67d0b6639..000000000 --- a/tests/tools/checker/test_checker_tools.py +++ /dev/null @@ -1,21 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Tests for checker tools. - -The Checker agent does not define its own specific exposed tools for testing. -It primarily reuses common tools (e.g. from scratchpad) and its behavior is -tested at the agent level. Therefore, there are no specific checker tools to -test here. -""" diff --git a/tests/tools/conftest.py b/tests/tools/conftest.py deleted file mode 100644 index 3c849cdde..000000000 --- a/tests/tools/conftest.py +++ /dev/null @@ -1,98 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from pathlib import Path -from unittest.mock import MagicMock - -from artemis.context import ArtemisContext -from artemis.data_engine.engine import DataEngine -import pytest - -INPUTS_DIR = Path(__file__).parent / "inputs" - - -@pytest.fixture(scope="session") -def inputs_dir(): - return INPUTS_DIR - - -@pytest.fixture -def mock_adb_client(): - client = MagicMock() - # Dummy outputs for commands and physical interactions that tools might use - client.shell.return_value = "dummy adb output" - # Basic UI hierarchy for mobile read_hierarchy tool - client.get_hierarchy.return_value = ( - "" - ) - return client - - -@pytest.fixture -def artemis_context(inputs_dir, mock_adb_client): - """Provides a ArtemisContext with loaded database and actual side-effects, - - except the ADB connection is customized to read from fixtures instead of a - real device. - """ - ctx = MagicMock(spec=ArtemisContext) - - ctx.execution_setup = MagicMock() - ctx.execution_setup.traces_path = str(inputs_dir) - # A bare MagicMock trace_name yields a repr with '<>' characters, which is an - # invalid path component on Windows once it flows into trace dir construction. - ctx.execution_setup.trace_name = "trace_output" - - # Initialize real DataEngine if the fixture database exists - db_path = inputs_dir / "data_engine.db" - if db_path.exists(): - engine = DataEngine(ctx) - engine.start_session(goal="Test goal") - import uuid - - engine.current_step_id = uuid.uuid4() - ctx.data_engine = engine - else: - ctx.data_engine = None - - ctx.ui_adb_client = mock_adb_client - - # Fake device representation - ctx.device = MagicMock() - ctx.device.device_width = 1080 - ctx.device.device_height = 2400 - - # Trace output directory setup to point inside inputs or a dedicated test folder - trace_out = inputs_dir / "trace_output" - trace_out.mkdir(exist_ok=True) - ctx.trace_dir = trace_out - - ctx.agent_config = MagicMock() - ctx.agent_config.denylisted_tools = {} - - return ctx - - -@pytest.fixture -def mock_state(inputs_dir): - """Provides a dummy graph State with realistic artifact paths for tools that need state injection.""" - from artemis.graph.state import State - - state = MagicMock(spec=State) - state.latest_screenshot = str(inputs_dir / "screenshot.jpg") - state.video_path = str(inputs_dir / "recording.mp4") - state.indexed_points = [] - state.indexed_elements = [] - return state diff --git a/tests/tools/diagnoser/test_diagnoser_tools.py b/tests/tools/diagnoser/test_diagnoser_tools.py deleted file mode 100644 index fbc957423..000000000 --- a/tests/tools/diagnoser/test_diagnoser_tools.py +++ /dev/null @@ -1,113 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from artemis.tools.diagnoser_submit_answer_tool import get_submit_answer_tool - - -def test_diagnoser_submit_answer_tool(artemis_context): - tool = get_submit_answer_tool(artemis_context) - - # Execute the tool with some typical payload - result = tool.invoke( - { - "analysis": "The problem is caused by a network timeout.", - "actionable_steps": ["Check network connection", "Restart the server"], - } - ) - - # Assert that the tool executes without raising errors and produces expected output structure - assert result == "Answer submitted successfully." - assert "error" not in result.lower() - - -def test_submit_answer_tool_subclass(): - """Verify SubmitAnswerTool is an ArtemisTool subclass.""" - from artemis.tools.base import ArtemisTool - from artemis.tools.diagnoser_submit_answer_tool import ( - DiagnoserSubmitAnswerTool, - SubmitAnswer, - SubmitAnswerArgs, - SubmitAnswerTool, - submit_answer, - ) - - assert issubclass(SubmitAnswerTool, ArtemisTool) - assert issubclass(SubmitAnswer, ArtemisTool) - assert issubclass(DiagnoserSubmitAnswerTool, ArtemisTool) - assert isinstance(submit_answer, ArtemisTool) - assert isinstance(submit_answer, SubmitAnswerTool) - - assert submit_answer.name == "submit_answer" - assert submit_answer.category == "custom" - assert submit_answer.args_schema == SubmitAnswerArgs - - # GenAI FunctionDeclaration export - declaration = submit_answer.to_genai_declaration() - assert declaration.name == "submit_answer" - assert "analysis" in declaration.parameters.properties - assert "actionable_steps" in declaration.parameters.properties - - -def test_ask_diagnoser_tool_subclass(): - """Verify AskDiagnoserTool is an ArtemisTool subclass.""" - from artemis.tools.base import ArtemisTool - from artemis.tools.diagnostic_tool import ( - AskDiagnoser, - AskDiagnoserArgs, - AskDiagnoserTool, - DiagnosticTool, - ask_diagnoser, - ) - - assert issubclass(AskDiagnoserTool, ArtemisTool) - assert issubclass(AskDiagnoser, ArtemisTool) - assert issubclass(DiagnosticTool, ArtemisTool) - assert isinstance(ask_diagnoser, ArtemisTool) - assert isinstance(ask_diagnoser, AskDiagnoserTool) - - assert ask_diagnoser.name == "ask_diagnoser" - assert ask_diagnoser.category == "custom" - assert ask_diagnoser.args_schema == AskDiagnoserArgs - - # GenAI FunctionDeclaration export - declaration = ask_diagnoser.to_genai_declaration() - assert declaration.name == "ask_diagnoser" - assert "query" in declaration.parameters.properties - - -def test_analyze_logs_tool_subclass(): - """Verify AnalyzeLogsTool is an ArtemisTool subclass.""" - from artemis.tools.base import ArtemisTool - from artemis.tools.log_tool import ( - AnalyzeLogs, - AnalyzeLogsArgs, - AnalyzeLogsTool, - LogTool, - analyze_logs, - ) - - assert issubclass(AnalyzeLogsTool, ArtemisTool) - assert issubclass(AnalyzeLogs, ArtemisTool) - assert issubclass(LogTool, ArtemisTool) - assert isinstance(analyze_logs, ArtemisTool) - assert isinstance(analyze_logs, AnalyzeLogsTool) - - assert analyze_logs.name == "analyze_logs" - assert analyze_logs.category == "custom" - assert analyze_logs.args_schema == AnalyzeLogsArgs - - # GenAI FunctionDeclaration export - declaration = analyze_logs.to_genai_declaration() - assert declaration.name == "analyze_logs" - assert "specific_query" in declaration.parameters.properties diff --git a/tests/tools/explorer/test_explorer_tools.py b/tests/tools/explorer/test_explorer_tools.py deleted file mode 100644 index d95d2f4bd..000000000 --- a/tests/tools/explorer/test_explorer_tools.py +++ /dev/null @@ -1,86 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from artemis.agents.explorer.explorer import Explorer -import pytest - - -@pytest.fixture -def explorer_instance(artemis_context, mock_state): - explorer = Explorer(artemis_context) - explorer.image_name = "test_img" - explorer.screenshot_path = mock_state.latest_screenshot - - # Initialize the image pool with img_0 required by processing tools - explorer.image_pool = { - "img_0": { - "path": mock_state.latest_screenshot, - "transform": { - "offset_x": 0.0, - "offset_y": 0.0, - "scale_x": 1.0, - "scale_y": 1.0, - }, - } - } - return explorer - - -@pytest.mark.asyncio -@pytest.mark.integration -async def test_exec_detect_objects(explorer_instance): - result = await explorer_instance.exec_detect_objects( - ["button", "text"], target_image_id="img_0" - ) - assert isinstance(result, dict) - assert "text" in result - assert "image_path" in result - - -@pytest.mark.asyncio -@pytest.mark.integration -async def test_exec_ask_perception_tool(explorer_instance): - result = await explorer_instance.exec_ask_perception_tool("login", 500, 500, ["button"]) - assert isinstance(result, dict) - assert "text" in result - assert "image_paths" in result - - -@pytest.mark.asyncio -async def test_exec_get_ocr_list(explorer_instance): - result = await explorer_instance.exec_get_ocr_list() - assert isinstance(result, dict) - assert "text" in result - assert "image_path" in result - - -@pytest.mark.asyncio -async def test_exec_inspect_region(explorer_instance): - # Depending on cv2 logic, it might return an error string if coordinates are out of bounds or image is missing - # But it shouldn't raise unhandled exceptions - result = await explorer_instance.exec_inspect_region(100, 100, 400, 400, 2.0) - assert isinstance(result, dict) - assert "text" in result - assert "image_path" in result - - -@pytest.mark.asyncio -@pytest.mark.integration -async def test_exec_ask_image_processor(explorer_instance): - result = await explorer_instance.exec_ask_image_processor( - "Invert colors", target_image_id="img_0" - ) - assert isinstance(result, dict) - assert "text" in result - assert "image_paths" in result diff --git a/tests/tools/history_analyzer/test_history_analyzer_tools.py b/tests/tools/history_analyzer/test_history_analyzer_tools.py deleted file mode 100644 index 0af6bdf80..000000000 --- a/tests/tools/history_analyzer/test_history_analyzer_tools.py +++ /dev/null @@ -1,69 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from artemis.agents.history_analyzer.history_analyzer import HistoryAnalyzer -from artemis.config import parse_llm_config -import pytest - - -@pytest.mark.asyncio -async def test_replay_steps_tool_with_fixture(artemis_context): - """The analyzer's replay_steps tool against real steps loaded from the - inputs/data_engine.db fixture.""" - if not artemis_context.data_engine: - pytest.skip("DataEngine fixture not initialized") - - artemis_context.data_engine.current_session_id = "1a7fc344-ea69-4b1a-b066-d8a667502b8c" - steps = artemis_context.data_engine.get_agent_friendly_steps() - assert len(steps) > 0 - - analyzer = HistoryAnalyzer(artemis_context) - tools = {t.name: t for t in analyzer._build_tools()} - assert { - "search_history", - "replay_steps", - "get_step_screenshot", - "list_notes", - "read_note", - } <= set(tools) - - result = tools["replay_steps"].invoke({"start_step": 1, "end_step": 3}) - assert isinstance(result, str) - assert not result.startswith("Error") - assert "- **Step 1 (" in result - assert "- **Step 3 (" in result - assert "YouTube" in result - - -@pytest.mark.asyncio -@pytest.mark.integration -async def test_history_analyzer_blackbox_run(artemis_context): - """End-to-end blackbox test of HistoryAnalyzer.run(query) using real steps from inputs/data_engine.db and real LLM calls.""" - if not artemis_context.data_engine: - pytest.skip("DataEngine fixture not initialized") - - artemis_context.llm_config = parse_llm_config() - artemis_context.data_engine.current_session_id = "1a7fc344-ea69-4b1a-b066-d8a667502b8c" - - analyzer = HistoryAnalyzer(artemis_context) - query = ( - "What application did the operator open and what query did they search" - " for? Briefly summarize the session steps." - ) - result = await analyzer.run(query) - - assert isinstance(result, str) - assert len(result) > 0 - assert "Error:" not in result - assert "No history recorded" not in result diff --git a/tests/tools/hopper/test_hopper_tools.py b/tests/tools/hopper/test_hopper_tools.py deleted file mode 100644 index deb7fb75e..000000000 --- a/tests/tools/hopper/test_hopper_tools.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""This file is intentionally empty. - -The hopper agent does not expose any tools directly. It is invoked -programmatically by other tools (e.g., launch_app_tool) as an LLM utility. -""" diff --git a/tests/tools/image_processor/test_image_processor_tools.py b/tests/tools/image_processor/test_image_processor_tools.py deleted file mode 100644 index 1317f8c0f..000000000 --- a/tests/tools/image_processor/test_image_processor_tools.py +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from artemis.tools.image_processor_tool import get_ask_image_processor_tool -import pytest - - -@pytest.mark.asyncio -@pytest.mark.integration -async def test_ask_image_processor_tool(artemis_context, mock_state): - """Tests the ask_image_processor tool as a blackbox. - - Allows actual side effects and full lifecycle execution (including real LLM - API calls). Uses shared pytest fixtures for realistic context. - """ - tool = get_ask_image_processor_tool(artemis_context) - - # Passing a simple visual reasoning instruction. - # The tool will use target_image_id="img_0" corresponding to the artifact in mock_state. - result = await tool.ainvoke( - { - "instruction": "Identify the primary colors present in this image.", - "target_image_id": "img_0", - "state": mock_state, - } - ) - - # Assert execution without errors and produces expected output structure - assert result is not None - assert isinstance(result, dict) - assert "outputs" in result - assert "summary" in result - assert isinstance(result["outputs"], list) diff --git a/tests/tools/inputs/audio_recording.mp3 b/tests/tools/inputs/audio_recording.mp3 deleted file mode 100644 index 8124f85a8..000000000 Binary files a/tests/tools/inputs/audio_recording.mp3 and /dev/null differ diff --git a/tests/tools/inputs/recording.mp4 b/tests/tools/inputs/recording.mp4 deleted file mode 100644 index 00fd1ef85..000000000 Binary files a/tests/tools/inputs/recording.mp4 and /dev/null differ diff --git a/tests/tools/inputs/screenshot.jpg b/tests/tools/inputs/screenshot.jpg deleted file mode 100644 index c22477f33..000000000 Binary files a/tests/tools/inputs/screenshot.jpg and /dev/null differ diff --git a/tests/tools/log_analyzer/test_log_analyzer_tools.py b/tests/tools/log_analyzer/test_log_analyzer_tools.py deleted file mode 100644 index e92339225..000000000 --- a/tests/tools/log_analyzer/test_log_analyzer_tools.py +++ /dev/null @@ -1,63 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from artemis.agents.log_analyzer.log_analyzer import LogAnalyzerNode -import pytest - - -@pytest.mark.asyncio -@pytest.mark.integration -async def test_spawn_log_reader(artemis_context, mock_state): - """Test the spawn_log_reader tool exposed by LogAnalyzerNode.""" - from artemis.sdk.utils import load_llm_config_override - from pathlib import Path - - node = LogAnalyzerNode(ctx=artemis_context) - artemis_context.llm_config = load_llm_config_override(Path("llm-config.json")) - if artemis_context.data_engine: - artemis_context.data_engine.start_session("test_session") - tool = node._get_spawn_log_reader_tool() - - # Let the tool run its lifecycle. It will use the real LLM APIs. - # Note: State injection is normally handled by LangGraph/tool invocation, - # but since we are testing the coroutine directly, we can pass it manually. - result = await tool.coroutine( - specific_query=("Find any errors related to network timeout in the logs."), - state=mock_state, - ) - - assert result is not None - assert isinstance(result, (str, list)) - assert len(result) > 0 - - -@pytest.mark.asyncio -@pytest.mark.integration -async def test_log_analyzer_run(artemis_context, mock_state): - """Test LogAnalyzerNode.run end-to-end execution with grounding configuration.""" - from artemis.config.llm import parse_llm_config - - node = LogAnalyzerNode(ctx=artemis_context) - artemis_context.llm_config = parse_llm_config() - if artemis_context.data_engine: - artemis_context.data_engine.start_session("test_log_analyzer_session") - - result = await node.run( - prompt="Analyze recent error logs for potential crash or exception tags.", - state=mock_state, - ) - - assert result is not None - assert isinstance(result, (str, list)) - assert len(result) > 0 diff --git a/tests/tools/object_detector/test_object_detector_tools.py b/tests/tools/object_detector/test_object_detector_tools.py deleted file mode 100644 index b3d349360..000000000 --- a/tests/tools/object_detector/test_object_detector_tools.py +++ /dev/null @@ -1,53 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Tests for object_detector universal tools.""" - -from artemis.tools.base import ArtemisTool -from artemis.tools.object_detection_tool import ( - ObjectDetection, - ObjectDetectionArgs, - ObjectDetectionTool, - ObjectDetectorTool, - OperatorObjectDetection, - OperatorObjectDetectionArgs, - OperatorObjectDetectionTool, - OperatorObjectDetectorTool, - object_detection, - operator_object_detection, -) - - -def test_object_detector_tool_subclass(): - """Verify ObjectDetectionTool is an ArtemisTool subclass.""" - assert issubclass(ObjectDetectionTool, ArtemisTool) - assert issubclass(ObjectDetection, ArtemisTool) - assert issubclass(ObjectDetectorTool, ArtemisTool) - assert issubclass(OperatorObjectDetectionTool, ArtemisTool) - assert issubclass(OperatorObjectDetectionTool, ObjectDetectionTool) - assert issubclass(OperatorObjectDetection, ArtemisTool) - assert issubclass(OperatorObjectDetectorTool, ArtemisTool) - - assert isinstance(object_detection, ArtemisTool) - assert isinstance(object_detection, ObjectDetectionTool) - assert isinstance(operator_object_detection, ArtemisTool) - assert isinstance(operator_object_detection, OperatorObjectDetectionTool) - - assert object_detection.name == "object_detection" - assert object_detection.category == "perception" - assert object_detection.args_schema == ObjectDetectionArgs - - assert operator_object_detection.name == "object_detection" - assert operator_object_detection.category == "perception" - assert operator_object_detection.args_schema == OperatorObjectDetectionArgs diff --git a/tests/tools/operator/test_operator_tools.py b/tests/tools/operator/test_operator_tools.py deleted file mode 100644 index 8fe40927b..000000000 --- a/tests/tools/operator/test_operator_tools.py +++ /dev/null @@ -1,39 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from langchain_core.tools import BaseTool -import pytest - -from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER, operator_shell_tool - - -@pytest.mark.asyncio -async def test_operator_shell_tools(): - """Every manifest-generated Operator shell is a declaration-only LangChain tool.""" - sample_args = { - "click": {"target": 1, "times": 1, "delay_ms": 100}, - "input_text": {"text": "hello", "target": 1, "clear_exist": True}, - "swipe": {"gesture": "up"}, - "press_key": {"key": "ENTER"}, - "manage_app": {"action": "launch", "app_name": "Settings"}, - "wait_for_delay": {"time_in_ms": 1000}, - "long_press": {"target": 1, "duration": 1000}, - } - assert set(sample_args) == set(OPERATOR_SHELL_ORDER) - - for name in OPERATOR_SHELL_ORDER: - tool = operator_shell_tool(name) - assert isinstance(tool, BaseTool) - assert tool.name == name - assert tool.invoke(sample_args[name]) == "Action Recorded" diff --git a/tests/tools/outputter/test_outputter_tools.py b/tests/tools/outputter/test_outputter_tools.py deleted file mode 100644 index 0b535531b..000000000 --- a/tests/tools/outputter/test_outputter_tools.py +++ /dev/null @@ -1,56 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""The outputter mounts the shared history tools; smoke-test them against the -fixture database.""" - -import pytest - -from artemis.context import ArtemisContext -from artemis.tools.history import get_history_tools - - -@pytest.fixture -def history_tools(artemis_context: ArtemisContext): - search, replay, screenshot = get_history_tools(artemis_context) - return {"search_history": search, "replay_steps": replay, "get_step_screenshot": screenshot} - - -def test_replay_steps_tool(history_tools): - """replay_steps runs against the fixture history without errors.""" - result = history_tools["replay_steps"].invoke({"start_step": 1, "end_step": 5}) - - assert isinstance(result, str) - assert len(result) > 0 - - -def test_get_step_screenshot_tool(history_tools): - """get_step_screenshot returns content blocks with an image, or a plain explanation.""" - result = history_tools["get_step_screenshot"].invoke({"step_number": 1}) - - if isinstance(result, list): - assert len(result) == 2 - assert result[0]["type"] == "text" - assert result[1]["type"] == "image_url" - else: - assert isinstance(result, str) - assert len(result) > 0 - - -def test_search_history_tool(history_tools): - """search_history searches the fixture history and always answers in text.""" - result = history_tools["search_history"].invoke({"query": "test"}) - - assert isinstance(result, str) - assert len(result) > 0 diff --git a/tests/tools/planner/test_planner_tools.py b/tests/tools/planner/test_planner_tools.py deleted file mode 100644 index 2954348a5..000000000 --- a/tests/tools/planner/test_planner_tools.py +++ /dev/null @@ -1,17 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -# The planner agent does not have exposed tools of its own to test here. -# It uses shared tools from artemis.tools.scratchpad, which are tested in their respective modules. diff --git a/tests/tools/summarizer/test_summarizer_tools.py b/tests/tools/summarizer/test_summarizer_tools.py deleted file mode 100644 index 4fe43507f..000000000 --- a/tests/tools/summarizer/test_summarizer_tools.py +++ /dev/null @@ -1,16 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -# The summarizer agent does not have exposed tools of its own to test here. diff --git a/tests/tools/validator/test_validator_tools.py b/tests/tools/validator/test_validator_tools.py deleted file mode 100644 index 0f6064f6a..000000000 --- a/tests/tools/validator/test_validator_tools.py +++ /dev/null @@ -1,75 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import base64 -from pathlib import Path - -from artemis.agents.validator.categories import ValidationErrorCategory -from artemis.agents.validator.validator import ValidatorNode -import pytest - - -@pytest.mark.asyncio -async def test_safety_net_validation(artemis_context, mock_state): - """Test the safety_net_validation tool.""" - node = ValidatorNode(artemis_context) - - action_item = { - "action": "tap", - "coordinates": [100, 200], - "target_text": "Submit", - } - - # We pass session=None so it relies on fallback or fails gracefully - # We assert it runs without unhandled exceptions. - try: - passed, category, reason = await node._validate_action_precondition( - session=None, action_item=action_item, state=mock_state - ) - except Exception as e: - pytest.fail(f"Tool raised an exception: {e}") - - assert isinstance(passed, bool) - assert isinstance(category, ValidationErrorCategory) - assert isinstance(reason, str) - - -@pytest.mark.asyncio -async def test_safety_net_pixel_validation(artemis_context, mock_state): - """Test the safety_net_pixel_validation tool.""" - node = ValidatorNode(artemis_context) - - action_item = {"action": "tap", "coordinates": [100, 200]} - - # Read the real screenshot from the mock_state to provide as pre_screenshot_b64 - screenshot_path = mock_state.latest_screenshot - pre_screenshot_b64 = "dummy_base64" - if screenshot_path and Path(screenshot_path).exists(): - with open(screenshot_path, "rb") as f: - pre_screenshot_b64 = base64.b64encode(f.read()).decode("utf-8") - - try: - passed, category, reason = await node._validate_action_precondition_pixel( - session=None, - action_item=action_item, - pre_screenshot_b64=pre_screenshot_b64, - original_coords=[100, 200], - state=mock_state, - ) - except Exception as e: - pytest.fail(f"Tool raised an exception: {e}") - - assert isinstance(passed, bool) - assert isinstance(category, ValidationErrorCategory) - assert isinstance(reason, str) diff --git a/tests/tools/video_analyzer/test_video_analyzer_tools.py b/tests/tools/video_analyzer/test_video_analyzer_tools.py deleted file mode 100644 index 54680af12..000000000 --- a/tests/tools/video_analyzer/test_video_analyzer_tools.py +++ /dev/null @@ -1,134 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest -from artemis.agents.video_analyzer.video_analyzer import VideoAnalyzer - - -@pytest.mark.asyncio -@pytest.mark.integration -async def test_video_analyzer_tools(artemis_context, mock_state, inputs_dir, tmp_path): - artemis_context.device.mobile_platform = "android" - artemis_context.adb_client = artemis_context.ui_adb_client - # A leaked MagicMock video_analyzer config coerces every numeric setting to - # 1 (float(MagicMock()) == 1.0), e.g. a 1s model-call timeout. None makes - # VideoAnalyzer fall back to its real defaults. - artemis_context.agent_config.video_analyzer = None - - agent = VideoAnalyzer(ctx=artemis_context) - - agent.local_files_to_cleanup = set() - agent.local_dirs_to_cleanup = set() - agent.cloud_files_to_cleanup = set() - agent.blackboard_entries = [] - agent.sub_system_prompt = "You are a sub agent" - agent.audio_system_prompt = "You are an audio sub agent" - agent.model_name = "gemini-3.7-flash" - - # We must provide a valid FunctionDeclaration for the tool otherwise Pydantic will complain - from google.genai import types - - agent.submit_answer_tool = types.FunctionDeclaration( - name="submit_answer", - description=("Submit the final findings for the requested segment analysis."), - parameters=types.Schema( - type=types.Type.OBJECT, - properties={ - "summary": types.Schema(type=types.Type.STRING, description="summary"), - "analysis": types.Schema(type=types.Type.STRING, description="analysis"), - "timeline_events": types.Schema( - type=types.Type.ARRAY, - items=types.Schema( - type=types.Type.OBJECT, - properties={ - "start_time": types.Schema(type=types.Type.NUMBER), - "end_time": types.Schema(type=types.Type.NUMBER), - "transcription": types.Schema(type=types.Type.STRING), - "confidence_score": types.Schema(type=types.Type.NUMBER), - "verification_timestamp_secs": types.Schema(type=types.Type.NUMBER), - }, - ), - ), - }, - ), - ) - - import google.genai as genai - from artemis.config import settings - - agent.client = genai.Client( - api_key=settings.GOOGLE_API_KEY.get_secret_value() if settings.GOOGLE_API_KEY else None - ) - artemis_context._genai_client = agent.client - - import shutil - import uuid - - mock_session = MagicMock() - mock_session.local_video_path = Path(inputs_dir) / "recording.mp4" - mock_session.start_time = 0.0 - mock_session.data_engine_start_time = None - mock_session.process = MagicMock() - mock_session.process.returncode = None - mock_session.android_video_segments = [] - mock_session.android_segment_index = 0 - # Real-typed fields consumed by UnifiedMobileController.extract_segment_metadata - # and the pydantic VideoRecordingResult; leaked MagicMock attributes fail - # validation or produce invalid paths. - mock_session.android_segment_records = [] - mock_session.android_segment_started_at = None - mock_session.video_id = uuid.uuid4() - mock_session.generation = 0 - mock_session.sealed_until = None - - from unittest.mock import AsyncMock - - sample_video = Path(inputs_dir) / "recording.mp4" - mock_driver = MagicMock() - mock_driver.stop_video_recording = AsyncMock(return_value=sample_video) - # A bare MagicMock attribute is truthy, which would trip the mock-driver - # short-circuit in extract_segment_metadata and return a nonexistent path. - mock_driver.is_mock = False - artemis_context._active_driver = mock_driver - - # Screen-recording segments carry no audio track (render_timeline_clip maps - # only the video stream), so real audio extraction can never succeed here. - # Substitute the fixture mp3 as the extracted audio for the audio sub-agent. - audio_copy = tmp_path / "audio_recording.mp3" - shutil.copyfile(Path(inputs_dir) / "audio_recording.mp3", audio_copy) - - with ( - patch( - "artemis.controllers.unified_controller.get_active_session", - return_value=mock_session, - ), - patch( - "artemis.agents.video_analyzer.video_analyzer.extract_audio_from_video", - AsyncMock(return_value=audio_copy), - ), - ): - result = await agent.exec_extract_segment_metadata(start_time=0.0, end_time=1.0) - assert isinstance(result, str) - assert len(result) > 0 - - # Test sub-agent with extremely short audio query to not waste resources and time - audio_result = await agent.exec_analyze_audio_only( - start_time=0.0, - end_time=1.0, - specific_query="Reply with a short summary only.", - ) - assert isinstance(audio_result, str) diff --git a/tests/unit/admin_console/test_graceful_stop_and_recovery.py b/tests/unit/admin_console/test_graceful_stop_and_recovery.py deleted file mode 100644 index f749afa65..000000000 --- a/tests/unit/admin_console/test_graceful_stop_and_recovery.py +++ /dev/null @@ -1,273 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Cancelling a task must let the worker finalize its recording, or recover it.""" - -import asyncio -from contextlib import ExitStack -from unittest.mock import MagicMock, patch - -import pytest - -from apps.admin_console.core.state import state -from apps.admin_console.services.task_queue_service import TaskQueueService, task_queue_service -from artemis.runtime import cancel_requests -from artemis.runtime.device_lock import DeviceLockOwner - -TQS = "apps.admin_console.services.task_queue_service" - - -def _reset_state() -> None: - state.clear_queue() - state.queue_items.clear() - state.active_connections.clear() - state.cancelled_session_ids.clear() - state.manually_stopped_run_ids.clear() - state.current_process = None - state.active_session_id = None - state.current_goal = None - state.current_profile = None - - -@pytest.fixture(autouse=True) -def isolated(tmp_path, monkeypatch): - marker_dir = tmp_path / "cancel-requests" - lock_dir = tmp_path / "device-locks" - lock_dir.mkdir() - monkeypatch.setattr(cancel_requests, "get_temp_dir", lambda _subfolder=None: marker_dir) - monkeypatch.setattr( - "artemis.runtime.device_lock.get_temp_dir", lambda _subfolder=None: lock_dir - ) - monkeypatch.setenv("ARTEMIS_CANCEL_GRACE_SECONDS", "5") - for task in list(TaskQueueService._forced_stop_tasks): - task.cancel() - TaskQueueService._forced_stop_tasks.clear() - _reset_state() - yield marker_dir - for task in list(TaskQueueService._forced_stop_tasks): - task.cancel() - TaskQueueService._forced_stop_tasks.clear() - _reset_state() - - -def _owner(pid: int = 24680, session_id: str = "sess-1") -> DeviceLockOwner: - return DeviceLockOwner( - pid=pid, - process_created_at=1234.5, - token="owner-token", - device_id="emulator-5554", - description="frontend task: demo", - acquired_at="2026-08-24T00:00:00+00:00", - session_id=session_id, - ingress="frontend", - ) - - -def _stop_patches(stack: ExitStack, owner: DeviceLockOwner, alive: bool = True): - stack.enter_context(patch.object(TaskQueueService, "ensure_worker_running")) - stack.enter_context( - patch(f"{TQS}.DeviceExecutionLock.get_active_owners", return_value={"dev": owner}) - ) - stack.enter_context(patch(f"{TQS}.DeviceExecutionLock.is_active_owner", return_value=True)) - stack.enter_context(patch(f"{TQS}.DeviceExecutionLock.cleanup_stale_locks")) - stack.enter_context(patch(f"{TQS}.session_repo.update_session_status")) - stack.enter_context(patch("artemis.runtime.trace_store.update_trace_status")) - pid_alive = stack.enter_context(patch(f"{TQS}.pid_is_alive", return_value=alive)) - terminate = stack.enter_context(patch(f"{TQS}.process_supervisor.terminate_tree_verified")) - return pid_alive, terminate - - -@pytest.mark.asyncio -async def test_stop_requests_graceful_cancel_and_skips_kill_when_worker_exits(): - owner = _owner() - with ExitStack() as stack: - pid_alive, terminate = _stop_patches(stack, owner) - - assert task_queue_service.stop_tasks(session_id="sess-1") is True - - # The worker is asked to cancel itself; nothing is killed yet. - terminate.assert_not_called() - assert cancel_requests.is_cancel_requested(session_id="sess-1") - assert cancel_requests.is_cancel_requested(pid=24680, process_created_at=1234.5) - assert len(TaskQueueService._forced_stop_tasks) == 1 - enforcer = next(iter(TaskQueueService._forced_stop_tasks)) - - # The worker finalizes and exits on its own within the grace period. - pid_alive.return_value = False - await asyncio.wait_for(enforcer, timeout=5) - - terminate.assert_not_called() - assert not cancel_requests.is_cancel_requested(session_id="sess-1") - - -@pytest.mark.asyncio -async def test_stop_kills_worker_that_ignores_the_cancel_request(monkeypatch): - monkeypatch.setenv("ARTEMIS_CANCEL_GRACE_SECONDS", "0.6") - owner = _owner() - with ExitStack() as stack: - _pid_alive, terminate = _stop_patches(stack, owner, alive=True) - terminate.return_value = True - - assert task_queue_service.stop_tasks(session_id="sess-1") is True - terminate.assert_not_called() - enforcer = next(iter(TaskQueueService._forced_stop_tasks)) - - await asyncio.wait_for(enforcer, timeout=5) - - terminate.assert_called_once_with(24680, 1234.5) - assert not cancel_requests.is_cancel_requested(session_id="sess-1") - - -def test_zero_grace_keeps_the_immediate_kill(monkeypatch): - monkeypatch.setenv("ARTEMIS_CANCEL_GRACE_SECONDS", "0") - owner = _owner() - with ExitStack() as stack: - _pid_alive, terminate = _stop_patches(stack, owner) - terminate.return_value = True - - assert task_queue_service.stop_tasks(session_id="sess-1") is True - - terminate.assert_called_once_with(24680, 1234.5) - assert not cancel_requests.is_cancel_requested(session_id="sess-1") - assert not TaskQueueService._forced_stop_tasks - - -def test_stop_of_dead_worker_writes_no_marker(): - owner = _owner() - with ExitStack() as stack: - _pid_alive, terminate = _stop_patches(stack, owner, alive=False) - - assert task_queue_service.stop_tasks(session_id="sess-1") is True - - terminate.assert_not_called() - assert not cancel_requests.is_cancel_requested(session_id="sess-1") - assert not TaskQueueService._forced_stop_tasks - - -@pytest.mark.asyncio -async def test_recover_or_fail_recording_finalizes_orphaned_raw_file(tmp_path): - final = tmp_path / "web_x_FAIL_2026-09-02T16-31-32" / "recording.mp4" - final.parent.mkdir() - final.write_bytes(b"video") - raw_path = str(tmp_path / "web_x" / "recording.mkv") - repo = MagicMock() - repo.get_video_recording_for_session.return_value = { - "status": "recording", - "local_video_path": raw_path, - "start_time": 1788391895.4, - } - media = MagicMock() - media.recover_orphaned_recording.return_value = final - media.path_to_video_url.return_value = "/videos/traces/web_x_FAIL/recording.mp4" - events: list[tuple[str, dict]] = [] - - with ( - patch(f"{TQS}.session_repo", repo), - patch(f"{TQS}.media_service", media), - patch.object( - TaskQueueService, "_broadcast_event", side_effect=lambda t, d: events.append((t, d)) - ), - ): - await TaskQueueService._recover_or_fail_recording("sess-1") - - media.recover_orphaned_recording.assert_called_once_with(raw_path, 1788391895.4) - repo.mark_recording_ready.assert_called_once_with("sess-1", str(final)) - repo.mark_recording_failed_if_pending.assert_not_called() - assert events == [ - ( - "recording_ready", - {"session_id": "sess-1", "video_url": "/videos/traces/web_x_FAIL/recording.mp4"}, - ) - ] - - -@pytest.mark.asyncio -async def test_recover_or_fail_recording_marks_failed_when_nothing_usable(): - repo = MagicMock() - repo.get_video_recording_for_session.return_value = { - "status": "recording", - "local_video_path": "/nowhere/recording.mkv", - "start_time": 1.0, - } - repo.get_video_recordings_map.return_value = {} - repo.mark_recording_failed_if_pending.return_value = True - media = MagicMock() - media.recover_orphaned_recording.return_value = None - media.build_video_index.return_value = {} - media.resolve_video_url.return_value = None - events: list[tuple[str, dict]] = [] - - with ( - patch(f"{TQS}.session_repo", repo), - patch(f"{TQS}.media_service", media), - patch.object( - TaskQueueService, "_broadcast_event", side_effect=lambda t, d: events.append((t, d)) - ), - ): - await TaskQueueService._recover_or_fail_recording("sess-2") - - repo.mark_recording_ready.assert_not_called() - repo.mark_recording_failed_if_pending.assert_called_once() - assert events[0][0] == "recording_failed" - - -def test_startup_sweep_publishes_orphaned_recordings(tmp_path): - final = tmp_path / "web_a_FAIL_2026-09-02T16-31-32" / "recording.mp4" - final.parent.mkdir() - final.write_bytes(b"video") - repo = MagicMock() - repo.get_unfinalized_video_recordings.return_value = [ - { - "session_id": "sess-a", - "local_video_path": str(tmp_path / "web_a" / "recording.mkv"), - "start_time": 10.0, - "session_status": "cancelled", - }, - { - "session_id": "sess-b", - "local_video_path": str(tmp_path / "web_b" / "recording.mkv"), - "start_time": None, - "session_start_time": 20.0, - "session_status": "failed", - }, - ] - repo.mark_recording_ready.return_value = True - media = MagicMock() - media.recover_orphaned_recording.side_effect = [final, None] - - with patch(f"{TQS}.session_repo", repo), patch(f"{TQS}.media_service", media): - assert TaskQueueService.recover_orphaned_recordings_on_launch() == 1 - - assert media.recover_orphaned_recording.call_args_list[0].args == ( - str(tmp_path / "web_a" / "recording.mkv"), - 10.0, - ) - assert media.recover_orphaned_recording.call_args_list[1].args == ( - str(tmp_path / "web_b" / "recording.mkv"), - 20.0, - ) - repo.mark_recording_ready.assert_called_once_with("sess-a", str(final)) - - -@pytest.mark.asyncio -async def test_recover_or_fail_recording_skips_rows_the_worker_finalized(): - repo = MagicMock() - repo.get_video_recording_for_session.return_value = {"status": "ready"} - media = MagicMock() - - with patch(f"{TQS}.session_repo", repo), patch(f"{TQS}.media_service", media): - await TaskQueueService._recover_or_fail_recording("sess-3") - - media.recover_orphaned_recording.assert_not_called() - repo.mark_recording_failed_if_pending.assert_not_called() diff --git a/tests/unit/admin_console/test_media_recording_lifecycle.py b/tests/unit/admin_console/test_media_recording_lifecycle.py deleted file mode 100644 index dff71f15e..000000000 --- a/tests/unit/admin_console/test_media_recording_lifecycle.py +++ /dev/null @@ -1,90 +0,0 @@ -from unittest.mock import MagicMock - -import pytest - -from apps.admin_console.routers import media as media_router - - -@pytest.mark.asyncio -async def test_session_video_does_not_expose_recording_in_progress(monkeypatch): - repo = MagicMock() - repo.get_video_recordings_map.return_value = {} - repo.get_session_by_id.return_value = {"session_id": "session-1", "status": "completed"} - repo.get_video_recording_for_session.return_value = { - "session_id": "session-1", - "status": "recording", - "local_video_path": "/tmp/recording.mkv", - } - monkeypatch.setattr(media_router, "session_repo", repo, raising=False) - monkeypatch.setattr(media_router.media_service, "build_video_index", MagicMock(return_value={})) - resolve = MagicMock(return_value="/videos/recording.mkv") - monkeypatch.setattr(media_router.media_service, "resolve_video_url", resolve) - - response = await media_router.get_session_video("session-1") - - assert response["status"] == "processing" - assert response["has_video"] is False - assert response["video_url"] is None - resolve.assert_not_called() - - -@pytest.mark.asyncio -async def test_session_video_publishes_only_finalized_versioned_media(monkeypatch): - repo = MagicMock() - repo.get_video_recordings_map.return_value = {"session-1": "/tmp/recording.mp4"} - repo.get_session_by_id.return_value = {"session_id": "session-1", "status": "completed"} - repo.get_video_recording_for_session.return_value = { - "session_id": "session-1", - "status": "ready", - "end_time": 123.456, - "local_video_path": "/tmp/recording.mp4", - } - monkeypatch.setattr(media_router, "session_repo", repo, raising=False) - monkeypatch.setattr(media_router.media_service, "build_video_index", MagicMock(return_value={})) - monkeypatch.setattr( - media_router.media_service, - "resolve_video_url", - MagicMock(return_value="/videos/recording.mp4"), - ) - monkeypatch.setattr( - media_router.media_service, - "resolve_video_segments", - MagicMock( - return_value=[ - { - "url": "/videos/recording.mp4", - "start": 0, - "duration": 4, - "width": 1080, - "height": 1920, - } - ] - ), - ) - - response = await media_router.get_session_video("session-1") - - assert response["status"] == "ready" - assert response["has_video"] is True - assert response["video_url"] == "/videos/recording.mp4?v=123456" - assert response["video_segments"][0]["url"] == "/videos/recording.mp4?v=123456" - - -@pytest.mark.asyncio -async def test_session_video_surfaces_terminal_recording_failure(monkeypatch): - repo = MagicMock() - repo.get_video_recordings_map.return_value = {} - repo.get_session_by_id.return_value = {"session_id": "session-1", "status": "failed"} - repo.get_video_recording_for_session.return_value = { - "session_id": "session-1", - "status": "failed", - "error": "ffmpeg failed", - } - monkeypatch.setattr(media_router, "session_repo", repo, raising=False) - monkeypatch.setattr(media_router.media_service, "build_video_index", MagicMock(return_value={})) - - response = await media_router.get_session_video("session-1") - - assert response["status"] == "failed" - assert response["message"] == "ffmpeg failed" - assert response["video_url"] is None diff --git a/tests/unit/admin_console/test_media_recording_recovery.py b/tests/unit/admin_console/test_media_recording_recovery.py deleted file mode 100644 index f4c777ae5..000000000 --- a/tests/unit/admin_console/test_media_recording_recovery.py +++ /dev/null @@ -1,232 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Recording files of cancelled / killed workers must survive and be served whole.""" - -import json -import os -from pathlib import Path -import time -from unittest.mock import patch - -import pytest - -from apps.admin_console.services import media_service as media_module - -MediaService = media_module.MediaService - - -@pytest.fixture(autouse=True) -def clean_cache(): - MediaService._playable_cache.clear() - yield - MediaService._playable_cache.clear() - - -def _fake_convert(src: Path, dst: Path) -> Path: - dst.write_bytes(b"mp4:" + src.read_bytes()) - return dst - - -def _age(path: Path, seconds: float) -> None: - stamp = time.time() - seconds - os.utime(path, (stamp, stamp)) - - -def test_live_recording_is_not_converted(tmp_path): - mkv = tmp_path / "recording.mkv" - mkv.write_bytes(b"x" * 10) # fresh mtime: scrcpy is still writing - - with patch.object(MediaService, "_convert_to_mp4", side_effect=_fake_convert) as convert: - assert MediaService.ensure_browser_playable_video(mkv) == mkv - - convert.assert_not_called() - assert not (tmp_path / "recording.mp4").exists() - - -def test_stale_partial_mp4_is_reconverted_from_newer_mkv(tmp_path): - mkv = tmp_path / "recording.mkv" - mkv.write_bytes(b"full") - partial = tmp_path / "recording.mp4" - partial.write_bytes(b"partial") - _age(partial, 120) - _age(mkv, 60) - - with patch.object(MediaService, "_convert_to_mp4", side_effect=_fake_convert) as convert: - result = MediaService.ensure_browser_playable_video(mkv) - - convert.assert_called_once() - assert result == partial - assert partial.read_bytes() == b"mp4:full" - - -def test_finished_mp4_newer_than_mkv_is_reused(tmp_path): - mkv = tmp_path / "recording.mkv" - mkv.write_bytes(b"full") - mp4 = tmp_path / "recording.mp4" - mp4.write_bytes(b"final") - _age(mkv, 120) - _age(mp4, 60) - - with patch.object(MediaService, "_convert_to_mp4", side_effect=_fake_convert) as convert: - result = MediaService.ensure_browser_playable_video(mkv) - - convert.assert_not_called() - assert result == mp4 - - -def test_build_video_index_skips_live_recordings(tmp_path, monkeypatch): - traces = tmp_path / "traces" - live = traces / "web_1_live" - live.mkdir(parents=True) - (live / "recording.mkv").write_bytes(b"live") - done = traces / "web_2_done_PASS_2026-09-02T12-00-00" - done.mkdir() - (done / "recording.mp4").write_bytes(b"done") - monkeypatch.setattr(media_module, "TRACES_PATH", traces) - monkeypatch.setattr(media_module, "WORKSPACE_ROOT", tmp_path) - - with patch.object(MediaService, "_convert_to_mp4", side_effect=_fake_convert) as convert: - idx = MediaService.build_video_index() - - convert.assert_not_called() - assert "web_1_live" not in idx - assert idx["web_2_done"] == idx["web_2_done_PASS_2026-09-02T12-00-00"] - assert idx["web_2_done"].endswith("recording.mp4") - - -def test_strip_trace_status_suffix_handles_every_terminal_marker(): - assert MediaService.strip_trace_status_suffix("web_1_PASS_2026") == "web_1" - assert MediaService.strip_trace_status_suffix("web_1_FAIL_2026") == "web_1" - assert MediaService.strip_trace_status_suffix("web_1_TESTFAIL_2026") == "web_1" - assert MediaService.strip_trace_status_suffix("web_1") == "web_1" - - -def test_recover_orphaned_recording_remuxes_and_renames(tmp_path, monkeypatch): - traces = tmp_path / "traces" - folder = traces / "web_1788391889_5d5287b9" - folder.mkdir(parents=True) - mkv = folder / "recording.mkv" - mkv.write_bytes(b"full") - partial = folder / "recording.mp4" - partial.write_bytes(b"partial") - _age(partial, 120) - _age(mkv, 60) - monkeypatch.setattr(media_module, "TRACES_PATH", traces) - start = time.mktime(time.strptime("2026-09-02 16:31:32", "%Y-%m-%d %H:%M:%S")) - - with patch.object(MediaService, "_convert_to_mp4", side_effect=_fake_convert): - final = MediaService.recover_orphaned_recording(str(mkv), start) - - assert final is not None - assert final.parent.name == "web_1788391889_5d5287b9_FAIL_2026-09-02T16-31-32" - assert final.name == "recording.mp4" - assert final.read_bytes() == b"mp4:full" - assert not folder.exists() - assert not (final.parent / "recording.mkv").exists() - - -def test_recover_orphaned_recording_leaves_live_file_alone(tmp_path, monkeypatch): - traces = tmp_path / "traces" - folder = traces / "web_live" - folder.mkdir(parents=True) - mkv = folder / "recording.mkv" - mkv.write_bytes(b"live") - monkeypatch.setattr(media_module, "TRACES_PATH", traces) - - with patch.object(MediaService, "_convert_to_mp4", side_effect=_fake_convert) as convert: - assert MediaService.recover_orphaned_recording(str(mkv), None) is None - - convert.assert_not_called() - assert folder.exists() - - -def test_recover_orphaned_recording_keeps_raw_when_remux_fails(tmp_path, monkeypatch): - traces = tmp_path / "traces" - folder = traces / "web_broken" - folder.mkdir(parents=True) - mkv = folder / "recording.mkv" - mkv.write_bytes(b"corrupt") - _age(mkv, 60) - monkeypatch.setattr(media_module, "TRACES_PATH", traces) - - with patch.object(MediaService, "_convert_to_mp4", side_effect=lambda src, dst: src): - assert MediaService.recover_orphaned_recording(str(mkv), None) is None - - assert mkv.exists() - assert folder.exists() - - -def test_recover_orphaned_recording_returns_none_without_files(tmp_path): - missing = tmp_path / "missing" / "recording.mkv" - assert MediaService.recover_orphaned_recording(str(missing)) is None - assert MediaService.recover_orphaned_recording(None) is None - - -def test_resolve_video_segments_passes_through_session_offsets(tmp_path, monkeypatch): - folder = tmp_path / "artemis-traces" / "web_9_done" - folder.mkdir(parents=True) - (folder / "recording.mp4").write_bytes(b"seg0") - (folder / "recording_001.mp4").write_bytes(b"seg1") - (folder / "recording.json").write_text( - json.dumps( - { - "version": 2, - "duration": 15.0, - "segments": [ - { - "file": "recording.mp4", - "start": 0.0, - "duration": 10.0, - "offset_ms": 1200, - "duration_ms": 10000, - "width": 1080, - "height": 1920, - }, - { - "file": "recording_001.mp4", - "start": 10.0, - "duration": 5.0, - "offset_ms": 12700, - "duration_ms": 5000, - "width": 1920, - "height": 1080, - }, - {"file": "legacy.mp4", "start": 15.0, "duration": 1.0, "width": 1, "height": 1}, - ], - } - ), - encoding="utf-8", - ) - (folder / "legacy.mp4").write_bytes(b"legacy") - monkeypatch.setattr(media_module, "WORKSPACE_ROOT", tmp_path) - - segments = MediaService.resolve_video_segments( - "/videos/artemis-traces/web_9_done/recording.mp4" - ) - - assert [s["url"] for s in segments] == [ - "/videos/artemis-traces/web_9_done/recording.mp4", - "/videos/artemis-traces/web_9_done/recording_001.mp4", - "/videos/artemis-traces/web_9_done/legacy.mp4", - ] - assert [(s["start"], s["duration"]) for s in segments] == [ - (0.0, 10.0), - (10.0, 5.0), - (15.0, 1.0), - ] - assert segments[0]["offset_ms"] == 1200 and segments[0]["duration_ms"] == 10000 - assert segments[1]["offset_ms"] == 12700 and segments[1]["duration_ms"] == 5000 - # Legacy entries without offsets stay offset-free so the UI falls back to back-to-back. - assert "offset_ms" not in segments[2] diff --git a/tests/unit/admin_console/test_model_service.py b/tests/unit/admin_console/test_model_service.py deleted file mode 100644 index a06346931..000000000 --- a/tests/unit/admin_console/test_model_service.py +++ /dev/null @@ -1,53 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json -from unittest.mock import patch, MagicMock - -import pytest - -from apps.admin_console.services.model_service import ModelService - - -def test_get_active_model_info_pro_architecture(): - """Verify that pro profile returns Pro architecture while keeping real LLM model.""" - info = ModelService.get_active_model_info("pro") - assert info["name"] == "Pro" - assert info["architecture"] == "ARTEMIS Pro" - assert info["provider"] == "google" - assert "id" in info - - -def test_get_active_model_info_flash_architecture(): - """Verify that flash profile returns Flash architecture.""" - info = ModelService.get_active_model_info("flash") - assert info["name"] == "Flash" - assert info["architecture"] == "ARTEMIS Flash" - assert info["provider"] == "google" - - -def test_resolve_session_profile_from_device_info(): - """Verify profile resolution from device_info JSON.""" - row = {"device_info": json.dumps({"profile": "pro"})} - assert ModelService.resolve_session_profile(row) == "pro" - - row_flash = {"device_info": json.dumps({"profile": "flash"})} - assert ModelService.resolve_session_profile(row_flash) == "flash" - - -def test_resolve_session_profile_from_agent_names(): - """Verify profile resolution from agent/trace names.""" - row = {"device_info": None} - assert ModelService.resolve_session_profile(row, agent_names=["planner", "operator"]) == "pro" - assert ModelService.resolve_session_profile(row, agent_names=["flashrunner"]) == "flash" diff --git a/tests/unit/admin_console/test_replay_state.py b/tests/unit/admin_console/test_replay_state.py deleted file mode 100644 index 0c5594600..000000000 --- a/tests/unit/admin_console/test_replay_state.py +++ /dev/null @@ -1,113 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Tests that ``ReplayManager.instantiate_state`` builds a valid ``State``. - -``State`` uses ``extra="forbid"``, so any unknown field makes replay fail. -""" - -import json -from pathlib import Path - -import pytest - -from apps.admin_console import replay_manager as rm_module -from apps.admin_console.replay_manager import ReplayManager -from artemis.graph.state import State - -SESSION_ID = "11111111-2222-3333-4444-555555555555" -REAL_DATA_DIR = Path(rm_module.TRACES_PATH) / "replay" / "data" - - -def _make_manager(tmp_path: Path, monkeypatch) -> ReplayManager: - manager = ReplayManager(workspace_root=tmp_path, original_db_path=tmp_path / "missing.db") - manager.test_data_dir = tmp_path / "data" - # Chunking reads the master DB; the fixture dir is already "chunked". - monkeypatch.setattr(manager, "_ensure_session_chunked", lambda session_id: None) - return manager - - -def _write_step_dir(tmp_path: Path) -> Path: - session_dir = tmp_path / "data" / f"{SESSION_ID}_chunked" - step_dir = session_dir / "step_01" - step_dir.mkdir(parents=True) - (session_dir / ".chunked").touch() - (session_dir / "session.json").write_text( - json.dumps({"initial_goal": "Open Settings and enable dark mode"}), encoding="utf-8" - ) - (step_dir / "step.json").write_text( - json.dumps( - { - "step_id": "step-uuid-1", - "session_id": SESSION_ID, - "step_number": 1, - "timestamp": 1790638731.79, - "action_taken": {"action": "manage_app", "app_name": "Settings"}, - } - ), - encoding="utf-8", - ) - (step_dir / "pre_image_meta.json").write_text( - json.dumps( - { - "timestamp": 1790638724.78, - "ui_tree": [ - {"index": "0", "text": "16:38", "package": "com.android.systemui"}, - {"index": "1", "text": "Display", "package": "com.android.settings"}, - ], - } - ), - encoding="utf-8", - ) - (step_dir / "pre.jpg").write_bytes(b"\xff\xd8\xff") - return step_dir - - -def test_instantiate_state_builds_valid_state_from_step_dir(tmp_path, monkeypatch): - step_dir = _write_step_dir(tmp_path) - manager = _make_manager(tmp_path, monkeypatch) - - state = manager.instantiate_state(SESSION_ID, 1) - - assert isinstance(state, State) - assert state.initial_goal == "Open Settings and enable dark mode" - assert state.current_step_id == "step-uuid-1" - assert state.latest_screenshot == str(step_dir / "pre.jpg") - assert state.latest_ui_hierarchy[1]["package"] == "com.android.settings" - assert json.loads(state.structured_decisions)["action"] == "manage_app" - assert state.subagent_calls == [] - # Must round-trip through the strict schema. - State.model_validate(state.model_dump()) - - -def _real_step_dirs() -> list[Path]: - if not REAL_DATA_DIR.is_dir(): - return [] - return sorted(REAL_DATA_DIR.glob("*_chunked/step_01"))[:5] - - -@pytest.mark.parametrize("step_dir", _real_step_dirs(), ids=lambda p: p.parent.name[:8]) -def test_instantiate_state_on_recorded_step_dirs(step_dir, monkeypatch): - """Exercise real chunked traces when present locally (traces/ is gitignored).""" - manager = ReplayManager() - manager.test_data_dir = step_dir.parent.parent - monkeypatch.setattr(manager, "_ensure_session_chunked", lambda session_id: None) - monkeypatch.setattr(manager, "load_session_goal", lambda *a, **k: "recorded goal") - session_id = step_dir.parent.name.removesuffix("_chunked") - - state = manager.instantiate_state(session_id, 1) - - assert isinstance(state, State) - assert state.initial_goal == "recorded goal" - assert state.current_step_id diff --git a/tests/unit/admin_console/test_security_boundary.py b/tests/unit/admin_console/test_security_boundary.py deleted file mode 100644 index a7d090ab7..000000000 --- a/tests/unit/admin_console/test_security_boundary.py +++ /dev/null @@ -1,208 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Security regression tests for the Artemis console boundary. - -Covers the invariants the no-auth security model depends on: -- secrets never appear in HTTP responses, -- media endpoints cannot read files outside the media allowlist, -- cross-origin browser traffic and DNS-rebinding Hosts are rejected, -- no CORS grants exist, -- lifecycle controls stay loopback-only. -""" - -import secrets as py_secrets -from unittest.mock import MagicMock, patch - -import pytest -from httpx import ASGITransport, AsyncClient -from pydantic import SecretStr - -from apps.admin_console.server import app -from artemis.config import TRACES_PATH, WORKSPACE_ROOT - - -def _client(**transport_kwargs) -> AsyncClient: - return AsyncClient( - transport=ASGITransport(app=app, **transport_kwargs), base_url="http://localhost" - ) - - -# --------------------------------------------------------------------------- -# Secret exposure -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_credentials_endpoint_never_returns_key_material(monkeypatch): - from artemis.config import settings - - honeytoken = f"sk-honeytoken-{py_secrets.token_hex(16)}" - monkeypatch.setattr(type(settings), "get_api_key", lambda self, provider: SecretStr(honeytoken)) - - async with _client() as ac: - res = await ac.get("/api/system/credentials") - - assert res.status_code == 200 - assert honeytoken not in res.text - providers = {entry["name"]: entry["configured"] for entry in res.json()["providers"]} - assert providers.get("google") is True - - -@pytest.mark.asyncio -async def test_server_status_omits_lifecycle_token_and_metadata(): - async with _client() as ac: - res = await ac.get("/api/system/server-status") - - assert res.status_code == 200 - data = res.json() - assert "metadata" not in data - assert "lifecycle_token" not in res.text - assert "cmdline" not in res.text - assert "current_pid" in data - - -# --------------------------------------------------------------------------- -# File access boundaries -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_videos_endpoint_refuses_dotenv_and_non_video_files(): - async with _client() as ac: - for target in (".env", "pyproject.toml", "artemis/__init__.py"): - res = await ac.get(f"/videos/{target}") - assert res.status_code in (403, 404), target - assert "API_KEY" not in res.text - - -@pytest.mark.asyncio -async def test_videos_endpoint_refuses_encoded_traversal(): - async with _client() as ac: - for target in ( - "%2e%2e/%2e%2e/etc/passwd", - "..%5c..%5cwindows%5cwin.ini", - "%2e%2e%2f.env", - ): - res = await ac.get(f"/videos/{target}") - assert res.status_code in (403, 404), target - - -@pytest.mark.asyncio -async def test_videos_endpoint_still_serves_real_recordings(): - TRACES_PATH.mkdir(parents=True, exist_ok=True) - probe = TRACES_PATH / f"security-probe-{py_secrets.token_hex(4)}.mp4" - probe.write_bytes(b"\x00\x00\x00\x18ftypmp42") - try: - async with _client() as ac: - res = await ac.get(f"/videos/{probe.name}") - assert res.status_code == 200 - assert res.headers["content-type"].startswith("video/mp4") - finally: - probe.unlink(missing_ok=True) - - -@pytest.mark.asyncio -async def test_local_file_endpoint_refuses_non_media_workspace_files(): - async with _client() as ac: - for target in ( - str(WORKSPACE_ROOT / ".env"), - str(WORKSPACE_ROOT / "pyproject.toml"), - "file://" + str(WORKSPACE_ROOT / ".env"), - ): - res = await ac.get("/local_file", params={"path": target}) - assert res.status_code in (403, 404), target - assert "API_KEY" not in res.text - - -@pytest.mark.asyncio -async def test_spa_route_does_not_leak_files_outside_static_roots(): - async with _client() as ac: - res = await ac.get("/%2e%2e/%2e%2e/pyproject.toml") - - # The catch-all SPA route must fall back to HTML, never the file content. - assert "requires-python" not in res.text - assert res.headers["content-type"].startswith("text/html") - - -# --------------------------------------------------------------------------- -# Browser boundary: Host, Origin, CORS -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_dns_rebinding_host_is_rejected(): - async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://attacker.example" - ) as ac: - res = await ac.get("/api/system/emulator/status") - - assert res.status_code == 403 - - -@pytest.mark.asyncio -async def test_cross_origin_browser_request_is_rejected_without_cors_grant(): - async with _client() as ac: - res = await ac.post( - "/api/system/emulator/dismiss", - headers={"Origin": "https://attacker.example"}, - ) - - assert res.status_code == 403 - assert "access-control-allow-origin" not in {k.lower() for k in res.headers} - - -@pytest.mark.asyncio -async def test_same_origin_browser_request_passes(): - async with _client() as ac: - res = await ac.get("/api/system/emulator/status", headers={"Origin": "http://localhost"}) - - assert res.status_code == 200 - - -@pytest.mark.asyncio -async def test_null_origin_is_rejected(): - async with _client() as ac: - res = await ac.get("/api/system/emulator/status", headers={"Origin": "null"}) - - assert res.status_code == 403 - - -@pytest.mark.asyncio -async def test_security_headers_present_and_api_responses_uncacheable(): - async with _client() as ac: - res = await ac.get("/api/system/emulator/status") - - assert res.headers.get("x-content-type-options") == "nosniff" - assert res.headers.get("referrer-policy") == "no-referrer" - assert res.headers.get("cache-control") == "no-store" - - -# --------------------------------------------------------------------------- -# Lifecycle controls -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_restart_is_loopback_only(): - with patch("threading.Thread") as mock_thread: - mock_thread.return_value = MagicMock() - async with AsyncClient( - transport=ASGITransport(app=app, client=("203.0.113.9", 51000)), - base_url="http://localhost", - ) as ac: - res = await ac.post("/api/system/restart") - - assert res.status_code == 403 - mock_thread.assert_not_called() diff --git a/tests/unit/admin_console/test_server_lifecycle.py b/tests/unit/admin_console/test_server_lifecycle.py deleted file mode 100644 index 18baa7cc6..000000000 --- a/tests/unit/admin_console/test_server_lifecycle.py +++ /dev/null @@ -1,166 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -from contextlib import suppress -import signal -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -import uvicorn - -from apps.admin_console.core.state import state -from apps.admin_console.routers.tasks import stream_events -from apps.admin_console.server import ArtemisUvicornServer, app, on_shutdown - -WINDOWS_FORCE_SIGNAL = getattr(signal, "SIGBREAK", signal.SIGTERM) - - -def _pending_queue_get_tasks(): - return { - task - for task in asyncio.all_tasks() - if not task.done() and task.get_coro().__qualname__ == "Queue.get" - } - - -@pytest.mark.asyncio -async def test_cancelled_event_stream_reaps_queue_waiter(): - state.is_shutting_down = False - state.shutdown_event.clear() - subscribers_before = list(state.ipc_subscribers) - queue_tasks_before = _pending_queue_get_tasks() - - response = await stream_events("test-session") - iterator = response.body_iterator - first_event = await anext(iterator) - assert "Subscribed to session test-session" in first_event - - next_event = asyncio.create_task(anext(iterator)) - await asyncio.sleep(0) - next_event.cancel() - with suppress(asyncio.CancelledError): - await next_event - await iterator.aclose() - await asyncio.sleep(0) - - assert state.ipc_subscribers == subscribers_before - assert _pending_queue_get_tasks() == queue_tasks_before - - -@pytest.mark.asyncio -async def test_stream_events_active_session_replay(): - """Verify stream_events with default 'active' replays running session info and steps.""" - state.is_shutting_down = False - state.shutdown_event.clear() - state.active_session_id = "replay-sess-123" - state.current_goal = "Replay Goal" - state.current_profile = "flash" - - with patch( - "apps.admin_console.database.repositories.step_repository.step_repo" - ) as mock_step_repo: - mock_step_repo.get_session_steps.return_value = [ - {"step_number": 1, "description": "Step 1", "status": "completed"} - ] - response = await stream_events() - iterator = response.body_iterator - first_event = await anext(iterator) - assert "Subscribed to session active" in first_event - second_event = await anext(iterator) - assert "session_started" in second_event - assert "replay-sess-123" in second_event - third_event = await anext(iterator) - assert "step_recorded" in third_event - assert "Step 1" in third_event - await iterator.aclose() - - -@pytest.mark.asyncio -async def test_windows_sigint_is_ignored_while_task_is_active(): - state.current_process = MagicMock(returncode=None) - state.is_shutting_down = False - state.shutdown_event.clear() - server = ArtemisUvicornServer(uvicorn.Config(app)) - - with ( - patch("apps.admin_console.server.sys.platform", "win32"), - patch.object(server, "_schedule_signal_report") as report, - patch.object(uvicorn.Server, "handle_exit") as base_handle_exit, - ): - server.handle_exit(signal.SIGINT, None) - - base_handle_exit.assert_not_called() - assert report.call_count == 2 - assert state.is_shutting_down is False - assert state.shutdown_event.is_set() is False - state.current_process = None - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("platform_name", "exit_signal"), - [("linux", signal.SIGINT), ("win32", WINDOWS_FORCE_SIGNAL)], -) -async def test_non_windows_sigint_and_windows_sigbreak_keep_uvicorn_behavior( - platform_name, exit_signal -): - state.current_process = MagicMock(returncode=None) - state.is_shutting_down = False - state.shutdown_event.clear() - server = ArtemisUvicornServer(uvicorn.Config(app)) - - with ( - patch("apps.admin_console.server.sys.platform", platform_name), - patch.object(server, "_schedule_signal_report"), - patch.object(uvicorn.Server, "handle_exit") as base_handle_exit, - ): - server.handle_exit(exit_signal, None) - - base_handle_exit.assert_called_once_with(exit_signal, None) - assert state.is_shutting_down is True - assert state.shutdown_event.is_set() is True - state.current_process = None - state.is_shutting_down = False - state.shutdown_event.clear() - - -@pytest.mark.asyncio -async def test_shutdown_marks_only_ui_owned_running_sessions(): - state.queue_items = [ - {"session_id": "ui-running", "status": "running"}, - {"session_id": "ui-pending", "status": "pending"}, - ] - state.active_session_id = "external-mcp-session" - state.current_process = None - state.worker_task = None - - update_status = MagicMock() - with ( - patch( - "apps.admin_console.server.session_repo.update_session_status", - update_status, - ), - patch( - "apps.admin_console.server.ipc_service.stop_server", - new=AsyncMock(), - ), - ): - await on_shutdown() - - update_status.assert_called_once_with("ui-running", "cancelled") - assert state.queue_items == [] - state.active_session_id = None - state.is_shutting_down = False - state.shutdown_event.clear() diff --git a/tests/unit/admin_console/test_server_status_endpoint.py b/tests/unit/admin_console/test_server_status_endpoint.py deleted file mode 100644 index 9e46c3d68..000000000 --- a/tests/unit/admin_console/test_server_status_endpoint.py +++ /dev/null @@ -1,73 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Tests for GET /api/system/server-status. - -``is_artemis_daemon`` calls this route with a short timeout, so it must not -use ``server_lifecycle.get_server_status`` (which runs ``lsof``). -""" - -import os - -import pytest -from httpx import ASGITransport, AsyncClient - -from apps.admin_console.server import app - - -def _boom(*args, **kwargs): - raise AssertionError("server-status must not scan ports/processes") - - -@pytest.mark.asyncio -async def test_server_status_answers_in_process_without_port_scans(monkeypatch): - from artemis.runtime import server_lifecycle - - monkeypatch.setattr(server_lifecycle, "find_server_pids", _boom) - monkeypatch.setattr(server_lifecycle, "get_server_status", _boom) - monkeypatch.setattr(server_lifecycle.subprocess, "run", _boom) - monkeypatch.setattr(server_lifecycle, "read_server_info", lambda: None) - - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://localhost") as ac: - res = await ac.get("/api/system/server-status") - - assert res.status_code == 200 - data = res.json() - assert data["running"] is True - assert data["current_pid"] == os.getpid() - assert data["active_pid"] == os.getpid() - assert os.getpid() in data["pids"] - assert data["url"] == f"http://localhost:{data['port']}" - assert data["uptime_seconds"] is None or data["uptime_seconds"] >= 0 - assert "metadata" not in data - - -@pytest.mark.asyncio -async def test_server_status_uses_metadata_start_time(monkeypatch): - from apps.admin_console.core.state import state - from artemis.runtime import server_lifecycle - - monkeypatch.setattr( - server_lifecycle, - "read_server_info", - lambda: {"pid": os.getpid(), "port": state.port, "started_at": 1.0, "lifecycle_token": "x"}, - ) - - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://localhost") as ac: - res = await ac.get("/api/system/server-status") - - data = res.json() - assert data["uptime_seconds"] > 1_000_000 - assert data["pids"] == [os.getpid()] - assert "lifecycle_token" not in res.text diff --git a/tests/unit/admin_console/test_session_checks_endpoint.py b/tests/unit/admin_console/test_session_checks_endpoint.py deleted file mode 100644 index 4a6149bb0..000000000 --- a/tests/unit/admin_console/test_session_checks_endpoint.py +++ /dev/null @@ -1,86 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""``/api/sessions/{id}/checks``: backfill source for the Checker panel.""" - -import json - -from apps.admin_console.services import media_service as media_service_module -from apps.admin_console.services.media_service import MediaService - - -def test_get_session_checks_reads_ledger_and_outcome(tmp_path, monkeypatch): - monkeypatch.setattr(media_service_module, "TRACES_PATH", tmp_path) - session_dir = tmp_path / "sess-1" - session_dir.mkdir() - records = [ - { - "attempt_id": "abc#1", - "checkpoint_id": "abc", - "subgoal_text": "Create the alarm", - "item_text": "alarm exists", - "kind": "verify", - "when": "on_complete", - "status": "passed", - "evidence": "seen", - "ts": 1.0, - }, - {"attempt_id": "final#1", "checkpoint_id": "final", "status": "passed", "ts": 2.0}, - ] - (session_dir / "check_ledger.jsonl").write_text( - "\n".join(json.dumps(r) for r in records) + "\nnot json\n\n", encoding="utf-8" - ) - (session_dir / "run_outcome.json").write_text( - json.dumps({"task_status": "completed", "tests": {"passed": 2, "failed": 0}}), - encoding="utf-8", - ) - stream = { - "attempt_id": "abc#1", - "checkpoint_id": "abc", - "phase": "checkpoint", - "trace_id": "trace-1", - "ts": 1.5, - "truncated": False, - "dropped_chars": 0, - "segments": [ - {"execution_id": "e1", "role": "thought", "when": 1.1, "text": "Looking"}, - {"execution_id": "e1", "role": "answer", "when": 1.2, "text": "Seen"}, - ], - } - (session_dir / "check_streams.jsonl").write_text( - json.dumps(stream) + "\nnot json\n", encoding="utf-8" - ) - - result = MediaService.get_session_checks("sess-1") - - assert [r["attempt_id"] for r in result["records"]] == ["abc#1", "final#1"] - assert result["records"][0]["subgoal_text"] == "Create the alarm" - assert result["run_outcome"]["task_status"] == "completed" - assert result["streams"] == [stream] - - -def test_get_session_checks_without_material(tmp_path, monkeypatch): - monkeypatch.setattr(media_service_module, "TRACES_PATH", tmp_path) - (tmp_path / "sess-2").mkdir() - - empty = {"records": [], "streams": [], "run_outcome": None} - assert MediaService.get_session_checks("sess-2") == empty - assert MediaService.get_session_checks("missing") == empty - - -def test_checks_route_is_registered(): - from apps.admin_console.routers import media - - paths = {getattr(r, "path", None) for r in media.router.routes} - assert "/api/sessions/{session_id}/checks" in paths diff --git a/tests/unit/admin_console/test_session_list_batching.py b/tests/unit/admin_console/test_session_list_batching.py deleted file mode 100644 index 2126af6f6..000000000 --- a/tests/unit/admin_console/test_session_list_batching.py +++ /dev/null @@ -1,98 +0,0 @@ -from unittest.mock import MagicMock - -import pytest - -from apps.admin_console.routers import sessions as sessions_router - - -@pytest.mark.asyncio -async def test_list_sessions_uses_batched_history_metadata(monkeypatch): - repo = MagicMock() - repo.get_all_sessions.return_value = [ - { - "session_id": "session-1", - "status": "completed", - "start_time": 1.0, - "device_info": '{"profile": "flash"}', - }, - { - "session_id": "session-2", - "status": "completed", - "start_time": 2.0, - "device_info": '{"profile": "pro"}', - }, - { - "session_id": "session-3", - "status": "completed", - "start_time": 3.0, - "device_info": None, - }, - ] - repo.get_video_recordings_map.return_value = {} - repo.get_latest_video_recordings_map.return_value = { - "session-1": {"status": "ready"}, - "session-2": {"status": "unavailable"}, - } - repo.get_llm_traces_for_profiles_map.return_value = { - "session-3": ['{"model": "gemini-3.7-pro"}'] - } - repo.get_agent_trace_names_map.return_value = {} - - monkeypatch.setattr(sessions_router, "session_repo", repo, raising=False) - monkeypatch.setattr( - sessions_router.media_service, "build_video_index", MagicMock(return_value={}) - ) - monkeypatch.setattr( - sessions_router.media_service, "resolve_video_url", MagicMock(return_value=None) - ) - - result = await sessions_router.list_sessions() - - assert len(result) == 3 - repo.get_latest_video_recordings_map.assert_called_once_with() - repo.get_llm_traces_for_profiles_map.assert_called_once_with(["session-3"]) - repo.get_agent_trace_names_map.assert_called_once_with() - repo.get_video_recording_for_session.assert_not_called() - repo.get_llm_traces_for_profile.assert_not_called() - repo.get_agent_trace_names.assert_not_called() - - -@pytest.mark.asyncio -async def test_list_sessions_extracts_device_serial(monkeypatch): - repo = MagicMock() - repo.get_all_sessions.return_value = [ - { - "session_id": "session-dev-1", - "status": "completed", - "start_time": 1.0, - "device_info": '{"device_id": "63191FDKX00062", "profile": "flash"}', - }, - { - "session_id": "session-dev-2", - "status": "completed", - "start_time": 2.0, - "device_info": None, - }, - ] - repo.get_video_recordings_map.return_value = {} - repo.get_latest_video_recordings_map.return_value = { - "session-dev-2": {"status": "ready", "device_id": "emulator-5554"}, - } - repo.get_llm_traces_for_profiles_map.return_value = {} - repo.get_agent_trace_names_map.return_value = {} - - monkeypatch.setattr(sessions_router, "session_repo", repo, raising=False) - monkeypatch.setattr( - sessions_router.media_service, "build_video_index", MagicMock(return_value={}) - ) - monkeypatch.setattr( - sessions_router.media_service, "resolve_video_url", MagicMock(return_value=None) - ) - - result = await sessions_router.list_sessions() - - assert len(result) == 2 - assert result[0]["device_serial"] == "63191FDKX00062" - assert result[0]["device_id"] == "63191FDKX00062" - assert result[1]["device_serial"] == "emulator-5554" - assert result[1]["device_id"] == "emulator-5554" diff --git a/tests/unit/admin_console/test_session_repository_liveness.py b/tests/unit/admin_console/test_session_repository_liveness.py deleted file mode 100644 index 5f5273a2e..000000000 --- a/tests/unit/admin_console/test_session_repository_liveness.py +++ /dev/null @@ -1,39 +0,0 @@ -from unittest.mock import patch - -from apps.admin_console.database.connection import db_session -from apps.admin_console.database.repositories.session_repository import SessionRepository - - -def _insert_running_session(db_path, session_id, pid): - with db_session(db_path) as conn: - conn.execute( - "INSERT INTO sessions " - "(session_id, initial_goal, start_time, status, device_info, pid) " - "VALUES (?, ?, ?, ?, ?, ?)", - (session_id, session_id, 1.0, "running", "{}", pid), - ) - conn.commit() - - -def test_startup_cleanup_keeps_live_cross_process_session(tmp_path): - db_path = tmp_path / "sessions.db" - repository = SessionRepository(db_path) - _insert_running_session(db_path, "live", 111) - _insert_running_session(db_path, "dead", 222) - - with patch.object(repository, "process_is_alive", side_effect=lambda pid: pid == 111): - assert repository.cleanup_orphans_on_startup() == 1 - - assert repository.get_session_status("live") == "running" - assert repository.get_session_status("dead") == "failed" - - -def test_session_list_harvest_rechecks_worker_liveness(tmp_path): - db_path = tmp_path / "sessions.db" - repository = SessionRepository(db_path) - _insert_running_session(db_path, "external-live", 333) - - with patch.object(repository, "process_is_alive", return_value=True): - assert repository.harvest_orphaned_sessions(["external-live"]) == 0 - - assert repository.get_session_status("external-live") == "running" diff --git a/tests/unit/admin_console/test_session_status_normalization.py b/tests/unit/admin_console/test_session_status_normalization.py deleted file mode 100644 index 790944106..000000000 --- a/tests/unit/admin_console/test_session_status_normalization.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Legacy "success" session rows must surface as the canonical "completed". - -The sessions table historically received "success" from the DataEngine while -the queue reconcile wrote "completed", so API consumers saw a mix of both -terminal strings. Reads through the repository now normalize the legacy alias; -get_session_status stays raw so the queue reconcile can persist the rewrite. -""" - -from apps.admin_console.database.connection import db_session -from apps.admin_console.database.repositories.session_repository import SessionRepository - - -def _insert_session(db_path, session_id, status): - with db_session(db_path) as conn: - conn.execute( - "INSERT INTO sessions " - "(session_id, initial_goal, start_time, end_time, status, device_info, pid) " - "VALUES (?, ?, ?, ?, ?, ?, ?)", - (session_id, session_id, 1.0, 2.0, status, "{}", None), - ) - conn.commit() - - -def test_get_all_sessions_normalizes_legacy_success(tmp_path): - db_path = tmp_path / "sessions.db" - repository = SessionRepository(db_path) - _insert_session(db_path, "legacy", "success") - _insert_session(db_path, "modern", "completed") - _insert_session(db_path, "broken", "failed") - - statuses = {r["session_id"]: r["status"] for r in repository.get_all_sessions()} - assert statuses == {"legacy": "completed", "modern": "completed", "broken": "failed"} - - -def test_get_session_by_id_normalizes_legacy_success(tmp_path): - db_path = tmp_path / "sessions.db" - repository = SessionRepository(db_path) - _insert_session(db_path, "legacy", "success") - - row = repository.get_session_by_id("legacy") - assert row is not None - assert row["status"] == "completed" - - -def test_get_latest_session_normalizes_legacy_success(tmp_path): - db_path = tmp_path / "sessions.db" - repository = SessionRepository(db_path) - _insert_session(db_path, "legacy", "success") - - row = repository.get_latest_session() - assert row is not None - assert row["status"] == "completed" - - -def test_get_session_status_stays_raw_for_queue_reconcile(tmp_path): - db_path = tmp_path / "sessions.db" - repository = SessionRepository(db_path) - _insert_session(db_path, "legacy", "success") - - assert repository.get_session_status("legacy") == "success" diff --git a/tests/unit/admin_console/test_session_usage_endpoint.py b/tests/unit/admin_console/test_session_usage_endpoint.py deleted file mode 100644 index 70a2f91ec..000000000 --- a/tests/unit/admin_console/test_session_usage_endpoint.py +++ /dev/null @@ -1,119 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""``/api/sessions/{id}/usage``: token totals, live context and run tuning.""" - -import json - -from apps.admin_console.database.connection import db_session -from apps.admin_console.database.repositories.session_repository import SessionRepository - - -def _insert_session(db_path, session_id, device_info): - with db_session(db_path) as conn: - conn.execute( - "INSERT INTO sessions (session_id, initial_goal, start_time, status, device_info)" - " VALUES (?, ?, ?, ?, ?)", - (session_id, "goal", 1.0, "completed", json.dumps(device_info)), - ) - conn.commit() - - -def _insert_usage(db_path, session_id, trace_id, ts, payload, *, name="llm_usage"): - with db_session(db_path) as conn: - conn.execute( - "INSERT INTO traces (trace_id, session_id, type, name, timestamp, status, payload)" - " VALUES (?, ?, 'llm_call', ?, ?, 'success', ?)", - (trace_id, session_id, name, ts, json.dumps(payload)), - ) - conn.commit() - - -def _usage(prompt, completion, node=None, cached=0): - payload = { - "prompt_tokens": prompt, - "completion_tokens": completion, - "total_tokens": prompt + completion, - "cached_tokens": cached, - "context_base_tokens": prompt, - } - if node: - payload["node"] = node - return payload - - -def test_usage_sums_all_agents_and_tracks_latest_operator_context(tmp_path): - db_path = tmp_path / "sessions.db" - repo = SessionRepository(db_path) - _insert_session( - db_path, - "s1", - { - "profile": "pro", - "run_tuning": {"verification_level": "checkpoints", "explorer_mode": "pro"}, - }, - ) - _insert_usage(db_path, "s1", "t1", 10.0, _usage(30_000, 500, node="planner")) - _insert_usage(db_path, "s1", "t2", 11.0, _usage(120_000, 800, node="operator", cached=90_000)) - _insert_usage(db_path, "s1", "t3", 12.0, _usage(5_000, 100, node="checker")) - _insert_usage(db_path, "s1", "t4", 13.0, _usage(150_000, 700, node="operator")) - # A retry trace shares the type but is not a usage record. - _insert_usage(db_path, "s1", "t5", 14.0, {"error": "503"}, name="llm_retry") - - usage = repo.get_session_usage("s1") - - assert usage["llm_calls"] == 4 - assert usage["prompt_tokens"] == 305_000 - assert usage["completion_tokens"] == 2_100 - assert usage["total_tokens"] == 307_100 - assert usage["cached_tokens"] == 90_000 - assert usage["operator_context_tokens"] == 150_000 - assert usage["operator_context_updated_at"] == 13.0 - assert usage["operator_context_window_tokens"] == 1_000_000 - assert usage["profile"] == "pro" - assert usage["run_tuning"] == {"verification_level": "checkpoints", "explorer_mode": "pro"} - - -def test_usage_flash_runner_counts_as_operator_and_has_no_tuning(tmp_path): - db_path = tmp_path / "sessions.db" - repo = SessionRepository(db_path) - _insert_session(db_path, "s2", {"profile": "flash"}) - _insert_usage(db_path, "s2", "t1", 1.0, _usage(20_000, 200, node="FlashRunner")) - - usage = repo.get_session_usage("s2") - - assert usage["operator_context_tokens"] == 20_000 - assert usage["total_tokens"] == 20_200 - assert usage["profile"] == "flash" - assert usage["run_tuning"] is None - - -def test_usage_for_unknown_or_empty_session_is_zeroed(tmp_path): - db_path = tmp_path / "sessions.db" - repo = SessionRepository(db_path) - - usage = repo.get_session_usage("missing") - - assert usage["llm_calls"] == 0 - assert usage["total_tokens"] == 0 - assert usage["operator_context_tokens"] is None - assert usage["profile"] is None - assert usage["run_tuning"] is None - - -def test_usage_route_is_registered(): - from apps.admin_console.routers import sessions - - paths = {getattr(r, "path", None) for r in sessions.router.routes} - assert "/api/sessions/{session_id}/usage" in paths diff --git a/tests/unit/admin_console/test_startup_progress.py b/tests/unit/admin_console/test_startup_progress.py deleted file mode 100644 index 0ae9a80c2..000000000 --- a/tests/unit/admin_console/test_startup_progress.py +++ /dev/null @@ -1,51 +0,0 @@ -from apps.admin_console.core.state import ServerState - - -def test_startup_progress_is_ordered_and_upserted_by_stage(): - state = ServerState() - - state.record_startup_progress( - { - "session_id": "session-1", - "stage": "queued", - "message": "Task queued", - "timestamp": 1.0, - } - ) - state.record_startup_progress( - { - "session_id": "session-1", - "stage": "device", - "message": "Checking device", - "timestamp": 2.0, - } - ) - state.record_startup_progress( - { - "session_id": "session-1", - "stage": "device", - "message": "Device connected", - "timestamp": 3.0, - } - ) - - events = state.get_startup_progress("session-1") - assert [event["stage"] for event in events] == ["queued", "device"] - assert events[-1]["message"] == "Device connected" - - -def test_startup_progress_snapshot_is_not_mutable_by_callers(): - state = ServerState() - state.record_startup_progress( - { - "session_id": "session-1", - "stage": "queued", - "message": "Task queued", - "timestamp": 1.0, - } - ) - - snapshot = state.get_startup_progress("session-1") - snapshot[0]["message"] = "changed" - - assert state.get_startup_progress("session-1")[0]["message"] == "Task queued" diff --git a/tests/unit/admin_console/test_step_repository.py b/tests/unit/admin_console/test_step_repository.py deleted file mode 100644 index da866a7a5..000000000 --- a/tests/unit/admin_console/test_step_repository.py +++ /dev/null @@ -1,114 +0,0 @@ -import json - -from apps.admin_console.database.repositories.step_repository import StepRepository - - -def test_legacy_pause_log_is_normalized_to_failed_llm_trace(): - repository = StepRepository() - trace = { - "trace_id": "pause-log", - "type": "log", - "name": "artemis.services.llm", - "status": "success", - "payload": json.dumps( - { - "message": ( - "LLM Error: 503 UNAVAILABLE. {'error': {'message': 'High demand'}}. " - "Pausing execution to wait for resume signal..." - ) - } - ), - } - - normalized = repository._normalize_display_trace(trace) - - assert normalized["type"] == "llm_call" - assert normalized["name"] == "llm_pause" - assert normalized["status"] == "failed" - assert normalized["payload"]["pause"] is True - assert normalized["payload"]["error"].startswith("503 UNAVAILABLE") - - -def test_unrelated_log_is_not_presented_as_llm_failure(): - repository = StepRepository() - trace = { - "trace_id": "ordinary-log", - "type": "log", - "name": "artemis.services.llm", - "status": "success", - "payload": json.dumps({"message": "LLM request completed"}), - } - - normalized = repository._normalize_display_trace(trace) - - assert normalized["type"] == "log" - assert normalized["status"] == "success" - - -def test_retrying_llm_payload_keeps_user_visible_retry_details(): - repository = StepRepository() - trace = { - "trace_id": "retry-1", - "type": "llm_call", - "name": "llm_retry", - "status": "retrying", - "payload": json.dumps( - { - "error": "503 UNAVAILABLE: high demand", - "delay": 1.18, - "provider": "google", - "source": "provider_sdk", - "recoverable": True, - "internal_noise": "do not expose", - } - ), - } - - normalized = repository._normalize_display_trace(trace) - - assert normalized["payload"] == { - "error": "503 UNAVAILABLE: high demand", - "delay": 1.18, - "provider": "google", - "source": "provider_sdk", - "recoverable": True, - } - - -def test_terminal_llm_payload_keeps_wait_and_retry_aggregation(): - repository = StepRepository() - retries = [ - { - "delay": 5.0, - "scheduled_at": 100.0, - "provider": "google", - "source": "provider_sdk", - } - ] - trace = { - "type": "llm_call", - "name": "llm_pause", - "status": "failed", - "payload": json.dumps( - { - "error": "503 UNAVAILABLE", - "pause": True, - "request_id": "request-1", - "provider": "google", - "waited_seconds": 18.5, - "retries": retries, - "messages": ["private prompt"], - } - ), - } - - normalized = repository._normalize_display_trace(trace) - - assert normalized["payload"] == { - "error": "503 UNAVAILABLE", - "provider": "google", - "pause": True, - "request_id": "request-1", - "waited_seconds": 18.5, - "retries": retries, - } diff --git a/tests/unit/admin_console/test_system_emulator_endpoints.py b/tests/unit/admin_console/test_system_emulator_endpoints.py deleted file mode 100644 index 65daab10f..000000000 --- a/tests/unit/admin_console/test_system_emulator_endpoints.py +++ /dev/null @@ -1,101 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for System Emulator Launch & Lifecycle Endpoints.""" - -import subprocess -import sys - -import pytest -from httpx import ASGITransport, AsyncClient - -from apps.admin_console.server import app -from artemis.core.diagnostics.emulator_manager import EmulatorManager - - -def test_emulator_uses_posix_session_isolation(monkeypatch): - monkeypatch.setattr(sys, "platform", "linux") - - assert EmulatorManager._subprocess_creation_kwargs() == {"start_new_session": True} - - -@pytest.mark.skipif(sys.platform != "win32", reason="Windows creation flags only exist on Windows") -def test_emulator_uses_windows_console_isolation(): - kwargs = EmulatorManager._subprocess_creation_kwargs() - - assert "start_new_session" not in kwargs - assert kwargs["creationflags"] & subprocess.CREATE_NEW_PROCESS_GROUP - assert kwargs["creationflags"] & subprocess.CREATE_NO_WINDOW - - -@pytest.mark.asyncio -async def test_system_emulator_status_endpoint(): - """Verify GET /api/system/emulator/status returns valid schema.""" - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://localhost") as ac: - res = await ac.get("/api/system/emulator/status") - assert res.status_code == 200 - data = res.json() - assert "status" in data - assert "stage_message" in data - assert "progress_percent" in data - assert "logs" in data - - -@pytest.mark.asyncio -async def test_system_emulator_dismiss_endpoint(): - """Verify POST /api/system/emulator/dismiss resets tracker state.""" - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://localhost") as ac: - res = await ac.post("/api/system/emulator/dismiss") - assert res.status_code == 200 - data = res.json() - assert data.get("success") is True - - -@pytest.mark.asyncio -async def test_system_emulator_launch_empty_validation(): - """Verify POST /api/system/emulator/launch validates empty avd_name.""" - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://localhost") as ac: - res = await ac.post("/api/system/emulator/launch", json={"avd_name": " "}) - assert res.status_code == 400 - - -@pytest.mark.asyncio -async def test_adb_endpoint_mutation_rejects_cross_origin_browser_requests(): - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://localhost") as ac: - res = await ac.post( - "/api/system/adb/server/probe", - json={"host": "127.0.0.1", "port": 5038, "persist": False}, - headers={"Origin": "https://untrusted.example"}, - ) - - assert res.status_code == 403 - assert "Artemis console" in res.json()["detail"] - - -@pytest.mark.asyncio -async def test_adb_endpoint_mutation_is_local_only_by_default(monkeypatch): - monkeypatch.delenv("ARTEMIS_ALLOW_REMOTE_ADB_CONFIGURATION", raising=False) - transport = ASGITransport(app=app, client=("192.168.1.20", 42000)) - async with AsyncClient(transport=transport, base_url="http://localhost") as ac: - res = await ac.post( - "/api/system/adb/server/probe", - json={"host": "127.0.0.1", "port": 5038, "persist": False}, - ) - - assert res.status_code == 403 - assert "local-only" in res.json()["detail"] diff --git a/tests/unit/admin_console/test_task_device_readiness.py b/tests/unit/admin_console/test_task_device_readiness.py deleted file mode 100644 index 3ecc9ef4c..000000000 --- a/tests/unit/admin_console/test_task_device_readiness.py +++ /dev/null @@ -1,228 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from types import SimpleNamespace -from unittest.mock import AsyncMock - -from fastapi import HTTPException -import pytest - -from apps.admin_console.routers import tasks -from apps.admin_console.schemas.task_schema import RunRequest -from artemis.core.diagnostics.schema import ( - ProbeCategory, - ProbeResult, - ProbeStatus, -) - - -@pytest.mark.asyncio -async def test_run_task_rejects_locked_device(monkeypatch): - locked_probe = ProbeResult( - id="android_adb", - category=ProbeCategory.DEVICE, - title="Device / Emulator Connected", - status=ProbeStatus.WARN, - is_blocker=True, - summary="Device Locked", - description="Unlock the device.", - ) - run_probe = AsyncMock(return_value=locked_probe) - enqueue_tasks = AsyncMock() - monkeypatch.setattr(tasks.readiness_engine, "run_device_submission_probe", run_probe) - monkeypatch.setattr(tasks.task_queue_service, "enqueue_tasks", enqueue_tasks) - - with pytest.raises(HTTPException) as exc_info: - await tasks.run_task(RunRequest(goal="Open Settings")) - - assert exc_info.value.status_code == 409 - assert "locked" in exc_info.value.detail.lower() - enqueue_tasks.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_run_task_enqueues_when_device_is_unlocked(monkeypatch): - unlocked_probe = ProbeResult( - id="android_adb", - category=ProbeCategory.DEVICE, - title="Device / Emulator Connected", - status=ProbeStatus.PASS, - is_blocker=True, - summary="Connected", - description="Ready.", - ) - run_probe = AsyncMock(return_value=unlocked_probe) - enqueue_tasks = AsyncMock(return_value={"status": "started", "tasks": []}) - monkeypatch.setattr(tasks.readiness_engine, "run_device_submission_probe", run_probe) - monkeypatch.setattr(tasks.task_queue_service, "enqueue_tasks", enqueue_tasks) - - result = await tasks.run_task(RunRequest(goal="Open Settings")) - - assert result["status"] == "started" - enqueue_tasks.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_run_task_binds_probe_verified_device_when_no_serial_requested(monkeypatch): - """Without an explicit serial, run_task enqueues on the probe-verified live device.""" - unlocked_probe = ProbeResult( - id="android_adb", - category=ProbeCategory.DEVICE, - title="Device / Emulator Connected", - status=ProbeStatus.PASS, - is_blocker=True, - summary="Connected", - description="Ready.", - metadata={ - "installed": True, - "submission_probe": True, - "active_device": {"serial": "emulator-5556", "is_locked": False}, - }, - ) - run_probe = AsyncMock(return_value=unlocked_probe) - enqueue_tasks = AsyncMock(return_value={"status": "started", "tasks": []}) - monkeypatch.setattr(tasks.readiness_engine, "run_device_submission_probe", run_probe) - monkeypatch.setattr(tasks.task_queue_service, "enqueue_tasks", enqueue_tasks) - - result = await tasks.run_task(RunRequest(goal="Run on any ready device")) - - assert result["status"] == "started" - enqueue_tasks.assert_awaited_once() - # Check that device_serial passed to enqueue_tasks is the verified unlocked device - _, kwargs = enqueue_tasks.call_args - assert kwargs.get("device_serial") == "emulator-5556" - - -@pytest.mark.asyncio -async def test_idempotent_retry_skips_device_probe_for_active_session(monkeypatch): - run_probe = AsyncMock() - monkeypatch.setattr(tasks.readiness_engine, "run_device_submission_probe", run_probe) - monkeypatch.setattr(tasks.state, "active_session_id", "sdk-task-1") - monkeypatch.setattr(tasks.state, "active_connections", {}) - monkeypatch.setattr( - tasks.session_repo, - "get_session_by_id", - lambda session_id: None, - ) - - result = await tasks.run_task( - RunRequest( - goal="Open Settings", - session_id="sdk-task-1", - device_serial="pixel-10", - ingress="python_sdk", - ) - ) - - assert result["status"] == "running" - assert result["enqueued_count"] == 0 - assert result["tasks"][0]["session_id"] == "sdk-task-1" - run_probe.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_unknown_explicit_device_is_rejected_before_readiness_probe(monkeypatch): - run_probe = AsyncMock() - monkeypatch.setattr(tasks.readiness_engine, "run_device_submission_probe", run_probe) - monkeypatch.setattr(tasks.state, "active_session_id", None) - monkeypatch.setattr(tasks.state, "active_connections", {}) - monkeypatch.setattr(tasks.state, "queue_items", []) - monkeypatch.setattr(tasks.session_repo, "get_session_by_id", lambda session_id: None) - monkeypatch.setattr( - tasks.device_pool, - "try_list_devices_async", - AsyncMock( - return_value=[ - SimpleNamespace(serial="pixel-10", state="device"), - ] - ), - ) - - result = await tasks.run_task( - RunRequest( - goal="Must not run", - device_serial="missing-device", - session_id="00000000-0000-4000-8000-000000000099", - ) - ) - - assert result["status"] == "rejected" - assert result["tasks"] == [] - assert "not connected" in result["error"] - run_probe.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_explicit_device_is_rejected_when_attached_but_not_ready(monkeypatch): - run_probe = AsyncMock() - monkeypatch.setattr(tasks.readiness_engine, "run_device_submission_probe", run_probe) - monkeypatch.setattr(tasks.state, "active_session_id", None) - monkeypatch.setattr(tasks.state, "active_connections", {}) - monkeypatch.setattr(tasks.state, "queue_items", []) - monkeypatch.setattr(tasks.session_repo, "get_session_by_id", lambda session_id: None) - monkeypatch.setattr( - tasks.device_pool, - "try_list_devices_async", - AsyncMock( - return_value=[ - SimpleNamespace(serial="pixel-10", state="unauthorized"), - ] - ), - ) - - result = await tasks.run_task( - RunRequest( - goal="Must not run", - device_serial="pixel-10", - session_id="00000000-0000-4000-8000-000000000100", - ) - ) - - assert result["status"] == "rejected" - assert "unauthorized" in result["error"] - run_probe.assert_not_awaited() - - -@pytest.mark.parametrize("enumeration", [None, []]) -@pytest.mark.asyncio -async def test_explicit_device_proceeds_when_enumeration_is_indeterminate(monkeypatch, enumeration): - """A failed (None) or empty enumeration must never hard-reject an explicit - serial: the submission queues and fails downstream if the device is truly - absent. This is the startup-storm regression guard.""" - run_probe = AsyncMock(return_value=None) - enqueue_tasks = AsyncMock(return_value={"status": "queued", "tasks": []}) - monkeypatch.setattr(tasks.readiness_engine, "run_device_submission_probe", run_probe) - monkeypatch.setattr(tasks.task_queue_service, "enqueue_tasks", enqueue_tasks) - monkeypatch.setattr(tasks.state, "active_session_id", None) - monkeypatch.setattr(tasks.state, "active_connections", {}) - monkeypatch.setattr(tasks.state, "queue_items", []) - monkeypatch.setattr(tasks.session_repo, "get_session_by_id", lambda session_id: None) - monkeypatch.setattr( - tasks.device_pool, - "try_list_devices_async", - AsyncMock(return_value=enumeration), - ) - - result = await tasks.run_task( - RunRequest( - goal="Queue through the startup storm", - device_serial="pixel-10", - session_id="00000000-0000-4000-8000-000000000101", - ) - ) - - assert result["status"] == "queued" - enqueue_tasks.assert_awaited_once() - _, kwargs = enqueue_tasks.call_args - assert kwargs.get("device_serial") == "pixel-10" diff --git a/tests/unit/admin_console/test_task_queue_service.py b/tests/unit/admin_console/test_task_queue_service.py deleted file mode 100644 index 24a572cdf..000000000 --- a/tests/unit/admin_console/test_task_queue_service.py +++ /dev/null @@ -1,1113 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -import importlib -import json -import os -import subprocess -import sys -from unittest.mock import AsyncMock, MagicMock, patch -import pytest - -from apps.admin_console.core.state import state -from apps.admin_console.routers.tasks import get_status -from apps.admin_console.services.task_queue_service import TaskQueueService, task_queue_service -from artemis.runtime.device_lock import DeviceLockOwner -from artemis.runtime.adb_endpoint import AdbEndpoint - - -@pytest.fixture(autouse=True) -def clean_state(tmp_path, monkeypatch): - """Reset global state between tests.""" - isolated_pause_file = tmp_path / ".artemis_paused" - # Redirect DeviceExecutionLock's lock/queue directory into tmp_path so that - # enqueue reservations never touch the real %TEMP%/artemis/device-locks dir - # (a live daemon merges those tickets into its /api/status queue view). - isolated_lock_dir = tmp_path / "device-locks" - isolated_lock_dir.mkdir(parents=True, exist_ok=True) - monkeypatch.setattr( - "artemis.runtime.device_lock.get_temp_dir", - lambda _subfolder=None: isolated_lock_dir, - ) - state_module = importlib.import_module("apps.admin_console.core.state") - queue_module = importlib.import_module("apps.admin_console.services.task_queue_service") - monkeypatch.setattr(state_module, "PAUSE_FILE", isolated_pause_file) - monkeypatch.setattr(queue_module, "PAUSE_FILE", isolated_pause_file) - # The stop assertions below describe the legacy immediate kill; the graceful - # cancel path has its own tests in test_graceful_stop_and_recovery.py. - monkeypatch.setenv("ARTEMIS_CANCEL_GRACE_SECONDS", "0") - monkeypatch.setattr( - "artemis.runtime.cancel_requests.get_temp_dir", - lambda _subfolder=None: isolated_lock_dir, - ) - state.clear_queue() - state.queue_items.clear() - state.current_process = None - state.current_goal = None - state.current_profile = None - state.active_session_id = None - state.active_connections.clear() - state.manually_stopped_run_ids.clear() - state.cancelled_session_ids.clear() - if state.worker_task and not state.worker_task.done(): - state.worker_task.cancel() - state.worker_task = None - yield - state.clear_queue() - state.queue_items.clear() - state.active_connections.clear() - state.cancelled_session_ids.clear() - state.active_session_id = None - state.current_process = None - state.current_goal = None - state.current_profile = None - state.manually_stopped_run_ids.clear() - if hasattr(state, "recent_submissions"): - state.recent_submissions.clear() - if state.worker_task and not state.worker_task.done(): - state.worker_task.cancel() - state.worker_task = None - - -def test_paused_error_reads_persisted_reason(tmp_path): - pause_file = tmp_path / ".artemis_paused" - pause_file.write_text("LLM Error: 503 UNAVAILABLE: model overloaded", encoding="utf-8") - - with patch("apps.admin_console.core.state.PAUSE_FILE", pause_file): - assert state.is_paused is True - assert state.paused_error == "503 UNAVAILABLE: model overloaded" - - -@pytest.mark.asyncio -async def test_get_next_pending_task(): - state.queue_items = [ - {"session_id": "s1", "goal": "Goal A", "status": "pending"}, - {"session_id": "s2", "goal": "Goal B", "status": "pending"}, - ] - next_task = TaskQueueService._get_next_pending_task() - assert next_task is not None - assert next_task["goal"] == "Goal A" - - -@pytest.mark.asyncio -async def test_enqueued_task_keeps_its_adb_endpoint_snapshot(): - original = AdbEndpoint.create("127.0.0.1", 5038) - changed = AdbEndpoint.local() - with ( - patch.object(TaskQueueService, "ensure_worker_running"), - patch( - "apps.admin_console.services.task_queue_service.current_adb_endpoint", - return_value=original, - ), - patch( - "artemis.runtime.device_pool.device_pool.select_device_async", - return_value="emulator-5554", - ), - ): - result = await TaskQueueService.enqueue_tasks(["Keep endpoint"]) - - task_item = result["tasks"][0] - assert task_item["adb_endpoint"]["identity"] == original.identity - - with patch( - "apps.admin_console.services.task_queue_service.current_adb_endpoint", - return_value=changed, - ): - target = TaskQueueService._task_target(task_item) - - assert target.endpoint == original - assert target.serial == "emulator-5554" - assert target.lock_key == f"{original.identity}/emulator-5554" - - -@pytest.mark.parametrize( - ("current_status", "returncode", "stopped", "expected"), - [ - ("completed", 1, False, ("completed", False)), - ("failed", 0, False, ("failed", False)), - ("cancelled", 0, False, ("cancelled", False)), - ("success", 1, False, ("completed", True)), - ("running", 0, False, ("completed", True)), - ("running", 1, False, ("failed", True)), - ("completed", 0, True, ("cancelled", True)), - ], -) -def test_resolve_terminal_status_preserves_authoritative_result( - current_status, returncode, stopped, expected -): - assert ( - TaskQueueService._resolve_terminal_status(current_status, returncode, stopped) == expected - ) - - -@pytest.mark.asyncio -async def test_remove_task(): - state.queue_items = [ - {"session_id": "s1", "goal": "Goal A", "status": "pending"}, - {"session_id": "s2", "goal": "Goal B", "status": "pending"}, - ] - TaskQueueService._remove_task("s1") - assert len(state.queue_items) == 1 - assert state.queue_items[0]["goal"] == "Goal B" - - -@pytest.mark.asyncio -async def test_stop_tasks_clear_all(): - state.queue_items = [ - {"session_id": "s1", "goal": "Goal 1", "status": "pending"}, - {"session_id": "s2", "goal": "Goal 2", "status": "pending"}, - {"session_id": "s3", "goal": "Goal 3", "status": "pending"}, - ] - assert len(state.queue_tasks) == 3 - - with patch.object(TaskQueueService, "ensure_worker_running"): - stopped = task_queue_service.stop_tasks(clear_all=True) - assert len(state.queue_tasks) == 0 - assert len(state.queue_items) == 0 - - -@pytest.mark.asyncio -async def test_stop_tasks_keep_remaining(): - state.queue_items = [ - {"session_id": "s1", "goal": "Goal 1", "status": "running"}, - {"session_id": "s2", "goal": "Goal 2", "status": "pending"}, - ] - state.active_session_id = "s1" - - mock_proc = MagicMock() - mock_proc.pid = 12345 - mock_proc.returncode = None - state.current_process = mock_proc - - with ( - patch( - "apps.admin_console.services.task_queue_service.process_supervisor.terminate_tree" - ) as mock_term, - patch.object(TaskQueueService, "ensure_worker_running"), - patch( - "apps.admin_console.services.task_queue_service.session_repo.update_session_status" - ) as update_status, - patch( - "apps.admin_console.services.task_queue_service.session_repo.mark_all_running_cancelled" - ) as mark_all, - ): - stopped = task_queue_service.stop_tasks(clear_all=False) - assert stopped is True - mock_proc.kill.assert_called_once() - mock_term.assert_called_once_with(12345) - update_status.assert_called_once() - assert update_status.call_args.args[:2] == ("s1", "cancelled") - mark_all.assert_not_called() - - -@pytest.mark.asyncio -async def test_queue_worker_execution_lifecycle(): - executed_goals = [] - - async def fake_subprocess_exec(*args, **kwargs): - goal_arg = args[3] - executed_goals.append(goal_arg) - proc = MagicMock() - proc.pid = 99999 - proc.wait = AsyncMock(return_value=0) - proc.returncode = 0 - return proc - - with ( - patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec), - patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo, - patch("apps.admin_console.services.task_queue_service.media_service"), - patch( - "artemis.runtime.device_pool.device_pool.select_device_async", - return_value="emulator-5554", - ), - ): - mock_repo.get_running_session_id.return_value = None - mock_repo.get_video_recording_for_session.return_value = {"status": "ready"} - # Enqueue two tasks - res = await task_queue_service.enqueue_tasks(["First Task", "Second Task"]) - assert res["enqueued_count"] == 2 - - # Give event loop time for queue_worker to execute both tasks - for _ in range(40): - if len(executed_goals) == 2 and len(state.queue_items) == 0: - break - await asyncio.sleep(0.05) - - assert executed_goals == ["First Task", "Second Task"] - assert len(state.queue_items) == 0 - task = state.worker_task - if task and not task.done(): - task.cancel() - try: - await task - except (asyncio.CancelledError, Exception): - pass - - -@pytest.mark.asyncio -async def test_queue_worker_cmd_construction(): - executed_cmds = [] - executed_kwargs = [] - - async def fake_subprocess_exec(*args, **kwargs): - executed_cmds.append(list(args)) - executed_kwargs.append(kwargs) - proc = MagicMock() - proc.pid = 88888 - proc.wait = AsyncMock(return_value=0) - proc.returncode = 0 - return proc - - with ( - patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec), - patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo, - patch("apps.admin_console.services.task_queue_service.media_service"), - patch( - "artemis.runtime.device_pool.device_pool.select_device_async", - return_value="emulator-5554", - ), - ): - mock_repo.get_running_session_id.return_value = None - mock_repo.get_video_recording_for_session.return_value = {"status": "ready"} - - enqueue_result = await task_queue_service.enqueue_tasks( - ["Test Goal with Outputter"], - profile="pro", - expected_output="Final summary", - enable_outputter=True, - locked_app_package="com.google.android.apps.maps", - app_path="/path/to/app.apk", - verification_level=" Checkpoints ", - explorer_mode="ULTRA", - ) - - for _ in range(30): - if len(executed_cmds) == 1 and len(state.queue_items) == 0: - break - await asyncio.sleep(0.05) - - assert len(executed_cmds) == 1 - cmd = executed_cmds[0] - assert "--enable-outputter" in cmd - assert "true" not in cmd - assert "--session-id" in cmd - assert "--output-description" in cmd - assert "Final summary" in cmd - assert "--locked-app" in cmd - assert "com.google.android.apps.maps" in cmd - assert "--app-path" in cmd - assert "/path/to/app.apk" in cmd - # Pro tuning knobs are normalised (trimmed, lower-cased) and forwarded - # to the worker as the CLI's existing spelling. - assert cmd[cmd.index("--verification-level") + 1] == "checkpoints" - assert cmd[cmd.index("--explorer-pro-mode") + 1] == "ultra" - assert enqueue_result["tasks"][0]["verification_level"] == "checkpoints" - assert enqueue_result["tasks"][0]["explorer_mode"] == "ultra" - assert ( - executed_kwargs[0]["env"]["ARTEMIS_DEVICE_QUEUE_TICKET"] - == (enqueue_result["tasks"][0]["queue_ticket"]) - ) - endpoint = enqueue_result["tasks"][0]["adb_endpoint"] - assert executed_kwargs[0]["env"]["ADB_HOST"] == endpoint["host"] - assert executed_kwargs[0]["env"]["ADB_PORT"] == str(endpoint["port"]) - assert executed_kwargs[0]["env"]["ADB_SERVER_SOCKET"] == endpoint["socket"] - assert executed_kwargs[0]["env"]["ARTEMIS_ADB_ENDPOINT_ID"] == endpoint["identity"] - if sys.platform == "win32": - assert executed_kwargs[0]["creationflags"] == ( - subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW - ) - assert executed_kwargs[0]["stdout"] == asyncio.subprocess.PIPE - assert executed_kwargs[0]["stderr"] == asyncio.subprocess.STDOUT - else: - assert "creationflags" not in executed_kwargs[0] - - task = state.worker_task - if task and not task.done(): - task.cancel() - try: - await task - except (asyncio.CancelledError, Exception): - pass - - -@pytest.mark.asyncio -async def test_forward_worker_output_preserves_split_utf8(capsys): - stream = asyncio.StreamReader() - # "worker output: \U0001f600\n" contains a 4-byte UTF-8 emoji (b'\xf0\x9f\x98\x80') starting at byte 15. - # Splitting at index 17 cuts across the multi-byte sequence to verify partial UTF-8 buffering. - encoded = "worker output: \U0001f600\n".encode("utf-8") - stream.feed_data(encoded[:17]) - stream.feed_data(encoded[17:]) - stream.feed_eof() - - await TaskQueueService._forward_worker_output(stream) - - assert capsys.readouterr().out == "worker output: \U0001f600\n" - - -@pytest.mark.skipif(sys.platform != "win32", reason="Windows console isolation only") -def test_windows_worker_has_no_inherited_console(): - probe = r""" -import ctypes -import json - -buffer = (ctypes.c_uint32 * 32)() -count = ctypes.windll.kernel32.GetConsoleProcessList(buffer, len(buffer)) -print(json.dumps(list(buffer[:min(count, len(buffer))]))) -""" - result = subprocess.run( - [sys.executable, "-c", probe], - check=True, - text=True, - **TaskQueueService._subprocess_creation_kwargs(), - ) - - attached_processes = json.loads(result.stdout) - assert os.getpid() not in attached_processes - - -@pytest.mark.asyncio -async def test_stop_tasks_dead_process_resets_state(): - state.queue_items = [ - {"session_id": "s1", "goal": "Goal 1", "status": "pending"}, - ] - mock_proc = MagicMock() - mock_proc.pid = 99999999 - mock_proc.returncode = None - mock_proc.kill.side_effect = ProcessLookupError("No such process") - state.current_process = mock_proc - - with patch.object(TaskQueueService, "ensure_worker_running"): - stopped = task_queue_service.stop_tasks(clear_all=False) - assert stopped is True - assert state.current_process is None - assert state.is_running is False - assert len(state.queue_items) == 0 - - -def test_stop_tasks_terminates_external_global_owner_and_preserves_local_waiter(): - external_owner = DeviceLockOwner( - pid=24680, - process_created_at=1234.5, - token="external-owner-token", - device_id="emulator-5554", - description="MCP task: inspect settings", - acquired_at="2026-08-24T00:00:00+00:00", - session_id="mcp-session", - ingress="mcp", - ) - local_waiter = MagicMock(pid=13579, returncode=None) - state.current_process = local_waiter - state.queue_items = [ - { - "session_id": "frontend-waiter", - "goal": "Run after MCP", - "status": "running", - "queue_ticket": "frontend-ticket", - } - ] - state.active_session_id = "mcp-session" - state.active_connections["mcp-session"] = {"pid": 24680} - - with ( - patch.object( - TaskQueueService, - "ensure_worker_running", - ), - patch( - "apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owner", - return_value=external_owner, - ), - patch( - "apps.admin_console.services.task_queue_service.DeviceExecutionLock.is_active_owner", - return_value=True, - ), - patch( - "apps.admin_console.services.task_queue_service.DeviceExecutionLock.cleanup_stale_locks" - ), - patch( - "apps.admin_console.services.task_queue_service.process_supervisor.terminate_tree_verified", - return_value=True, - ) as terminate_verified, - patch( - "apps.admin_console.services.task_queue_service.session_repo.update_session_status" - ) as update_status, - patch("artemis.runtime.trace_store.update_trace_status") as update_trace_status, - ): - assert task_queue_service.stop_tasks(clear_all=False) is True - - terminate_verified.assert_called_once_with(24680, 1234.5) - local_waiter.kill.assert_not_called() - assert state.current_process is local_waiter - assert state.queue_items[0]["session_id"] == "frontend-waiter" - assert not state.manually_stopped_run_ids - assert "frontend-waiter" not in state.cancelled_session_ids - assert "mcp-session" not in state.active_connections - update_status.assert_called_once() - assert update_status.call_args.args[:2] == ("mcp-session", "cancelled") - update_trace_status.assert_called_once_with( - "mcp-session", - "cancelled", - error="Task stopped from the Artemis frontend.", - ) - - -def test_stop_tasks_does_not_kill_stale_reused_pid(): - stale_owner = DeviceLockOwner( - pid=24680, - process_created_at=1234.5, - token="stale-token", - device_id="emulator-5554", - description="CLI task", - acquired_at="2026-08-24T00:00:00+00:00", - session_id="cli-session", - ingress="sdk", - ) - with ( - patch( - "apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owner", - return_value=stale_owner, - ), - patch( - "apps.admin_console.services.task_queue_service.DeviceExecutionLock.is_active_owner", - return_value=False, - ), - patch( - "apps.admin_console.services.task_queue_service.process_supervisor.terminate_tree_verified" - ) as terminate_verified, - ): - assert task_queue_service.stop_tasks(clear_all=False) is False - - terminate_verified.assert_not_called() - - -def test_stop_tasks_session_target_never_adopts_mismatched_fallback_owner(): - """No live owners is a legal state: a session-targeted stop must not - terminate a fallback owner that belongs to a different session.""" - other_owner = DeviceLockOwner( - pid=24680, - process_created_at=1234.5, - token="other-owner-token", - device_id="emulator-5554", - description="Task for another session", - acquired_at="2026-08-24T00:00:00+00:00", - session_id="other-session", - ingress="cli", - ) - with ( - patch( - "apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owners", - return_value={}, - ), - patch( - "apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owner", - return_value=other_owner, - ), - patch( - "apps.admin_console.services.task_queue_service.DeviceExecutionLock.is_active_owner", - return_value=True, - ), - patch( - "apps.admin_console.services.task_queue_service.process_supervisor.terminate_tree_verified" - ) as terminate_verified, - patch("apps.admin_console.services.task_queue_service.session_repo.update_session_status"), - patch( - "apps.admin_console.services.task_queue_service.session_repo.get_session_by_id", - return_value=None, - ), - ): - task_queue_service.stop_tasks(clear_all=False, session_id="wanted-session") - - terminate_verified.assert_not_called() - - -@pytest.mark.asyncio -async def test_status_reports_external_global_owner_without_ipc_connection(): - external_owner = DeviceLockOwner( - pid=24680, - process_created_at=1234.5, - token="external-owner-token", - device_id="emulator-5554", - description="CLI task: inspect settings", - acquired_at="2026-08-24T00:00:00+00:00", - session_id="cli-session", - ingress="cli", - ) - with ( - patch.object(TaskQueueService, "ensure_worker_running"), - patch( - "apps.admin_console.routers.tasks.DeviceExecutionLock.get_active_owner", - return_value=external_owner, - ), - patch("apps.admin_console.routers.tasks.session_repo") as repo, - patch("apps.admin_console.routers.tasks.model_service") as models, - ): - repo.get_latest_session.return_value = None - repo.get_session_by_id.return_value = None - models.get_active_model_info.return_value = None - result = await get_status() - - assert result["status"] == "running" - assert result["session_id"] == "cli-session" - assert result["pid"] == 24680 - assert result["goal"] == "CLI task: inspect settings" - - -@pytest.mark.asyncio -async def test_cancel_task_triggers_next_pending_task(): - executed_goals = [] - - async def fake_subprocess_exec(*args, **kwargs): - goal_arg = args[3] - executed_goals.append(goal_arg) - proc = MagicMock() - proc.pid = 77777 - - async def wait_side_effect(): - if goal_arg == "Task 1": - # Simulate task 1 being stopped manually mid-execution - await asyncio.sleep(0.05) - task_queue_service.stop_tasks(clear_all=False) - return -9 - await asyncio.sleep(0.02) - return 0 - - proc.wait = AsyncMock(side_effect=wait_side_effect) - proc.returncode = 0 - return proc - - with ( - patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec), - patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo, - patch("apps.admin_console.services.task_queue_service.media_service"), - patch( - "artemis.runtime.device_pool.device_pool.select_device_async", - return_value="emulator-5554", - ), - ): - mock_repo.get_running_session_id.return_value = None - mock_repo.get_video_recording_for_session.return_value = {"status": "ready"} - await task_queue_service.enqueue_tasks(["Task 1", "Task 2"]) - - for _ in range(40): - if len(executed_goals) == 2 and len(state.queue_items) == 0: - break - await asyncio.sleep(0.05) - - assert executed_goals == ["Task 1", "Task 2"] - assert len(state.queue_items) == 0 - - task = state.worker_task - if task and not task.done(): - task.cancel() - try: - await task - except (asyncio.CancelledError, Exception): - pass - - -@pytest.mark.asyncio -async def test_immediate_cancel_ignores_stale_ipc_and_runs_next_task(): - executed_goals = [] - task1_session_id = None - - async def fake_subprocess_exec(*args, **kwargs): - goal_arg = args[3] - executed_goals.append(goal_arg) - proc = MagicMock() - proc.pid = 88888 - - async def wait_side_effect(): - if goal_arg == "Task 1": - return -9 - await asyncio.sleep(0.02) - return 0 - - proc.wait = AsyncMock(side_effect=wait_side_effect) - proc.returncode = 0 - return proc - - with ( - patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec), - patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo, - patch("apps.admin_console.services.task_queue_service.media_service"), - patch( - "artemis.runtime.device_pool.device_pool.select_device_async", - return_value="emulator-5554", - ), - ): - mock_repo.get_running_session_id.return_value = None - mock_repo.get_video_recording_for_session.return_value = {"status": "ready"} - - # Enqueue Task 1 - res1 = await task_queue_service.enqueue_tasks(["Task 1"]) - task1_session_id = res1["tasks"][0]["session_id"] - - # Immediately stop Task 1 - task_queue_service.stop_tasks(clear_all=False) - assert task1_session_id in state.cancelled_session_ids - - # Simulate stale session_started arriving over IPC for the cancelled Task 1 - from apps.admin_console.services.ipc_service import ipc_service - - ipc_service.sanitize_event_data("session_started", {"session_id": task1_session_id}) - # If IPC handler checks cancelled_session_ids, state.active_session_id should not get stuck - assert state.active_session_id != task1_session_id - assert state.is_running is False - - # Enqueue Task 2 and verify it runs immediately without getting stuck in pending - await task_queue_service.enqueue_tasks(["Task 2"]) - - for _ in range(40): - if "Task 2" in executed_goals and len(state.queue_items) == 0: - break - await asyncio.sleep(0.05) - - assert "Task 2" in executed_goals - assert len(state.queue_items) == 0 - - task = state.worker_task - if task and not task.done(): - task.cancel() - try: - await task - except (asyncio.CancelledError, Exception): - pass - - -@pytest.mark.asyncio -async def test_wait_for_worker_process_watchdog_handles_reaped_process(): - """Verify that _wait_for_worker_process never hangs even if the process was already reaped.""" - mock_proc = MagicMock() - mock_proc.pid = 999999 - mock_proc.returncode = None - - async def hanging_wait(): - await asyncio.sleep(10) - return 0 - - mock_proc.wait = AsyncMock(side_effect=hanging_wait) - - with patch("psutil.Process") as mock_psutil_proc: - mock_p = MagicMock() - mock_p.is_running.return_value = False - mock_psutil_proc.return_value = mock_p - - rc = await TaskQueueService._wait_for_worker_process(mock_proc) - assert rc == -15 - - -def test_darwin_terminate_process_tree_preserves_direct_child_for_asyncio(): - """Verify darwin terminate_process_tree does not pass direct children to psutil.wait_procs.""" - import os - from artemis.platform.darwin import DarwinPlatformProcess - - process = DarwinPlatformProcess() - current_pid = os.getpid() - - with ( - patch("psutil.Process") as mock_psutil_proc, - patch("psutil.wait_procs") as mock_wait_procs, - ): - parent = MagicMock() - parent.pid = 12345 - parent.ppid.return_value = current_pid - parent.children.return_value = [] - parent.is_running.return_value = False - - mock_psutil_proc.return_value = parent - mock_wait_procs.return_value = ([], []) - - success = process.terminate_process_tree(12345, timeout_seconds=0.1) - assert success is True - parent.send_signal.assert_called_once() - for call_args in mock_wait_procs.call_args_list: - procs_waited = call_args[0][0] - assert parent not in procs_waited - - -@pytest.mark.asyncio -async def test_enqueue_tasks_unified_ingress(): - """Verify enqueue_tasks correctly propagates ingress, custom session_id, and conversation_id.""" - with ( - patch.object(TaskQueueService, "ensure_worker_running"), - patch( - "apps.admin_console.services.task_queue_service.DeviceExecutionLock.reserve", - return_value="mock-ticket-unified", - ), - ): - res = await task_queue_service.enqueue_tasks( - ["Test unified goal"], - profile="flash", - ingress="mcp", - session_id="custom-mcp-session-123", - conversation_id="conv-456", - ) - assert res["status"] in ("started", "queued") - assert len(res["tasks"]) == 1 - task = res["tasks"][0] - assert task["session_id"] == "custom-mcp-session-123" - assert task["ingress"] == "mcp" - assert task["conversation_id"] == "conv-456" - assert task["goal"] == "Test unified goal" - - -@pytest.mark.asyncio -async def test_queue_worker_notifies_conversation(): - """Verify queue_worker calls notify() when conversation_id is attached to task.""" - executed_cmds = [] - - async def fake_subprocess_exec(*args, **kwargs): - executed_cmds.append(list(args)) - proc = MagicMock() - proc.pid = 99999 - proc.wait = AsyncMock(return_value=0) - proc.returncode = 0 - return proc - - with ( - patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec), - patch("apps.admin_console.services.task_queue_service.media_service"), - patch("mcp_server.notifiers.notify") as mock_notify, - patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo, - patch( - "artemis.runtime.device_pool.device_pool.try_list_devices_async", - new=AsyncMock(return_value=[]), - ), - ): - mock_repo.get_running_session_id.return_value = None - - await task_queue_service.enqueue_tasks( - ["Notify goal"], - profile="flash", - ingress="mcp", - conversation_id="conv-789", - device_serial="test-mock-serial", - ) - - for _ in range(30): - if mock_notify.called and len(state.queue_items) == 0: - break - await asyncio.sleep(0.05) - - mock_notify.assert_called_once() - kwargs = mock_notify.call_args[1] - assert kwargs["conversation_id"] == "conv-789" - assert "Notify goal" in kwargs["message"] - - task = state.worker_task - if task and not task.done(): - task.cancel() - try: - await task - except (asyncio.CancelledError, Exception): - pass - - -def test_stop_tasks_by_session_id_targets_correct_task_among_multiple(): - """Stopping by session_id terminates only the targeted owner among multiple concurrent devices.""" - owner_a = DeviceLockOwner( - pid=11111, - process_created_at=100.0, - token="token-a", - device_id="device-a", - description="Task on device A", - acquired_at="2026-08-24T00:00:00+00:00", - session_id="session-a", - ingress="cli", - ) - owner_b = DeviceLockOwner( - pid=22222, - process_created_at=200.0, - token="token-b", - device_id="device-b", - description="Task on device B", - acquired_at="2026-08-24T00:00:00+00:00", - session_id="session-b", - ingress="mcp", - ) - - with ( - patch( - "apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owners", - return_value={"device_a": owner_a, "device_b": owner_b}, - ), - patch( - "apps.admin_console.services.task_queue_service.DeviceExecutionLock.is_active_owner", - return_value=True, - ), - patch( - "apps.admin_console.services.task_queue_service.DeviceExecutionLock.cleanup_stale_locks" - ) as cleanup_locks, - patch( - "apps.admin_console.services.task_queue_service.process_supervisor.terminate_tree_verified", - return_value=True, - ) as terminate_verified, - patch( - "apps.admin_console.services.task_queue_service.session_repo.update_session_status" - ) as update_status, - patch("artemis.runtime.trace_store.update_trace_status") as update_trace, - ): - # Explicitly stop session-b - assert task_queue_service.stop_tasks(clear_all=False, session_id="session-b") is True - - # Only session-b's process (22222) should be terminated - terminate_verified.assert_called_once_with(22222, 200.0) - cleanup_locks.assert_called_once_with("device-b") - update_status.assert_called_once() - assert update_status.call_args[0][0] == "session-b" - assert update_status.call_args[0][1] == "cancelled" - update_trace.assert_called_once_with( - "session-b", - "cancelled", - error="Task stopped from the Artemis frontend.", - ) - - -def test_stop_tasks_by_device_id_targets_specific_device(): - """Stopping by device_id targets the active task on that specific device.""" - owner_a = DeviceLockOwner( - pid=33333, - process_created_at=300.0, - token="token-a", - device_id="pixel-8", - description="Task on Pixel 8", - acquired_at="2026-08-24T00:00:00+00:00", - session_id="session-pixel8", - ingress="sdk", - ) - - with ( - patch( - "apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owners", - return_value={"pixel_8": owner_a}, - ), - patch( - "apps.admin_console.services.task_queue_service.DeviceExecutionLock.is_active_owner", - return_value=True, - ), - patch( - "apps.admin_console.services.task_queue_service.DeviceExecutionLock.cleanup_stale_locks" - ), - patch( - "apps.admin_console.services.task_queue_service.process_supervisor.terminate_tree_verified", - return_value=True, - ) as terminate_verified, - patch("apps.admin_console.services.task_queue_service.session_repo.update_session_status"), - ): - assert task_queue_service.stop_tasks(clear_all=False, device_id="pixel-8") is True - terminate_verified.assert_called_once_with(33333, 300.0) - - -def test_stop_tasks_queued_item_by_session_id(): - """Stopping a queued task by session_id cancels its reservation and removes it from queue.""" - state.queue_items = [ - { - "session_id": "queue-1", - "goal": "Goal 1", - "status": "pending", - "queue_ticket": "ticket-1", - }, - { - "session_id": "queue-2", - "goal": "Goal 2", - "status": "pending", - "queue_ticket": "ticket-2", - }, - ] - - with ( - patch( - "apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owners", - return_value={}, - ), - patch( - "apps.admin_console.services.task_queue_service.DeviceExecutionLock.get_active_owner", - return_value=None, - ), - patch( - "apps.admin_console.services.task_queue_service.DeviceExecutionLock.cancel_reservation", - return_value=True, - ) as cancel_res, - patch( - "apps.admin_console.services.task_queue_service.session_repo.update_session_status" - ) as update_status, - ): - assert task_queue_service.stop_tasks(clear_all=False, session_id="queue-1") is True - cancel_res.assert_called_once_with("ticket-1") - # queue-2 should remain in queue - assert len(state.queue_items) == 1 - update_status.assert_called_once() - assert update_status.call_args[0][0] == "queue-1" - assert update_status.call_args[0][1] == "cancelled" - - -@pytest.mark.asyncio -async def test_enqueue_tasks_deduplicates_by_session_id(): - state.queue_items = [] - state.active_session_id = None - - with ( - patch("apps.admin_console.services.task_queue_service.session_repo"), - patch( - "apps.admin_console.services.task_queue_service.DeviceExecutionLock.reserve", - return_value="ticket-123", - ), - ): - res1 = await task_queue_service.enqueue_tasks(["Task goal"], session_id="sid-dedup-1") - assert len(state.queue_items) == 1 - assert res1["enqueued_count"] == 1 - - # Second submission with same session_id must not enqueue a duplicate - res2 = await task_queue_service.enqueue_tasks(["Task goal again"], session_id="sid-dedup-1") - assert len(state.queue_items) == 1 - assert res2["enqueued_count"] == 0 - assert res2["tasks"][0]["session_id"] == "sid-dedup-1" - - -@pytest.mark.asyncio -async def test_manual_stop_of_one_run_does_not_pollute_concurrent_run(): - """Stopping run A must not flip run B's terminal status or its payload. - - Regression test for the process-global ``was_stopped_manually`` flag that - used to mark *every* in-flight run as manually stopped. Two concurrent runs - on different devices: device A's task is stopped manually, device B's task - then finishes normally and must still be reported as completed. - """ - ended_payloads: dict[str, dict] = {} - - def capture(event_type, data): - if event_type == "session_ended": - ended_payloads[str(data.get("session_id"))] = dict(data) - - class FakeProc: - def __init__(self, pid): - self.pid = pid - self.returncode = None - self.stdout = None - self._done = asyncio.Event() - - async def wait(self): - await self._done.wait() - return self.returncode - - def finish(self, returncode): - self.returncode = returncode - self._done.set() - - def kill(self): - self.finish(-9) - - procs: dict[str, FakeProc] = {} - - async def fake_subprocess_exec(*args, **kwargs): - goal = args[3] - # Use our own pid so the reaped-process watchdog keeps waiting. - proc = FakeProc(os.getpid()) - procs[goal] = proc - return proc - - item_a = { - "session_id": "run-a", - "goal": "Goal A", - "status": "pending", - "device_serial": "dev-a", - } - item_b = { - "session_id": "run-b", - "goal": "Goal B", - "status": "pending", - "device_serial": "dev-b", - } - state.queue_items = [item_a, item_b] - state.ipc_subscribers.append(capture) - - try: - with ( - patch("asyncio.create_subprocess_exec", side_effect=fake_subprocess_exec), - patch("apps.admin_console.services.task_queue_service.session_repo") as mock_repo, - patch("apps.admin_console.services.task_queue_service.media_service"), - patch( - "apps.admin_console.services.task_queue_service.process_supervisor.terminate_tree", - return_value=True, - ), - ): - mock_repo.get_session_status.return_value = "running" - mock_repo.get_video_recording_for_session.return_value = {"status": "ready"} - - task_a = asyncio.create_task(TaskQueueService._execute_task_item(item_a)) - task_b = asyncio.create_task(TaskQueueService._execute_task_item(item_b)) - for _ in range(40): - if "run-a" in state.active_runs and "run-b" in state.active_runs: - break - await asyncio.sleep(0.02) - assert {"run-a", "run-b"} <= set(state.active_runs) - # B launched last, so the legacy single-task mirror points at B. - assert state.active_session_id == "run-b" - - # Manually stop device A's task without naming its session id. - assert task_queue_service.stop_tasks(clear_all=False, device_id="dev-a") is True - await asyncio.wait_for(task_a, timeout=5.0) - - # B finishes normally afterwards. - procs["Goal B"].finish(0) - await asyncio.wait_for(task_b, timeout=5.0) - - assert ended_payloads["run-a"]["status"] == "cancelled" - assert ended_payloads["run-a"]["was_stopped_manually"] is True - assert ended_payloads["run-b"]["status"] == "completed" - assert ended_payloads["run-b"]["was_stopped_manually"] is False - - persisted = { - call.args[0]: call.args[1] for call in mock_repo.update_session_status.call_args_list - } - assert persisted.get("run-a") == "cancelled" - assert persisted.get("run-b") == "completed" - assert "run-b" not in state.cancelled_session_ids - assert "run-b" not in state.manually_stopped_run_ids - finally: - state.ipc_subscribers.remove(capture) - - -@pytest.mark.asyncio -async def test_enqueue_tasks_debounces_rapid_identical_submissions(): - state.queue_items = [] - state.active_session_id = None - - with ( - patch("apps.admin_console.services.task_queue_service.session_repo"), - patch( - "apps.admin_console.services.task_queue_service.DeviceExecutionLock.reserve", - return_value="ticket-456", - ), - patch( - "artemis.runtime.device_pool.device_pool.try_list_devices_async", - new=AsyncMock(return_value=[]), - ), - patch.object(TaskQueueService, "ensure_worker_running"), - ): - res1 = await task_queue_service.enqueue_tasks( - ["Rapid duplicate goal"], device_serial="dev-1" - ) - assert len(state.queue_items) == 1 - assert res1["enqueued_count"] == 1 - - # Immediate second submission (within 1s) must debounce - res2 = await task_queue_service.enqueue_tasks( - ["Rapid duplicate goal"], device_serial="dev-1" - ) - assert len(state.queue_items) == 1 - assert res2["enqueued_count"] == 0 diff --git a/tests/unit/agents/flash/test_turn_index_snapshot.py b/tests/unit/agents/flash/test_turn_index_snapshot.py deleted file mode 100644 index 0c6e42278..000000000 --- a/tests/unit/agents/flash/test_turn_index_snapshot.py +++ /dev/null @@ -1,302 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""A Flash multi-action turn resolves every index against one element list. - -Defect: each action's post-observation replaced ``state.indexed_elements`` -and the executor resolved index targets against the state at call time, so -``click(3)`` then ``click(5)`` decided on one screen sent the second tap to -whatever the screen after the first click listed as ``[5]``. The runner now -snapshots the list once per turn and threads it through -``McpActionExecutor.execute(index_elements=...)``; the state's list is still -refreshed after every action (that is what the next observation shows). -""" - -from unittest.mock import AsyncMock, Mock, patch - -import pytest -from langchain_core.messages import ToolMessage - -from artemis.agents.flash.runner import FlashRunner, _TurnRecord -from artemis.context import ArtemisContext -from artemis.graph.state import State -from artemis.mcp.action_executor import McpActionExecutor, _ArgError -from artemis.mcp.action_types import ActionResult, ObserveResult - - -def _el(index: int, text: str, cx: int, cy: int) -> dict: - return { - "index": index, - "center": [cx, cy], - "text": text, - "bounds": [cx - 100, cy - 50, cx + 100, cy + 50], - "class": "android.widget.TextView", - "resource_id": f"id/{text.lower().replace(' ', '_')}", - "is_ocr": False, - } - - -# The list of the observation the model decided on (1080x2400 device). -ORIGINAL = [ - _el(1, "Home", 540, 200), - _el(2, "Search", 540, 600), - _el(3, "Wi-Fi", 540, 1000), - _el(4, "Bluetooth", 540, 1400), - _el(5, "Display", 540, 1800), -] -# The screen after the first click: fewer elements, different numbering. -REFRESHED = [_el(1, "Back", 100, 100), _el(2, "Wi-Fi toggle", 900, 300)] - - -class _FakeSession: - """An action session whose observations replay a scripted list sequence.""" - - started = True - - def __init__(self, observations: list[list[dict]]): - self.calls: list[tuple[str, dict]] = [] - self._observations = list(observations) - - async def call(self, name, args): - self.calls.append((name, dict(args))) - return ActionResult.success(name, f"{name} dispatched") - - async def observe(self, settle_ms=0): - elements = self._observations.pop(0) if self._observations else [] - return ObserveResult( - ok=True, - elements_text="\n".join(f"[{e['index']}] {e['text']}" for e in elements), - elements=elements, - hierarchy_ok=True, - ) - - -@pytest.fixture -def mock_context(): - ctx = Mock(spec=ArtemisContext) - ctx.llm_config = Mock() - llm_cfg = Mock() - llm_cfg.model = "gemini-2.5-flash" - llm_cfg.temperature = 0.1 - ctx.llm_config.get_agent.return_value = llm_cfg - ctx.device = Mock() - ctx.device.device_width = 1080 - ctx.device.device_height = 2400 - ctx.data_engine = None - ctx.adb_client = None - ctx.driver = Mock() - return ctx - - -def _runner(mock_context, observations): - with patch("artemis.controllers.unified_controller.get_driver"): - runner = FlashRunner(mock_context, goal="Open Wi-Fi and Display") - runner.summarizer = None - runner.executor._session = _FakeSession(observations) - # No device: the executor's observation already refreshed the state. - runner._capture_post_screenshot = AsyncMock(return_value=None) - return runner - - -def _state(elements): - state = State(initial_goal="g") - state.indexed_elements = list(elements) - state.indexed_points = [e["center"] for e in elements] - return state - - -def _click(index, tc_id): - return {"name": "click", "args": {"target": index}, "id": tc_id} - - -# --- Executor: the explicit list wins over the state --------------------------------- - - -def _executor(): - ctx = Mock() - ctx.device.device_width = 1080 - ctx.device.device_height = 2400 - actuator = Mock() - actuator.controller = Mock() - return McpActionExecutor(ctx, actuator=actuator) - - -def test_resolve_index_uses_the_given_list_and_falls_back_to_the_state(): - executor = _executor() - state = _state(REFRESHED) - - # None -> the state's current list (Pro operator / validator / explorer callers). - point, recorded = executor._resolve_index(2, "click", state) - assert point == [833, 125] and recorded["target_text"] == "Wi-Fi toggle" - - # An explicit list: index 5 exists only there. - point, recorded = executor._resolve_index(5, "click", state, index_elements=ORIGINAL) - assert point == [500, 750] and recorded["target_text"] == "Display" - assert recorded["target_resource_id"] == "id/display" - - # The snapshot bounds the valid range, not the state. - with pytest.raises(_ArgError, match="Active index range is 1 to 2"): - executor._resolve_index(5, "click", state, index_elements=REFRESHED) - _, _, _, recorded = executor._translate( - "long_press", {"target": 4}, state, index_elements=ORIGINAL - ) - assert recorded["target_text"] == "Bluetooth" - _, wire, _, recorded = executor._translate( - "input_text", {"target": 2, "text": "hi"}, state, index_elements=ORIGINAL - ) - assert wire["target"] == [500, 250] and recorded["target_text"] == "Search" - - -# --- Runner: one snapshot per turn ------------------------------------------------------ - - -@pytest.mark.asyncio -async def test_two_index_clicks_in_one_turn_resolve_against_the_original_list(mock_context): - """``click(3)`` then ``click(5)``: the first click's observation lists only - two elements, yet the second index still lands on the original ``[5]`` - (Display), with its semantics recorded from the original element.""" - runner = _runner(mock_context, observations=[REFRESHED, [_el(1, "Display page", 1, 1)]]) - state = _state(ORIGINAL) - messages: list = [] - turn = _TurnRecord() - - final, _, xml_list, _ = await runner._process_tool_calls( - [_click(3, "tc-1"), _click(5, "tc-2")], - state, - messages, - "thought", - {}, - b"pre", - "xml", - 0, - turn, - ) - - assert final is None - session = runner.executor._session - assert [args["target"] for _, args in session.calls] == [[500, 417], [500, 750]] - assert [status for _, status, _ in turn.actions] == ["success", "success"] - tool_msgs = [m for m in messages if isinstance(m, ToolMessage)] - assert [m.status for m in tool_msgs] == ["success", "success"] - # The state kept being refreshed: the NEXT observation shows the newest list. - assert [e["text"] for e in state.indexed_elements] == ["Display page"] - assert xml_list == "[1] Display page" - - -@pytest.mark.asyncio -async def test_second_index_beyond_the_refreshed_list_is_still_valid(mock_context): - """Without the snapshot ``click(5)`` after the refresh was an - 'Active index range is 1 to 2' error; with it the turn's own list is - authoritative and the recorded metadata comes from that list.""" - runner = _runner(mock_context, observations=[REFRESHED, REFRESHED]) - state = _state(ORIGINAL) - captured = [] - real_execute = runner.executor.execute - - async def spy(name, args, tc_id, st, **kwargs): - result = await real_execute(name, args, tc_id, st, **kwargs) - captured.append(result) - return result - - runner.executor.execute = spy - - await runner._process_tool_calls( - [_click(4, "tc-1"), _click(5, "tc-2")], - state, - [], - "thought", - {}, - b"pre", - "xml", - 0, - _TurnRecord(), - ) - - assert [r.status for r in captured] == ["success", "success"] - assert [r.metadata["target_coordinates"] for r in captured] == [[500, 583], [500, 750]] - assert [r.metadata["target_semantics"]["target_text"] for r in captured] == [ - "Bluetooth", - "Display", - ] - assert captured[1].metadata["target_semantics"]["target_bounds"] == [407, 729, 593, 771] - - -@pytest.mark.asyncio -async def test_explorer_candidates_registered_before_an_action_join_the_snapshot(mock_context): - """``ask_explorer`` appends to the state's list in place; before any - action that list is the snapshot, so the new index resolves in the same - turn even though the action's observation then replaces the state list.""" - runner = _runner(mock_context, observations=[REFRESHED]) - state = _state(ORIGINAL) - - async def fake_explorer(query, context_feedback, st): - st.indexed_points.append([540, 2200]) - st.indexed_elements.append(_el(6, "Hidden gear", 540, 2200)) - return "Found 'gear' at index 6.", True - - runner.executor._ask_explorer = fake_explorer - - await runner._process_tool_calls( - [ - {"name": "ask_explorer", "args": {"query": "gear"}, "id": "tc-0"}, - _click(6, "tc-1"), - ], - state, - [], - "thought", - {}, - b"pre", - "xml", - 0, - _TurnRecord(), - ) - - session = runner.executor._session - assert [args["target"] for _, args in session.calls] == [[500, 917]] - assert [e["text"] for e in state.indexed_elements] == ["Back", "Wi-Fi toggle"] - - -@pytest.mark.asyncio -async def test_explorer_after_an_action_moves_the_snapshot_to_the_list_it_numbered(mock_context): - """After an action replaced the state list, the explorer numbers its - candidates against that replaced list; the snapshot follows so the index - the explorer just handed out resolves correctly.""" - runner = _runner(mock_context, observations=[REFRESHED, REFRESHED]) - state = _state(ORIGINAL) - - async def fake_explorer(query, context_feedback, st): - st.indexed_points.append([200, 2000]) - st.indexed_elements.append(_el(len(st.indexed_elements) + 1, "Gear", 200, 2000)) - return "Found.", True - - runner.executor._ask_explorer = fake_explorer - - await runner._process_tool_calls( - [ - _click(3, "tc-1"), - {"name": "ask_explorer", "args": {"query": "gear"}, "id": "tc-2"}, - _click(3, "tc-3"), # the explorer's index on the refreshed list - ], - state, - [], - "thought", - {}, - b"pre", - "xml", - 0, - _TurnRecord(), - ) - - session = runner.executor._session - assert [args["target"] for _, args in session.calls] == [[500, 417], [185, 833]] diff --git a/tests/unit/agents/test_check_prompt_assembly.py b/tests/unit/agents/test_check_prompt_assembly.py deleted file mode 100644 index b1b0b894a..000000000 --- a/tests/unit/agents/test_check_prompt_assembly.py +++ /dev/null @@ -1,190 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""§1.3 assembly discipline: prompts are functions of configuration x scenario. -Both check gates off => zero context pollution; with a gate on, the check-line -guidance lives once in the static system prompt, worded for that gate.""" - -import json -from pathlib import Path -from unittest.mock import MagicMock - -import pytest - -from artemis.agents.operator.prompts import ( - FeedbackPromptComponent, - PromptBuilder, - TemplatePromptComponent, - render_transcript_static_system, -) -from artemis.agents.planner.planner import build_planner_system_blocks -from artemis.context import ArtemisContext, ExecutionSetup -from artemis.graph.state import State - -PLANNER_JSON = Path(__file__).resolve().parents[3] / "artemis/agents/planner/planner.json" -OPERATOR_JSON = Path(__file__).resolve().parents[3] / "artemis/agents/operator/operator.json" - - -def _planner_data(): - return json.loads(PLANNER_JSON.read_text(encoding="utf-8")) - - -def test_planner_blocks_without_checks_are_byte_identical_to_legacy(): - data = _planner_data() - baseline = list(data["modes"]["initial_plan"]["system"]) - assert build_planner_system_blocks(data, "initial_plan", include_checks=False) == baseline - # And the disabled assembly never references the check blocks - rendered = "\n\n".join( - data["blocks"][b] - for b in build_planner_system_blocks(data, "initial_plan", include_checks=False) - ) - assert "- verify:" not in rendered - assert "- assert:" not in rendered - - -def test_planner_blocks_with_checks_mount_generation_and_audit(): - data = _planner_data() - initial = build_planner_system_blocks(data, "initial_plan", include_checks=True) - assert initial[-1] == "check_generation" - validator = build_planner_system_blocks(data, "validator", include_checks=True) - assert validator[-1] == "check_audit" - - generation = data["blocks"]["check_generation"] - # Restraint, honesty about post-hoc auditing, and no silent ordering promises - assert "restraint" in generation.lower() or "Do NOT attach" in generation - assert "post-hoc" in generation.lower() - assert "Never silently promise" in generation - - audit = data["blocks"]["check_audit"] - # Advisory review: the audit flags weakened check standards, never blocks. - assert "FLAG" in audit - assert "REJECT" not in audit - - -def _mock_ctx(tmp_path=None, plan: str | None = None, **setup_kwargs): - ctx = MagicMock(spec=ArtemisContext) - ctx.execution_setup = ExecutionSetup(**setup_kwargs) if setup_kwargs is not None else None - if plan is not None and tmp_path is not None: - notes = tmp_path / "notes" - notes.mkdir(parents=True, exist_ok=True) - (notes / "task_plan.md").write_text(plan, encoding="utf-8") - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = tmp_path - else: - ctx.data_engine = None - return ctx - - -def _state(**overrides): - state = MagicMock(spec=State) - state.operator_feedback = None - for k, v in overrides.items(): - setattr(state, k, v) - return state - - -@pytest.mark.asyncio -async def test_check_line_guidance_lives_in_the_static_prompt_once(): - """The check-line guidance is a static-prompt section gated on the check - gates (never a per-turn observation block), worded for the active gate.""" - off = await _render_main_template(disable_checker=True) - assert "*Check Lines*" not in off - assert "never declare a check passed" not in off - - final_only = await _render_main_template(disable_midway_checks=True, disable_final_check=False) - assert final_only.count("*Check Lines*") == 1 - assert "never declare a check passed" in final_only - assert "restores deleted ones unless user guidance called for the change" in final_only - # No midway repair loop -> no repair budget to recite, and the grammar says so. - assert "reopens its milestone at most" not in final_only - assert "there is no midway repair loop" in final_only - - midway = await _render_main_template(disable_midway_checks=False, checkpoint_max_repairs=3) - assert midway.count("*Check Lines*") == 1 - assert "reopens its milestone at most 3 times" in midway - assert "- finding:" in midway - - -def test_transcript_static_system_recites_the_repair_budget_from_setup(): - ctx = _mock_ctx(disable_midway_checks=False, checkpoint_max_repairs=5) - ctx.actuator = None - prompts = json.loads(OPERATOR_JSON.read_text(encoding="utf-8")) - text = render_transcript_static_system(prompts, ctx, _state(initial_goal="G")) - assert text.count("*Check Lines*") == 1 - assert "reopens its milestone at most 5 times" in text - - quiet = _mock_ctx(disable_checker=True) - quiet.actuator = None - assert "*Check Lines*" not in render_transcript_static_system( - prompts, quiet, _state(initial_goal="G") - ) - - -@pytest.mark.asyncio -async def test_static_prompt_declares_the_user_guidance_channel_once(): - text = await _render_main_template(disable_checker=True) - assert text.count("--- User Guidance ---") == 1 - assert "outranks the task plan and its check lines" in text - - -async def _render_main_template(**setup_kwargs) -> str: - component = TemplatePromptComponent() - builder = PromptBuilder() - ctx = _mock_ctx(**setup_kwargs) - ctx.actuator = None - state = _state(initial_goal="G") - prompts = json.loads(OPERATOR_JSON.read_text(encoding="utf-8")) - await component(builder, state, ctx, prompts=prompts, plan_and_history="") - return "".join(builder.system_parts) + (builder.human_footer or "") - - -@pytest.mark.asyncio -async def test_main_template_diagnosis_trigger_gated_on_verification(): - """The rejection/finding diagnosis trigger is a dead instruction unless a - mechanism that can produce rejections or findings is active (§1.3).""" - # Factory default: checks off (master alias) AND planner validation off. - default = await _render_main_template(disable_checker=True, disable_planner_validation=True) - assert "Verification Finding" not in default - assert "Ambiguous Validation Rejection" not in default - - # Checks on -> the trigger is real and must be documented. - with_checks = await _render_main_template( - disable_checker=False, disable_planner_validation=True - ) - assert "Ambiguous Verification Finding" in with_checks - - # Planner validation alone can also produce (advisory) findings. - with_validation = await _render_main_template( - disable_checker=True, disable_planner_validation=False - ) - assert "Ambiguous Verification Finding" in with_validation - - -@pytest.mark.asyncio -async def test_check_feedback_component_appends_findings_only_when_present(): - component = FeedbackPromptComponent() - - builder = PromptBuilder() - await component(builder, _state(operator_feedback=None), _mock_ctx()) - assert builder.human_parts == [] - - builder2 = PromptBuilder() - await component( - builder2, - _state(operator_feedback=["[verify failed] 'X': missing"]), - _mock_ctx(), - ) - joined = "\n".join(p for p in builder2.human_parts if isinstance(p, str)) - assert "Verification Findings" in joined - assert "[verify failed] 'X': missing" in joined diff --git a/tests/unit/agents/test_checker.py b/tests/unit/agents/test_checker.py deleted file mode 100644 index d992b353e..000000000 --- a/tests/unit/agents/test_checker.py +++ /dev/null @@ -1,781 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import AsyncMock, MagicMock, patch - -from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage -from langchain_core.tools import StructuredTool -import pytest - -from artemis.agents.checker.checker import ( - CheckReport, - CheckVerdict, - _normalize_report, - _run_check_loop, - assemble_checker_prompt_segments, - build_checker_tools, - build_probe_argv, - run_checkpoint_check, - run_final_check, - verdicts_allow_release, -) -from artemis.context import ArtemisContext, ExecutionSetup -from artemis.core.tool_failure import ToolFailure -from artemis.graph.checkpoints import EvidenceAnchor -from artemis.utils.plan_grammar import CheckItem - - -def _ci(kind="verify", when="on_complete", text="expected state", parent="p"): - return CheckItem(kind=kind, when=when, text=text, parent_key=parent) - - -def _mock_ctx(**setup_kwargs): - ctx = MagicMock(spec=ArtemisContext) - ctx.execution_setup = ExecutionSetup(**setup_kwargs) - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = "unused" - ctx.data_engine.get_agent_friendly_steps.return_value = [] - ctx.data_engine.get_step_number.return_value = 7 - return ctx - - -# --- probe_device: enumerated table, programmatic argv (§8 item 12) ------------------ - - -def test_probe_argv_enumerated_kinds_allowed(): - assert build_probe_argv("alarms") == ["dumpsys", "alarm"] - assert build_probe_argv("battery") == ["dumpsys", "battery"] - assert build_probe_argv("foreground") == ["dumpsys", "activity", "activities"] - assert build_probe_argv("packages") == ["pm", "list", "packages"] - assert build_probe_argv("setting", {"namespace": "system", "key": "screen_brightness"}) == [ - "settings", - "get", - "system", - "screen_brightness", - ] - assert build_probe_argv("content", {"uri": "content://settings/system"}) == [ - "content", - "query", - "--uri", - "content://settings/system", - ] - assert build_probe_argv("prop", {"key": "ro.build.version.sdk"}) == [ - "getprop", - "ro.build.version.sdk", - ] - - -def test_probe_argv_rejects_unknown_kind(): - with pytest.raises(ValueError): - build_probe_argv("shell") - with pytest.raises(ValueError): - build_probe_argv("dumpsys") - - -def test_probe_argv_rejects_whitespace_and_metachars(): - for bad in ("a key", "key;rm", "key|x", "key$", "key`x`", "key&&y", "a\nb"): - with pytest.raises(ValueError): - build_probe_argv("prop", {"key": bad}) - with pytest.raises(ValueError): - build_probe_argv("setting", {"namespace": "system", "key": "a b"}) - with pytest.raises(ValueError): - build_probe_argv("content", {"uri": "content://a; rm -rf /"}) - - -def test_probe_argv_battery_set_inexpressible(): - """Mutating dumpsys subcommands cannot be expressed: no-parameter probes - reject every parameter.""" - with pytest.raises(ValueError): - build_probe_argv("battery", {"extra": "set level 100"}) - with pytest.raises(ValueError): - build_probe_argv("alarms", {"args": "anything"}) - # And the setting namespace is a closed set - with pytest.raises(ValueError): - build_probe_argv("setting", {"namespace": "battery", "key": "level"}) - - -# --- Release decision is node-side and never rewrites verdicts ----------------------- - - -def test_verdicts_allow_release_semantics(): - ok = CheckReport( - verdicts=[ - CheckVerdict(item_text="a", kind="verify", status="passed", evidence="e"), - CheckVerdict(item_text="b", kind="verify", status="inconclusive", evidence="e"), - CheckVerdict(item_text="c", kind="assert", status="failed", evidence="e"), - ] - ) - # Assert failures never block release - assert verdicts_allow_release(ok) - - blocked = CheckReport( - verdicts=[ - CheckVerdict(item_text="a", kind="verify", status="failed", evidence="e"), - ] - ) - assert not verdicts_allow_release(blocked) - - -def test_normalize_report_downgrades_vague_failures_and_fills_missing(): - items = [_ci(kind="verify", text="v1"), _ci(kind="assert", text="a1")] - report = CheckReport( - verdicts=[CheckVerdict(item_text="v1", kind="verify", status="failed", evidence=" ")] - ) - normalized = _normalize_report(report, items) - by_text = {(v.kind, v.item_text): v for v in normalized.verdicts} - # Vague failed -> inconclusive (verdict value hygiene, not release logic) - assert by_text[("verify", "v1")].status == "inconclusive" - # Missing item gets an inconclusive verdict, never a silent pass - assert by_text[("assert", "a1")].status == "inconclusive" - - -# --- Assembly: prompt segments x entry x content x config (§8 item 18) --------------- - - -def test_prompt_segments_verify_only_has_no_assert_section(): - prompts = { - "base_rules": "BASE", - "verify_semantics": "VERIFY-SEG", - "assert_semantics": "ASSERT-SEG", - "anchor_guide": "ANCHOR-SEG", - "final_guide": "FINAL-SEG", - "probe_guide": "PROBE-SEG", - } - text = assemble_checker_prompt_segments( - "checkpoint", [_ci(kind="verify")], probe_tool_registered=True, prompts=prompts - ) - assert "VERIFY-SEG" in text - assert "ASSERT-SEG" not in text - assert "ANCHOR-SEG" in text - assert "FINAL-SEG" not in text - assert "PROBE-SEG" in text - - text2 = assemble_checker_prompt_segments( - "final", [_ci(kind="assert")], probe_tool_registered=False, prompts=prompts - ) - assert "ASSERT-SEG" in text2 - assert "VERIFY-SEG" not in text2 - assert "FINAL-SEG" in text2 - assert "ANCHOR-SEG" not in text2 - # Probe disabled -> zero mention of probing anywhere in the prompt - assert "PROBE-SEG" not in text2 - - -def test_tool_table_probe_gate(): - ctx = _mock_ctx(disable_device_probes=False) - names = {t.name for t in build_checker_tools(ctx, "checkpoint")} - assert "probe_device" in names - assert "read_note" in names - # The shared history tools, mounted as a set. - assert {"search_history", "replay_steps", "get_step_screenshot"} <= names - # Never any device action, note write, or sub-agent - assert not names & { - "save_note", - "update_note", - "append_note", - "click", - "swipe", - "ask_diagnoser", - "ask_explorer", - "run_adb_command", - } - - ctx2 = _mock_ctx(disable_device_probes=True) - names2 = {t.name for t in build_checker_tools(ctx2, "checkpoint")} - assert "probe_device" not in names2 - - -@pytest.mark.asyncio -async def test_checkpoint_entry_never_touches_live_screen(): - """Evidence discipline: the checkpoint entry must not capture the current - screen — its evidence is anchored history plus persistent probes.""" - ctx = _mock_ctx() - report = CheckReport( - verdicts=[CheckVerdict(item_text="x", kind="verify", status="passed", evidence="e")] - ) - - structured = MagicMock() - structured.ainvoke = AsyncMock(return_value=report) - llm = MagicMock() - llm.with_structured_output.return_value = structured - response = MagicMock() - response.tool_calls = [] - - with ( - patch("artemis.agents.checker.checker.get_llm", return_value=llm), - patch( - "artemis.agents.checker.checker.acomplete", - new=AsyncMock(return_value=response), - ), - patch( - "artemis.agents.checker.checker._capture_final_screen", - new=AsyncMock(return_value=(None, "SHOULD NOT BE CALLED")), - ) as capture_mock, - ): - result = await run_checkpoint_check( - ctx, - check_items=[_ci(text="x")], - anchor=EvidenceAnchor(anchor_step_id="sid", trigger_ts=0.0, plan_text="- [x] G"), - goal="the goal", - subgoal_text="G", - ) - - capture_mock.assert_not_awaited() - assert result.verdicts[0].status == "passed" - - -@pytest.mark.asyncio -async def test_final_entry_captures_live_screen(): - ctx = _mock_ctx() - report = CheckReport(verdicts=[]) - - structured = MagicMock() - structured.ainvoke = AsyncMock(return_value=report) - llm = MagicMock() - llm.with_structured_output.return_value = structured - response = MagicMock() - response.tool_calls = [] - - with ( - patch("artemis.agents.checker.checker.get_llm", return_value=llm), - patch( - "artemis.agents.checker.checker.acomplete", - new=AsyncMock(return_value=response), - ), - patch( - "artemis.agents.checker.checker._capture_final_screen", - new=AsyncMock(return_value=("b64img", "elements")), - ) as capture_mock, - ): - await run_final_check( - ctx, - goal="the goal", - plan_text="- [x] G", - ledger=[], - check_items=[_ci(kind="assert", when="at_end", text="no crash", parent=None)], - ) - - capture_mock.assert_awaited_once() - - -# --- Shared history tools in the check loop ------------------------------------------ - - -def _jpeg_file(tmp_path, color="white"): - from PIL import Image - - path = tmp_path / "pre.jpg" - Image.new("RGB", (200, 400), color).save(path, format="JPEG") - return path - - -def _screenshot_then_verdict(provider, tool_call=None): - """LLM double: first turn asks for a step screenshot (or ``tool_call``), - second turn answers.""" - from types import SimpleNamespace - - report = CheckReport( - verdicts=[CheckVerdict(item_text="x", kind="verify", status="passed", evidence="e")] - ) - structured = MagicMock() - structured.ainvoke = AsyncMock(return_value=report) - llm = MagicMock() - llm.endpoint = SimpleNamespace(provider=provider) - llm.with_structured_output.return_value = structured - - ask = MagicMock() - ask.tool_calls = [ - tool_call - or { - "name": "get_step_screenshot", - "args": {"step_number": 2, "which": "pre"}, - "id": "tc-shot", - } - ] - done = MagicMock() - done.tool_calls = [] - return llm, AsyncMock(side_effect=[ask, done]) - - -async def _run_with_screenshot(tmp_path, provider): - path = _jpeg_file(tmp_path) - ctx = _mock_ctx() - ctx.data_engine.get_step_image_path.return_value = path - llm, acomplete = _screenshot_then_verdict(provider) - - with ( - patch("artemis.agents.checker.checker.get_llm", return_value=llm), - patch("artemis.agents.checker.checker.acomplete", new=acomplete), - ): - report = await run_checkpoint_check( - ctx, - check_items=[_ci(text="x")], - anchor=EvidenceAnchor(anchor_step_id="sid", trigger_ts=0.0, plan_text="- [x] G"), - goal="the goal", - subgoal_text="G", - ) - ctx.data_engine.get_step_image_path.assert_called_once_with(2, "pre") - # The message list handed to the second model call carries the tool result. - messages = acomplete.call_args_list[1][0][1] - return report, messages - - -@pytest.mark.asyncio -async def test_step_screenshot_rides_inside_the_tool_message_for_gemini(tmp_path): - from langchain_core.messages import HumanMessage, ToolMessage - - report, messages = await _run_with_screenshot(tmp_path, "google") - assert report.verdicts[0].status == "passed" - - tool_msgs = [m for m in messages if isinstance(m, ToolMessage)] - assert len(tool_msgs) == 1 - assert tool_msgs[0].tool_call_id == "tc-shot" - assert tool_msgs[0].name == "get_step_screenshot" - assert tool_msgs[0].status == "success" - blocks = tool_msgs[0].content - assert blocks[0]["text"] == "Screenshot of step 2 (pre-action) is attached." - assert blocks[1]["image_url"]["url"].startswith("data:image/jpeg;base64,") - # No extra human turn was spliced in for this provider. - assert sum(isinstance(m, HumanMessage) for m in messages) == 1 - - -@pytest.mark.asyncio -async def test_step_screenshot_follows_as_a_human_message_for_openai(tmp_path): - from langchain_core.messages import HumanMessage, ToolMessage - - _, messages = await _run_with_screenshot(tmp_path, "openai") - tool_msgs = [m for m in messages if isinstance(m, ToolMessage)] - assert len(tool_msgs) == 1 - assert tool_msgs[0].content == "Screenshot of step 2 (pre-action) is attached." - follower = messages[messages.index(tool_msgs[0]) + 1] - assert isinstance(follower, HumanMessage) - assert follower.content[0]["text"] == "[Screenshot returned by get_step_screenshot for step 2]" - assert follower.content[1]["image_url"]["url"].startswith("data:image/jpeg;base64,") - - -@pytest.mark.asyncio -async def test_replay_steps_result_is_a_plain_tool_message(tmp_path): - from langchain_core.messages import ToolMessage - - ctx = _mock_ctx() - ctx.data_engine.get_agent_friendly_steps_in_range.return_value = [ - { - "step_id": "s3", - "step_number": 3, - "relative_time": "9.0s", - "summary": "Saved the alarm.", - "action_taken": {"action": "click", "target": [0.5, 0.5], "target_text": "Save"}, - "last_execution_result": {"status": "success"}, - "interleaved_events": [], - } - ] - llm, acomplete = _screenshot_then_verdict( - "google", {"name": "replay_steps", "args": {"start_step": 3}, "id": "tc-replay"} - ) - with ( - patch("artemis.agents.checker.checker.get_llm", return_value=llm), - patch("artemis.agents.checker.checker.acomplete", new=acomplete), - ): - await run_checkpoint_check( - ctx, - check_items=[_ci(text="x")], - anchor=EvidenceAnchor(anchor_step_id="sid", trigger_ts=0.0, plan_text="- [x] G"), - goal="the goal", - subgoal_text="G", - ) - ctx.data_engine.get_agent_friendly_steps_in_range.assert_called_once_with(3, 3) - messages = acomplete.call_args_list[1][0][1] - tool_msg = next(m for m in messages if isinstance(m, ToolMessage)) - assert tool_msg.name == "replay_steps" - assert "- **Step 3 (Start: 9.0s)**" in tool_msg.content - assert "[Screen]: Saved the alarm." in tool_msg.content - assert "[Action]: Tapped 'Save' at [0.5, 0.5]" in tool_msg.content - - -# --- Streamed-reasoning transcript (replay of the live Thought/Work text) ------------- - - -class _FakeBus: - """Minimal DataEngine bus: subscribers receive every published event.""" - - def __init__(self): - self.subscribers = [] - - def subscribe(self, cb): - self.subscribers.append(cb) - - def unsubscribe(self, cb): - self.subscribers.remove(cb) - - def _publish(self, event_type, data): - for cb in list(self.subscribers): - cb(event_type, data) - - -def _bus_ctx(tmp_path): - """Mock engine whose bus actually delivers events (the rest stays mocked).""" - ctx = _mock_ctx() - ctx.data_engine.base_dir = tmp_path - bus = _FakeBus() - ctx.data_engine.subscribers = bus.subscribers - ctx.data_engine.subscribe = bus.subscribe - ctx.data_engine.unsubscribe = bus.unsubscribe - ctx.data_engine._publish = bus._publish - return ctx - - -def _stream(ctx, exec_id, chunk, *, stream_type="text", parent=None): - from artemis.data_engine.context_vars import CURRENT_TRACE_ID - - ctx.data_engine._publish( - "llm_stream", - { - "execution_id": exec_id, - "parent_trace_id": parent or str(CURRENT_TRACE_ID.get()), - "chunk": chunk, - "stream_type": stream_type, - }, - ) - - -def _read_streams(tmp_path): - from artemis.graph.checkpoints import read_attempt_streams - - return read_attempt_streams(tmp_path) - - -@pytest.mark.asyncio -async def test_checker_persists_its_streamed_turns_as_timestamped_segments(tmp_path): - """Every llm_stream chunk emitted under the Checker's own trace is kept as - one segment per (turn, kind) in first-chunk order; other agents' streams - and a discarded (mid-stream reset) turn are left out.""" - ctx = _bus_ctx(tmp_path) - - async def loop(ctx, messages, tools, items): - _stream(ctx, "turn-1", "Looking at ", stream_type="thinking") - _stream(ctx, "turn-1", "step 2.", stream_type="thinking") - _stream(ctx, "turn-1", "Let me replay it.") - _stream(ctx, "operator-turn", "not mine", parent="someone-else") - _stream(ctx, "turn-2", "partial ") - ctx.data_engine._publish( - "llm_stream_reset", {"stream_exec_id": "turn-2", "action": "discard"} - ) - _stream(ctx, "turn-3", "The alarm is there.") - return CheckReport(verdicts=[]) - - with ( - patch("artemis.agents.checker.checker._run_check_loop", side_effect=loop), - patch("artemis.agents.checker.checker.build_checker_tools", return_value=[]), - ): - await run_checkpoint_check( - ctx, - check_items=[_ci(text="x")], - anchor=EvidenceAnchor(anchor_step_id="sid", trigger_ts=0.0, plan_text="- [x] G"), - goal="the goal", - subgoal_text="G", - attempt_id="abc#1", - ) - - assert ctx.data_engine.subscribers == [] # detached after the run - records = _read_streams(tmp_path) - assert len(records) == 1 - rec = records[0] - assert rec["attempt_id"] == "abc#1" - assert rec["checkpoint_id"] == "abc" - assert rec["phase"] == "checkpoint" - assert rec["trace_id"] - assert rec["truncated"] is False and rec["dropped_chars"] == 0 - assert isinstance(rec["ts"], float) - segs = rec["segments"] - assert [(s["execution_id"], s["role"], s["text"]) for s in segs] == [ - ("turn-1", "thought", "Looking at step 2."), - ("turn-1", "answer", "Let me replay it."), - ("turn-3", "answer", "The alarm is there."), - ] - assert all(isinstance(s["when"], float) for s in segs) - assert segs[0]["when"] <= segs[1]["when"] <= segs[2]["when"] - - -@pytest.mark.asyncio -async def test_final_entry_persists_transcript_even_when_the_loop_fails(tmp_path): - """The transcript is written in a ``finally``: an attempt that times out, - raises or is superseded still leaves what it said.""" - ctx = _bus_ctx(tmp_path) - - async def loop(ctx, messages, tools, items): - _stream(ctx, "turn-1", "Checking the final screen…") - raise RuntimeError("provider down") - - with ( - patch("artemis.agents.checker.checker._run_check_loop", side_effect=loop), - patch("artemis.agents.checker.checker.build_checker_tools", return_value=[]), - patch( - "artemis.agents.checker.checker._capture_final_screen", - AsyncMock(return_value=(None, "")), - ), - pytest.raises(RuntimeError), - ): - await run_final_check( - ctx, - goal="g", - plan_text="- [x] G", - ledger=[], - check_items=[_ci(text="x")], - attempt_id="final#1", - ) - - records = _read_streams(tmp_path) - assert [r["attempt_id"] for r in records] == ["final#1"] - assert records[0]["phase"] == "final" and records[0]["checkpoint_id"] == "final" - assert records[0]["segments"][0]["text"] == "Checking the final screen…" - - -@pytest.mark.asyncio -async def test_no_transcript_without_attempt_id_or_streamed_text(tmp_path): - ctx = _bus_ctx(tmp_path) - - async def silent(ctx, messages, tools, items): - return CheckReport(verdicts=[]) - - async def talkative(ctx, messages, tools, items): - _stream(ctx, "turn-1", "text") - return CheckReport(verdicts=[]) - - with patch("artemis.agents.checker.checker.build_checker_tools", return_value=[]): - with patch("artemis.agents.checker.checker._run_check_loop", side_effect=talkative): - await run_checkpoint_check( - ctx, - check_items=[_ci(text="x")], - anchor=EvidenceAnchor(anchor_step_id="sid", trigger_ts=0.0, plan_text=""), - goal="g", - subgoal_text="G", - ) - with patch("artemis.agents.checker.checker._run_check_loop", side_effect=silent): - await run_checkpoint_check( - ctx, - check_items=[_ci(text="x")], - anchor=EvidenceAnchor(anchor_step_id="sid", trigger_ts=0.0, plan_text=""), - goal="g", - subgoal_text="G", - attempt_id="abc#2", - ) - - assert _read_streams(tmp_path) == [] - assert not (tmp_path / "check_streams.jsonl").exists() - - -# --- Probe failures are structural; history rows keep target provenance --------------- - - -@pytest.mark.asyncio -async def test_probe_device_invalid_kind_is_a_tool_failure(): - """A refused probe answers with a ToolFailure, so the tool loop marks the - ToolMessage as an error structurally rather than by sniffing its wording.""" - from artemis.agents.checker.checker import get_probe_tool - from artemis.core.tool_failure import is_tool_failure - - tool = get_probe_tool(_mock_ctx(disable_device_probes=False)) - result = await tool.ainvoke({"kind": "shell", "params": None}) - assert is_tool_failure(result) - assert "Error" in str(result) - - -@pytest.mark.asyncio -async def test_probe_device_execution_error_is_a_tool_failure(): - from artemis.agents.checker.checker import get_probe_tool - from artemis.core.tool_failure import is_tool_failure - - ctx = _mock_ctx(disable_device_probes=False) - ctx.get_adb_client.side_effect = RuntimeError("no adb") - tool = get_probe_tool(ctx) - result = await tool.ainvoke({"kind": "battery", "params": None}) - assert is_tool_failure(result) - assert "no adb" in str(result) - - -def test_format_history_marks_self_described_targets_only(): - """A step without a summary renders its action through the shared renderer, - so the Checker sees which target labels are the model's own statement.""" - from artemis.agents.checker.checker import _format_history - from artemis.utils.task_tree import SELF_DESCRIBED_MARKER - - ctx = _mock_ctx() - ctx.data_engine.get_agent_friendly_steps.return_value = [ - { - "step_number": 1, - "relative_time": "T+00:01", - "summary": "", - "action_taken": { - "action": "click", - "coordinates": [500, 600], - "target_description": "play button", - }, - }, - { - "step_number": 2, - "relative_time": "T+00:05", - "summary": None, - "action_taken": { - "action": "click", - "coordinates": [100, 200], - "target_text": "Login", - "target_resource_id": "com.app:id/login", - }, - }, - ] - - text = _format_history(ctx) - lines = text.splitlines() - assert lines[0].startswith("- Step 1 (T+00:01): ") - assert f"'play button' {SELF_DESCRIBED_MARKER}" in lines[0] - assert lines[1].startswith("- Step 2 (T+00:05): ") - assert "'Login'" in lines[1] - assert SELF_DESCRIBED_MARKER not in lines[1] - # Neither row is the raw JSON record. - assert "target_description" not in text - assert "target_resource_id" not in text - - -def test_format_history_uses_session_offsets_and_skips_empty_steps(): - """Rows carry the session-relative ``T+mm:ss`` clock every agent shares - (from the step timestamp, else from the engine's ``97.7s`` label), and a - step with neither summary nor action renders no row.""" - from artemis.agents.checker.checker import _format_history - - ctx = _mock_ctx() - ctx.data_engine.session_start_time = 1000.0 - ctx.data_engine.get_agent_friendly_steps.return_value = [ - {"step_number": 1, "timestamp": 1097.7, "summary": "Opened the app"}, - {"step_number": 2, "relative_time": "12.4s", "summary": "", "action_taken": None}, - { - "step_number": 3, - "relative_time": "130.0s", - "summary": None, - "action_taken": {"action": "click", "coordinates": [1, 2], "target_text": "Login"}, - }, - ] - - lines = _format_history(ctx).splitlines() - assert len(lines) == 2 - assert lines[0] == "- Step 1 (T+01:37): Opened the app" - assert lines[1].startswith("- Step 3 (T+02:10): ") - assert "'Login'" in lines[1] - - -async def _final_check_human_text(ctx) -> str: - report = CheckReport(verdicts=[]) - structured = MagicMock() - structured.ainvoke = AsyncMock(return_value=report) - llm = MagicMock() - llm.with_structured_output.return_value = structured - response = MagicMock() - response.tool_calls = [] - acomplete_mock = AsyncMock(return_value=response) - - with ( - patch("artemis.agents.checker.checker.get_llm", return_value=llm), - patch("artemis.agents.checker.checker.acomplete", new=acomplete_mock), - patch( - "artemis.agents.checker.checker._capture_final_screen", - new=AsyncMock(return_value=(None, "elements")), - ), - ): - await run_final_check(ctx, goal="the goal", plan_text="- [x] G", ledger=[], check_items=[]) - - messages = acomplete_mock.await_args.args[1] - human = messages[-1] - return "\n".join( - block["text"] for block in human.content if isinstance(block, dict) and "text" in block - ) - - -@pytest.mark.asyncio -async def test_final_review_lists_user_guidance_from_the_step_records(): - """Every mid-run user instruction reaches the final review, read from the - step it was stamped on, so the goal is audited as amended by it.""" - ctx = _mock_ctx() - ctx.data_engine.get_agent_friendly_steps.return_value = [ - {"step_number": 1, "relative_time": "3.0s", "summary": "s1", "extra_metadata": {}}, - { - "step_number": 2, - "relative_time": "65.0s", - "summary": "s2", - "extra_metadata": {"injected_instruction": "Skip the login"}, - }, - ] - text = await _final_check_human_text(ctx) - section = text.split("# User guidance received during the run\n", 1)[1].split("\n\n", 1)[0] - assert section == '- Step 2 (T+01:05): "Skip the login"' - # The system prompt audits the goal as amended by that guidance. - from artemis.agents.checker.checker import _load_prompts - - assert "as amended by the user guidance" in _load_prompts()["final_guide"] - - -@pytest.mark.asyncio -async def test_final_review_reports_no_guidance_explicitly(): - ctx = _mock_ctx() - text = await _final_check_human_text(ctx) - assert "# User guidance received during the run\n(none)\n" in text - - -# --- tool results: status is structural, never sniffed from the words ----------------- - - -# A helper tool reports failure structurally (``ToolFailure``); free-form text -# that merely starts with "Error" is an ordinary answer. -_STATUS_CASES = [ - pytest.param(ToolFailure("Error: note 'progress' not found"), "error", id="tool_failure"), - pytest.param("Error 404 was typed into the search box", "success", id="plain_error_text"), -] - - -def _text_tool(name: str, result): - async def _run(key: str) -> str: - return result - - return StructuredTool.from_function(coroutine=_run, name=name, description=name) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("result, expected_status", _STATUS_CASES) -async def test_check_loop_tool_message_status_is_structural(result, expected_status): - ctx = _mock_ctx() - tool = _text_tool("read_note", result) - tool_turn = AIMessage( - content="", tool_calls=[{"name": "read_note", "args": {"key": "progress"}, "id": "c1"}] - ) - final_turn = AIMessage(content="done", tool_calls=[]) - - llm = MagicMock() - llm.bind_tools.return_value = llm - llm.with_structured_output.return_value.ainvoke = AsyncMock( - return_value=CheckReport(verdicts=[]) - ) - messages = [SystemMessage(content="s"), HumanMessage(content="h")] - - with ( - patch("artemis.agents.checker.checker.get_llm", return_value=llm), - patch( - "artemis.agents.checker.checker.acomplete", - new=AsyncMock(side_effect=[tool_turn, final_turn]), - ), - ): - await _run_check_loop(ctx, messages, [tool], check_items=[]) - - tool_msgs = [m for m in messages if isinstance(m, ToolMessage)] - assert len(tool_msgs) == 1 - assert tool_msgs[0].tool_call_id == "c1" - assert tool_msgs[0].status == expected_status - assert tool_msgs[0].content == str(result) diff --git a/tests/unit/agents/test_checker_structured_report.py b/tests/unit/agents/test_checker_structured_report.py deleted file mode 100644 index 3eaa87871..000000000 --- a/tests/unit/agents/test_checker_structured_report.py +++ /dev/null @@ -1,55 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""The structured verdict request must never end on a model turn (Gemini -rejects such requests with 400 "Requests ending with a model turn are not -supported"), which is the natural state after the loop's tool-free reply.""" - -from unittest.mock import AsyncMock, MagicMock - -import pytest -from langchain_core.messages import AIMessage, HumanMessage, SystemMessage - -from artemis.agents.checker.checker import CheckReport, _structured_report - - -def _llm(report: CheckReport): - structured = MagicMock() - structured.ainvoke = AsyncMock(return_value=report) - llm = MagicMock() - llm.with_structured_output.return_value = structured - return llm, structured - - -@pytest.mark.asyncio -async def test_structured_report_appends_user_turn_after_model_reply(): - llm, structured = _llm(CheckReport(verdicts=[])) - messages = [SystemMessage(content="s"), HumanMessage(content="h"), AIMessage(content="done")] - - await _structured_report(llm, messages) - - sent = structured.ainvoke.call_args.args[0] - assert isinstance(sent[-1], HumanMessage) - assert sent[-2] is messages[-1] - assert len(messages) == 3 # caller's list untouched - - -@pytest.mark.asyncio -async def test_structured_report_keeps_user_ending_conversation_as_is(): - llm, structured = _llm(CheckReport(verdicts=[])) - messages = [SystemMessage(content="s"), HumanMessage(content="final iteration")] - - await _structured_report(llm, messages) - - assert structured.ainvoke.call_args.args[0] is messages diff --git a/tests/unit/agents/test_diagnoser.py b/tests/unit/agents/test_diagnoser.py deleted file mode 100644 index f067b6744..000000000 --- a/tests/unit/agents/test_diagnoser.py +++ /dev/null @@ -1,370 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import AsyncMock, Mock, patch -from langchain_core.messages import AIMessage, ToolMessage -from langchain_core.tools import StructuredTool -from artemis.agents.diagnoser.diagnoser import Diagnoser -from artemis.context import ArtemisContext -from artemis.core.tool_failure import ToolFailure -from artemis.graph.state import State -import pytest - - -@pytest.fixture -def mock_context(): - ctx = Mock(spec=ArtemisContext) - ctx.llm_config = Mock() - ctx.data_engine = Mock() - ctx.data_engine.base_dir = "/tmp" - ctx.data_engine.get_agent_friendly_steps.return_value = [] - - # Mock execution_setup - mock_setup = Mock() - mock_setup.video_recording_tools_enabled = True - ctx.execution_setup = mock_setup - - return ctx - - -@pytest.fixture -def mock_state(): - state = Mock(spec=State) - state.initial_goal = "Test goal" - state.latest_screenshot = None - state.latest_ui_hierarchy = [] - return state - - -@patch("artemis.agents.diagnoser.diagnoser.get_llm") -@patch("artemis.agents.diagnoser.diagnoser.get_video_analyzer_tool") -@patch("artemis.agents.diagnoser.diagnoser.get_analyze_logs_tool") -@pytest.mark.asyncio -async def test_diagnostic_agent_simple_call( - mock_get_log_tool, - mock_get_video_tool, - mock_get_llm, - mock_context, - mock_state, -): - """Test that DiagnosticAgent calls LLM and returns result.""" - mock_llm = Mock() - mock_llm.bind_tools.return_value = mock_llm - - mock_response = AIMessage(content="I diagnosed the issue: it is a network error.") - - async def mock_astream(*args, **kwargs): - yield mock_response - - mock_llm.astream.side_effect = mock_astream - mock_get_llm.return_value = mock_llm - - # Mock tools - mock_video_tool = Mock() - mock_video_tool.name = "video_analyzer" - mock_get_video_tool.return_value = mock_video_tool - - mock_log_tool = Mock() - mock_log_tool.name = "analyze_logs" - mock_get_log_tool.return_value = mock_log_tool - - agent = Diagnoser(mock_context) - result = await agent.run("Diagnose why the app crashed", mock_state) - - assert result == "I diagnosed the issue: it is a network error." - - -@patch("artemis.agents.diagnoser.diagnoser.get_llm") -@patch("artemis.agents.diagnoser.diagnoser.get_video_analyzer_tool") -@patch("artemis.agents.diagnoser.diagnoser.get_analyze_logs_tool") -@pytest.mark.asyncio -async def test_diagnostic_agent_tool_calls( - mock_get_log_tool, - mock_get_video_tool, - mock_get_llm, - mock_context, - mock_state, -): - """Test that DiagnosticAgent handles tool calls.""" - mock_llm = Mock() - mock_llm.bind_tools.return_value = mock_llm - - # Simulate response with tool calls - mock_response = AIMessage( - content="I need to check logs and video.", - tool_calls=[ - { - "name": "analyze_logs", - "args": {"specific_query": "Check for crashes"}, - "id": "call_1", - }, - { - "name": "video_analyzer", - "args": { - "time_description": "around 10s", - "purpose": "Check UI", - }, - "id": "call_2", - }, - ], - ) - # Second call returns final answer - mock_final_response = AIMessage(content="Diagnosis complete.") - - responses = [mock_response, mock_final_response] - call_count = 0 - - async def mock_astream(*args, **kwargs): - nonlocal call_count - if call_count < len(responses): - yield responses[call_count] - call_count += 1 - - mock_llm.astream.side_effect = mock_astream - mock_get_llm.return_value = mock_llm - - # Mock tools - mock_log_tool = AsyncMock() - mock_log_tool.name = "analyze_logs" - mock_log_tool.args = {"specific_query": None, "state": None} - mock_log_tool.ainvoke.return_value = "Logs show no crash." - mock_get_log_tool.return_value = mock_log_tool - - mock_video_tool = AsyncMock() - mock_video_tool.name = "video_analyzer" - mock_video_tool.args = { - "time_description": None, - "purpose": None, - "state": None, - } - mock_video_tool.ainvoke.return_value = ToolMessage( - content="Video shows UI loading.", tool_call_id="call_2" - ) - mock_get_video_tool.return_value = mock_video_tool - - agent = Diagnoser(mock_context) - result = await agent.run("Diagnose issue", mock_state) - - assert result == "Diagnosis complete." - mock_log_tool.ainvoke.assert_called_once_with( - {"specific_query": "Check for crashes", "state": mock_state} - ) - mock_video_tool.ainvoke.assert_called_once() - - -@patch("artemis.agents.diagnoser.diagnoser.get_llm") -@patch("artemis.agents.diagnoser.diagnoser.get_video_analyzer_tool") -@patch("artemis.agents.diagnoser.diagnoser.get_analyze_logs_tool") -@pytest.mark.asyncio -async def test_diagnostic_agent_no_video_tool_when_disabled( - mock_get_log_tool, - mock_get_video_tool, - mock_get_llm, - mock_context, - mock_state, -): - """Test that Diagnoser does not bind video tool when disabled.""" - # Set video recording to False - mock_context.execution_setup.video_recording_tools_enabled = False - - mock_llm = Mock() - mock_llm.bind_tools.return_value = mock_llm - - mock_response = AIMessage(content="Diagnosis complete.") - - async def mock_astream(*args, **kwargs): - yield mock_response - - mock_llm.astream.side_effect = mock_astream - mock_get_llm.return_value = mock_llm - - # Mock tools - mock_video_tool = Mock() - mock_video_tool.name = "video_analyzer" - mock_get_video_tool.return_value = mock_video_tool - - mock_log_tool = Mock() - mock_log_tool.name = "analyze_logs" - mock_get_log_tool.return_value = mock_log_tool - - agent = Diagnoser(mock_context) - await agent.run("Diagnose issue", mock_state) - - # Verify bind_tools was called - mock_llm.bind_tools.assert_called_once() - bound_tools = mock_llm.bind_tools.call_args[1]["tools"] - - # Verify video_analyzer is NOT in bound tools - tool_names = [t.name for t in bound_tools if hasattr(t, "name")] - assert "video_analyzer" not in tool_names - # Verify log tool IS in bound tools (should still be there) - assert "analyze_logs" in tool_names - - -@patch("artemis.agents.diagnoser.diagnoser.get_llm") -@patch("artemis.tools.video_tool.VideoAnalyzer") -@pytest.mark.asyncio -async def test_diagnoser_background_job_lifecycle( - mock_video_analyzer_class, mock_get_llm, mock_state -): - # Setup context - ctx = ArtemisContext.model_construct( - device=Mock(), - llm_config=Mock(), - execution_setup=Mock(video_recording_tools_enabled=True), - ) - ctx.data_engine = None - - # The Mock VideoAnalyzer agent running inside the background job - mock_agent_instance = AsyncMock() - - async def mock_agent_run(time_desc, purpose): - return "Video shows step successfully completed.", "success" - - mock_agent_instance.run.side_effect = mock_agent_run - mock_video_analyzer_class.return_value = mock_agent_instance - - # Prepare LLM responses - mock_llm = Mock() - mock_llm.bind_tools.return_value = mock_llm - - # Turn 1: LLM requests tool call - mock_response_1 = AIMessage( - content="Checking the video first.", - tool_calls=[ - { - "name": "video_analyzer", - "args": { - "time_description": "from 0s to 5s", - "purpose": "check UI", - }, - "id": "call_video_1", - } - ], - ) - # Turn 2: LLM gets the initial job start message, continues, then background job finishes, and result is injected. - mock_response_2 = AIMessage(content="Final complete diagnosis.") - - responses = [mock_response_1, mock_response_2] - call_count = 0 - - async def mock_astream(*args, **kwargs): - nonlocal call_count - if call_count < len(responses): - yield responses[call_count] - call_count += 1 - - mock_llm.astream.side_effect = mock_astream - mock_get_llm.return_value = mock_llm - - # Instantiate the Diagnoser - agent = Diagnoser(ctx) - - # We want to use the actual video analyzer tool so it triggers the background task - from artemis.tools.video_tool import get_video_analyzer_tool - - real_video_tool = get_video_analyzer_tool(ctx, role="diagnoser") - - with patch( - "artemis.tools.log_tool.get_analyze_logs_tool", - return_value=Mock(name="analyze_logs"), - ): - with patch( - "artemis.tools.video_tool.get_video_analyzer_tool", - return_value=real_video_tool, - ): - result = await agent.run("Diagnose video issue", mock_state) - - assert result == "Final complete diagnosis." - # Verify job registry - assert len(ctx.background_jobs) == 1 - job_id = list(ctx.background_jobs.keys())[0] - job = ctx.background_jobs[job_id] - assert job["tool_call_id"] == "call_video_1" - assert job["status"] == "completed" - assert job["result"] == "Video shows step successfully completed." - assert job["consumed"] is True - - -# --- tool results: status is structural, never sniffed from the words ----------------- - - -# A helper tool reports failure structurally (``ToolFailure``); free-form text -# that merely starts with "Error" is an ordinary answer. -_STATUS_CASES = [ - pytest.param(ToolFailure("Error: note 'progress' not found"), "error", id="tool_failure"), - pytest.param("Error 404 was typed into the search box", "success", id="plain_error_text"), -] - - -def _text_tool(name: str, result): - async def _run(key: str) -> str: - return result - - return StructuredTool.from_function(coroutine=_run, name=name, description=name) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("result, expected_status", _STATUS_CASES) -async def test_run_tool_message_status_is_structural( - result, expected_status, mock_context, mock_state -): - agent = Diagnoser(mock_context) - tool = _text_tool("read_note", result) - - messages = await agent._run_tool( - {"name": "read_note", "args": {"key": "progress"}, "id": "c1"}, [tool], mock_state - ) - - assert len(messages) == 1 - assert isinstance(messages[0], ToolMessage) - assert messages[0].tool_call_id == "c1" - assert messages[0].status == expected_status - assert messages[0].content == str(result) - - -# --- submit_answer rendering ------------------------------------------------------------ - - -def test_submit_answer_renders_numbered_steps(mock_context): - agent = Diagnoser(mock_context) - call = { - "name": "submit_answer", - "args": { - "analysis": "Airplane mode blocks the toggle.", - "actionable_steps": ["Turn off airplane mode", "Retry the toggle"], - }, - } - - outcome = agent._handle_submit_call(call, []) - - assert outcome == ( - "Analysis: Airplane mode blocks the toggle.\n" - "Actionable steps:\n" - "1. Turn off airplane mode\n" - "2. Retry the toggle" - ) - - -def test_submit_answer_without_steps_omits_section(mock_context): - agent = Diagnoser(mock_context) - call = { - "name": "submit_answer", - "args": {"analysis": "Nothing to fix.", "actionable_steps": []}, - } - - outcome = agent._handle_submit_call(call, []) - - assert outcome == "Analysis: Nothing to fix." - assert "Actionable" not in outcome diff --git a/tests/unit/agents/test_diagnoser_failures.py b/tests/unit/agents/test_diagnoser_failures.py deleted file mode 100644 index 1f9e66d67..000000000 --- a/tests/unit/agents/test_diagnoser_failures.py +++ /dev/null @@ -1,344 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import AsyncMock, Mock, patch -from adbutils import AdbClient -from langchain_core.messages import AIMessage, ToolMessage -from artemis.agents.diagnoser.diagnoser import Diagnoser -from artemis.context import ArtemisContext -from artemis.core.tool_failure import ToolFailure -from artemis.graph.state import State -import pytest - - -@pytest.fixture(scope="session", autouse=True) -def ensure_device_connected(): - try: - client = AdbClient() - if not client.device_list(): - pytest.skip("No active Android device/emulator found. Skipping diagnostic tool tests.") - except Exception as e: - pytest.skip(f"ADB Client connection failed: {e}. Skipping tests.") - - -@pytest.fixture -def mock_context(): - ctx = Mock(spec=ArtemisContext) - ctx.llm_config = Mock() - ctx.data_engine = Mock() - ctx.data_engine.base_dir = "/tmp" - ctx.data_engine.get_agent_friendly_steps.return_value = [] - - # Mock execution_setup - mock_setup = Mock() - mock_setup.video_recording_tools_enabled = True - ctx.execution_setup = mock_setup - - return ctx - - -@pytest.fixture -def mock_state(): - state = Mock(spec=State) - state.initial_goal = "Test goal" - state.latest_screenshot = None - state.latest_ui_hierarchy = [] - return state - - -@patch("artemis.agents.diagnoser.diagnoser.get_llm") -@patch("artemis.tools.video_tool.get_video_analyzer_tool") -@patch("artemis.tools.log_tool.get_analyze_logs_tool") -@patch("artemis.agents.diagnoser.diagnoser.get_ui_hierarchy_tool") -@pytest.mark.asyncio -async def test_maestro_uninstall_timeout( - mock_get_ui_tool, - mock_get_log_tool, - mock_get_video_tool, - mock_get_llm, - mock_context, - mock_state, -): - """Test Maestro Uninstall Timeout / Blocker Failure.""" - # Mock LLM calls - mock_llm = Mock() - mock_llm.bind_tools.return_value = mock_llm - - mock_response_1 = AIMessage( - content="I need to check the UI hierarchy.", - tool_calls=[ - { - "name": "get_ui_hierarchy", - "args": {}, - "id": "call_hierarchy", - } - ], - ) - mock_response_2 = AIMessage( - content=( - "Diagnoser identifies the blocker failure and recommends restarting" - " ADB/uninstalling conflicting tools manually." - ) - ) - - responses = [mock_response_1, mock_response_2] - call_count = 0 - - async def mock_astream(*args, **kwargs): - nonlocal call_count - if call_count < len(responses): - yield responses[call_count] - call_count += 1 - - mock_llm.astream.side_effect = mock_astream - mock_get_llm.return_value = mock_llm - - # Mock tool - mock_ui_tool = AsyncMock() - mock_ui_tool.name = "get_ui_hierarchy" - mock_ui_tool.args = {} - mock_ui_tool.ainvoke.return_value = ToolFailure("Error: pm list packages timed out") - mock_get_ui_tool.return_value = mock_ui_tool - - # Run agent - agent = Diagnoser(mock_context) - result = await agent.run("Diagnose why the app crashed", mock_state) - - assert "recommends restarting ADB" in result - assert "uninstalling conflicting tools" in result - - # Assert get_ui_hierarchy was called - mock_ui_tool.ainvoke.assert_called_once() - - # Assert that the ToolMessage was sent back to LLM - assert len(mock_llm.astream.call_args_list) == 2 - sent_messages = mock_llm.astream.call_args_list[1][0][0] - tool_message = sent_messages[-2] - assert isinstance(tool_message, ToolMessage) - assert tool_message.tool_call_id == "call_hierarchy" - assert tool_message.content == "Error: pm list packages timed out" - assert tool_message.status == "error" - - -@patch("artemis.agents.diagnoser.diagnoser.get_llm") -@patch("artemis.tools.video_tool.get_video_analyzer_tool") -@patch("artemis.tools.log_tool.get_analyze_logs_tool") -@patch("artemis.agents.diagnoser.diagnoser.get_ui_hierarchy_tool") -@pytest.mark.asyncio -async def test_uiautomator2_connection_lost( - mock_get_ui_tool, - mock_get_log_tool, - mock_get_video_tool, - mock_get_llm, - mock_context, - mock_state, -): - """Test UIAutomator2 Daemon Connection Lost.""" - # Mock LLM - mock_llm = Mock() - mock_llm.bind_tools.return_value = mock_llm - - mock_response_1 = AIMessage( - content="Let me check the UI hierarchy.", - tool_calls=[ - { - "name": "get_ui_hierarchy", - "args": {}, - "id": "call_hierarchy_lost", - } - ], - ) - mock_response_2 = AIMessage(content="Diagnoser reports connection loss with UIAutomator2.") - - responses = [mock_response_1, mock_response_2] - call_count = 0 - - async def mock_astream(*args, **kwargs): - nonlocal call_count - if call_count < len(responses): - yield responses[call_count] - call_count += 1 - - mock_llm.astream.side_effect = mock_astream - mock_get_llm.return_value = mock_llm - - # Mock tool to throw ConnectionError - mock_ui_tool = AsyncMock() - mock_ui_tool.name = "get_ui_hierarchy" - mock_ui_tool.args = {} - mock_ui_tool.ainvoke.side_effect = ConnectionError( - "UIAutomator2 connection lost, reconnecting..." - ) - mock_get_ui_tool.return_value = mock_ui_tool - - # Run agent - agent = Diagnoser(mock_context) - result = await agent.run("Diagnose crash", mock_state) - - assert "reports connection loss" in result - - # Assert that tool error is wrapped and returned in the ToolMessage - assert len(mock_llm.astream.call_args_list) == 2 - sent_messages = mock_llm.astream.call_args_list[1][0][0] - tool_message = sent_messages[-2] - assert isinstance(tool_message, ToolMessage) - assert tool_message.tool_call_id == "call_hierarchy_lost" - assert "UIAutomator2 connection lost, reconnecting..." in tool_message.content - assert tool_message.status == "error" - - # Verify that the trace span recorded the failure - record_trace_calls = mock_context.data_engine.record_trace.call_args_list - tool_trace_calls = [c for c in record_trace_calls if c[1].get("name") == "get_ui_hierarchy"] - failed_trace = next((c for c in tool_trace_calls if c[1].get("status") == "failed"), None) - assert failed_trace is not None - assert "UIAutomator2 connection lost, reconnecting..." in failed_trace[1]["payload"]["error"] - - -@patch("artemis.agents.diagnoser.diagnoser.get_llm") -@patch("artemis.tools.video_tool.get_video_analyzer_tool") -@patch("artemis.tools.log_tool.get_analyze_logs_tool") -@patch("artemis.agents.diagnoser.diagnoser.get_ui_hierarchy_tool") -@pytest.mark.asyncio -async def test_ui_hierarchy_dump_stall( - mock_get_ui_tool, - mock_get_log_tool, - mock_get_video_tool, - mock_get_llm, - mock_context, - mock_state, -): - """Test UI Hierarchy Dump Stall (UI Thread Frozen).""" - # Mock LLM - mock_llm = Mock() - mock_llm.bind_tools.return_value = mock_llm - - mock_response_1 = AIMessage( - content="Let me fetch the UI hierarchy to see what's on screen.", - tool_calls=[ - { - "name": "get_ui_hierarchy", - "args": {}, - "id": "call_hierarchy_stall", - } - ], - ) - mock_response_2 = AIMessage( - content=( - "Diagnoser identifies frozen UI and suggests rendering-safe" - " recovery actions (like ADB wake commands or a wait/retry cycle)." - ) - ) - - responses = [mock_response_1, mock_response_2] - call_count = 0 - - async def mock_astream(*args, **kwargs): - nonlocal call_count - if call_count < len(responses): - yield responses[call_count] - call_count += 1 - - mock_llm.astream.side_effect = mock_astream - mock_get_llm.return_value = mock_llm - - # Mock tool - mock_ui_tool = AsyncMock() - mock_ui_tool.name = "get_ui_hierarchy" - mock_ui_tool.args = {} - mock_ui_tool.ainvoke.return_value = ToolFailure( - "Error retrieving UI hierarchy: dump_hierarchy timed out after 3 seconds" - ) - mock_get_ui_tool.return_value = mock_ui_tool - - # Run agent - agent = Diagnoser(mock_context) - result = await agent.run("Diagnose stall", mock_state) - - assert "rendering-safe recovery actions" in result - - # Assert ToolMessage was passed back with correct error details - assert len(mock_llm.astream.call_args_list) == 2 - sent_messages = mock_llm.astream.call_args_list[1][0][0] - tool_message = sent_messages[-2] - assert isinstance(tool_message, ToolMessage) - assert tool_message.tool_call_id == "call_hierarchy_stall" - assert "dump_hierarchy timed out after 3 seconds" in tool_message.content - assert tool_message.status == "error" - - -@patch("artemis.agents.diagnoser.diagnoser.get_llm") -@patch("artemis.tools.video_tool.get_video_analyzer_tool") -@patch("artemis.tools.log_tool.get_analyze_logs_tool") -@patch("artemis.agents.diagnoser.diagnoser.get_ui_hierarchy_tool") -@pytest.mark.asyncio -async def test_device_offline( - mock_get_ui_tool, - mock_get_log_tool, - mock_get_video_tool, - mock_get_llm, - mock_context, - mock_state, -): - """Test Device Offline (ADB Transport Lost).""" - # Mock LLM - mock_llm = Mock() - mock_llm.bind_tools.return_value = mock_llm - - mock_response_1 = AIMessage( - content="Let's grab the UI hierarchy.", - tool_calls=[ - { - "name": "get_ui_hierarchy", - "args": {}, - "id": "call_hierarchy_offline", - } - ], - ) - mock_response_2 = AIMessage( - content=("The device is offline. Diagnoser flags the offline device state.") - ) - - responses = [mock_response_1, mock_response_2] - call_count = 0 - - async def mock_astream(*args, **kwargs): - nonlocal call_count - if call_count < len(responses): - yield responses[call_count] - call_count += 1 - - mock_llm.astream.side_effect = mock_astream - mock_get_llm.return_value = mock_llm - - # Mock tool - mock_ui_tool = AsyncMock() - mock_ui_tool.name = "get_ui_hierarchy" - mock_ui_tool.args = {} - mock_ui_tool.ainvoke.side_effect = RuntimeError("device offline") - mock_get_ui_tool.return_value = mock_ui_tool - - # Run agent - agent = Diagnoser(mock_context) - result = await agent.run("Diagnose offline", mock_state) - - assert "flags the offline device state" in result - - # Assert ToolMessage was passed back with correct error details - assert len(mock_llm.astream.call_args_list) == 2 - sent_messages = mock_llm.astream.call_args_list[1][0][0] - tool_message = sent_messages[-2] - assert isinstance(tool_message, ToolMessage) - assert tool_message.tool_call_id == "call_hierarchy_offline" - assert "device offline" in tool_message.content - assert tool_message.status == "error" diff --git a/tests/unit/agents/test_execution_incident_prompt.py b/tests/unit/agents/test_execution_incident_prompt.py deleted file mode 100644 index b19b1156a..000000000 --- a/tests/unit/agents/test_execution_incident_prompt.py +++ /dev/null @@ -1,221 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""The Operator-facing execution incident block and the incident record.""" - -from types import SimpleNamespace - -import pytest - -from artemis.agents.operator.prompts import ( - EXECUTION_INCIDENT_MARKER, - ExecutionIncidentPromptComponent, - PromptBuilder, - render_closed_incident, - render_execution_incident, -) -from artemis.agents.validator.categories import ValidationErrorCategory -from artemis.agents.validator.incidents import ( - KIND_EXEC_ERROR, - KIND_SAFETY_NET, - ExecutionIncident, - open_incident, -) - -STEPS = [ - { - "step_number": 11, - "action_taken": [{"action": "click", "coordinates": [540, 1800], "target_text": "player"}], - "last_execution_result": {"status": "dispatched"}, - }, - { - "step_number": 12, - "action_taken": [{"action": "click", "target_text": "Skip"}], - "last_execution_result": {"status": "failed"}, - }, -] - - -def _disappeared(consecutive=1, **overrides): - incident = open_incident( - previous=None, - kind=KIND_SAFETY_NET, - category=ValidationErrorCategory.TARGET_DISAPPEARED, - reason="Target element 'Skip' was not found on the screen.", - action_item={ - "action": "tap", - "coordinates": [950, 288], - "normalized_coordinates": [880, 120], - "target_text": "Skip", - "attempts": ["Pre-execution validation failed: ..."], - }, - action_description="Tapped 'Skip' at [950, 288]", - step_number=12, - ) - incident["consecutive_failures"] = consecutive - incident.update(overrides) - return incident - - -# --- record ------------------------------------------------------------------------- - - -def test_open_incident_strips_internal_keys_and_starts_at_one(): - incident = _disappeared() - assert incident["consecutive_failures"] == 1 - assert "attempts" not in incident["action"] - assert incident["category"] == "target_disappeared" - assert incident["burst_size"] == 1 and incident["action_index"] == 0 - - -def test_open_incident_continues_the_previous_count(): - previous = _disappeared(consecutive=2) - incident = open_incident( - previous=previous, - kind=KIND_EXEC_ERROR, - category=ValidationErrorCategory.GENERAL, - reason="Error: tap rejected", - action_item={"action": "tap"}, - action_description="Tapped element", - ) - assert incident["consecutive_failures"] == 3 - assert ExecutionIncident.from_dict(incident).is_burst is False - assert ExecutionIncident.from_dict(None) is None - assert ExecutionIncident.from_dict({"kind": "safety_net", "unknown_key": 1}) is not None - - -# --- rendering ---------------------------------------------------------------------- - - -def test_render_labels_a_described_coordinate_target_by_its_description(): - incident = _disappeared() - incident["action"] = { - "action": "tap", - "coordinates": [950, 288], - "normalized_coordinates": [880, 120], - "target_description": "skip ad button", - } - text = render_execution_incident(incident, STEPS) - assert 'normalized [880, 120] "skip ad button" (self-described)' in text - # An observed (index) target carries no marker. - assert '"Skip" (self-described)' not in render_execution_incident(_disappeared(), STEPS) - - -def test_render_disappeared_names_target_trigger_and_burst_exception(): - text = render_execution_incident(_disappeared(), STEPS) - assert text.startswith(EXECUTION_INCIDENT_MARKER) - assert "Opened at Step 12; consecutive failed turns: 1." in text - assert "was NOT executed. The pre-execution safety net refused it" in text - # Recorded target with normalized coordinates and text. - assert 'normalized [880, 120] "Skip"' in text - # The last successful action before the incident is the trigger candidate. - assert "Your last dispatched action was `Tapped 'player' at [540, 1800]`" in text - assert "(Step 11)" in text - # Facts and evidence only: the response protocol is stated once, in the - # static system prompt, so the per-turn block never restates it. - assert "How to respond" not in text - assert "settle the original intent" not in text - assert "e.g." not in text - # No escalation ladder: the Operator keeps its freedom. - assert "Escalation" not in text - assert "ask_diagnoser" not in text - - -def test_render_shifted_and_occupied_evidence(): - shifted = _disappeared( - category="target_shifted", - evidence={"new_location": [500, 640], "new_bounds": [400, 600, 600, 680]}, - ) - text = render_execution_incident(shifted, STEPS) - assert "has moved at normalized [500, 640] (bounds [400, 600, 600, 680])" in text - - occupied = _disappeared(category="target_occupied", evidence={"occupant": "'Sign Up' button"}) - text = render_execution_incident(occupied, STEPS) - assert "now covered by 'Sign Up' button" in text - - -def test_render_burst_abort_and_exec_error(): - burst = _disappeared(kind=KIND_EXEC_ERROR, category="general", burst_size=3, action_index=1) - burst["reason"] = "Error: tap rejected" - text = render_execution_incident(burst, STEPS) - assert "action 2 of your 3-action fast burst" in text - assert "The 1 action(s) after it were NOT executed" in text - - plain = _disappeared(kind=KIND_EXEC_ERROR, category="general") - text = render_execution_incident(plain, STEPS) - # A failed action is quoted as an intent, never as a done outcome, and - # the sentence does not claim it was dispatched. - assert ( - "your planned action `tap 'Skip' at [950, 288]` could not be executed; the" - " device/executor reported:" in text - ) - assert "was dispatched" not in text - assert "`Tapped 'Skip'" not in text - - -def test_render_without_history_omits_the_trigger_hint(): - text = render_execution_incident(_disappeared(), []) - assert "last dispatched action" not in text - assert 'Its recorded target was normalized [880, 120] "Skip".' in text - - -# --- component ---------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_component_renders_only_while_an_incident_is_open(): - builder = PromptBuilder() - ctx = SimpleNamespace(data_engine=None) - await ExecutionIncidentPromptComponent()( - builder, SimpleNamespace(open_incident=None), ctx, steps=STEPS - ) - assert builder.human_parts == [] - - await ExecutionIncidentPromptComponent()( - builder, SimpleNamespace(open_incident=_disappeared()), ctx, steps=STEPS - ) - assert len(builder.human_parts) == 1 - assert builder.human_parts[0].startswith(EXECUTION_INCIDENT_MARKER) - # Re-rendered every turn while open, so older copies leave the history. - assert builder.ephemeral_indices == [0] - - -def test_render_closed_notice_settles_the_intent(): - closed = dict(_disappeared(), closed_at_step=15) - text = render_closed_incident(closed) - assert text.startswith("--- Execution Incident (CLOSED at Step 15) ---") - # The blocked action is quoted in intent form: it never happened. - assert "opened at Step 12 on `tap 'Skip' at [950, 288]`" in text - assert "already served, still pending, or no longer needed" in text - - -@pytest.mark.asyncio -async def test_component_renders_closed_notice_once_no_incident_is_open(): - builder = PromptBuilder() - ctx = SimpleNamespace(data_engine=None) - state = SimpleNamespace( - open_incident=None, last_closed_incident=dict(_disappeared(), closed_at_step=15) - ) - await ExecutionIncidentPromptComponent()(builder, state, ctx, steps=STEPS) - assert len(builder.human_parts) == 1 - assert builder.human_parts[0].startswith("--- Execution Incident (CLOSED at Step 15) ---") - assert builder.ephemeral_indices == [0] - - # An open incident takes precedence over a stale closed notice. - builder = PromptBuilder() - state = SimpleNamespace( - open_incident=_disappeared(), last_closed_incident=dict(_disappeared(), closed_at_step=15) - ) - await ExecutionIncidentPromptComponent()(builder, state, ctx, steps=STEPS) - assert builder.human_parts[0].startswith(EXECUTION_INCIDENT_MARKER) diff --git a/tests/unit/agents/test_explorer.py b/tests/unit/agents/test_explorer.py deleted file mode 100644 index 0735746cb..000000000 --- a/tests/unit/agents/test_explorer.py +++ /dev/null @@ -1,1357 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os - -os.environ["ARTEMIS_USE_FILE_API"] = "false" -import pytest -from unittest.mock import AsyncMock, MagicMock, patch -import json -from google.genai import types - -from artemis.agents.explorer.explorer import Explorer -from artemis.context import ArtemisContext -from artemis.graph.state import State - -MOCK_PROMPT_JSON = """{ - "IDENTITY": "You are the agentic UI Explorer designed to identify objects, scan text, and extract UI attributes on a phone screen.", - "OPERATING PRINCIPLES": [ - "Confidence-Driven Cost-Benefit Strategy: Maximize localization accuracy...", - "Tool Capabilities: You have three classes of tools...", - "Parallel vs Sequential Invocations: You are fully supported..." - ] -}""" - - -@pytest.fixture(autouse=True) -def disable_file_api(monkeypatch): - monkeypatch.setenv("ARTEMIS_USE_FILE_API", "false") - - -@pytest.mark.asyncio -async def test_explorer_run(): - # Mock context and state - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.device = MagicMock() - mock_ctx.device.device_width = 1080 - mock_ctx.device.device_height = 2400 - mock_ctx.data_engine = MagicMock() - mock_ctx.data_engine.base_dir = "/tmp/test_session" - mock_ctx.llm_config = MagicMock() - mock_ctx.llm_config.explorer = MagicMock() - mock_ctx.llm_config.explorer.model = "gemini-3.7-flash" - - mock_state = MagicMock(spec=State) - mock_state.latest_screenshot = "/tmp/test_screenshot.jpg" - - # Mock Gemini Client and Response - mock_client = MagicMock() - mock_client.aio = MagicMock() - mock_client.aio.models = MagicMock() - mock_response = MagicMock() - mock_response.text = "" - mock_func_call = MagicMock() - mock_func_call.name = "submit_answer" - mock_func_call.args = { - "candidates": [ - { - "label": "S1", - "coords": [500, 500], - "description": "Target button", - } - ], - "fallback_message": "", - } - mock_response.function_calls = [mock_func_call] - - mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) - - # Mock storage manager - mock_storage = MagicMock() - mock_storage.get_image.return_value = "dummy_hash" - - # Mock file reading for prompt - mock_prompt = MOCK_PROMPT_JSON - - # Custom mock open to handle both binary and text reads - def custom_open(file, mode="r", *args, **kwargs): - mock_file = MagicMock() - if "b" in mode: - # Provide a sequence of bytes for iter(lambda: f.read(4096), b"") and b64 encode - mock_file.read.side_effect = [ - b"fake_image_data", - b"", - b"fake_image_data", - ] - else: - mock_file.read.return_value = '{"templates": [], "instructions": ""}' - - mock_file.__enter__.return_value = mock_file - return mock_file - - with ( - patch( - "artemis.agents.explorer.explorer.genai.Client", - return_value=mock_client, - ), - patch( - "artemis.agents.explorer.run_setup.StorageManager", - return_value=mock_storage, - ), - patch("pathlib.Path.exists", return_value=True), - patch("pathlib.Path.read_text", return_value=mock_prompt), - patch("builtins.open", custom_open), - ): - explorer = Explorer(mock_ctx) - result = await explorer.run( - query="Find button", - context_feedback="", - screenshot_path="/tmp/test_screenshot.jpg", - minimal_list="", - state=mock_state, - ) - - expected_outcome = json.dumps(mock_func_call.args, ensure_ascii=False) - assert result == expected_outcome - assert mock_client.aio.models.generate_content.called - - -@pytest.mark.asyncio -async def test_explorer_submit_answer(): - # Mock context and state - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.device = MagicMock() - mock_ctx.device.device_width = 1080 - mock_ctx.device.device_height = 2400 - mock_ctx.data_engine = MagicMock() - mock_ctx.data_engine.base_dir = "/tmp/test_session" - mock_ctx.llm_config = MagicMock() - mock_ctx.llm_config.explorer = MagicMock() - mock_ctx.llm_config.explorer.model = "gemini-3.7-flash" - - mock_state = MagicMock(spec=State) - mock_state.latest_screenshot = "/tmp/test_screenshot.jpg" - - # Mock Gemini Client and Function Call for submit_answer - mock_client = MagicMock() - mock_client.aio = MagicMock() - mock_client.aio.models = MagicMock() - mock_response = MagicMock() - mock_response.text = "" - - mock_func_call = MagicMock() - mock_func_call.name = "submit_answer" - mock_func_call.args = { - "candidates": [ - { - "label": "S1", - "coords": [500, 500], - "description": "Target button", - } - ], - "fallback_message": "", - } - mock_response.function_calls = [mock_func_call] - - mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) - - # Mock storage manager - mock_storage = MagicMock() - mock_storage.get_image.return_value = "dummy_hash" - - # Mock file reading for prompt - mock_prompt = MOCK_PROMPT_JSON - - def custom_open(file, mode="r", *args, **kwargs): - mock_file = MagicMock() - if "b" in mode: - mock_file.read.side_effect = [ - b"fake_image_data", - b"", - b"fake_image_data", - ] - else: - mock_file.read.return_value = '{"templates": [], "instructions": ""}' - - mock_file.__enter__.return_value = mock_file - return mock_file - - with ( - patch( - "artemis.agents.explorer.explorer.genai.Client", - return_value=mock_client, - ), - patch( - "artemis.agents.explorer.run_setup.StorageManager", - return_value=mock_storage, - ), - patch("pathlib.Path.exists", return_value=True), - patch("pathlib.Path.read_text", return_value=mock_prompt), - patch("builtins.open", custom_open), - ): - explorer = Explorer(mock_ctx) - result = await explorer.run( - query="Find button", - context_feedback="", - screenshot_path="/tmp/test_screenshot.jpg", - minimal_list="", - state=mock_state, - ) - - # Ensure it returned the structured JSON outcome - expected_outcome = json.dumps(mock_func_call.args, ensure_ascii=False) - assert result == expected_outcome - assert mock_client.aio.models.generate_content.called - - -@pytest.mark.asyncio -async def test_explorer_submit_answer_self_correction(): - # Mock context and state - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.device = MagicMock() - mock_ctx.device.device_width = 1080 - mock_ctx.device.device_height = 2400 - mock_ctx.data_engine = MagicMock() - mock_ctx.data_engine.base_dir = "/tmp/test_session" - mock_ctx.llm_config = MagicMock() - mock_ctx.llm_config.explorer = MagicMock() - mock_ctx.llm_config.explorer.model = "gemini-3.7-flash" - - mock_state = MagicMock(spec=State) - mock_state.latest_screenshot = "/tmp/test_screenshot.jpg" - - # Response 1: Returns invalid coords [1500, -200] (out of bounds) - mock_response1 = MagicMock() - mock_response1.text = "" - - mock_func_call1 = MagicMock() - mock_func_call1.name = "submit_answer" - mock_func_call1.args = { - "candidates": [ - { - "label": "S1", - "coords": [1500, -200], - "description": "Ambiguous button", - } - ] - } - - mock_response1.candidates = [ - MagicMock() - ] # Provide dummy candidate content for loop history tracking - mock_response1.candidates[0].content = types.Content( - role="model", - parts=[ - types.Part.from_function_call( - name="submit_answer", - args={ - "candidates": [ - { - "label": "S1", - "coords": [1500, -200], - "description": "Ambiguous button", - } - ] - }, - ) - ], - ) - mock_response1.function_calls = [mock_func_call1] - - # Response 2: Returns corrected valid coords [500, 500] - mock_response2 = MagicMock() - mock_response2.text = "" - - mock_func_call2 = MagicMock() - mock_func_call2.name = "submit_answer" - mock_func_call2.args = { - "candidates": [ - { - "label": "S1", - "coords": [500, 500], - "description": "Corrected button", - } - ] - } - - mock_response2.candidates = [MagicMock()] - mock_response2.candidates[0].content = types.Content( - role="model", - parts=[ - types.Part.from_function_call( - name="submit_answer", - args={ - "candidates": [ - { - "label": "S1", - "coords": [500, 500], - "description": "Corrected button", - } - ] - }, - ) - ], - ) - mock_response2.function_calls = [mock_func_call2] - - mock_client = MagicMock() - mock_client.aio = MagicMock() - mock_client.aio.models = MagicMock() - mock_client.aio.models.generate_content = AsyncMock( - side_effect=[mock_response1, mock_response2] - ) - - # Mock storage manager - mock_storage = MagicMock() - mock_storage.get_image.return_value = "dummy_hash" - - # Mock file reading for prompt - mock_prompt = MOCK_PROMPT_JSON - - def custom_open(file, mode="r", *args, **kwargs): - mock_file = MagicMock() - if "b" in mode: - mock_file.read.side_effect = [ - b"fake_image_data", - b"", - b"fake_image_data", - ] - else: - mock_file.read.return_value = '{"templates": [], "instructions": ""}' - - mock_file.__enter__.return_value = mock_file - return mock_file - - with ( - patch( - "artemis.agents.explorer.explorer.genai.Client", - return_value=mock_client, - ), - patch( - "artemis.agents.explorer.run_setup.StorageManager", - return_value=mock_storage, - ), - patch("pathlib.Path.exists", return_value=True), - patch("pathlib.Path.read_text", return_value=mock_prompt), - patch("builtins.open", custom_open), - ): - explorer = Explorer(mock_ctx) - result = await explorer.run( - query="Find button", - context_feedback="", - screenshot_path="/tmp/test_screenshot.jpg", - minimal_list="", - state=mock_state, - ) - - # Assert loops successfully completed on the second corrected turn - expected_args = { - "candidates": [ - { - "label": "S1", - "coords": [500, 500], - "description": "Corrected button", - } - ] - } - assert result == json.dumps(expected_args, ensure_ascii=False) - assert mock_client.aio.models.generate_content.call_count == 2 - - -@pytest.mark.asyncio -async def test_explorer_initial_visual_marking(): - # Mock context and state with latest_ui_hierarchy - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.device = MagicMock() - mock_ctx.device.device_width = 1080 - mock_ctx.device.device_height = 2400 - mock_ctx.data_engine = MagicMock() - mock_ctx.data_engine.base_dir = "/tmp/test_session" - mock_ctx.llm_config = MagicMock() - mock_ctx.llm_config.explorer = MagicMock() - mock_ctx.llm_config.explorer.model = "gemini-3.7-flash" - - mock_state = MagicMock(spec=State) - mock_state.latest_screenshot = "/tmp/test_screenshot.jpg" - mock_state.latest_ui_hierarchy = [{"text": "Clickable Button", "bounds": "[100,100][200,200]"}] - - # Mock Gemini Client and response - mock_client = MagicMock() - mock_client.aio = MagicMock() - mock_client.aio.models = MagicMock() - mock_response = MagicMock() - mock_response.text = "" - - mock_func_call = MagicMock() - mock_func_call.name = "submit_answer" - mock_func_call.args = { - "candidates": [{"label": "1", "coords": [500, 500], "description": "Visual target"}] - } - mock_response.function_calls = [mock_func_call] - mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) - - # Mock storage manager - mock_storage = MagicMock() - mock_storage.get_image.return_value = "dummy_hash" - - # Mock file reading for prompt - mock_prompt = MOCK_PROMPT_JSON - - def custom_open(file, mode="r", *args, **kwargs): - mock_file = MagicMock() - if "b" in mode: - mock_file.read.side_effect = [ - b"fake_image_marked_data", - b"", - b"fake_image_marked_data", - ] - else: - mock_file.read.return_value = '{"templates": [], "instructions": ""}' - mock_file.__enter__.return_value = mock_file - return mock_file - - with ( - patch( - "artemis.agents.explorer.explorer.genai.Client", - return_value=mock_client, - ), - patch( - "artemis.agents.explorer.run_setup.StorageManager", - return_value=mock_storage, - ), - patch("artemis.agents.explorer.run_setup.draw_dots") as mock_draw_dots, - patch("pathlib.Path.exists", return_value=True), - patch("pathlib.Path.read_text", return_value=mock_prompt), - patch("builtins.open", custom_open), - ): - explorer = Explorer(mock_ctx) - await explorer.run( - query="Find button", - context_feedback="", - screenshot_path="/tmp/test_screenshot.jpg", - state=mock_state, - minimal_list="", - ) - - # Ensure draw_dots was successfully called with calculated center coordinates of bounds [100,100][200,200] -> [150, 150] - mock_draw_dots.assert_called_once() - points_arg = mock_draw_dots.call_args[0][1] - assert points_arg == [[150, 150]] - - labels_arg = mock_draw_dots.call_args[0][2] - assert labels_arg == ["1"] - - -@pytest.mark.asyncio -async def test_explorer_initial_visual_marking_previous_screenshot(): - from artemis.data_engine.models import ImageRecord - - # Mock context and state - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.device = MagicMock() - mock_ctx.device.device_width = 1080 - mock_ctx.device.device_height = 2400 - mock_ctx.data_engine = MagicMock() - mock_ctx.data_engine.base_dir = "/tmp/test_session" - mock_ctx.llm_config = MagicMock() - mock_ctx.llm_config.explorer = MagicMock() - mock_ctx.llm_config.explorer.model = "gemini-3.7-flash" - - mock_state = MagicMock(spec=State) - # current screenshot is different - mock_state.latest_screenshot = "/tmp/current_screenshot.jpg" - mock_state.latest_ui_hierarchy = [{"text": "Current Button", "bounds": "[300,300][400,400]"}] - - # Mock Gemini Client and response - mock_client = MagicMock() - mock_client.aio = MagicMock() - mock_client.aio.models = MagicMock() - mock_response = MagicMock() - mock_response.text = "" - - mock_func_call = MagicMock() - mock_func_call.name = "submit_answer" - mock_func_call.args = { - "candidates": [{"label": "1", "coords": [500, 500], "description": "Visual target"}] - } - mock_response.function_calls = [mock_func_call] - mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) - - # Mock storage manager to return correct previous image record from DB - mock_storage = MagicMock() - - # Mock ImageRecord with the previous screenshot's UI hierarchy - mock_record = MagicMock(spec=ImageRecord) - mock_record.ui_tree = [{"text": "Previous Button", "bounds": "[100,100][200,200]"}] - mock_storage.get_image.return_value = mock_record - - # Mock file reading for prompt - mock_prompt = MOCK_PROMPT_JSON - - def custom_open(file, mode="r", *args, **kwargs): - mock_file = MagicMock() - if "b" in mode: - mock_file.read.side_effect = [ - b"fake_image_marked_data", - b"", - b"fake_image_marked_data", - ] - else: - mock_file.read.return_value = '{"templates": [], "instructions": ""}' - mock_file.__enter__.return_value = mock_file - return mock_file - - with ( - patch( - "artemis.agents.explorer.explorer.genai.Client", - return_value=mock_client, - ), - patch( - "artemis.agents.explorer.run_setup.StorageManager", - return_value=mock_storage, - ), - patch("artemis.agents.explorer.run_setup.draw_dots") as mock_draw_dots, - patch("pathlib.Path.exists", return_value=True), - patch("pathlib.Path.read_text", return_value=mock_prompt), - patch("builtins.open", custom_open), - ): - explorer = Explorer(mock_ctx) - await explorer.run( - query="Find button", - context_feedback="", - # Pass previous screenshot path (different from state.latest_screenshot) - screenshot_path="/tmp/previous_screenshot.jpg", - state=mock_state, - minimal_list="", - ) - - # Ensure draw_dots was called with previous bounds [100,100][200,200] -> [150, 150] - # instead of current bounds [300,300][400,400] -> [350, 350] - mock_draw_dots.assert_called_once() - points_arg = mock_draw_dots.call_args[0][1] - assert points_arg == [[150, 150]] # Uses previous screenshot bounds! - assert points_arg != [[350, 350]] # Does not use current screenshot bounds! - - labels_arg = mock_draw_dots.call_args[0][2] - assert labels_arg == ["1"] - - -@pytest.mark.asyncio -async def test_explorer_initial_visual_marking_previous_screenshot_no_ui_tree(): - - # Mock context and state - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.device = MagicMock() - mock_ctx.device.device_width = 1080 - mock_ctx.device.device_height = 2400 - mock_ctx.data_engine = MagicMock() - mock_ctx.data_engine.base_dir = "/tmp/test_session" - mock_ctx.llm_config = MagicMock() - mock_ctx.llm_config.explorer = MagicMock() - mock_ctx.llm_config.explorer.model = "gemini-3.7-flash" - - mock_state = MagicMock(spec=State) - mock_state.latest_screenshot = "/tmp/current_screenshot.jpg" - mock_state.latest_ui_hierarchy = [{"text": "Current Button", "bounds": "[300,300][400,400]"}] - - # Mock Gemini Client and response - mock_client = MagicMock() - mock_client.aio = MagicMock() - mock_client.aio.models = MagicMock() - mock_response = MagicMock() - mock_response.text = "" - - mock_func_call = MagicMock() - mock_func_call.name = "submit_answer" - mock_func_call.args = { - "candidates": [{"label": "1", "coords": [500, 500], "description": "Visual target"}] - } - mock_response.function_calls = [mock_func_call] - mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) - - # Mock storage manager to return None (meaning not found in DB) - mock_storage = MagicMock() - mock_storage.get_image.return_value = None - - # Mock file reading for prompt - mock_prompt = MOCK_PROMPT_JSON - - def custom_open(file, mode="r", *args, **kwargs): - mock_file = MagicMock() - if "b" in mode: - mock_file.read.side_effect = [ - b"fake_image_raw_data", - b"", - b"fake_image_raw_data", - ] - else: - mock_file.read.return_value = '{"templates": [], "instructions": ""}' - mock_file.__enter__.return_value = mock_file - return mock_file - - with ( - patch( - "artemis.agents.explorer.explorer.genai.Client", - return_value=mock_client, - ), - patch( - "artemis.agents.explorer.run_setup.StorageManager", - return_value=mock_storage, - ), - patch("artemis.agents.explorer.run_setup.draw_dots") as mock_draw_dots, - patch("pathlib.Path.exists", return_value=True), - patch("pathlib.Path.read_text", return_value=mock_prompt), - patch("builtins.open", custom_open), - ): - explorer = Explorer(mock_ctx) - await explorer.run( - query="Find button", - context_feedback="", - # Pass previous screenshot path (different from state.latest_screenshot) - screenshot_path="/tmp/previous_screenshot.jpg", - state=mock_state, - minimal_list="", - ) - - # Ensure draw_dots was NEVER called since no ui_tree was available for previous screenshot - mock_draw_dots.assert_not_called() - - -@pytest.mark.asyncio -async def test_explorer_initial_visual_marking_previous_screenshot_ocr_fusion(): - from artemis.data_engine.models import ImageRecord - - # Mock context and state - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.device = MagicMock() - mock_ctx.device.device_width = 1080 - mock_ctx.device.device_height = 2400 - mock_ctx.data_engine = MagicMock() - mock_ctx.data_engine.base_dir = "/tmp/test_session" - mock_ctx.llm_config = MagicMock() - mock_ctx.llm_config.explorer = MagicMock() - mock_ctx.llm_config.explorer.model = "gemini-3.7-flash" - - mock_state = MagicMock(spec=State) - mock_state.latest_screenshot = "/tmp/current_screenshot.jpg" - - # Mock Gemini Client and response - mock_client = MagicMock() - mock_client.aio = MagicMock() - mock_client.aio.models = MagicMock() - mock_response = MagicMock() - mock_response.text = "" - - mock_func_call = MagicMock() - mock_func_call.name = "submit_answer" - mock_func_call.args = { - "candidates": [{"label": "1", "coords": [500, 500], "description": "Visual target"}] - } - mock_response.function_calls = [mock_func_call] - mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) - - # Mock storage manager - mock_storage = MagicMock() - - # Mock ImageRecord with previous raw XML and raw OCR - mock_record = MagicMock(spec=ImageRecord) - mock_record.ui_tree = [{"text": "", "bounds": "[100,100][200,200]"}] - # First ocr_result is full screen text and ignored; second overlaps with bounds - mock_record.ocr_result = [ - {"text": "full screen", "position": [{"x": 0, "y": 0}]}, - { - "text": "OCR Text", - "position": [ - {"x": 110, "y": 110}, - {"x": 190, "y": 110}, - {"x": 190, "y": 190}, - {"x": 110, "y": 190}, - ], - }, - ] - mock_storage.get_image.return_value = mock_record - - # Mock file reading for prompt - mock_prompt = MOCK_PROMPT_JSON - - def custom_open(file, mode="r", *args, **kwargs): - mock_file = MagicMock() - if "b" in mode: - mock_file.read.side_effect = [ - b"fake_image_marked_data", - b"", - b"fake_image_marked_data", - ] - else: - mock_file.read.return_value = '{"templates": [], "instructions": ""}' - mock_file.__enter__.return_value = mock_file - return mock_file - - with ( - patch( - "artemis.agents.explorer.explorer.genai.Client", - return_value=mock_client, - ), - patch( - "artemis.agents.explorer.run_setup.StorageManager", - return_value=mock_storage, - ), - patch("artemis.agents.explorer.run_setup.draw_dots") as mock_draw_dots, - patch("pathlib.Path.exists", return_value=True), - patch("pathlib.Path.read_text", return_value=mock_prompt), - patch("builtins.open", custom_open), - ): - explorer = Explorer(mock_ctx) - await explorer.run( - query="Find button", - context_feedback="", - screenshot_path="/tmp/previous_screenshot.jpg", - state=mock_state, - minimal_list="", - ) - - # Ensure draw_dots was called with center coordinates of the fused OCR element [110,110][190,190] -> [150, 150] - mock_draw_dots.assert_called_once() - points_arg = mock_draw_dots.call_args[0][1] - assert points_arg == [[150, 150]] # Successfully fused and drawn! - - labels_arg = mock_draw_dots.call_args[0][2] - assert labels_arg == ["1"] - - -@pytest.mark.asyncio -async def test_explorer_initial_visual_marking_previous_screenshot_on_the_fly_ocr(): - from artemis.data_engine.models import ImageRecord - - # Mock context and state - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.device = MagicMock() - mock_ctx.device.device_width = 1080 - mock_ctx.device.device_height = 2400 - mock_ctx.data_engine = MagicMock() - mock_ctx.data_engine.base_dir = "/tmp/test_session" - mock_ctx.llm_config = MagicMock() - mock_ctx.llm_config.explorer = MagicMock() - mock_ctx.llm_config.explorer.model = "gemini-3.7-flash" - - mock_state = MagicMock(spec=State) - mock_state.latest_screenshot = "/tmp/current_screenshot.jpg" - - # Mock Gemini Client and response - mock_client = MagicMock() - mock_client.aio = MagicMock() - mock_client.aio.models = MagicMock() - mock_response = MagicMock() - mock_response.text = "" - - mock_func_call = MagicMock() - mock_func_call.name = "submit_answer" - mock_func_call.args = { - "candidates": [{"label": "1", "coords": [500, 500], "description": "Visual target"}] - } - mock_response.function_calls = [mock_func_call] - mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) - - # Mock storage manager - mock_storage = MagicMock() - - # Mock ImageRecord containing ui_tree but ocr_result is None - mock_record = MagicMock(spec=ImageRecord) - mock_record.ui_tree = [{"text": "", "bounds": "[100,100][200,200]"}] - mock_record.ocr_result = None - mock_storage.get_image.return_value = mock_record - - # Mock file reading for prompt - mock_prompt = MOCK_PROMPT_JSON - - # Mock dynamic OCR result - mock_ocr_result = [ - {"text": "full screen", "position": [{"x": 0, "y": 0}]}, - { - "text": "On-the-fly OCR Text", - "position": [ - {"x": 110, "y": 110}, - {"x": 190, "y": 110}, - {"x": 190, "y": 190}, - {"x": 110, "y": 190}, - ], - }, - ] - - def custom_open(file, mode="r", *args, **kwargs): - mock_file = MagicMock() - if "b" in mode: - mock_file.read.side_effect = [ - b"fake_image_marked_data", - b"", - b"fake_image_marked_data", - ] - else: - mock_file.read.return_value = '{"templates": [], "instructions": ""}' - mock_file.__enter__.return_value = mock_file - return mock_file - - with ( - patch( - "artemis.agents.explorer.explorer.genai.Client", - return_value=mock_client, - ), - patch( - "artemis.agents.explorer.run_setup.StorageManager", - return_value=mock_storage, - ), - patch( - "artemis.agents.explorer.run_setup.perform_ocr", - new_callable=AsyncMock, - return_value=mock_ocr_result, - ) as mock_perform_ocr, - patch( - "artemis.agents.explorer.run_setup.is_ocr_configured", - return_value=True, - ), - patch("artemis.agents.explorer.run_setup.draw_dots") as mock_draw_dots, - patch("pathlib.Path.exists", return_value=True), - patch("pathlib.Path.read_text", return_value=mock_prompt), - patch("builtins.open", custom_open), - ): - explorer = Explorer(mock_ctx) - await explorer.run( - query="Find button", - context_feedback="", - screenshot_path="/tmp/previous_screenshot.jpg", - state=mock_state, - minimal_list="", - ) - - # Verify perform_ocr was triggered on-the-fly - mock_perform_ocr.assert_called_once() - - # Ensure draw_dots was called with center coordinates of the fused OCR element [110,110][190,190] -> [150, 150] - mock_draw_dots.assert_called_once() - points_arg = mock_draw_dots.call_args[0][1] - assert points_arg == [[150, 150]] - - labels_arg = mock_draw_dots.call_args[0][2] - assert labels_arg == ["1"] - - -@pytest.mark.asyncio -async def test_explorer_denylisted_tool(): - # Mock context and state - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.device = MagicMock() - mock_ctx.device.device_width = 1080 - mock_ctx.device.device_height = 2400 - mock_ctx.data_engine = MagicMock() - mock_ctx.data_engine.base_dir = "/tmp/test_session" - mock_ctx.llm_config = MagicMock() - mock_ctx.llm_config.explorer = MagicMock() - mock_ctx.llm_config.explorer.model = "gemini-3.7-flash" - mock_ctx.agent_config = MagicMock() - mock_ctx.agent_config.denylisted_tools = {"explorer": ["search_xml_ocr"]} - - mock_state = MagicMock(spec=State) - mock_state.latest_screenshot = "/tmp/test_screenshot.jpg" - - # Response 1: Model calls search_xml_ocr (which is denylisted) - mock_response1 = MagicMock() - mock_response1.text = "" - mock_func_call1 = MagicMock() - mock_func_call1.name = "search_xml_ocr" - mock_func_call1.args = {"use_coords": False, "search_value": "test"} - mock_response1.function_calls = [mock_func_call1] - mock_response1.candidates = [MagicMock()] - mock_response1.candidates[0].content = types.Content( - role="model", - parts=[types.Part.from_function_call(name="search_xml_ocr", args=mock_func_call1.args)], - ) - - # Response 2: Model calls submit_answer - mock_response2 = MagicMock() - mock_response2.text = "" - mock_func_call2 = MagicMock() - mock_func_call2.name = "submit_answer" - mock_func_call2.args = { - "candidates": [ - { - "label": "S1", - "coords": [500, 500], - "description": "Corrected button", - } - ], - "fallback_message": "", - } - mock_response2.function_calls = [mock_func_call2] - mock_response2.candidates = [MagicMock()] - mock_response2.candidates[0].content = types.Content( - role="model", - parts=[types.Part.from_function_call(name="submit_answer", args=mock_func_call2.args)], - ) - - mock_client = MagicMock() - mock_client.aio = MagicMock() - mock_client.aio.models = MagicMock() - mock_client.aio.models.generate_content = AsyncMock( - side_effect=[mock_response1, mock_response2] - ) - - # Mock storage manager - mock_storage = MagicMock() - mock_storage.get_image.return_value = "dummy_hash" - - # Mock file reading for prompt - mock_prompt = MOCK_PROMPT_JSON - - def custom_open(file, mode="r", *args, **kwargs): - mock_file = MagicMock() - if "b" in mode: - mock_file.read.side_effect = [ - b"fake_image_data", - b"", - b"fake_image_data", - ] - else: - mock_file.read.return_value = '{"templates": [], "instructions": ""}' - - mock_file.__enter__.return_value = mock_file - return mock_file - - with ( - patch( - "artemis.agents.explorer.explorer.genai.Client", - return_value=mock_client, - ), - patch( - "artemis.agents.explorer.run_setup.StorageManager", - return_value=mock_storage, - ), - patch("pathlib.Path.exists", return_value=True), - patch("pathlib.Path.read_text", return_value=mock_prompt), - patch("builtins.open", custom_open), - ): - explorer = Explorer(mock_ctx) - - # Verify get_exposed_tools doesn't expose search_xml_ocr - exposed_tools = explorer.get_exposed_tools() - exposed_tool_names = [tool.name for tool in exposed_tools] - assert "search_xml_ocr" not in exposed_tool_names - - result = await explorer.run( - query="Find button", - context_feedback="", - screenshot_path="/tmp/test_screenshot.jpg", - minimal_list="", - state=mock_state, - ) - - expected_outcome = json.dumps(mock_func_call2.args, ensure_ascii=False) - assert result == expected_outcome - - # Ensure we called generate_content twice - assert mock_client.aio.models.generate_content.call_count == 2 - - # Verify the contents passed to the second call contain the error response - # indicating that the tool is denylisted and unavailable. - call_args = mock_client.aio.models.generate_content.call_args_list[1] - contents = call_args[1]["contents"] - - # Find the tool response in contents - tool_content = next( - ( - c - for c in contents - if c.role in ("tool", "user") and any(p.function_response for p in c.parts) - ), - None, - ) - assert tool_content is not None - - # Verify that the response part contains the expected error message - found_error = False - for part in tool_content.parts: - func_resp = part.function_response - if func_resp and func_resp.name == "search_xml_ocr": - assert "denylisted and unavailable" in func_resp.response["error"] - found_error = True - - assert found_error - - -@pytest.mark.asyncio -async def test_explorer_inspect_region(): - import pathlib - import numpy as np - - # Mock context - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.device = MagicMock() - mock_ctx.device.device_width = 1080 - mock_ctx.device.device_height = 2400 - mock_ctx.data_engine = MagicMock() - - explorer = Explorer(mock_ctx) - explorer.width = 1080 - explorer.height = 2400 - explorer.screenshot_path = "/tmp/test_screenshot.jpg" - explorer.image_name = "test_image" - - dummy_img = np.zeros((2400, 1080, 3), dtype=np.uint8) - - with ( - patch("cv2.imread", return_value=dummy_img) as mock_imread, - patch("cv2.resize", return_value=dummy_img) as mock_resize, - patch("cv2.imwrite", return_value=True) as mock_imwrite, - patch("pathlib.Path.mkdir") as mock_mkdir, - patch("glob.glob", return_value=[]), - patch("artemis.agents.explorer.perception_tools.settings") as mock_settings, - ): - mock_settings.TRACES_PATH = pathlib.Path("/tmp/traces") - res = await explorer.exec_inspect_region( - x_min=100, y_min=200, x_max=300, y_max=400, zoom_factor=2.0 - ) - assert res["image_path"] is not None - assert "Inspected region coordinates" in res["text"] - mock_imread.assert_called_once_with("/tmp/test_screenshot.jpg") - - # Verify resize calculations: - # px_start = 100 * 1080 / 1000 = 108 - # py_start = 200 * 2400 / 1000 = 480 - # px_end = 300 * 1080 / 1000 = 324 - # py_end = 400 * 2400 / 1000 = 960 - # width = 324 - 108 = 216 - # height = 960 - 480 = 480 - # new_width = 216 * 2.0 = 432 - # new_height = 480 * 2.0 = 960 - mock_resize.assert_called_once() - resize_args = mock_resize.call_args[0] - assert resize_args[1] == (432, 960) - mock_imwrite.assert_called_once() - - -@pytest.mark.asyncio -async def test_explorer_final_turn_tool_stripping(): - """On the final turn the Explorer strips other tools and injects the warning.""" - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.device = MagicMock() - mock_ctx.device.device_width = 1080 - mock_ctx.device.device_height = 2400 - mock_ctx.data_engine = MagicMock() - mock_ctx.data_engine.base_dir = "/tmp/test_session" - mock_ctx.llm_config = MagicMock() - mock_ctx.llm_config.explorer = MagicMock() - mock_ctx.llm_config.explorer.model = "gemini-3.7-flash" - - # A client already shared on the context selects the native engine and is - # reused lazily by ``run`` (no ``genai.Client`` construction). - mock_client = MagicMock() - mock_ctx._genai_client = mock_client - explorer = Explorer(mock_ctx) - assert explorer.use_native_gemini is True - - mock_state = MagicMock(spec=State) - mock_state.latest_screenshot = "/tmp/test_screenshot.jpg" - - # Turn 1: model returns search_xml_ocr - fc_search = MagicMock() - fc_search.name = "search_xml_ocr" - fc_search.args = {"use_coords": False, "search_value": "test"} - mock_response = MagicMock() - mock_response.function_calls = [fc_search] - mock_response.candidates = [MagicMock()] - mock_response.candidates[0].content = types.Content( - role="model", - parts=[types.Part.from_function_call(name="search_xml_ocr", args=fc_search.args)], - ) - - # Always return the same mock response to force it to run out of turns - mock_client.aio.models.generate_content = AsyncMock(return_value=mock_response) - - from unittest.mock import mock_open - - with ( - patch("builtins.open", mock_open(read_data=b"")), - patch("hashlib.sha256") as mock_sha, - patch("pathlib.Path.exists", return_value=True), - patch("pathlib.Path.read_text", return_value="{}"), - ): - mock_sha.return_value.hexdigest.return_value = "dummy_hash" - - await explorer.run( - query="test", - context_feedback="", - screenshot_path="/tmp/test_screenshot.jpg", - state=mock_state, - minimal_list="test list", - ) - - # The default "pro" tier budgets 3 turns (tiers.EXPLORER_TIERS) - assert mock_client.aio.models.generate_content.call_count == 3 - - # Check the final turn call (index 2) keyword args - call_args_3 = mock_client.aio.models.generate_content.call_args_list[2] - config = call_args_3.kwargs["config"] - - # Tools in the final call should only contain "submit_answer" - tools = config.tools[0].function_declarations - assert len(tools) == 1 - assert tools[0].name == "submit_answer" - - # Warning should be injected in contents - contents = call_args_3.kwargs["contents"] - # Find warning in contents parts - warning_found = False - for content in contents: - if content.role == "user": - for part in content.parts: - if part.text and "[WARNING] This is your final iteration" in part.text: - warning_found = True - assert warning_found, "Warning message was not found in contents" - - -@pytest.mark.asyncio -async def test_explorer_ask_perception_tool(): - """The perception bundle answers from the in-memory screen index. - - No Data Engine lookup is involved: the text search, the coordinate - audit and the OCR inventory all read ``explorer.screen_index``. - """ - import numpy as np - from unittest.mock import mock_open - - from artemis.agents.explorer.screen_index import ScreenIndex - - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = MagicMock() - mock_ctx.data_engine.base_dir = "/tmp/test_session" - explorer = Explorer(mock_ctx) - explorer.image_name = "dummy_hash" - explorer.screenshot_path = "/tmp/test_screenshot.jpg" - explorer.width = 1080 - explorer.height = 2400 - explorer.global_label_idx = 1 - explorer.image_pool = { - "img_0": { - "path": "/tmp/test_screenshot.jpg", - "transform": { - "offset_x": 0.0, - "offset_y": 0.0, - "scale_x": 1.0, - "scale_y": 1.0, - }, - "description": "Original complete screenshot", - } - } - explorer.screen_index = ScreenIndex.from_hierarchy( - [ - { - "bounds": "[500,400][600,560]", - "text": "Settings", - "resource-id": "com.android.settings:id/btn_settings", - "class": "android.widget.Button", - "clickable": "true", - }, - { - "bounds": "[100,100][200,200]", - "class": "android.widget.FrameLayout [OCR]", - "ocr_elements": [{"text": "Settings page", "bounds": "[100,100][200,200]"}], - }, - ], - 1080, - 2400, - ) - - # Mock _run_object_detection - mock_detector_res = { - "detected": [{"label": "profile icon", "point": [900, 100]}], - "failed": [], - } - - # Mock draw_dots - mock_draw_dots = MagicMock() - - with ( - patch("artemis.agents.explorer.run_setup.StorageManager") as storage_cls, - patch( - "artemis.agents.explorer.perception_tools._run_object_detection", - return_value=mock_detector_res, - ), - patch("artemis.agents.explorer.perception_tools.draw_dots", mock_draw_dots), - patch("artemis.utils.visualization.draw_dots", mock_draw_dots), - patch("pathlib.Path.mkdir"), - patch("glob.glob", return_value=[]), - patch("builtins.open", mock_open(read_data=b'{"templates": []}')), - patch("cv2.imread", return_value=np.zeros((2400, 1080, 3), dtype=np.uint8)), - ): - result = await explorer.exec_ask_perception_tool( - search_query="Settings", - nx=550, - ny=200, - detect_queries=["profile icon"], - ) - - storage_cls.assert_not_called() - assert "Text Search Results are:" in result["text"] - assert "Coordinate Search Results are:" in result["text"] - assert "Object Detection Results are:" in result["text"] - - # UI-tree text match -> X1, OCR text match -> O2 (both from the index) - assert "[X1] 'Settings' at [509,200]" in result["text"] - assert "[O2] 'Settings page' at [138,62]" in result["text"] - # Coordinate audit -> X3 with the innermost element's description - assert ( - "Matched element at [550,200]: [X3] | 'Settings' class=android.widget.Button" - " id=com.android.settings:id/btn_settings source=ui-tree interactive" - ) in result["text"] - # Object detection -> D4 (the label counter is shared and contiguous) - assert "[D4]" in result["text"] - - # Element-backed labels are registered; detection points are not. - assert explorer.label_registry["X1"].text == "Settings" - assert explorer.label_registry["O2"].source == "ocr" - assert explorer.label_registry["X3"] is explorer.label_registry["X1"] - assert "D4" not in explorer.label_registry - - assert len(result["image_paths"]) == 3 - - # Verify call arguments of draw_dots - assert mock_draw_dots.call_count == 3 - # First call (XML/OCR Text search): green color - first_call = mock_draw_dots.call_args_list[0] - assert first_call.kwargs.get("color") == "green" - assert first_call.args[2] == ["X1", "O2"] - - # Second call (XML Coordinate audit): blue color - second_call = mock_draw_dots.call_args_list[1] - assert second_call.kwargs.get("color") == "blue" - assert second_call.args[2] == ["X3"] - - # Third call (Object detection): red color - third_call = mock_draw_dots.call_args_list[2] - assert third_call.kwargs.get("color") == "red" - assert third_call.args[2] == ["D4"] - - -def test_explorer_prune_historical_images(): - from artemis.agents.explorer.explorer import Explorer - from google.genai import types - - mock_ctx = MagicMock() - explorer = Explorer(mock_ctx) - - part1 = types.Part(inline_data=types.Blob(data=b"img1", mime_type="image/jpeg")) - part2 = types.Part(inline_data=types.Blob(data=b"img2", mime_type="image/jpeg")) - part3 = types.Part.from_text(text="Some text") - - contents = [ - types.Content(role="user", parts=[part1, part3]), - types.Content(role="user", parts=[part2]), - ] - - explorer._prune_historical_images(contents, keep_last=1) - - # First image part should be pruned to text part - assert contents[0].parts[0].text == "[Image pruned to maintain visual focus on latest state]" - assert contents[0].parts[1].text == "Some text" - # Second (last) image part should be kept intact - assert contents[1].parts[0] is part2 - - -class _FakeGenaiError(Exception): - """Mimics google.genai.errors.APIError's shape (a ``code`` attribute).""" - - def __init__(self, message: str, code: int | None = None): - super().__init__(message) - self.code = code - - -@pytest.mark.asyncio -async def test_generate_content_reliability_permanent_error_is_not_retried(): - from artemis.agents.explorer.native_runner import _generate_content_with_reliability - from artemis.llm.reliability import LLMPermanentError - - calls = 0 - - async def op(): - nonlocal calls - calls += 1 - raise _FakeGenaiError("PERMISSION_DENIED: forbidden", code=403) - - with pytest.raises(LLMPermanentError) as exc_info: - await _generate_content_with_reliability(op) - assert calls == 1 - assert exc_info.value.failure.category.value == "authentication" - assert isinstance(exc_info.value.__cause__, _FakeGenaiError) - - -@pytest.mark.asyncio -async def test_generate_content_reliability_bad_request_is_not_retried(): - from artemis.agents.explorer.native_runner import _generate_content_with_reliability - from artemis.llm.reliability import LLMPermanentError - - calls = 0 - - async def op(): - nonlocal calls - calls += 1 - raise _FakeGenaiError("invalid request payload", code=400) - - with pytest.raises(LLMPermanentError) as exc_info: - await _generate_content_with_reliability(op) - assert calls == 1 - assert exc_info.value.failure.category.value == "bad_request" - - -@pytest.mark.asyncio -async def test_generate_content_reliability_retries_transient_then_succeeds(): - from artemis.agents.explorer import native_runner as native_runner_module - - calls = 0 - sentinel = object() - - async def op(): - nonlocal calls - calls += 1 - if calls < 2: - raise _FakeGenaiError("429 rate limit exceeded", code=429) - return sentinel - - with patch.object(native_runner_module.asyncio, "sleep", new=AsyncMock()) as sleep_mock: - result = await native_runner_module._generate_content_with_reliability(op) - assert result is sentinel - assert calls == 2 - assert sleep_mock.await_count == 1 - - -@pytest.mark.asyncio -async def test_generate_content_reliability_exhausts_to_typed_error(): - from artemis.agents.explorer import native_runner as native_runner_module - from artemis.llm.reliability import ( - FailureCategory, - LLMExhaustedError, - retry_policy_for, - ) - - calls = 0 - - async def op(): - nonlocal calls - calls += 1 - raise _FakeGenaiError("503 service unavailable", code=503) - - with patch.object(native_runner_module.asyncio, "sleep", new=AsyncMock()): - with pytest.raises(LLMExhaustedError) as exc_info: - await native_runner_module._generate_content_with_reliability(op) - assert calls == retry_policy_for(FailureCategory.PROVIDER_UNAVAILABLE).max_attempts - assert exc_info.value.failure.category.value == "provider_unavailable" - assert isinstance(exc_info.value.__cause__, _FakeGenaiError) diff --git a/tests/unit/agents/test_explorer_geometry.py b/tests/unit/agents/test_explorer_geometry.py deleted file mode 100644 index 83d78fd46..000000000 --- a/tests/unit/agents/test_explorer_geometry.py +++ /dev/null @@ -1,150 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Screen geometry shared by the Explorer and the ``ask_explorer`` tool.""" - -from types import SimpleNamespace - -import pytest - -from artemis.agents.explorer.geometry import ( - FALLBACK_SCREEN_SIZE, - NORMALIZED_MAX, - is_valid_norm_point, - norm_to_pixel, - pixel_to_norm, - resolve_screen_size, -) - - -def _ctx(width=None, height=None, device=True): - if not device: - return SimpleNamespace(device=None) - return SimpleNamespace(device=SimpleNamespace(device_width=width, device_height=height)) - - -def _state(raw): - return SimpleNamespace(operator_raw_data=raw) - - -# --------------------------------------------------------------------------- -# resolve_screen_size -# --------------------------------------------------------------------------- - - -def test_operator_observation_wins_over_the_device_context(): - ctx = _ctx(1080, 2400) - state = _state({"width": 720, "height": 1600}) - assert resolve_screen_size(ctx, state) == (720, 1600) - - -def test_device_context_is_used_when_the_observation_has_no_size(): - ctx = _ctx(1080, 2400) - assert resolve_screen_size(ctx, _state(None)) == (1080, 2400) - assert resolve_screen_size(ctx, _state({})) == (1080, 2400) - assert resolve_screen_size(ctx, _state("not a dict")) == (1080, 2400) - - -@pytest.mark.parametrize( - "raw", - [ - {"width": 0, "height": 1600}, - {"width": 720, "height": -1}, - {"width": "720", "height": 1600}, - {"width": 720.0, "height": 1600}, - {"width": None, "height": None}, - {"width": 720}, - ], -) -def test_non_int_or_non_positive_observation_sizes_are_ignored(raw): - ctx = _ctx(1080, 2400) - assert resolve_screen_size(ctx, _state(raw)) == (1080, 2400) - - -def test_fallback_when_neither_source_reports_a_size(): - assert resolve_screen_size(_ctx(device=False), _state(None)) == FALLBACK_SCREEN_SIZE - assert resolve_screen_size(_ctx(None, None), _state(None)) == FALLBACK_SCREEN_SIZE - assert resolve_screen_size(SimpleNamespace(), SimpleNamespace()) == FALLBACK_SCREEN_SIZE - - -def test_device_dimensions_fall_back_independently(): - fb_w, fb_h = FALLBACK_SCREEN_SIZE - assert resolve_screen_size(_ctx(1440, 0), _state(None)) == (1440, fb_h) - assert resolve_screen_size(_ctx("1440", 3200), _state(None)) == (fb_w, 3200) - assert resolve_screen_size(_ctx(None, 3200), _state(None)) == (fb_w, 3200) - - -# --------------------------------------------------------------------------- -# norm_to_pixel / pixel_to_norm -# --------------------------------------------------------------------------- - - -def test_norm_to_pixel_scales_on_the_0_1000_grid(): - assert norm_to_pixel(500, 500, 1080, 2400) == (540, 1200) - assert norm_to_pixel(0, 0, 1080, 2400) == (0, 0) - assert norm_to_pixel(NORMALIZED_MAX, NORMALIZED_MAX, 1080, 2400) == (1080, 2400) - assert norm_to_pixel(250, 750, 1080, 2400) == (270, 1800) - - -def test_norm_to_pixel_clamps_out_of_range_inputs(): - assert norm_to_pixel(-50, -1, 1080, 2400) == (0, 0) - assert norm_to_pixel(1500, 2000, 1080, 2400) == (1080, 2400) - - -def test_pixel_to_norm_scales_back_to_the_0_1000_grid(): - assert pixel_to_norm(540, 1200, 1080, 2400) == (500, 500) - assert pixel_to_norm(0, 0, 1080, 2400) == (0, 0) - assert pixel_to_norm(1080, 2400, 1080, 2400) == (NORMALIZED_MAX, NORMALIZED_MAX) - - -def test_pixel_to_norm_clamps_out_of_range_inputs(): - assert pixel_to_norm(-10, -10, 1080, 2400) == (0, 0) - assert pixel_to_norm(5000, 9000, 1080, 2400) == (NORMALIZED_MAX, NORMALIZED_MAX) - - -def test_conversions_round_trip_on_grid_points(): - for nx, ny in [(0, 0), (500, 500), (1000, 1000), (250, 750)]: - px, py = norm_to_pixel(nx, ny, 1000, 2000) - assert pixel_to_norm(px, py, 1000, 2000) == (nx, ny) - - -# --------------------------------------------------------------------------- -# is_valid_norm_point -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize( - "coords", - [[0, 0], [500, 500], (1000, 1000), ["500", "250"], [12.7, 999.2]], -) -def test_valid_norm_points(coords): - assert is_valid_norm_point(coords) - - -@pytest.mark.parametrize( - "coords", - [ - None, - "500,500", - [500], - [500, 500, 500], - [-1, 0], - [0, 1001], - ["x", 0], - [None, 5], - {"x": 1, "y": 2}, - ], -) -def test_invalid_norm_points(coords): - assert not is_valid_norm_point(coords) diff --git a/tests/unit/agents/test_explorer_perception_regressions.py b/tests/unit/agents/test_explorer_perception_regressions.py deleted file mode 100644 index 559dfa1a4..000000000 --- a/tests/unit/agents/test_explorer_perception_regressions.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Regression tests for Explorer perception-tool failure paths.""" - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from artemis.agents.explorer.perception_tools import PerceptionToolsMixin -from artemis.agents.explorer.screen_index import ScreenIndex - - -def _perception_host() -> PerceptionToolsMixin: - host = PerceptionToolsMixin() - host.ctx = MagicMock() - host.global_label_idx = 1 - host.width = 1080 - host.height = 2400 - host.image_name = "screen-1" - host.screenshot_path = "screen-1.jpg" - host.image_pool = { - "img_0": { - "path": "screen-1.jpg", - "transform": { - "offset_x": 0.0, - "offset_y": 0.0, - "scale_x": 1.0, - "scale_y": 1.0, - }, - } - } - host.next_img_id = 1 - host.screen_index = ScreenIndex.empty(1080, 2400) - host.label_registry = {} - return host - - -@pytest.mark.asyncio -async def test_get_ocr_list_reads_the_screen_index_without_storage() -> None: - host = _perception_host() - - with patch("artemis.agents.explorer.run_setup.StorageManager") as storage_cls: - result = await host.exec_get_ocr_list() - - assert result == { - "text": "No text elements detected on the screen.", - "image_path": None, - } - storage_cls.assert_not_called() - - -@pytest.mark.asyncio -async def test_image_processor_empty_outputs_returns_stable_error() -> None: - host = _perception_host() - processor = MagicMock() - processor.run = AsyncMock(return_value={"outputs": [], "summary": "nothing found"}) - - with patch( - "artemis.agents.explorer.perception_tools.ImageProcessor", - return_value=processor, - ): - result = await host.exec_ask_image_processor("crop", target_image_id="img_0") - - assert result == { - "text": "ImageProcessor error: no output images were produced.", - "image_paths": [], - } diff --git a/tests/unit/agents/test_explorer_prepass.py b/tests/unit/agents/test_explorer_prepass.py deleted file mode 100644 index 998a0ad83..000000000 --- a/tests/unit/agents/test_explorer_prepass.py +++ /dev/null @@ -1,466 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Explorer in-memory screen search, label registry, bounds enrichment and pre-pass.""" - -import json -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from artemis.agents.explorer.explorer import Explorer -from artemis.agents.explorer.screen_index import ScreenElement, ScreenIndex -from artemis.context import ArtemisContext -from artemis.graph.state import State - -RUN_SETUP = "artemis.agents.explorer.run_setup" -PERCEPTION_TOOLS = "artemis.agents.explorer.perception_tools" -W, H = 1080, 2400 - -#: One "Settings" button, two "Send" buttons and two OCR-only texts. -HIERARCHY = [ - { - "text": "Settings", - "bounds": "[500,400][600,560]", - "class": "android.widget.Button", - "resource-id": "app:id/settings", - "clickable": "true", - }, - {"text": "Send", "bounds": "[100,2000][300,2100]", "class": "android.widget.Button"}, - {"text": "Send", "bounds": "[700,2000][900,2100]", "class": "android.widget.Button"}, - { - "bounds": "[0,0][1080,2400]", - "class": "android.widget.FrameLayout", - "ocr_elements": [ - {"text": "Settings page", "bounds": "[100,100][300,160]"}, - {"text": "Welcome", "bounds": "[100,200][400,260]"}, - ], - }, -] - -#: "Settings" [500,400][600,560] on a 1080x2400 screen, in normalized units. -SETTINGS_COORDS = [509, 200] -SETTINGS_BOUNDS = [462, 166, 555, 233] - - -def _context() -> MagicMock: - ctx = MagicMock(spec=ArtemisContext) - ctx.device = MagicMock() - ctx.device.device_width = W - ctx.device.device_height = H - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = None - ctx.llm_config = MagicMock() - ctx.llm_config.explorer = MagicMock() - ctx.llm_config.explorer.model = "gemini-3.8-flash" - ctx.agent_config = MagicMock() - ctx.agent_config.denylisted_tools = {} - return ctx - - -def _state(screenshot: str, hierarchy=None) -> MagicMock: - state = MagicMock(spec=State) - state.latest_screenshot = screenshot - state.latest_ui_hierarchy = HIERARCHY if hierarchy is None else hierarchy - state.operator_raw_data = {} - return state - - -def _indexed_explorer(hierarchy=None) -> Explorer: - """An Explorer whose screen is already indexed (no ``run`` involved).""" - explorer = Explorer(_context()) - explorer.width, explorer.height = W, H - explorer.screenshot_path = "/tmp/screen.jpg" - explorer.screen_index = ScreenIndex.from_hierarchy( - HIERARCHY if hierarchy is None else hierarchy, W, H - ) - return explorer - - -def _storage(record=None) -> MagicMock: - storage = MagicMock() - storage.get_image.return_value = record - return storage - - -def _outcome(candidates=None, fallback: str = "") -> str: - return json.dumps({"candidates": candidates or [], "fallback_message": fallback}) - - -@pytest.fixture -def screenshot(tmp_path): - path = tmp_path / "screen.jpg" - path.write_bytes(b"fake-jpeg-bytes") - return str(path) - - -@pytest.fixture -def no_drawing(): - """Keeps the annotation helpers away from the file system.""" - draw_dots = MagicMock() - with ( - patch(f"{RUN_SETUP}.draw_dots", draw_dots), - patch(f"{PERCEPTION_TOOLS}.draw_dots", draw_dots), - patch("pathlib.Path.mkdir"), - patch("glob.glob", return_value=[]), - ): - yield draw_dots - - -# --------------------------------------------------------------------------- # -# Screen index construction inside run() -# --------------------------------------------------------------------------- # - - -@pytest.mark.asyncio -async def test_index_is_built_from_state_hierarchy_when_record_is_missing(screenshot, no_drawing): - explorer = Explorer(_context()) - explorer._run_flash = AsyncMock(return_value=_outcome(fallback="nothing")) - - with ( - patch(f"{RUN_SETUP}.StorageManager", return_value=_storage(None)), - patch(f"{RUN_SETUP}.perform_ocr", new_callable=AsyncMock) as perform_ocr, - ): - await explorer.run("gear icon", "", screenshot, _state(screenshot), version="flash") - text = (await explorer._search_ui_helper("Settings", prefix="X"))["text"] - - perform_ocr.assert_not_awaited() - assert len(explorer.screen_index) == 5 - assert "not found in Data Engine" not in text - assert "[X1] 'Settings' at [509,200]" in text - - -@pytest.mark.asyncio -async def test_index_ignores_state_hierarchy_for_another_screenshot(screenshot): - explorer = Explorer(_context()) - explorer._run_flash = AsyncMock(return_value=_outcome()) - state = _state("/tmp/other.jpg") - - with patch(f"{RUN_SETUP}.StorageManager", return_value=_storage(None)): - await explorer.run("Settings", "", screenshot, state, version="flash") - - assert len(explorer.screen_index) == 0 - explorer._run_flash.assert_awaited_once_with("Settings", screenshot) - assert (await explorer._search_ui_helper("Settings"))["text"] == ( - "No UI-tree data is available for this screen; rely on visual detection." - ) - - -# --------------------------------------------------------------------------- # -# Perception helpers answer from the index -# --------------------------------------------------------------------------- # - - -@pytest.mark.asyncio -async def test_search_ui_helper_merges_lenient_matches_without_duplicates(no_drawing): - hierarchy = [*HIERARCHY, {"text": "Settled", "bounds": "[100,300][300,360]"}] - explorer = _indexed_explorer(hierarchy) - spy = MagicMock(wraps=explorer.screen_index.search_text) - explorer.screen_index.search_text = spy - - result = await explorer._search_ui_helper("Settings", prefix="X", color="green") - - assert [call.args[1] for call in spy.call_args_list] == [0.7, 0.4] - parts = result["text"].split(" | ") - assert parts[0] == "[X1] 'Settings' at [509,200]" - assert parts[1] == "[O2] 'Settings page' at [185,54]" - # "Settled" (similarity ~0.53) only arrives through the lenient pass... - assert any(part.startswith("[X") and "'Settled'" in part for part in parts) - # ...and the strict hits are not listed a second time. - assert sum("'Settings'" in part for part in parts) == 1 - assert explorer.label_registry["X1"].text == "Settings" - assert explorer.label_registry["O2"].source == "ocr" - assert no_drawing.call_args.kwargs["color"] == "green" - assert result["image_path"] is not None - - -@pytest.mark.asyncio -async def test_search_ui_helper_skips_lenient_pass_with_enough_strict_hits(no_drawing): - explorer = _indexed_explorer() - strict = explorer.screen_index.search_text("Settings", 0.4) # 5 hits, all "strict" - explorer.screen_index.search_text = MagicMock(return_value=strict) - - await explorer._search_ui_helper("Settings", prefix="X") - - explorer.screen_index.search_text.assert_called_once_with("Settings", 0.7) - - -@pytest.mark.asyncio -async def test_search_by_coords_helper_reports_innermost_first(no_drawing): - explorer = _indexed_explorer( - [ - {"text": "Card", "bounds": "[0,0][1080,1200]", "class": "android.widget.FrameLayout"}, - {"text": "Title", "bounds": "[500,400][600,560]", "class": "android.widget.TextView"}, - ] - ) - - hit = await explorer._search_by_coords_helper(550, 200, prefix="X", color="blue") - miss = await explorer._search_by_coords_helper(10, 990, prefix="X", color="blue") - - assert hit["text"] == ( - "Matched element at [550,200]: [X1]" - " | 'Title' class=android.widget.TextView source=ui-tree" - " | 'Card' class=android.widget.FrameLayout source=ui-tree" - ) - assert explorer.label_registry["X1"].text == "Title" - assert miss["text"] == "No elements found at [10,990]." - assert "X2" not in explorer.label_registry - # The blue dot is drawn for both probes at the pixel position. - assert no_drawing.call_count == 2 - assert no_drawing.call_args_list[0].args[1] == [[594, 480]] - assert no_drawing.call_args_list[0].args[2] == ["X1"] - - -@pytest.mark.asyncio -async def test_get_ocr_list_reads_the_index(no_drawing): - explorer = _indexed_explorer() - - with patch(f"{RUN_SETUP}.StorageManager") as storage_cls: - result = await explorer.exec_get_ocr_list() - - storage_cls.assert_not_called() - assert result["text"].splitlines() == [ - "[O1] 'Settings page' coords: [185,54]", - "[O2] 'Welcome' coords: [231,95]", - ] - assert result["image_path"] is not None - assert {k: v.source for k, v in explorer.label_registry.items()} == {"O1": "ocr", "O2": "ocr"} - - -@pytest.mark.asyncio -async def test_get_ocr_list_without_ocr_elements(): - explorer = _indexed_explorer([{"text": "Only XML", "bounds": "[0,0][100,100]"}]) - result = await explorer.exec_get_ocr_list() - assert result == {"text": "No text elements detected on the screen.", "image_path": None} - - -# --------------------------------------------------------------------------- # -# Label registry from the initial numbered list -# --------------------------------------------------------------------------- # - - -def test_initial_list_registers_numbered_labels(no_drawing): - explorer = _indexed_explorer() - - minimal_list, _marked = explorer._annotate_initial_screenshot( - HIERARCHY, "/tmp/screen.jpg", None, "" - ) - - assert minimal_list.splitlines()[0].startswith("[1] Text: 'Settings'") - assert sorted(explorer.label_registry) == ["1", "2", "3", "4", "5"] - settings = explorer.label_registry["1"] - assert settings.bounds == (500, 400, 600, 560) - assert settings.source == "xml" and settings.interactive # taken from the index - assert explorer.label_registry["4"].source == "ocr" - assert explorer.label_registry["4"].text == "Settings page" - assert explorer.global_label_idx == 6 - assert no_drawing.call_args.args[1][0] == [550, 480] - - -# --------------------------------------------------------------------------- # -# Submit enrichment -# --------------------------------------------------------------------------- # - - -def test_enrich_candidates_adds_normalized_bounds_only_when_label_and_coords_agree(): - explorer = _indexed_explorer() - settings = explorer.screen_index.exact_matches("Settings")[0] - explorer._register_label("X1", settings) - - inside = {"label": "X1", "coords": SETTINGS_COORDS, "description": "gear"} - # 614px is 14px right of the element, inside the 2% (21.6px) tolerance. - near = {"label": "X1", "coords": [569, 200], "description": "gear"} - far = {"label": "X1", "coords": [100, 900], "description": "gear"} - unknown = {"label": "D7", "coords": SETTINGS_COORDS, "description": "gear"} - invalid = {"label": "X1", "coords": [5000, 5], "description": "gear"} - - enriched = explorer._enrich_candidates([inside, near, far, unknown, invalid, "junk"]) - - assert enriched[0] == {**inside, "bounds": SETTINGS_BOUNDS, "source": "xml"} - assert enriched[1] == {**near, "bounds": SETTINGS_BOUNDS, "source": "xml"} - assert enriched[2:] == [far, unknown, invalid, "junk"] - assert "bounds" not in far and "bounds" not in inside # inputs are never mutated - - -def test_enrich_candidates_withholds_degenerate_bounds(): - explorer = _indexed_explorer() - explorer._register_label("X9", ScreenElement("dot", (10, 10, 11, 11), "xml")) - cand = {"label": "X9", "coords": [9, 4]} - assert explorer._enrich_candidates([cand]) == [cand] - - -def test_native_submit_enriches_registered_candidates(): - explorer = _indexed_explorer() - explorer._register_label("O2", explorer.screen_index.ocr_elements[0]) - call = MagicMock() - call.name = "submit_answer" - call.args = { - "candidates": [ - {"label": "O2", "coords": [185, 54], "description": "header"}, - {"label": "D3", "coords": [900, 100], "description": "icon"}, - ], - "fallback_message": "", - } - turn_record = {"tool_calls": []} - - raw = explorer._process_submit_answer([call], turn_record, []) - - data = json.loads(raw) - assert list(data) == ["candidates", "fallback_message"] - assert data["candidates"][0] == { - "label": "O2", - "coords": [185, 54], - "description": "header", - "bounds": [92, 41, 277, 66], - "source": "ocr", - } - assert data["candidates"][1] == call.args["candidates"][1] - - -def test_universal_submit_enriches_registered_candidates(): - explorer = _indexed_explorer() - explorer._register_label("1", explorer.screen_index.exact_matches("Settings")[0]) - tool_calls = [ - { - "name": "submit_answer", - "args": { - "candidates": [ - {"label": "1", "coords": SETTINGS_COORDS, "description": "gear"}, - {"label": "1", "coords": [5, 5000], "description": "bad"}, - ], - "fallback_message": "note", - }, - "id": "c1", - } - ] - - data = json.loads(explorer._universal_submit_outcome(tool_calls)) - - assert data["fallback_message"] == "note" - assert data["candidates"] == [ - { - "label": "1", - "coords": SETTINGS_COORDS, - "description": "gear", - "bounds": SETTINGS_BOUNDS, - "source": "xml", - } - ] - - -# --------------------------------------------------------------------------- # -# Structural pre-pass -# --------------------------------------------------------------------------- # - - -@pytest.mark.parametrize("version", ["flash", "pro"]) -@pytest.mark.asyncio -async def test_prepass_answers_unique_exact_match_without_the_engine(screenshot, version): - explorer = Explorer(_context()) - explorer._run_flash = AsyncMock() - explorer._run_loop = AsyncMock() - - with patch(f"{RUN_SETUP}.StorageManager", return_value=_storage(None)): - raw = await explorer.run(" settings ", "", screenshot, _state(screenshot), version=version) - - explorer._run_flash.assert_not_called() - explorer._run_loop.assert_not_called() - assert json.loads(raw) == { - "candidates": [ - { - "label": "T1", - "coords": SETTINGS_COORDS, - "bounds": SETTINGS_BOUNDS, - "source": "xml", - "description": "Settings (exact text match)", - } - ], - "fallback_message": "", - } - assert explorer.label_registry["T1"].text == "Settings" - - -@pytest.mark.asyncio -async def test_prepass_leaves_ambiguous_matches_to_the_engine(screenshot): - explorer = Explorer(_context()) - engine = _outcome([{"label": "D1", "coords": [185, 854], "description": "Send"}]) - explorer._run_flash = AsyncMock(return_value=engine) - - with patch(f"{RUN_SETUP}.StorageManager", return_value=_storage(None)): - raw = await explorer.run("Send", "", screenshot, _state(screenshot), version="flash") - - explorer._run_flash.assert_awaited_once_with("Send", screenshot) - assert raw == engine - - -@pytest.mark.asyncio -async def test_prepass_without_pipe_or_match_passes_the_query_through(screenshot): - explorer = Explorer(_context()) - engine = _outcome(fallback="Failed to detect: gear icon") - explorer._run_flash = AsyncMock(return_value=engine) - - with patch(f"{RUN_SETUP}.StorageManager", return_value=_storage(None)): - raw = await explorer.run("gear icon", "", screenshot, _state(screenshot), version="flash") - - explorer._run_flash.assert_awaited_once_with("gear icon", screenshot) - assert raw == engine - - -@pytest.mark.asyncio -async def test_prepass_merges_partial_results_with_the_engine_outcome(screenshot): - explorer = Explorer(_context()) - detected = {"label": "D1", "coords": [185, 854], "description": "Send"} - explorer._run_loop = AsyncMock(return_value=_outcome([detected], fallback="two Send buttons")) - - with patch(f"{RUN_SETUP}.StorageManager", return_value=_storage(None)): - raw = await explorer.run( - "Settings | Send | Welcome", "", screenshot, _state(screenshot), version="pro" - ) - - # Only the unresolved part reaches the engine; "Welcome" is an OCR exact match. - assert explorer._run_loop.await_args.args[1] == "Send" - data = json.loads(raw) - assert [c["label"] for c in data["candidates"]] == ["T1", "T2", "D1"] - assert data["candidates"][1]["source"] == "ocr" - assert data["candidates"][1]["description"] == "Welcome (exact text match)" - assert data["candidates"][2] == detected - assert data["fallback_message"] == "two Send buttons" - - -def test_merge_prepass_keeps_structural_hits_over_an_unreadable_engine_answer(): - prepass = [{"label": "T1", "coords": [1, 2]}] - merged = json.loads(Explorer._merge_prepass(prepass, "Error: max iterations")) - assert merged == {"candidates": prepass, "fallback_message": "Error: max iterations"} - assert Explorer._merge_prepass([], "raw") == "raw" - - -@pytest.mark.asyncio -async def test_prepass_never_runs_on_the_fly_ocr_for_flash(screenshot): - record = SimpleNamespace(ui_tree=HIERARCHY[:3], ocr_result=None) - explorer = Explorer(_context()) - explorer._run_flash = AsyncMock(return_value=_outcome()) - explorer._run_loop = AsyncMock(return_value=_outcome()) - - with ( - patch(f"{RUN_SETUP}.StorageManager", return_value=_storage(record)), - patch(f"{RUN_SETUP}.is_ocr_configured", return_value=True), - patch(f"{RUN_SETUP}.perform_ocr", new_callable=AsyncMock, return_value=[]) as perform_ocr, - ): - await explorer.run("gear icon", "", screenshot, _state(screenshot, []), version="flash") - perform_ocr.assert_not_awaited() - assert len(explorer.screen_index) == 3 # the record still feeds the index - - await explorer.run("gear icon", "", screenshot, _state(screenshot, []), version="pro") - perform_ocr.assert_awaited_once() diff --git a/tests/unit/agents/test_explorer_screen_index.py b/tests/unit/agents/test_explorer_screen_index.py deleted file mode 100644 index c3b6fa831..000000000 --- a/tests/unit/agents/test_explorer_screen_index.py +++ /dev/null @@ -1,120 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for the Explorer's in-memory screen index.""" - -import pytest - -from artemis.agents.explorer.screen_index import ( - ScreenElement, - ScreenIndex, - normalize_text, - text_similarity, -) - -W, H = 1080, 2400 - - -def _node(text=None, bounds="[0,0][100,100]", desc=None, **attrs): - node = {"bounds": bounds, "class": "android.widget.TextView"} - if text is not None: - node["text"] = text - if desc is not None: - node["content-desc"] = desc - node.update(attrs) - return node - - -def _hierarchy(): - return [ - _node("Gmail", "[300,1600][500,1800]", clickable="true", **{"resource-id": "app:gmail"}), - _node(None, "[0,0][1080,2400]"), # unlabeled root wrapper is skipped - _node(desc="Settings", bounds="[900,50][1000,150]", clickable="true"), - { - "bounds": "[100,2100][900,2300]", - "class": "android.widget.FrameLayout [OCR]", - "ocr_elements": [{"text": "Search", "bounds": "[150,2150][350,2250]"}], - }, - _node("Search", "[120,2120][380,2280]"), # overlaps the OCR "Search" - _node("Offscreen", "[-500,-500][-400,-400]"), - _node("Zero", "[10,10][10,10]"), - ] - - -def test_normalize_and_similarity(): - assert normalize_text(" Send\n Message ") == "send message" - assert text_similarity("gmail", "Gmail") == 1.0 - assert text_similarity("send", "Send message") >= 0.85 - assert text_similarity("ok", "Look here") < 0.85 - assert text_similarity("", "x") == 0.0 - - -def test_from_hierarchy_skips_invisible_and_unlabeled(): - index = ScreenIndex.from_hierarchy(_hierarchy(), W, H) - texts = sorted(e.text for e in index.elements) - assert texts == ["Gmail", "Search", "Search", "Settings"] - assert len(index.ocr_elements) == 1 - gmail = next(e for e in index.elements if e.text == "Gmail") - assert gmail.interactive and gmail.resource_id == "app:gmail" and gmail.source == "xml" - assert gmail.center == (400, 1700) - - -def test_from_hierarchy_tolerates_garbage(): - index = ScreenIndex.from_hierarchy([None, "x", {"bounds": "bad"}, {}], W, H) - assert len(index) == 0 - assert ScreenIndex.empty(W, H).search_text("x") == [] - - -def test_search_text_orders_by_score_and_respects_threshold(): - index = ScreenIndex.from_hierarchy(_hierarchy(), W, H) - matches = index.search_text("Gmai", threshold=0.6) - assert matches and matches[0].element.text == "Gmail" - assert all(m.score > 0.6 for m in matches) - assert index.search_text("zzzz", threshold=0.6) == [] - assert len(index.search_text("Search", threshold=0.4, limit=1)) == 1 - - -def test_exact_matches_collapse_overlapping_duplicates_preferring_xml(): - index = ScreenIndex.from_hierarchy(_hierarchy(), W, H) - hits = index.exact_matches(" search ") - assert len(hits) == 1 - assert hits[0].source == "xml" - assert index.exact_matches("settings")[0].text == "Settings" - assert index.exact_matches("") == [] - assert index.exact_matches("nope") == [] - - -def test_elements_at_reports_innermost_then_ocr(): - elements = [ - ScreenElement("outer", (0, 0, 1000, 1000), "xml"), - ScreenElement("middle", (100, 100, 900, 900), "xml", interactive=False), - ScreenElement("button", (400, 400, 600, 600), "xml", interactive=True), - ScreenElement("label", (450, 450, 550, 550), "ocr"), - ] - index = ScreenIndex(elements, W, H) - found = index.elements_at(500, 500) - # innermost interactive node stops the UI-tree walk; OCR appended after - assert [e.text for e in found] == ["button", "label"] - found = index.elements_at(150, 150) - assert [e.text for e in found] == ["middle", "outer"] - assert index.elements_at(1050, 1050) == [] - - -def test_describe_and_overlap(): - a = ScreenElement("A", (0, 0, 100, 100), "xml", class_name="Btn", resource_id="id/a") - b = ScreenElement("B", (50, 50, 150, 150), "ocr") - assert "class=Btn" in a.describe() and "id=id/a" in a.describe() - assert "source=ocr" in b.describe() - assert a.overlap_ratio(b) == pytest.approx(0.25) - assert a.overlap_ratio(ScreenElement("C", (200, 200, 300, 300), "xml")) == 0.0 diff --git a/tests/unit/agents/test_explorer_tiers.py b/tests/unit/agents/test_explorer_tiers.py deleted file mode 100644 index e8c400d3d..000000000 --- a/tests/unit/agents/test_explorer_tiers.py +++ /dev/null @@ -1,557 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Explorer tier integration: engine selection, tool exposure, caching, prompts.""" - -import json -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch - -from google.genai import types -from langchain_core.messages import AIMessage, HumanMessage, ToolMessage -import pytest - -from artemis.agents.explorer.explorer import Explorer -from artemis.agents.explorer.tiers import EXPLORER_TIERS, PERCEPTION_TOOLS, get_tier -from artemis.config import settings -from artemis.context import ArtemisContext -from artemis.graph.state import State - -EXPLORER = "artemis.agents.explorer.explorer" -RUN_SETUP = "artemis.agents.explorer.run_setup" -UNIVERSAL_RUNNER = "artemis.agents.explorer.universal_runner" - - -def _context(model: str = "gemini-3.8-flash", denylist: list[str] | None = None) -> MagicMock: - ctx = MagicMock(spec=ArtemisContext) - ctx.device = MagicMock() - ctx.device.device_width = 1080 - ctx.device.device_height = 2400 - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = None - ctx.llm_config = MagicMock() - ctx.llm_config.explorer = MagicMock() - ctx.llm_config.explorer.model = model - ctx.llm_config.explorer.temperature = 0.1 - ctx.llm_config.explorer.thinking_level = None - ctx.llm_config.explorer.fallback = None - ctx.agent_config = MagicMock() - ctx.agent_config.denylisted_tools = {"explorer": denylist or []} - ctx.agent_config.explorer = SimpleNamespace(caching=None) - ctx.execution_setup = SimpleNamespace(explorer=SimpleNamespace(caching=None)) - return ctx - - -def _state(screenshot: str) -> MagicMock: - state = MagicMock(spec=State) - state.latest_screenshot = screenshot - state.latest_ui_hierarchy = [] - state.operator_raw_data = {} - return state - - -def _submit_response(coords=(500, 600)) -> MagicMock: - call = MagicMock() - call.name = "submit_answer" - call.args = { - "candidates": [{"label": "D1", "coords": list(coords), "description": "target"}], - "fallback_message": "", - } - response = MagicMock() - response.function_calls = [call] - response.candidates = [MagicMock()] - response.candidates[0].content = types.Content( - role="model", - parts=[types.Part.from_function_call(name="submit_answer", args=call.args)], - ) - return response - - -def _native_client(responses) -> MagicMock: - client = MagicMock() - client.aio.models.generate_content = AsyncMock(side_effect=list(responses)) - cache = SimpleNamespace(name="cachedContents/abc") - client.aio.caches.create = AsyncMock(return_value=cache) - client.aio.caches.delete = AsyncMock() - client.aio.files.upload = AsyncMock( - return_value=SimpleNamespace(uri="files/1", mime_type="image/jpeg", name="files/1") - ) - client.aio.files.delete = AsyncMock() - return client - - -@pytest.fixture -def screenshot(tmp_path): - path = tmp_path / "screen.jpg" - path.write_bytes(b"fake-jpeg-bytes") - return str(path) - - -@pytest.fixture(autouse=True) -def _isolated_env(monkeypatch): - monkeypatch.delenv("ARTEMIS_EXPLORER_CACHING", raising=False) - monkeypatch.setenv("ARTEMIS_USE_FILE_API", "false") - - -# --------------------------------------------------------------------------- # -# Tier -> turn budget / tool exposure -# --------------------------------------------------------------------------- # - - -def test_construction_creates_no_genai_client(): - with patch(f"{EXPLORER}.genai.Client") as client_cls: - explorer = Explorer(_context()) - client_cls.assert_not_called() - assert explorer.client is None - assert explorer.tier is None - - -@pytest.mark.parametrize( - ("version", "max_turns", "expected_tools"), - [ - ("pro", 3, {"ask_perception_tool", "submit_answer"}), - ("ultra", 8, PERCEPTION_TOOLS | {"submit_answer"}), - ], -) -def test_tier_sets_turn_budget_and_exposed_tools(version, max_turns, expected_tools): - explorer = Explorer(_context()) - with patch(f"{EXPLORER}.is_ocr_configured", return_value=True): - explorer._apply_tier(get_tier(version)) - native_names = {t.name for t in explorer.get_exposed_tools()} - universal_names = {t["function"]["name"] for t in explorer._universal_exposed_tools()} - - assert explorer.max_turns == max_turns - assert native_names == expected_tools - assert universal_names == expected_tools - assert explorer.denylisted_tools >= get_tier(version).hidden_tools - - -def test_unconfigured_ocr_and_user_denylist_hide_tools_on_ultra(): - explorer = Explorer(_context(denylist=["inspect_region"])) - with patch(f"{EXPLORER}.is_ocr_configured", return_value=False): - explorer._apply_tier(get_tier("ultra")) - names = {t.name for t in explorer.get_exposed_tools()} - assert "get_ocr_list" not in names - assert "inspect_region" not in names - assert {"detect_objects", "ask_image_processor", "ask_perception_tool"} <= names - - -# --------------------------------------------------------------------------- # -# Engine selection -# --------------------------------------------------------------------------- # - - -def test_gemini_model_without_google_key_uses_universal_engine(): - with patch.object(settings, "GOOGLE_API_KEY", None): - explorer = Explorer(_context(model="gemini-3.8-flash")) - assert explorer.use_native_gemini is False - - -def test_gemini_model_with_shared_client_uses_native_engine(): - ctx = _context(model="google/gemini-3.8-pro") - ctx._genai_client = MagicMock() - with patch.object(settings, "GOOGLE_API_KEY", None): - explorer = Explorer(ctx) - assert explorer.use_native_gemini is True - assert explorer.model_name == "gemini-3.8-pro" - - -@pytest.mark.asyncio -async def test_universal_run_without_google_key_never_touches_genai(screenshot): - ctx = _context(model="claude-sonnet-4") - response = AIMessage( - content="", - tool_calls=[ - { - "name": "submit_answer", - "args": { - "candidates": [{"label": "1", "coords": [100, 200], "description": "ok"}], - "fallback_message": "", - }, - "id": "call_1", - } - ], - ) - llm = MagicMock() - bound = MagicMock() - bound.ainvoke = AsyncMock(return_value=response) - llm.bind_tools.return_value = bound - storage = MagicMock() - storage.get_image.return_value = None - - with ( - patch.object(settings, "GOOGLE_API_KEY", None), - patch(f"{EXPLORER}.genai.Client") as client_cls, - patch(f"{UNIVERSAL_RUNNER}.get_llm", return_value=llm), - patch(f"{RUN_SETUP}.StorageManager", return_value=storage), - ): - explorer = Explorer(ctx) - raw = await explorer.run("find it", "", screenshot, _state(screenshot), version="pro") - - client_cls.assert_not_called() - outcome = json.loads(raw) - assert outcome["candidates"][0]["coords"] == [100, 200] - # The universal engine only sees the pro tier's tools. - bound_names = {t["function"]["name"] for t in llm.bind_tools.call_args_list[0].args[0]} - assert bound_names == {"ask_perception_tool", "submit_answer"} - - -# --------------------------------------------------------------------------- # -# Universal dispatch delivers tool images -# --------------------------------------------------------------------------- # - - -@pytest.mark.asyncio -async def test_universal_dispatch_appends_image_message(tmp_path): - explorer = Explorer(_context(model="claude-sonnet-4")) - img_a = tmp_path / "a.jpg" - img_b = tmp_path / "b.jpg" - img_a.write_bytes(b"aaa") - img_b.write_bytes(b"bbb") - explorer.exec_ask_perception_tool = AsyncMock( - return_value={"text": "perception text", "image_paths": [str(img_a), str(tmp_path / "x")]} - ) - explorer.exec_inspect_region = AsyncMock( - return_value={"text": "zoomed", "image_path": str(img_b)} - ) - messages = [] - tool_calls = [ - { - "name": "ask_perception_tool", - "args": {"search_query": "q", "nx": 1, "ny": 2, "detect_queries": ["q"]}, - "id": "c1", - }, - { - "name": "inspect_region", - "args": {"x_min": 0, "y_min": 0, "x_max": 10, "y_max": 10}, - "id": "c2", - }, - ] - - await explorer._universal_dispatch_tools(tool_calls, messages, turn=1) - - assert [type(m) for m in messages] == [ToolMessage, ToolMessage, HumanMessage] - assert messages[0].content == "perception text" - human = messages[2] - assert human.content[0] == { - "type": "text", - "text": "[Annotated image(s) returned by: ask_perception_tool, inspect_region]", - } - image_blocks = human.content[1:] - assert len(image_blocks) == 2 # the missing file is skipped - assert all(b["type"] == "image_url" for b in image_blocks) - assert image_blocks[0]["image_url"]["url"].startswith("data:image/jpeg;base64,") - - -@pytest.mark.asyncio -async def test_universal_dispatch_without_images_adds_no_human_message(): - explorer = Explorer(_context(model="claude-sonnet-4")) - explorer.exec_get_ocr_list = AsyncMock(return_value={"text": "none", "image_path": None}) - messages = [] - await explorer._universal_dispatch_tools( - [{"name": "get_ocr_list", "args": {}, "id": "c1"}], messages, turn=1 - ) - assert [type(m) for m in messages] == [ToolMessage] - - -@pytest.mark.asyncio -async def test_universal_dispatch_rejects_denylisted_tool(): - explorer = Explorer(_context(model="claude-sonnet-4")) - explorer._apply_tier(get_tier("pro")) - explorer.exec_inspect_region = AsyncMock() - messages = [] - await explorer._universal_dispatch_tools( - [{"name": "inspect_region", "args": {}, "id": "c1"}], messages, turn=1 - ) - explorer.exec_inspect_region.assert_not_called() - assert "denylisted" in messages[0].content - - -# --------------------------------------------------------------------------- # -# Caching precedence -# --------------------------------------------------------------------------- # - - -def test_caching_tier_defaults(): - explorer = Explorer(_context()) - with patch.object(settings, "EXPLORER_CACHING", None): - assert explorer._resolve_caching(get_tier("pro"), None) is False - assert explorer._resolve_caching(get_tier("ultra"), None) is True - - -def test_caching_precedence(monkeypatch): - ctx = _context() - explorer = Explorer(ctx) - pro = get_tier("pro") - - with patch.object(settings, "EXPLORER_CACHING", None): - ctx.agent_config.explorer = SimpleNamespace(caching=True) - assert explorer._resolve_caching(pro, None) is True # agent config beats tier - - monkeypatch.setenv("ARTEMIS_EXPLORER_CACHING", "false") - assert explorer._resolve_caching(pro, None) is False # env beats agent config - - assert explorer._resolve_caching(pro, True) is True # explicit beats env - - with patch.object(settings, "EXPLORER_CACHING", True): - monkeypatch.delenv("ARTEMIS_EXPLORER_CACHING") - ctx.agent_config.explorer = SimpleNamespace(caching=None) - ctx.execution_setup = SimpleNamespace(explorer=SimpleNamespace(caching=False)) - assert explorer._resolve_caching(pro, None) is False # execution setup beats settings - - ctx.execution_setup = SimpleNamespace(explorer=SimpleNamespace(caching=None)) - assert explorer._resolve_caching(pro, None) is True # settings beat tier - - -def test_caching_ignores_mock_config_values(): - """A MagicMock context must fall through to the tier default, not force True.""" - ctx = MagicMock() - explorer = Explorer(ctx) - with patch.object(settings, "EXPLORER_CACHING", None): - assert explorer._resolve_caching(get_tier("pro"), None) is False - - -# --------------------------------------------------------------------------- # -# Tier-aware prompt -# --------------------------------------------------------------------------- # - - -def test_pro_prompt_only_describes_exposed_tools(): - explorer = Explorer(_context()) - explorer._apply_tier(get_tier("pro")) - prompt, error = explorer._build_prompt_template(get_tier("pro")) - assert error is None - assert "ask_perception_tool" in prompt - assert "inspect_region" not in prompt - assert "Image Pool" not in prompt - assert "ask_image_processor" not in prompt - assert "get_ocr_list" not in prompt - assert "# TOOL DENYLIST" not in prompt - assert "# EXECUTION CONSTRAINT" in prompt - assert "maximum of 3 turns" in prompt - - -def test_ultra_prompt_describes_all_tools(): - explorer = Explorer(_context()) - with patch(f"{EXPLORER}.is_ocr_configured", return_value=True): - explorer._apply_tier(get_tier("ultra")) - prompt, error = explorer._build_prompt_template(get_tier("ultra")) - assert error is None - for tool in PERCEPTION_TOOLS: - assert tool in prompt - assert "Image Pool" in prompt - assert "maximum of 8 turns" in prompt - assert "# TOOL DENYLIST" not in prompt - - -def test_prompt_denylist_section_only_for_user_denied_tier_tools(): - explorer = Explorer(_context(denylist=["inspect_region", "search_xml_ocr"])) - with patch(f"{EXPLORER}.is_ocr_configured", return_value=True): - explorer._apply_tier(get_tier("ultra")) - ultra_prompt, _ = explorer._build_prompt_template(get_tier("ultra")) - explorer._apply_tier(get_tier("pro")) - pro_prompt, _ = explorer._build_prompt_template(get_tier("pro")) - - assert "# TOOL DENYLIST" in ultra_prompt - assert "cannot be used: inspect_region" in ultra_prompt - assert "search_xml_ocr" not in ultra_prompt - # Denied tools lose their guidance bullets too. - assert "Targeted Verification" not in ultra_prompt - # On pro, inspect_region is tier-hidden, so it is never mentioned. - assert "# TOOL DENYLIST" not in pro_prompt - assert "inspect_region" not in pro_prompt - - -def test_prompt_sections_render_shape(): - explorer = Explorer(_context()) - explorer._apply_tier(get_tier("pro")) - prompt, _ = explorer._build_prompt_template(get_tier("pro")) - lines = prompt.splitlines() - assert lines[0] == "# IDENTITY" - assert any(line.startswith("# OPERATING PRINCIPLES") for line in lines) - body = [line for line in lines if line and not line.startswith("#")] - assert all(line.startswith("- ") or line.startswith("You are") for line in body) - - -# --------------------------------------------------------------------------- # -# Flash path -# --------------------------------------------------------------------------- # - - -@pytest.mark.asyncio -async def test_flash_run_returns_detected_candidates(screenshot): - detection = AsyncMock( - return_value={ - "detected": [ - {"label": "gear icon", "point": [900, 100]}, - {"label": "send button", "point": [500, 950]}, - ], - "failed": [], - } - ) - with ( - patch(f"{RUN_SETUP}._run_object_detection", detection), - patch(f"{EXPLORER}.genai.Client") as client_cls, - ): - explorer = Explorer(_context()) - raw = await explorer.run( - "gear icon | send button", "", screenshot, _state(screenshot), version="flash" - ) - - client_cls.assert_not_called() - outcome = json.loads(raw) - assert [c["label"] for c in outcome["candidates"]] == ["D1", "D2"] - assert outcome["candidates"][0] == { - "label": "D1", - "coords": [900, 100], - "description": "gear icon", - } - assert outcome["fallback_message"] == "" - assert detection.call_args.args[2] == ["gear icon", "send button"] - - -@pytest.mark.asyncio -async def test_flash_run_reports_missing_detection(screenshot): - with patch(f"{RUN_SETUP}._run_object_detection", AsyncMock(return_value={"detected": []})): - raw = await Explorer(_context()).run( - "ghost", "", screenshot, _state(screenshot), version="flash" - ) - assert json.loads(raw) == {"candidates": [], "fallback_message": "Failed to detect: ghost"} - - -# --------------------------------------------------------------------------- # -# Native engine: async SDK usage and resource lifecycle -# --------------------------------------------------------------------------- # - - -@pytest.mark.asyncio -async def test_native_ultra_run_uses_async_cache_api(screenshot): - ctx = _context() - client = _native_client([_submit_response()]) - ctx._genai_client = client - storage = MagicMock() - storage.get_image.return_value = None - - with ( - patch(f"{EXPLORER}.genai.Client") as client_cls, - patch(f"{RUN_SETUP}.StorageManager", return_value=storage), - patch.object(settings, "EXPLORER_CACHING", None), - ): - explorer = Explorer(ctx) - raw = await explorer.run("target", "", screenshot, _state(screenshot), version="ultra") - - client_cls.assert_not_called() - assert json.loads(raw)["candidates"][0]["coords"] == [500, 600] - client.aio.caches.create.assert_awaited_once() - client.aio.caches.delete.assert_awaited_once_with(name="cachedContents/abc") - config = client.aio.models.generate_content.call_args.kwargs["config"] - assert config.cached_content == "cachedContents/abc" - - -@pytest.mark.asyncio -async def test_native_pro_run_skips_cache_by_default(screenshot): - ctx = _context() - client = _native_client([_submit_response()]) - ctx._genai_client = client - storage = MagicMock() - storage.get_image.return_value = None - - with ( - patch(f"{RUN_SETUP}.StorageManager", return_value=storage), - patch.object(settings, "EXPLORER_CACHING", None), - ): - await Explorer(ctx).run("target", "", screenshot, _state(screenshot), version="pro") - - client.aio.caches.create.assert_not_awaited() - client.aio.caches.delete.assert_not_awaited() - config = client.aio.models.generate_content.call_args.kwargs["config"] - assert {t.name for t in config.tools[0].function_declarations} == { - "ask_perception_tool", - "submit_answer", - } - - -@pytest.mark.asyncio -async def test_native_file_api_uploads_and_deletes_asynchronously(screenshot, monkeypatch): - monkeypatch.setenv("ARTEMIS_USE_FILE_API", "true") - ctx = _context() - client = _native_client([_submit_response()]) - ctx._genai_client = client - storage = MagicMock() - storage.get_image.return_value = None - - with patch(f"{RUN_SETUP}.StorageManager", return_value=storage): - await Explorer(ctx).run("target", "", screenshot, _state(screenshot), version="pro") - - client.aio.files.upload.assert_awaited_once_with(file=screenshot) - client.aio.files.delete.assert_awaited_once_with(name="files/1") - - -@pytest.mark.asyncio -async def test_run_closes_http_client_on_both_engines(screenshot): - storage = MagicMock() - storage.get_image.return_value = None - - # Native engine - ctx = _context() - client = _native_client([_submit_response()]) - ctx._genai_client = client - native = Explorer(ctx) - native.http_client = SimpleNamespace(aclose=AsyncMock()) - with patch(f"{RUN_SETUP}.StorageManager", return_value=storage): - await native.run("t", "", screenshot, _state(screenshot), version="pro") - native_close = native.http_client # already reset - assert native_close is None - - # Universal engine (model call fails -> finally still closes) - llm = MagicMock() - llm.bind_tools.return_value.ainvoke = AsyncMock(side_effect=RuntimeError("boom")) - universal = Explorer(_context(model="claude-sonnet-4")) - http_client = SimpleNamespace(aclose=AsyncMock()) - universal.http_client = http_client - with ( - patch(f"{RUN_SETUP}.StorageManager", return_value=storage), - patch(f"{UNIVERSAL_RUNNER}.get_llm", return_value=llm), - pytest.raises(RuntimeError), - ): - await universal.run("t", "", screenshot, _state(screenshot), version="pro") - http_client.aclose.assert_awaited_once() - assert universal.http_client is None - - -def test_missing_tool_calls_without_candidates_does_not_crash(): - explorer = Explorer(_context()) - response = MagicMock() - response.candidates = None - contents = [] - turn_record = {"tool_calls": []} - explorer._handle_missing_tool_calls(contents, response, turn_record, ["hi"], 3, 1) - assert contents[0].role == "model" - assert contents[0].parts[0].text == "hi" - assert "2 more" in contents[1].parts[0].text - assert turn_record["tool_calls"][0]["name"] == "hallucinated_plain_text" - - -def test_tier_table_is_single_source_for_versions(): - assert set(EXPLORER_TIERS) == {"flash", "pro", "ultra"} - assert get_tier("bogus").name == "flash" - - -@pytest.mark.parametrize("version", [None, "", "turbo", "PRO ", "Ultra"]) -def test_get_tier_normalizes_or_falls_back(version): - """Unknown or empty names fall back to the default tier; case and spaces are tolerated.""" - tier = get_tier(version) - normalized = str(version or "").strip().lower() - expected = normalized if normalized in EXPLORER_TIERS else "flash" - assert tier.name == expected diff --git a/tests/unit/agents/test_explorer_universal.py b/tests/unit/agents/test_explorer_universal.py deleted file mode 100644 index 65e86e477..000000000 --- a/tests/unit/agents/test_explorer_universal.py +++ /dev/null @@ -1,124 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for Universal Explorer and Multi-Model Grounding Engine.""" - -import json -from unittest.mock import AsyncMock, MagicMock, Mock, patch - -import pytest -from langchain_core.messages import AIMessage - -from artemis.agents.explorer.explorer import Explorer -from artemis.agents.explorer.tool_declarations import UNIVERSAL_EXPLORER_TOOLS -from artemis.config import settings -from artemis.context import ArtemisContext -from artemis.graph.state import State - - -@pytest.fixture -def mock_context(tmp_path): - ctx = Mock(spec=ArtemisContext) - ctx.llm_config = Mock() - mock_llm_cfg = Mock() - mock_llm_cfg.model = "claude-3-7-sonnet" - mock_llm_cfg.temperature = 0.1 - ctx.llm_config.explorer = mock_llm_cfg - - ctx.device = Mock() - ctx.device.device_width = 1080 - ctx.device.device_height = 2400 - - ctx.agent_config = Mock() - ctx.agent_config.denylisted_tools = {} - - ctx.data_engine = Mock() - ctx.data_engine.base_dir = tmp_path - return ctx - - -def test_explorer_universal_engine_detection(mock_context): - """Test that Explorer routes to Universal Engine when non-Gemini model is configured.""" - with patch.object(settings, "GOOGLE_API_KEY", None): - agent = Explorer(mock_context) - assert agent.use_native_gemini is False - assert agent.model_name == "claude-3-7-sonnet" - - -def test_universal_explorer_tool_schemas(): - """Verify schema integrity of Universal Explorer tool declarations.""" - assert len(UNIVERSAL_EXPLORER_TOOLS) == 6 - names = [t["function"]["name"] for t in UNIVERSAL_EXPLORER_TOOLS] - assert "ask_perception_tool" in names - assert "detect_objects" in names - assert "get_ocr_list" in names - assert "ask_image_processor" in names - assert "inspect_region" in names - assert "submit_answer" in names - - -@pytest.mark.asyncio -async def test_run_universal_explorer_success(mock_context, tmp_path): - """Test universal Explorer tool invocation and submit_answer coordinate parsing.""" - agent = Explorer(mock_context) - agent.use_native_gemini = False - - fake_screenshot = tmp_path / "screenshot.jpg" - fake_screenshot.write_bytes(b"fake_image_bytes") - - # Mock tool call response for submit_answer - msg_response = AIMessage( - content="Submitting grounded elements.", - tool_calls=[ - { - "name": "submit_answer", - "args": { - "candidates": [ - { - "label": "D1", - "coords": [500, 600], - "description": "Settings button", - } - ], - "fallback_message": "", - }, - "id": "exp_call_1", - } - ], - ) - - mock_llm = MagicMock() - mock_bound = MagicMock() - mock_bound.ainvoke = AsyncMock(return_value=msg_response) - mock_llm.bind_tools.return_value = mock_bound - - with patch( - "artemis.agents.explorer.universal_runner.get_llm", - return_value=mock_llm, - ): - mock_state = Mock(spec=State) - res_str = await agent._run_universal( - query="find settings icon", - context_feedback="", - screenshot_path=str(fake_screenshot), - state=mock_state, - minimal_list="", - prompt_template="You are an explorer.", - max_turns=3, - ) - - res = json.loads(res_str) - assert len(res["candidates"]) == 1 - assert res["candidates"][0]["label"] == "D1" - assert res["candidates"][0]["coords"] == [500, 600] diff --git a/tests/unit/agents/test_flash_runner.py b/tests/unit/agents/test_flash_runner.py deleted file mode 100644 index f188b5e1b..000000000 --- a/tests/unit/agents/test_flash_runner.py +++ /dev/null @@ -1,558 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for Universal FlashRunner.""" - -import base64 -from types import SimpleNamespace -from unittest.mock import AsyncMock, Mock, patch -import pytest -from langchain_core.messages import HumanMessage, ToolMessage - -from artemis.agents.explorer.constants import ASK_EXPLORER_DESCRIPTION -from artemis.agents.flash.runner import FlashRunner -from artemis.agents.validator.tool_declarations import ( - ASK_EXPLORER_TOOL, - ToolExecutionResult, -) -from artemis.context import ArtemisContext - - -@pytest.fixture -def mock_context(): - ctx = Mock(spec=ArtemisContext) - ctx.llm_config = Mock() - mock_llm_cfg = Mock() - mock_llm_cfg.model = "gemini-2.5-flash" - mock_llm_cfg.temperature = 0.1 - ctx.llm_config.get_agent.return_value = mock_llm_cfg - - ctx.device = Mock() - ctx.device.device_width = 1080 - ctx.device.device_height = 2400 - ctx.data_engine = None - ctx.adb_client = None - ctx.driver = Mock() - return ctx - - -def test_flash_runner_tools_initialization(mock_context): - """Verify FlashRunner gathers correct active tools.""" - with patch("artemis.controllers.unified_controller.get_driver"): - runner = FlashRunner(mock_context, goal="Test Open Settings") - tools = runner._get_tools() - - tool_names = [t.name for t in tools] - assert "click" in tool_names - assert "click_sequence" in tool_names - assert "swipe" in tool_names - assert "input_text" in tool_names - assert "ask_explorer" in tool_names - assert "report_task_status" in tool_names - # Validator's report_failure_analysis should NOT be in FlashRunner - assert "report_failure_analysis" not in tool_names - # Flash has no note-writing tool and its prompt never teaches notes: - # the Validator set's note readers are not bound. - assert "read_note" not in tool_names - assert "list_notes" not in tool_names - assert tool_names[:2] == ["click", "click_sequence"] - - -# --- Post-action screenshot fallback (failed action / unknown tool) ---------------- - - -def _screen(elements): - return SimpleNamespace( - base64=base64.b64encode(b"POST-IMG").decode("ascii"), - elements=elements, - width=1080, - height=2400, - ) - - -def _failed_click(tc_id="tc1"): - return ToolExecutionResult( - tool_call_id=tc_id, - tool_name="click", - status="error", - text_summary="Error during click: Invalid target format: 3", - ) - - -@pytest.mark.asyncio -async def test_fallback_observation_renders_the_indexed_ui_list(mock_context, tmp_path): - """After an action that produced no observation, the fallback capture must - hand the next tail the same indexed minimal list the success path builds - (never the raw element dict repr) and refresh the state like it.""" - with patch("artemis.controllers.unified_controller.get_driver"): - runner = FlashRunner(mock_context, goal="g") - runner.controller = Mock() - runner.controller.get_screen_data = AsyncMock( - return_value=_screen( - [ - { - "text": "Settings", - "content-desc": "", - "bounds": "[40,120][1040,270]", - "class": "android.widget.TextView", - "resource-id": "android:id/title", - } - ] - ) - ) - state = Mock() - state.latest_screenshot = "old.jpg" - exec_result = _failed_click() - - with patch("artemis.mcp.observation.get_temp_dir", return_value=tmp_path): - post = await runner._capture_post_screenshot( - exec_result, "click", frozenset({"click"}), state - ) - - assert post == b"POST-IMG" - assert isinstance(exec_result.ui_elements_text, str) - assert exec_result.ui_elements_text == "[1] Text: 'Settings' | Bounds: [37,50][962,112]" - assert "{'text'" not in exec_result.ui_elements_text - # State refreshed exactly as the executor's success path does. - assert state.indexed_elements[0]["index"] == 1 - assert state.indexed_elements[0]["text"] == "Settings" - assert state.indexed_points == [state.indexed_elements[0]["center"]] - assert state.latest_screenshot != "old.jpg" - assert state.latest_screenshot.startswith(str(tmp_path)) - assert exec_result.screenshot_path == state.latest_screenshot - - -@pytest.mark.asyncio -async def test_fallback_observation_is_skipped_for_non_action_tools(mock_context): - with patch("artemis.controllers.unified_controller.get_driver"): - runner = FlashRunner(mock_context, goal="g") - runner.controller = Mock() - runner.controller.get_screen_data = AsyncMock(return_value=_screen([])) - state = Mock() - exec_result = ToolExecutionResult( - tool_call_id="tc", tool_name="ask_explorer", status="success", text_summary="found" - ) - - post = await runner._capture_post_screenshot( - exec_result, "ask_explorer", frozenset({"click"}), state - ) - - assert post is None - assert exec_result.ui_elements_text is None - runner.controller.get_screen_data.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_fallback_observation_keeps_the_executors_ui_list(mock_context, tmp_path): - """An action that reported a UI list but no screenshot keeps its own list.""" - with patch("artemis.controllers.unified_controller.get_driver"): - runner = FlashRunner(mock_context, goal="g") - runner.controller = Mock() - runner.controller.get_screen_data = AsyncMock( - return_value=_screen([{"text": "Other", "bounds": "[0,0][100,100]"}]) - ) - exec_result = ToolExecutionResult( - tool_call_id="tc", - tool_name="click", - status="success", - text_summary="click executed", - ui_elements_text="[1] Text: 'Mine' | Bounds: [0,0][10,10]", - ) - - with patch("artemis.mcp.observation.get_temp_dir", return_value=tmp_path): - post = await runner._capture_post_screenshot( - exec_result, "click", frozenset({"click"}), None - ) - - assert post == b"POST-IMG" - assert exec_result.ui_elements_text == "[1] Text: 'Mine' | Bounds: [0,0][10,10]" - - -@pytest.mark.asyncio -async def test_fallback_observation_failure_is_contained(mock_context): - with patch("artemis.controllers.unified_controller.get_driver"): - runner = FlashRunner(mock_context, goal="g") - runner.controller = Mock() - runner.controller.get_screen_data = AsyncMock(side_effect=RuntimeError("adb gone")) - state = Mock() - state.latest_screenshot = "old.jpg" - exec_result = _failed_click() - - post = await runner._capture_post_screenshot(exec_result, "click", frozenset({"click"}), state) - - assert post is None - assert exec_result.ui_elements_text is None - assert state.latest_screenshot == "old.jpg" - - -def test_flash_runner_executor_follows_the_flash_explorer_tier(mock_context): - """The executor is tagged as the Flash profile so ask_explorer follows flash_mode.""" - with ( - patch("artemis.controllers.unified_controller.get_driver"), - patch("artemis.agents.flash.runner.McpActionExecutor") as executor_cls, - ): - runner = FlashRunner(mock_context, goal="Test Open Settings") - - executor_cls.assert_called_once() - assert executor_cls.call_args.kwargs["agent_name"] == "flash" - assert runner.executor is executor_cls.return_value - - -def test_ask_explorer_declaration_is_tier_agnostic(mock_context): - """Flash binds the shared ask_explorer contract: query + context_feedback, no tier.""" - with patch("artemis.controllers.unified_controller.get_driver"): - runner = FlashRunner(mock_context, goal="Test Open Settings") - declaration = next(t for t in runner._get_tools() if t.name == "ask_explorer") - - assert declaration is ASK_EXPLORER_TOOL - assert declaration.description == ASK_EXPLORER_DESCRIPTION - assert set(declaration.parameters["properties"]) == {"query", "context_feedback"} - assert declaration.parameters["required"] == ["query"] - - -def test_flash_runner_screenshot_pruning(mock_context): - """Verify that earlier screenshot blocks are pruned, keeping only the latest.""" - with patch("artemis.controllers.unified_controller.get_driver"): - runner = FlashRunner(mock_context, goal="Test Pruning") - - messages = [ - HumanMessage( - content=[ - {"type": "text", "text": "Turn 1 goal"}, - { - "type": "image_url", - "image_url": {"url": "data:image/jpeg;base64,SCREENSHOT_1"}, - }, - ] - ), - ToolMessage( - tool_call_id="tc1", - name="click", - content=[ - {"type": "text", "text": "Clicked button"}, - { - "type": "image_url", - "image_url": {"url": "data:image/jpeg;base64,SCREENSHOT_2"}, - }, - ], - ), - ToolMessage( - tool_call_id="tc2", - name="swipe", - content=[ - {"type": "text", "text": "Swiped up"}, - { - "type": "image_url", - "image_url": {"url": "data:image/jpeg;base64,SCREENSHOT_3"}, - }, - ], - ), - ] - - runner._prune_intermediate_screenshots(messages) - - # Check Turn 1: image should be removed - assert len(messages[0].content) == 1 - assert messages[0].content[0]["type"] == "text" - - # Check Turn 2 (Tool 1): image should be removed - assert len(messages[1].content) == 1 - assert messages[1].content[0]["type"] == "text" - - # Check Turn 3 (Tool 2 - Latest): image MUST be preserved - assert len(messages[2].content) == 2 - assert messages[2].content[0]["type"] == "text" - assert messages[2].content[1]["type"] == "image_url" - assert messages[2].content[1]["image_url"]["url"] == "data:image/jpeg;base64,SCREENSHOT_3" - - -# --- Recorded action shape (coordinate space + ledger rendering) --------------------- - - -def _record(runner, name, args, metadata=None): - from types import SimpleNamespace - - runner.ctx.data_engine = Mock() - runner.ctx.data_engine.current_step_id = None - runner.ctx.data_engine.record_step.return_value = "step-1" - exec_result = SimpleNamespace( - status="success", - text_summary=f"{name} executed", - metadata=metadata or {}, - ui_elements_text="ui", - ) - step_id = runner._record_action_step( - name, args, exec_result, "thought", {}, b"pre", ["xml"], b"post" - ) - assert step_id == "step-1" - return runner.ctx.data_engine.record_step.call_args.kwargs["action_taken"] - - -def test_record_action_step_stamps_the_normalized_coordinate_space(mock_context): - """Flash stores the model's own 0–1000 target verbatim, marked as - normalized, so the agent-friendly view never re-normalizes it.""" - from artemis.utils.coordinates import ( - COORDINATE_SPACE_KEY, - COORDINATE_SPACE_NORMALIZED, - normalize_any_structure, - ) - - with patch("artemis.controllers.unified_controller.get_driver"): - runner = FlashRunner(mock_context, goal="g") - recorded = _record(runner, "click", {"target": [320, 399]}) - assert recorded["coordinates"] == [320, 399] - assert recorded["normalized_coordinates"] == [320, 399] - assert recorded[COORDINATE_SPACE_KEY] == COORDINATE_SPACE_NORMALIZED - assert normalize_any_structure(recorded, 1080, 2400)["coordinates"] == [320, 399] - - -def test_flash_records_render_in_the_ledger_with_their_arguments(mock_context): - """The band-③ phrase reads the tool arguments Flash keeps under ``args``: - app launches name the package, key presses the key, inputs the text, - direction swipes the direction — never ``'None'``.""" - from artemis.utils.task_tree import format_actions_clean - - with patch("artemis.controllers.unified_controller.get_driver"): - runner = FlashRunner(mock_context, goal="g") - - launch = _record(runner, "manage_app", {"action": "launch", "app_name": "com.android.settings"}) - assert format_actions_clean(launch) == "Launched app 'com.android.settings'" - stop = _record(runner, "manage_app", {"action": "stop", "app_name": "com.android.settings"}) - assert format_actions_clean(stop) == "Stopped app 'com.android.settings'" - direct = _record(runner, "launch_app", {"app_name": "com.android.settings"}) - assert format_actions_clean(direct) == "Launched app 'com.android.settings'" - - key = _record(runner, "press_key", {"key": "BACK"}) - assert format_actions_clean(key) == "Pressed key 'BACK'" - - typed = _record(runner, "input_text", {"target": [500, 600], "text": "hello"}) - assert format_actions_clean(typed) == "Inputted 'hello' into field at [500, 600]" - - # A directional swipe names the direction first; the recorded canonical - # path is the parenthesised detail. - swipe_dir = _record(runner, "swipe", {"direction": "up"}) - assert format_actions_clean(swipe_dir) == "Swiped up (from [600, 700] to [600, 300])" - swipe_fa = _record(runner, "swipe", {"action": "down"}) - assert format_actions_clean(swipe_fa) == "Swiped down (from [600, 300] to [600, 700])" - swipe_coords = _record(runner, "swipe", {"coordinates": [556, 289, 556, 124]}) - assert format_actions_clean(swipe_coords) == "Swiped from [556, 289] to [556, 124]" - - -def test_flash_records_the_executors_target_semantics(mock_context): - """Flash targets are coordinates, so the model's own description (validated - and shaped by the executor into ``metadata.target_semantics``) is the only - target semantics on record: top level, the shape Pro records, rendered as the - action's label. Nothing is inferred when the executor hands back nothing.""" - from artemis.utils.task_tree import format_actions_clean - - with patch("artemis.controllers.unified_controller.get_driver"): - runner = FlashRunner(mock_context, goal="g") - - click_args = {"target": [320, 399], "target_description": "play button"} - click = _record( - runner, - "click", - click_args, - metadata={"target_semantics": {"target_description": "play button"}}, - ) - assert click["target_description"] == "play button" - assert "target_text" not in click - assert format_actions_clean(click) == "Tapped 'play button' (self-described) at [320, 399]" - - burst = _record( - runner, - "click_sequence", - {"sequence": [[500, 280], [885, 362]], "target_descriptions": ["video body", "skip"]}, - metadata={"target_semantics": {"target_descriptions": ["video body", "skip"]}}, - ) - assert burst["target_descriptions"] == ["video body", "skip"] - assert format_actions_clean(burst) == ( - "Tapped sequence of targets: 'video body' (self-described) at [500, 280]," - " 'skip' (self-described) at [885, 362]" - ) - - bare = _record(runner, "click", {"target": [320, 399]}, metadata={"target_semantics": {}}) - assert "target_description" not in bare - assert "target_text" not in bare - - -def test_flash_records_an_index_target_as_the_resolved_point_with_observed_semantics( - mock_context, -): - """An element index is not a coordinate: the record carries the normalized - point the executor resolved it to, plus the element's observed fields in - the shape Pro records (never a self-described label).""" - from artemis.utils.task_tree import format_actions_clean - - with patch("artemis.controllers.unified_controller.get_driver"): - runner = FlashRunner(mock_context, goal="g") - - click = _record( - runner, - "click", - {"target": 3}, - metadata={ - "target_coordinates": [500, 520], - "target_semantics": { - "target_text": "Wi-Fi", - "target_bounds": [37, 480, 962, 560], - "target_resource_id": "android:id/title", - "target_class": "android.widget.TextView", - }, - }, - ) - assert click["coordinates"] == [500, 520] - assert click["normalized_coordinates"] == [500, 520] - assert click["target_text"] == "Wi-Fi" - assert click["target_bounds"] == [37, 480, 962, 560] - assert "target_description" not in click - assert click["args"] == {"target": 3} # the tool call itself, verbatim - assert format_actions_clean(click) == "Tapped 'Wi-Fi' at [500, 520]" - - typed = _record( - runner, - "input_text", - {"target": 2, "text": "hello"}, - metadata={"target_coordinates": [500, 300], "target_semantics": {"target_text": "Search"}}, - ) - assert typed["coordinates"] == [500, 300] - assert format_actions_clean(typed) == "Inputted 'hello' into 'Search' at [500, 300]" - - -@pytest.mark.asyncio -async def test_visual_lens_receives_the_recorded_action_shape(mock_context): - """The lens gets the recorded action minus its verb (what the Pro - summarizer hands it), so ``manage_app``'s own ``action`` argument can no - longer overwrite the action name in the rendered phrase.""" - from artemis.agents.flash.runner import _TurnRecord - from artemis.agents.flash.summarizer import VisualStepSummarizer - - with patch("artemis.controllers.unified_controller.get_driver"): - runner = FlashRunner(mock_context, goal="g") - runner.summarizer = Mock() - runner.executor.execute = AsyncMock( - return_value=ToolExecutionResult( - tool_call_id="tc", - tool_name="manage_app", - status="success", - text_summary="Launched app 'Settings' (com.android.settings); foreground confirmed.", - screenshot_bytes=b"post", - metadata={"target_semantics": {}, "target_coordinates": None}, - ) - ) - args = {"action": "launch", "app_name": "Settings"} - - await runner._execute_and_record_action( - "manage_app", args, "tc", Mock(), [], "thought", {}, b"pre", "xml", 0, _TurnRecord() - ) - - runner.summarizer.dispatch.assert_called_once() - kwargs = runner.summarizer.dispatch.call_args.kwargs - assert kwargs["action_name"] == "manage_app" - assert "action" not in kwargs["action_args"] - assert kwargs["action_args"]["args"] == args - assert VisualStepSummarizer._action_phrase("manage_app", kwargs["action_args"]) == ( - "Launched app 'Settings'" - ) - - -# --- Native thinking (thought summaries) reach the step record ---------------------- - - -def _thinking_response(content): - from types import SimpleNamespace - - return SimpleNamespace(content=content, tool_calls=[]) - - -def test_extract_response_thinking_reads_only_thinking_blocks(mock_context): - """``include_thoughts`` replies carry ``thinking`` blocks next to ``text`` - blocks; only the former are native thinking, and plain-string replies - carry none.""" - with patch("artemis.controllers.unified_controller.get_driver"): - runner = FlashRunner(mock_context, goal="g") - - blocks = [ - {"type": "thinking", "thinking": "**Verifying**\n\nMaps is open."}, - {"type": "text", "text": "Tapping the search bar."}, - {"type": "thinking", "thinking": " "}, - ] - assert runner._extract_response_thinking(_thinking_response(blocks)) == ( - "**Verifying**\n\nMaps is open." - ) - assert runner._extract_response_text(_thinking_response(blocks)) == "Tapping the search bar." - assert runner._extract_response_thinking(_thinking_response("plain reply")) == "" - assert ( - runner._extract_response_thinking(_thinking_response([{"type": "text", "text": "t"}])) == "" - ) - - -def test_record_action_step_persists_native_thinking(mock_context): - from types import SimpleNamespace - - with patch("artemis.controllers.unified_controller.get_driver"): - runner = FlashRunner(mock_context, goal="g") - runner.ctx.data_engine = Mock() - runner.ctx.data_engine.current_step_id = None - runner.ctx.data_engine.record_step.return_value = "step-1" - exec_result = SimpleNamespace( - status="success", text_summary="click executed", metadata={}, ui_elements_text="ui" - ) - - runner._record_action_step( - "click", - {"target": [1, 2]}, - exec_result, - "thought", - {}, - b"pre", - ["xml"], - b"post", - native_text="native summary", - ) - kwargs = runner.ctx.data_engine.record_step.call_args.kwargs - assert kwargs["operator_raw_thinking"] == "thought" - assert kwargs["operator_native_thinking"] == "native summary" - - runner._record_action_step( - "click", {"target": [1, 2]}, exec_result, "thought", {}, b"pre", ["xml"], b"post" - ) - assert runner.ctx.data_engine.record_step.call_args.kwargs["operator_native_thinking"] is None - - -@pytest.mark.asyncio -async def test_final_report_persists_native_thinking(mock_context): - with patch("artemis.controllers.unified_controller.get_driver"): - runner = FlashRunner(mock_context, goal="g") - runner.ctx.data_engine = Mock() - runner.ctx.data_engine.current_step_id = None - runner.summarizer = None - messages = [] - - report = await runner._finalize_task_report( - "report_task_status", - {"status": "completed"}, - "tc", - "final text", - {}, - b"pre", - ["xml"], - messages, - native_text="native summary", - ) - assert report == {"status": "completed"} - kwargs = runner.ctx.data_engine.record_step.call_args.kwargs - assert kwargs["operator_raw_thinking"] == "final text" - assert kwargs["operator_native_thinking"] == "native summary" - assert isinstance(messages[-1], ToolMessage) diff --git a/tests/unit/agents/test_flash_runner_ledger.py b/tests/unit/agents/test_flash_runner_ledger.py deleted file mode 100644 index 57ff1ef87..000000000 --- a/tests/unit/agents/test_flash_runner_ledger.py +++ /dev/null @@ -1,686 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""FlashRunner over the session transcript ledger (Pro-identical history). - -Covers: the ``video_analyzer`` availability gate (same as the Pro graph) and -its prompt segment, the unbounded-by-default turn limit, the Pro-shaped -observation tail (``# CURRENT OBSERVATION [T+mm:ss]`` header, screenshot, -``--- Visible UI Elements ---`` list, ephemeral per-turn notices, the -reasoning reminder after a silent turn), and the end-to-end loop: committed -turns carry text-only tool messages and no execution-result message unless -an action failed, every recorded step of a multi-action turn is registered -on the committed turn, the final-turn tool restriction only applies to -bounded loops, and a no-tool-call turn is nudged, not terminated. -""" - -import base64 -import re -from unittest.mock import AsyncMock, Mock, PropertyMock, patch - -import pytest -from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage - -from artemis.agents.flash.runner import _FINAL_TURN_WARNING, FlashRunner, _TurnRecord -from artemis.agents.operator.prompts import ( - REASONING_REMINDER, - USER_GUIDANCE_MARKER, - UserGuidance, - render_user_guidance, - render_user_instruction, -) -from artemis.agents.validator.tool_declarations import ToolExecutionResult -from artemis.context import ArtemisContext -from artemis.graph.state import State -from artemis.memory.transcript import ( - EPHEMERAL_BLOCKS_KEY, - EXECUTION_RESULT_MARKER, - PRO_UI_LIST_MARKER, - TranscriptLedger, -) - -OBSERVATION_HEADER_RE = re.compile(r"^# CURRENT OBSERVATION \[T\+\d{2,}:\d{2}\]$") -FAILED_RESULT_RE = re.compile( - rf"^{re.escape(EXECUTION_RESULT_MARKER)} \(T\+\d{{2,}}:\d{{2}}\) ---\nStatus: failed\n" -) - - -@pytest.fixture -def mock_context(): - ctx = Mock(spec=ArtemisContext) - ctx.llm_config = Mock() - mock_llm_cfg = Mock() - mock_llm_cfg.model = "gemini-2.5-flash" - mock_llm_cfg.temperature = 0.1 - ctx.llm_config.get_agent.return_value = mock_llm_cfg - ctx.device = Mock() - ctx.device.device_width = 1080 - ctx.device.device_height = 2400 - ctx.data_engine = None - ctx.adb_client = None - ctx.driver = Mock() - return ctx - - -# --------------------------------------------------------------------------- -# video_analyzer availability (same gate as the Pro graph) -# --------------------------------------------------------------------------- - - -def test_video_analyzer_bound_only_when_recording_tools_enabled(mock_context): - with patch("artemis.controllers.unified_controller.get_driver"): - mock_context.execution_setup = Mock() - mock_context.execution_setup.video_recording_tools_enabled = True - names = [t.name for t in FlashRunner(mock_context, goal="g")._get_tools()] - assert "video_analyzer" in names - assert names[-1] == "report_task_status" - - mock_context.execution_setup.video_recording_tools_enabled = False - names = [t.name for t in FlashRunner(mock_context, goal="g")._get_tools()] - assert "video_analyzer" not in names - - # A Mock attribute (truthy but not True) must not enable the tool. - mock_context.execution_setup = Mock() - names = [t.name for t in FlashRunner(mock_context, goal="g")._get_tools()] - assert "video_analyzer" not in names - - -HISTORY_TOOLS = {"search_history", "replay_steps", "get_step_screenshot"} - - -def test_history_tools_bound_only_with_a_data_engine_session(mock_context): - with patch("artemis.controllers.unified_controller.get_driver"): - # No DataEngine: nothing to read, the tools stay out (as in Pro). - names = [t.name for t in FlashRunner(mock_context, goal="g")._get_tools()] - assert not (HISTORY_TOOLS & set(names)) - prompt = FlashRunner(mock_context, goal="g")._render_system_prompt( - FlashRunner(mock_context, goal="g")._get_tools() - ) - assert "search_history" not in prompt and "replay_steps" not in prompt - - mock_context.data_engine = Mock() - with patch("artemis.tools.history._recall_config", return_value=None): - runner = FlashRunner(mock_context, goal="g") - tools = runner._get_tools() - names = [t.name for t in tools] - assert HISTORY_TOOLS <= set(names) - assert names[-1] == "report_task_status" - - # Same declarations as the LangChain tools: derived from one args schema. - search = next(t for t in tools if t.name == "search_history") - assert set(search.parameters["properties"]) == {"query", "step_range", "max_results"} - assert search.parameters["required"] == [] - replay = next(t for t in tools if t.name == "replay_steps") - assert set(replay.parameters["properties"]) == {"start_step", "end_step"} - assert replay.parameters["required"] == ["start_step"] - shot = next(t for t in tools if t.name == "get_step_screenshot") - assert shot.parameters["properties"]["which"]["enum"] == ["pre", "post", "overlay"] - - prompt = runner._render_system_prompt(tools) - assert "`search_history`" in prompt - assert "`replay_steps`" in prompt - assert "`get_step_screenshot`" in prompt - - -def test_search_history_alone_follows_the_recall_config_gate(mock_context): - from types import SimpleNamespace - - with patch("artemis.controllers.unified_controller.get_driver"): - mock_context.data_engine = Mock() - with patch( - "artemis.tools.history._recall_config", - return_value=SimpleNamespace(enabled=False), - ): - names = [t.name for t in FlashRunner(mock_context, goal="g")._get_tools()] - assert "search_history" not in names - assert {"replay_steps", "get_step_screenshot"} <= set(names) - - -def test_video_analyzer_prompt_segment_follows_availability(mock_context): - with patch("artemis.controllers.unified_controller.get_driver"): - runner = FlashRunner(mock_context, goal="g") - mock_context.execution_setup = Mock() - mock_context.execution_setup.video_recording_tools_enabled = True - with_video = runner._render_system_prompt(runner._get_tools()) - assert "`video_analyzer`" in with_video - - mock_context.execution_setup.video_recording_tools_enabled = False - without_video = runner._render_system_prompt(runner._get_tools()) - assert "video_analyzer" not in without_video - # Session-clock / history teaching is unconditional. - assert "T+mm:ss" in without_video - assert "Action Execution Result" in without_video - - -# --------------------------------------------------------------------------- -# Turn limit: unbounded by default, explicit caps honoured -# --------------------------------------------------------------------------- - - -def test_turn_limit_semantics(mock_context): - with patch("artemis.controllers.unified_controller.get_driver"): - assert FlashRunner(mock_context, goal="g", max_turns=0).turn_limit is None - assert FlashRunner(mock_context, goal="g", max_turns=7).turn_limit == 7 - with patch("artemis.agents.flash.runner.load_agent_config", side_effect=RuntimeError): - assert FlashRunner(mock_context, goal="g").turn_limit is None - - -# --------------------------------------------------------------------------- -# Observation tail: Pro shape with the session-relative header -# --------------------------------------------------------------------------- - - -def test_observation_tail_has_pro_shape(mock_context): - """The objective lives in the system prompt only; the tail opens with the - observation header on turn 1 exactly as on every later turn, and carries - no reminder unless the previous turn was silent.""" - with patch("artemis.controllers.unified_controller.get_driver"): - runner = FlashRunner(mock_context, goal="Open Settings") - ledger = TranscriptLedger() - - tail = runner._build_tail(ledger, 1, b"IMG", "[1] Settings") - texts = [b["text"] for b in tail.content if b["type"] == "text"] - assert OBSERVATION_HEADER_RE.match(texts[0]) - assert texts[1] == "--- Current Screenshot ---" - assert texts[2] == f"{PRO_UI_LIST_MARKER}\n[1] Settings" - assert len(texts) == 3 - assert sum(1 for b in tail.content if b["type"] == "image_url") == 1 - joined = "".join(texts) - assert "Open Settings" not in joined and "objective" not in joined.lower() - assert "CRITICAL RULE" not in joined and REASONING_REMINDER not in joined - assert EPHEMERAL_BLOCKS_KEY not in tail.additional_kwargs - - later = runner._build_tail( - ledger, - 5, - None, - None, - injected=UserGuidance(body='User instruction: "stop"', wrapper="[INJECTED]"), - notices=["nudge"], - is_final=True, - ) - texts = [b["text"] for b in later.content] - assert OBSERVATION_HEADER_RE.match(texts[0]) - assert "objective" not in "".join(texts).lower() - assert texts[1:] == [ - "nudge", - "[INJECTED]", - 'User instruction: "stop"', - _FINAL_TURN_WARNING, - ] - - -def test_per_turn_notices_are_marked_ephemeral(mock_context): - """Nudges, the user-guidance wrapper, the final-turn warning and the - reasoning reminder are only meaningful for the turn they were built for: - every one of them is flagged ephemeral (by block index) so the scrub edge - drops them before the message freezes; the observation blocks never are.""" - with patch("artemis.controllers.unified_controller.get_driver"): - runner = FlashRunner(mock_context, goal="Open Settings") - ledger = TranscriptLedger() - guidance = render_user_guidance("stop", release_loop=False, has_plan=False) - - tail = runner._build_tail( - ledger, - 3, - b"IMG", - "[1] Settings", - notices=["nudge"], - injected=guidance, - is_final=True, - previous_turn_silent=True, - ) - texts = [b.get("text") for b in tail.content] - # header, screenshot label, image, UI list, then the per-turn notices with - # the persistent instruction body right after its wrapper - assert texts[4:] == [ - "nudge", - guidance.wrapper, - guidance.body, - _FINAL_TURN_WARNING, - REASONING_REMINDER, - ] - assert tail.additional_kwargs[EPHEMERAL_BLOCKS_KEY] == [4, 5, 7, 8] - - -def test_injected_instruction_body_outlives_its_wrapper(mock_context): - """Defect: a standing instruction ("don't send messages from now on") - vanished after one turn because the whole guidance block was ephemeral. - Now the wrapper is ephemeral and the verbatim body is a regular block, so - it stays in the active window until the turn is chunked.""" - with patch("artemis.controllers.unified_controller.get_driver"): - runner = FlashRunner(mock_context, goal="g") - ledger = TranscriptLedger() - guidance = render_user_guidance( - "don't send messages from now on", has_plan=False, offset_label="T+01:05" - ) - - tail = runner._build_tail(ledger, 2, b"IMG", "[1] Send", injected=guidance) - texts = [b.get("text") for b in tail.content] - wrapper_index = texts.index(guidance.wrapper) - body_index = texts.index(guidance.body) - ephemeral = tail.additional_kwargs[EPHEMERAL_BLOCKS_KEY] - assert wrapper_index in ephemeral - assert body_index not in ephemeral - assert body_index == wrapper_index + 1 - assert guidance.body == 'User instruction (T+01:05): "don\'t send messages from now on"' - assert guidance.body == render_user_instruction( - "don't send messages from now on", offset_label="T+01:05" - ) - # The wrapper frames the quoted line but no longer carries the words. - assert guidance.wrapper.startswith(USER_GUIDANCE_MARKER) - assert "don't send messages" not in guidance.wrapper - - # Scrub edge parity: once the turn has left the live position (a newer - # committed observation exists) the wrapper is gone and the body is - # still in the committed turn. - ledger.stage_turn([tail, AIMessage(content="ok")]) - ledger.commit_staged(step_key="s1") - ledger.stage_turn([runner._build_tail(ledger, 3, b"IMG", "[1] Send"), AIMessage(content="ok")]) - ledger.commit_staged(step_key="s2") - rendered = ledger.render([runner._build_tail(ledger, 4, b"IMG", "[1] Send")]) - committed = next( - m - for m in rendered - if isinstance(m, HumanMessage) and guidance.body in [b.get("text") for b in m.content] - ) - committed_texts = [b.get("text", "") for b in committed.content if isinstance(b, dict)] - assert guidance.wrapper not in committed_texts - assert not any(t.startswith(USER_GUIDANCE_MARKER) for t in committed_texts) - - -def test_reasoning_reminder_follows_a_silent_turn_only(mock_context): - with patch("artemis.controllers.unified_controller.get_driver"): - runner = FlashRunner(mock_context, goal="g") - ledger = TranscriptLedger() - - quiet = runner._build_tail(ledger, 2, None, "[1] x", previous_turn_silent=False) - assert REASONING_REMINDER not in [b["text"] for b in quiet.content] - - reminded = runner._build_tail(ledger, 2, None, "[1] x", previous_turn_silent=True) - texts = [b["text"] for b in reminded.content] - assert texts[-1] == REASONING_REMINDER - assert texts.count(REASONING_REMINDER) == 1 - assert reminded.additional_kwargs[EPHEMERAL_BLOCKS_KEY] == [len(texts) - 1] - - -# --------------------------------------------------------------------------- -# Injected instruction: verbatim text for the record and the lens, wrapped -# notice for the observation tail only -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_injected_instruction_splits_verbatim_text_from_operator_notice( - mock_context, tmp_path -): - """The user's words are what the step record, the chunk ledger and the - visual lens receive (Pro parity); the shared ``--- User Guidance ---`` - block (the Pro rendering, without a plan to edit) rides on the - observation tail alone.""" - import json - - (tmp_path / "injected_instruction.json").write_text( - json.dumps({"instruction": "Skip the popup and log in", "release_loop": True}), - encoding="utf-8", - ) - mock_context.data_engine = Mock() - mock_context.data_engine.base_dir = str(tmp_path) - - with patch("artemis.controllers.unified_controller.get_driver"): - runner = FlashRunner(mock_context, goal="Log in") - instruction, notice = await runner._read_injected_instruction() - - assert instruction == "Skip the popup and log in" - assert notice == render_user_guidance( - "Skip the popup and log in", release_loop=True, has_plan=False - ) - assert notice.wrapper.startswith(USER_GUIDANCE_MARKER) - assert notice.body == 'User instruction: "Skip the popup and log in"' - assert "outranks your current milestones" in notice.wrapper - assert "task plan" not in notice.wrapper - assert "authorized stopping any ongoing monitoring loop" in notice.wrapper - assert "REAL-TIME INJECTED" not in notice.wrapper and "You MUST" not in notice.wrapper - assert not (tmp_path / "injected_instruction.json").exists() - - with patch("artemis.controllers.unified_controller.get_driver"): - assert await runner._read_injected_instruction() == (None, None) - - -# --------------------------------------------------------------------------- -# End-to-end loop over the ledger (mocked model / executor) -# --------------------------------------------------------------------------- - - -def _exec_result(tc_id, name, post_bytes, ui="[1] next"): - return ToolExecutionResult( - tool_call_id=tc_id, - tool_name=name, - status="success", - text_summary=f"{name} executed", - screenshot_bytes=post_bytes, - ui_elements_text=ui, - ) - - -def _make_runner(mock_context, responses, max_turns=None): - runner = FlashRunner(mock_context, goal="Open Settings", max_turns=max_turns) - runner.summarizer = None - runner._init_llm = Mock(return_value=Mock()) - # Snapshot each invocation: the runner appends to the same message list - # after the call, so the recorded call args would otherwise drift. - runner.calls = [] - pending = list(responses) - - async def _invoke(llm, tools, messages): - runner.calls.append({"tools": list(tools), "messages": list(messages)}) - return pending.pop(0) - - runner._invoke_model = AsyncMock(side_effect=_invoke) - runner.executor.execute = AsyncMock( - side_effect=lambda name, args, tc_id, state, **_: _exec_result( - tc_id, name, f"IMG-{tc_id}".encode() - ) - ) - return runner - - -def _report(status="completed", tc_id="tc-report", **extra): - return AIMessage( - content="Done.", - tool_calls=[ - {"name": "report_task_status", "args": {"status": status, **extra}, "id": tc_id} - ], - ) - - -_INITIAL_OBSERVATION = ("shot0.png", b"IMG0", "[1] Settings") - - -@pytest.mark.asyncio -async def test_run_builds_prompt_from_ledger_with_session_offsets(mock_context): - """Turn 2 must see: the static system prefix, the committed turn 1 (its - tail, the AI message, text-only tool messages that already carry each - action's outcome — no execution-result message repeats them) and a fresh - observation tail carrying the last action's post screenshot — the Pro - transcript shape, with no cap.""" - responses = [ - AIMessage( - content="I will tap then wait.", - tool_calls=[ - { - "name": "click", - "args": {"target": [500, 600], "target_description": "Settings"}, - "id": "tc1", - }, - {"name": "wait_for_delay", "args": {"seconds": 1}, "id": "tc2"}, - ], - ), - _report(explanation="ok"), - ] - with ( - patch("artemis.controllers.unified_controller.get_driver"), - patch( - "artemis.agents.flash.runner.capture_screenshot_and_parse_ui", - AsyncMock(return_value=_INITIAL_OBSERVATION), - ), - ): - runner = _make_runner(mock_context, responses, max_turns=0) - assert runner.turn_limit is None - result = await runner.run(State(initial_goal="Open Settings")) - - assert result == {"status": "completed", "explanation": "ok"} - assert runner._invoke_model.await_count == 2 - - turn2_messages = runner.calls[1]["messages"] - assert isinstance(turn2_messages[0], SystemMessage) - assert "T+mm:ss" in turn2_messages[0].content - - tail1 = turn2_messages[1] - assert isinstance(tail1, HumanMessage) - tail1_texts = [b["text"] for b in tail1.content if b["type"] == "text"] - assert OBSERVATION_HEADER_RE.match(tail1_texts[0]) - assert "objective" not in "".join(tail1_texts).lower() - assert "Open Settings" in turn2_messages[0].content # stated once, in the prefix - - assert isinstance(turn2_messages[2], AIMessage) - tool_msgs = [m for m in turn2_messages if isinstance(m, ToolMessage)] - assert [m.content for m in tool_msgs] == ["click executed", "wait_for_delay executed"] - - # Every action succeeded: the tool messages are the outcome, nothing repeats them. - assert not any( - isinstance(m, HumanMessage) - and any(EXECUTION_RESULT_MARKER in b.get("text", "") for b in m.content) - for m in turn2_messages - ) - - tail2 = turn2_messages[5] - tail2_texts = [b["text"] for b in tail2.content if b["type"] == "text"] - assert OBSERVATION_HEADER_RE.match(tail2_texts[0]) - assert "objective" not in "".join(tail2_texts).lower() - images = [b for b in tail2.content if b["type"] == "image_url"] - assert len(images) == 1 - assert images[0]["image_url"]["url"].endswith(base64.b64encode(b"IMG-tc2").decode()) - assert tail2_texts[1] == "--- Current Screenshot ---" - assert tail2_texts[2] == f"{PRO_UI_LIST_MARKER}\n[1] next" - assert len(turn2_messages) == 6 - - # Both actions of the multi-action turn were registered on the committed turn. - ledger = mock_context.transcript_ledger - assert ledger.unchunked_turns()[0]["step_keys"] == ["tc1", "tc2"] - - -@pytest.mark.asyncio -async def test_run_failed_action_turn_ends_with_the_error_result(mock_context): - """A failed action is the one case worth a result message: it carries the - error the turn ended on, after the tool message.""" - responses = [ - AIMessage( - content="Tapping.", - tool_calls=[{"name": "click", "args": {"target": 99}, "id": "tc1"}], - ), - _report(), - ] - with ( - patch("artemis.controllers.unified_controller.get_driver"), - patch( - "artemis.agents.flash.runner.capture_screenshot_and_parse_ui", - AsyncMock(return_value=_INITIAL_OBSERVATION), - ), - ): - runner = _make_runner(mock_context, responses, max_turns=0) - runner.executor.execute = AsyncMock( - return_value=ToolExecutionResult( - tool_call_id="tc1", - tool_name="click", - status="error", - text_summary="Error during click: Invalid target index 99.", - ) - ) - runner._capture_post_screenshot = AsyncMock(return_value=None) - await runner.run(State(initial_goal="Open Settings")) - - turn2_messages = runner.calls[1]["messages"] - result_msgs = [ - m - for m in turn2_messages - if isinstance(m, HumanMessage) - and any(EXECUTION_RESULT_MARKER in b.get("text", "") for b in m.content) - ] - assert len(result_msgs) == 1 - text = result_msgs[0].content[0]["text"] - assert FAILED_RESULT_RE.match(text) - assert "Invalid target index 99" in text - assert isinstance(turn2_messages[turn2_messages.index(result_msgs[0]) - 1], ToolMessage) - - -@pytest.mark.asyncio -async def test_run_reminds_after_a_silent_turn_without_a_bounce(mock_context): - """The ledger judges the committed turn (sibling logic, authoritative - here); the runner only reads ``last_turn_silent`` when it builds the - next tail: one reminder block, marked ephemeral, one model call per turn.""" - responses = [ - AIMessage( - content="", # bare tool call, no visible reasoning - tool_calls=[ - {"name": "click", "args": {"target": 1}, "id": "tc1"}, - ], - ), - _report(), - ] - with ( - patch("artemis.controllers.unified_controller.get_driver"), - patch( - "artemis.agents.flash.runner.capture_screenshot_and_parse_ui", - AsyncMock(return_value=_INITIAL_OBSERVATION), - ), - patch.object(TranscriptLedger, "last_turn_silent", new_callable=PropertyMock) as silent, - ): - silent.side_effect = lambda: silent.call_count > 1 # turn 1: False, later: True - runner = _make_runner(mock_context, responses, max_turns=0) - await runner.run(State(initial_goal="Open Settings")) - - assert runner._invoke_model.await_count == 2 - tail1 = runner.calls[0]["messages"][-1] - assert REASONING_REMINDER not in [b.get("text") for b in tail1.content] - tail2 = runner.calls[1]["messages"][-1] - texts = [b.get("text") for b in tail2.content] - assert texts.count(REASONING_REMINDER) == 1 - assert tail2.additional_kwargs[EPHEMERAL_BLOCKS_KEY] == [texts.index(REASONING_REMINDER)] - - -@pytest.mark.asyncio -async def test_run_calibrates_the_ledger_only_from_measured_usage(mock_context): - """A provider-reported prompt size calibrates the chars-per-token ratio - with the sent messages; an estimated size (no usage metadata) records the - base but never calibrates — the estimate is itself derived from character - counts, so calibrating on it would be circular.""" - measured = AIMessage( - content="Tapping.", - tool_calls=[{"name": "click", "args": {"target": 1}, "id": "tc1"}], - usage_metadata={"input_tokens": 1200, "output_tokens": 5, "total_tokens": 1205}, - ) - responses = [measured, _report()] - with ( - patch("artemis.controllers.unified_controller.get_driver"), - patch( - "artemis.agents.flash.runner.capture_screenshot_and_parse_ui", - AsyncMock(return_value=_INITIAL_OBSERVATION), - ), - patch.object(TranscriptLedger, "record_prompt_tokens") as record, - ): - runner = _make_runner(mock_context, responses, max_turns=0) - await runner.run(State(initial_goal="Open Settings")) - - assert record.call_count == 2 - first_call, second_call = record.call_args_list - assert first_call.args[0] == 1200 and first_call.kwargs["messages"] is not None - # The report turn carries no usage metadata: estimated, recorded, not calibrated. - assert second_call.args[0] >= 1 and second_call.kwargs["messages"] is None - - -@pytest.mark.asyncio -async def test_run_final_turn_restricts_tools_and_fails_without_report(mock_context): - responses = [AIMessage(content="I give up.", tool_calls=[])] - with ( - patch("artemis.controllers.unified_controller.get_driver"), - patch( - "artemis.agents.flash.runner.capture_screenshot_and_parse_ui", - AsyncMock(return_value=_INITIAL_OBSERVATION), - ), - ): - runner = _make_runner(mock_context, responses, max_turns=1) - result = await runner.run(State(initial_goal="Open Settings")) - - assert result == {"status": "failed", "explanation": "I give up."} - tools = runner.calls[0]["tools"] - assert [t.name for t in tools] == ["report_task_status"] - tail = runner.calls[0]["messages"][-1] - assert any("final turn" in b.get("text", "") for b in tail.content) - - -@pytest.mark.asyncio -async def test_run_no_tool_call_turn_is_nudged_not_terminated(mock_context): - responses = [AIMessage(content="thinking only", tool_calls=[]), _report()] - with ( - patch("artemis.controllers.unified_controller.get_driver"), - patch( - "artemis.agents.flash.runner.capture_screenshot_and_parse_ui", - AsyncMock(return_value=_INITIAL_OBSERVATION), - ), - ): - runner = _make_runner(mock_context, responses, max_turns=0) - result = await runner.run(State(initial_goal="Open Settings")) - - assert result == {"status": "completed"} - turn2_messages = runner.calls[1]["messages"] - # Unbounded loop: no final-turn restriction was ever applied. - tools = runner.calls[1]["tools"] - assert "click" in [t.name for t in tools] - tail2_texts = [b["text"] for b in turn2_messages[-1].content if b["type"] == "text"] - assert any("did not call any tools" in t for t in tail2_texts) - # A helper-only / empty turn commits without an execution-result message. - assert not any( - isinstance(m, HumanMessage) - and any(EXECUTION_RESULT_MARKER in b.get("text", "") for b in m.content) - for m in turn2_messages - ) - - -# --------------------------------------------------------------------------- -# _TurnRecord.result: the turn's execution result in the validator-report shape -# --------------------------------------------------------------------------- - - -def test_turn_record_result_is_none_for_helper_only_turns(): - assert _TurnRecord().result() is None - - -def test_turn_record_result_is_none_when_every_action_succeeded(): - """The tool messages already say ``Swiped up.``; a ``Status: dispatched`` - line after them would only duplicate the outcome (and put two human - messages back to back).""" - record = _TurnRecord( - actions=[("click", "success", "Tapped at [500, 500]"), ("swipe", "success", "Swiped up.")] - ) - assert record.result() is None - - -def test_turn_record_failed_result_keeps_an_executor_message_that_names_the_action(): - """Executor messages already name the action; they are not prefixed again.""" - record = _TurnRecord( - actions=[ - ("click", "success", "Tapped at [500, 500]"), - ("swipe", "error", "Error executing swipe: direction 'sideways' is not valid"), - ] - ) - assert record.result() == { - "status": "failed", - "error": "Error executing swipe: direction 'sideways' is not valid", - } - - -def test_turn_record_failed_result_prefixes_an_error_that_does_not_name_the_action(): - record = _TurnRecord(actions=[("type_text", "error", "Device is offline")]) - assert record.result() == {"status": "failed", "error": "type_text: Device is offline"} - - -def test_turn_record_reports_the_first_failure_of_a_multi_action_turn(): - record = _TurnRecord( - actions=[ - ("click", "error", "click rejected: coordinates out of bounds"), - ("swipe", "error", "never reached"), - ] - ) - assert record.result()["error"] == "click rejected: coordinates out of bounds" diff --git a/tests/unit/agents/test_flash_scrub_edge.py b/tests/unit/agents/test_flash_scrub_edge.py deleted file mode 100644 index 1524c3b9c..000000000 --- a/tests/unit/agents/test_flash_scrub_edge.py +++ /dev/null @@ -1,694 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Tests for screenshot summaries, text cleanup, and frozen history. - -Covers pending-summary recovery and expiry, failed-summary placeholders, -independent text and image depths, ephemeral-block remapping, and stable -content after both cleanup passes. -""" - -import json -from unittest.mock import Mock - -import pytest -from langchain_core.messages import AIMessage, HumanMessage, ToolMessage - -from artemis.agents.flash.context_compressor import ScrubEdgeCompressor -from artemis.agents.flash.summarizer import VisualStepSummarizer -from artemis.context import ArtemisContext - - -@pytest.fixture -def mock_context(): - ctx = Mock(spec=ArtemisContext) - ctx.data_engine = None - return ctx - - -def _initial_observation(): - return HumanMessage( - content=[ - {"type": "text", "text": "Task: Search flights"}, - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_0"}}, - {"type": "text", "text": "--- UI Element List ---\nTree 0"}, - ] - ) - - -def _step(i: int) -> ToolMessage: - return ToolMessage( - tool_call_id=f"tc{i}", - name="click", - content=[ - {"type": "text", "text": f"Action {i} completed."}, - {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,IMG_{i}"}}, - {"type": "text", "text": f"--- UI Element List ---\nTree {i}"}, - ], - ) - - -def _simulate_turns(compressor, messages, new_messages): - """Mimic FlashRunner's ordering: compress at turn start, then append.""" - for msg in new_messages: - compressor.compress(messages) - messages.append(msg) - compressor.compress(messages) - - -def _content_fingerprint(msg) -> str: - return json.dumps(msg.content, sort_keys=True, default=str) - - -def _has_image(msg) -> bool: - return any(isinstance(b, dict) and b.get("type") in ("image_url", "image") for b in msg.content) - - -def test_success_path_product_behind_the_scrub_edge(mock_context): - """With summaries ready in time, every message behind the scrub edge is the - legacy-shaped product: action text kept, XML stripped, screenshot replaced - by the ``--- Historical Visual Transition ---`` summary block. - - (Asserted directly; the legacy ``compress_flash_messages`` reference this - was originally diffed against was removed in M5.) The swap happens at - depth K, so the newest K-1 historical steps still carry their screenshots; - their UI lists leave earlier, at the shallow text edge (depth 1), so only - the live observation ever carries an indexed element list. - """ - summarizer = VisualStepSummarizer(mock_context) - for i in range(1, 7): - summarizer._summaries[f"tc{i}"] = f"Visual summary {i}." - - new_messages: list = [] - compressor = ScrubEdgeCompressor( - summarizer=summarizer, - prune_history_xml=True, - image_scrub_depth=3, - pending_grace_steps=3, - ) - _simulate_turns( - compressor, new_messages, [_initial_observation()] + [_step(i) for i in range(1, 7)] - ) - - # Initial observation behind the edge: image silently dropped (no summary - # job), task text kept, XML stripped. - assert new_messages[0].content == [{"type": "text", "text": "Task: Search flights"}] - - # Steps 1-4 behind the scrub edge: exact legacy-shaped swap product. - for i in range(1, 5): - assert new_messages[i].content == [ - {"type": "text", "text": f"Action {i} completed."}, - { - "type": "text", - "text": f"--- Historical Visual Transition ---\nVisual summary {i}.", - }, - ], f"message {i} diverged from the legacy-shaped product" - - # The live observation is intact. - assert _has_image(new_messages[6]) - assert "Tree 6" in str(new_messages[6].content) - - # Step 5 (depth 2 < K) is past the text edge only: screenshot intact, - # UI list already gone, summary not yet swapped in. - assert _has_image(new_messages[5]) - assert "Visual summary 5." not in str(new_messages[5].content) - assert "Tree 5" not in str(new_messages[5].content) - - -def test_race_pending_at_edge_recovers_within_grace(mock_context): - """A step whose summary is late keeps its image at the scrub edge and is - swapped to the summary once it arrives within the grace window.""" - summarizer = VisualStepSummarizer(mock_context) - summarizer._step_inputs["tc1"] = {"step_number": 1} # job pending - - messages: list = [] - compressor = ScrubEdgeCompressor( - summarizer=summarizer, - image_scrub_depth=3, - pending_grace_steps=3, - ) - _simulate_turns( - compressor, messages, [_initial_observation()] + [_step(i) for i in range(1, 4)] - ) - - # Step 1 sits at the scrub edge with a pending summary: image retained. - assert _has_image(messages[1]) - - # Summary arrives within grace; the next pass applies it. - summarizer._summaries["tc1"] = "Recovered visual summary 1." - _simulate_turns(compressor, messages, [_step(4)]) - - assert not _has_image(messages[1]) - assert { - "type": "text", - "text": "--- Historical Visual Transition ---\nRecovered visual summary 1.", - } in messages[1].content - - -def test_race_grace_exhausted_placeholder_never_backfilled(mock_context): - """After the grace window a pending step becomes a placeholder, and a late - summary never mutates the frozen message again.""" - summarizer = VisualStepSummarizer(mock_context) - summarizer._step_inputs["tc1"] = {"step_number": 1} # pending forever (for now) - - messages: list = [] - compressor = ScrubEdgeCompressor( - summarizer=summarizer, - image_scrub_depth=2, - pending_grace_steps=1, - ) - _simulate_turns(compressor, messages, [_step(i) for i in range(1, 5)]) - - # Depth 4 > K + grace = 3: grace exhausted, placeholder applied. - step1_text = str(messages[0].content) - assert not _has_image(messages[0]) - assert "[visual summary pending; evidence at DataEngine step 1]" in step1_text - frozen_fingerprint = _content_fingerprint(messages[0]) - - # The summary arriving later must NOT backfill the frozen message. - summarizer._summaries["tc1"] = "Too late summary." - _simulate_turns(compressor, messages, [_step(5)]) - compressor.compress(messages) - - assert _content_fingerprint(messages[0]) == frozen_fingerprint - assert "Too late summary." not in str(messages[0].content) - - -def test_race_failed_summary_becomes_unavailable_placeholder(mock_context): - """A failed summary is replaced by an unavailable placeholder at the scrub - edge without waiting out the grace window.""" - summarizer = VisualStepSummarizer(mock_context) - summarizer._step_inputs["tc1"] = {"step_number": 1} - summarizer._failed.add("tc1") - - messages: list = [] - compressor = ScrubEdgeCompressor( - summarizer=summarizer, - image_scrub_depth=2, - pending_grace_steps=5, - ) - _simulate_turns(compressor, messages, [_step(1), _step(2), _step(3)]) - - assert not _has_image(messages[0]) - assert "[visual summary unavailable; evidence at DataEngine step 1]" in str(messages[0].content) - - -def test_freeze_invariant_bytes_never_change(mock_context): - """Once a message crosses the scrub edge its content bytes never change, - across many turns and late-arriving summaries.""" - summarizer = VisualStepSummarizer(mock_context) - # Odd steps have summaries ready from the start; even steps stay pending. - for i in range(1, 11): - summarizer._step_inputs[f"tc{i}"] = {"step_number": i} - if i % 2 == 1: - summarizer._summaries[f"tc{i}"] = f"Ready summary {i}." - - messages: list = [] - compressor = ScrubEdgeCompressor( - summarizer=summarizer, - image_scrub_depth=2, - pending_grace_steps=1, - ) - - frozen_fingerprints: dict[int, str] = {} - - def check_frozen(): - for idx in compressor._frozen: - fingerprint = _content_fingerprint(messages[idx]) - if idx in frozen_fingerprints: - assert frozen_fingerprints[idx] == fingerprint, f"frozen message {idx} mutated" - else: - frozen_fingerprints[idx] = fingerprint - - for i in range(1, 11): - compressor.compress(messages) - check_frozen() - messages.append(AIMessage(content=f"Thinking about step {i}.")) - messages.append(_step(i)) - if i == 6: - # Late summaries for already-frozen even steps: must be ignored. - summarizer._summaries["tc2"] = "Late summary 2." - summarizer._summaries["tc4"] = "Late summary 4." - compressor.compress(messages) - check_frozen() - - assert len(frozen_fingerprints) >= 6 - # A frozen pending step carries the placeholder, not the late summary. - assert "Late summary 2." not in " ".join(str(m.content) for m in messages) - - -def test_strip_at_the_edge_keeps_combined_block_prefix_and_live_list(mock_context): - """The XML strip at the edge keeps combined-block prefixes and the live list.""" - messages: list = [] - compressor = ScrubEdgeCompressor(summarizer=None, prune_history_xml=True, image_scrub_depth=2) - - combined = ToolMessage( - tool_call_id="tc1", - name="click", - content=[ - { - "type": "text", - "text": "Tapped Settings.\n--- UI Element List ---\n[huge tree]", - }, - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,A"}}, - ], - ) - _simulate_turns(compressor, messages, [combined, _step(2)]) - - # Combined block at the edge: prefix retained, tree stripped (the exact - # legacy ``compress_flash_messages`` product for this input). - assert messages[0].content[0] == {"type": "text", "text": "Tapped Settings."} - # Live observation keeps its UI list untouched. - assert "Tree 2" in str(messages[1].content) - - -def test_imageless_ui_list_carrier_is_stripped_as_soon_as_a_newer_observation_exists( - mock_context, -): - """A step whose screenshot failed still carries a UI list; it follows the - text edge like any observation: while it is the newest observation it keeps - the list, and it is stripped as soon as a newer observation exists — even - when that newer one has no image either — so only the live observation ever - carries an indexed element list.""" - messages: list = [] - compressor = ScrubEdgeCompressor(summarizer=None, image_scrub_depth=2) - - def imageless(n: int) -> ToolMessage: - return ToolMessage( - tool_call_id=f"tc{n}", - name="click", - content=[ - {"type": "text", "text": f"Tapped {n}, screenshot failed."}, - {"type": "text", "text": f"--- UI Element List ---\nTree {n}"}, - ], - ) - - _simulate_turns(compressor, messages, [imageless(1)]) - assert "Tree 1" in str(messages[0].content) # newest observation: kept - _simulate_turns(compressor, messages, [imageless(2)]) # screenshots keep failing - assert messages[0].content == [{"type": "text", "text": "Tapped 1, screenshot failed."}] - assert "Tree 2" in str(messages[1].content) - _simulate_turns(compressor, messages, [_step(3)]) - assert messages[1].content == [{"type": "text", "text": "Tapped 2, screenshot failed."}] - assert "Tree 3" in str(messages[2].content) # live observation untouched - - -def test_prune_history_xml_disabled_keeps_ui_lists(mock_context): - """With the switch off, UI lists survive both depth-1 and freeze scrubs.""" - summarizer = VisualStepSummarizer(mock_context) - summarizer._summaries["tc1"] = "Summary 1." - - messages: list = [] - compressor = ScrubEdgeCompressor( - summarizer=summarizer, - prune_history_xml=False, - image_scrub_depth=2, - pending_grace_steps=0, - ) - _simulate_turns(compressor, messages, [_step(1), _step(2), _step(3)]) - - # Step 1 froze (summary applied) but its UI list is preserved. - assert not _has_image(messages[0]) - assert "Tree 1" in str(messages[0].content) - assert "Summary 1." in str(messages[0].content) - - -def test_untracked_messages_are_never_touched(mock_context): - """Injected instructions and AI turns pass through untouched; a frozen - image-only tool message falls back to non-empty content.""" - messages: list = [ - HumanMessage(content="[REAL-TIME INJECTED INSTRUCTION from user]: stay put"), - ] - compressor = ScrubEdgeCompressor(summarizer=None, image_scrub_depth=2) - - image_only = ToolMessage( - tool_call_id="tc1", - name="click", - content=[{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,A"}}], - ) - _simulate_turns( - compressor, - messages, - [image_only, AIMessage(content="thinking"), _step(2), _step(3)], - ) - - assert messages[0].content == "[REAL-TIME INJECTED INSTRUCTION from user]: stay put" - assert messages[2].content == "thinking" - # Image-only tool message froze with the defensive fallback text. - assert messages[1].content == [{"type": "text", "text": "Action dispatched."}] - - -def test_depth_override_tightens_once_and_deeper_pass_never_backfills(mock_context): - """A per-call depth override: a shallower pass scrubs the extra depths in - one go; a deeper pass afterwards touches nothing already frozen.""" - summarizer = VisualStepSummarizer(mock_context) - for i in range(1, 9): - summarizer._step_inputs[f"tc{i}"] = {"step_number": i} - summarizer._summaries[f"tc{i}"] = f"Summary {i}." - messages: list = [_initial_observation()] - compressor = ScrubEdgeCompressor(summarizer=summarizer, image_scrub_depth=3) - - for i in range(1, 9): - compressor.compress(messages, image_scrub_depth=6) - messages.append(_step(i)) - compressor.compress(messages, image_scrub_depth=6) - with_images = [m for m in messages if _has_image(m)] - # Depth 6 with the live step at depth 1: tc4..tc7 (depths 5..2) keep - # their screenshots, tc3 (depth 6) is scrubbed. - assert [m.tool_call_id for m in with_images] == ["tc4", "tc5", "tc6", "tc7", "tc8"] - - compressor.compress(messages, image_scrub_depth=3) # tighten: three more scrubbed at once - with_images = [m for m in messages if _has_image(m)] - assert [m.tool_call_id for m in with_images] == ["tc7", "tc8"] - frozen = {idx: _content_fingerprint(messages[idx]) for idx in compressor._frozen} - - compressor.compress(messages, image_scrub_depth=6) # relax: nothing restored - assert [m.tool_call_id for m in messages if _has_image(m)] == ["tc7", "tc8"] - assert {idx: _content_fingerprint(messages[idx]) for idx in frozen} == frozen - - -# --------------------------------------------------------------------------- -# Screenshot replacement, ephemeral blocks, and frozen content -# --------------------------------------------------------------------------- - - -def _labelled_step(i: int, *, footer: str | None = None) -> ToolMessage: - """A step in the Flash observation shape with the screenshot label block.""" - blocks = [ - {"type": "text", "text": f"Action {i} completed."}, - {"type": "text", "text": "--- Current Screenshot ---"}, - {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,IMG_{i}"}}, - {"type": "text", "text": f"--- UI Element List ---\nTree {i}"}, - ] - if footer is not None: - blocks.append({"type": "text", "text": footer}) - return ToolMessage(tool_call_id=f"tc{i}", name="click", content=blocks) - - -def test_summary_takes_the_images_place_and_the_label_leaves_with_it(mock_context): - """(i) The visual summary block sits exactly where the image block was — - not appended after whatever followed — and no ``--- Current Screenshot ---`` - label is left dangling above it.""" - summarizer = VisualStepSummarizer(mock_context) - summarizer._summaries["tc1"] = "Summary 1." - messages: list = [] - compressor = ScrubEdgeCompressor(summarizer=summarizer, image_scrub_depth=2) - _simulate_turns( - compressor, messages, [_labelled_step(1, footer="Footer note 1"), _labelled_step(2)] - ) - - assert messages[0].content == [ - {"type": "text", "text": "Action 1 completed."}, - {"type": "text", "text": "--- Historical Visual Transition ---\nSummary 1."}, - {"type": "text", "text": "Footer note 1"}, - ] - assert "--- Current Screenshot ---" not in str(messages[0].content) - # The live step keeps its label above its screenshot. - assert messages[1].content[1] == {"type": "text", "text": "--- Current Screenshot ---"} - - -def test_ephemeral_blocks_vanish_at_the_text_edge_and_not_before(mock_context): - """(ii) Blocks listed under ``EPHEMERAL_BLOCKS_KEY`` survive only while - the message is the live observation; at the text edge (a newer observation - exists) they are deleted together with the UI list, the screenshot stays - until depth K; the consumed indices are cleared at the text edge.""" - from artemis.memory.transcript import EPHEMERAL_BLOCKS_KEY, mark_ephemeral - - summarizer = VisualStepSummarizer(mock_context) - summarizer._summaries["tc1"] = "Summary 1." - step1 = ToolMessage( - tool_call_id="tc1", - name="click", - content=[ - {"type": "text", "text": "Action 1 completed."}, - {"type": "text", "text": "[reminder: state your reasoning]"}, - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_1"}}, - {"type": "text", "text": "--- UI Element List ---\nTree 1"}, - {"type": "text", "text": "[hint: valid this turn only]"}, - ], - ) - mark_ephemeral(step1, [1, 4]) - - messages: list = [] - compressor = ScrubEdgeCompressor(summarizer=summarizer, image_scrub_depth=3) - _simulate_turns(compressor, messages, [step1]) - # Live observation: everything, ephemeral blocks included, still there. - assert "[reminder: state your reasoning]" in str(messages[0].content) - assert "[hint: valid this turn only]" in str(messages[0].content) - assert messages[0].additional_kwargs[EPHEMERAL_BLOCKS_KEY] == [1, 4] - - _simulate_turns(compressor, messages, [_step(2)]) # step 1 reaches depth 2 - # Text edge: notices and UI list gone, the screenshot stays (depth 2 < K). - assert messages[0].content == [ - {"type": "text", "text": "Action 1 completed."}, - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_1"}}, - ] - assert EPHEMERAL_BLOCKS_KEY not in messages[0].additional_kwargs - - _simulate_turns(compressor, messages, [_step(3)]) # step 1 reaches depth 3 - assert messages[0].content == [ - {"type": "text", "text": "Action 1 completed."}, - {"type": "text", "text": "--- Historical Visual Transition ---\nSummary 1."}, - ] - assert EPHEMERAL_BLOCKS_KEY not in messages[0].additional_kwargs - - -def test_grace_does_the_text_edits_at_k_once_and_only_the_image_swap_waits(mock_context): - """A pending summary keeps the screenshot (and its label) inside the grace - window, but the strip and the ephemeral deletion happen at K exactly once; - the later swap puts the summary where the image was and freezes.""" - from artemis.memory.transcript import mark_ephemeral - - summarizer = VisualStepSummarizer(mock_context) - summarizer._step_inputs["tc1"] = {"step_number": 1} # pending - step1 = _labelled_step(1, footer="[per-turn notice]") - mark_ephemeral(step1, [4]) - - messages: list = [] - compressor = ScrubEdgeCompressor( - summarizer=summarizer, image_scrub_depth=2, pending_grace_steps=3 - ) - _simulate_turns(compressor, messages, [step1, _labelled_step(2)]) - assert messages[0].content == [ - {"type": "text", "text": "Action 1 completed."}, - {"type": "text", "text": "--- Current Screenshot ---"}, - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_1"}}, - ] - after_text_edits = _content_fingerprint(messages[0]) - _simulate_turns(compressor, messages, [_labelled_step(3)]) # still pending, in grace - assert _content_fingerprint(messages[0]) == after_text_edits - - summarizer._summaries["tc1"] = "Late but in time." - _simulate_turns(compressor, messages, [_labelled_step(4)]) - assert messages[0].content == [ - {"type": "text", "text": "Action 1 completed."}, - {"type": "text", "text": "--- Historical Visual Transition ---\nLate but in time."}, - ] - assert 0 in compressor._frozen - - -def test_messages_past_the_edge_are_byte_identical_on_every_later_pass(mock_context): - """(iii) Once a message is past the edge, compressing again — with new - turns appended, depths relaxed or tightened, summaries arriving late — - leaves it byte-identical. Each pass rewrites exactly the two messages - that reached an edge (the text edge at depth 2, the screenshot edge at - K) and nothing older.""" - summarizer = VisualStepSummarizer(mock_context) - for i in range(1, 13): - summarizer._step_inputs[f"tc{i}"] = {"step_number": i} - summarizer._summaries[f"tc{i}"] = f"Summary {i}." - messages: list = [] - compressor = ScrubEdgeCompressor(summarizer=summarizer, image_scrub_depth=3) - - previous: list[str] = [] - for i in range(1, 13): - messages.append(_labelled_step(i)) - compressor.compress(messages) - current = [_content_fingerprint(m) for m in messages] - # Everything before the messages that reached an edge is unchanged. - changed = [idx for idx, fp in enumerate(previous) if current[idx] != fp] - # The message reaching depth 2 (text edge) is index len-1 of the previous - # pass's list, the one reaching depth 3 (screenshot edge) is len-2; the - # new live message is not in that list yet. - expected = [idx for idx in (len(previous) - 2, len(previous) - 1) if idx >= 0] - assert changed == expected, changed - previous = current - - # No new messages: a repeated pass, relaxed or not, is a no-op byte for byte. - compressor.compress(messages) - compressor.compress(messages, image_scrub_depth=6) - assert [_content_fingerprint(m) for m in messages] == previous - # Tightening rewrites exactly the message at the new depth, nothing older. - compressor.compress(messages, image_scrub_depth=2) - changed = [i for i, fp in enumerate(previous) if _content_fingerprint(messages[i]) != fp] - assert changed == [len(messages) - 2] - - -def test_text_edge_deeper_than_screenshot_edge_keeps_the_edges_independent(mock_context): - """xml_scrub_depth=4 with K=3: the screenshot is resolved at depth 3 while - the UI list stays until the message is deeper than 4; the message is only - frozen once both edits have happened.""" - summarizer = VisualStepSummarizer(mock_context) - for i in range(1, 8): - summarizer._summaries[f"tc{i}"] = f"Summary {i}." - messages: list = [] - compressor = ScrubEdgeCompressor(summarizer=summarizer, image_scrub_depth=3, xml_scrub_depth=4) - _simulate_turns(compressor, messages, [_labelled_step(i) for i in range(1, 4)]) - # Step 1 at depth 3: image swapped, label gone, UI list still there. - assert "Summary 1." in str(messages[0].content) - assert not _has_image(messages[0]) - assert "--- Current Screenshot ---" not in str(messages[0].content) - assert "Tree 1" in str(messages[0].content) - - _simulate_turns(compressor, messages, [_labelled_step(4), _labelled_step(5)]) - # Step 1 at depth 5 (> 4): UI list gone now; the summary is untouched. - assert "Tree 1" not in str(messages[0].content) - assert messages[0].content == [ - {"type": "text", "text": "Action 1 completed."}, - {"type": "text", "text": "--- Historical Visual Transition ---\nSummary 1."}, - ] - frozen = _content_fingerprint(messages[0]) - _simulate_turns(compressor, messages, [_labelled_step(6), _labelled_step(7)]) - assert _content_fingerprint(messages[0]) == frozen - - -# --------------------------------------------------------------------------- -# Ephemeral indices stay aligned when the image edge is shallower than the -# text edge (a rewrite before the text edit shifts the later blocks) -# --------------------------------------------------------------------------- - - -def _step_with_notices(i: int) -> ToolMessage: - """Flash observation shape with one ephemeral notice on each side of the - screenshot and a body block *after* the notice that follows the image, so - a stale index would delete the body instead of the notice.""" - from artemis.memory.transcript import mark_ephemeral - - msg = ToolMessage( - tool_call_id=f"tc{i}", - name="click", - content=[ - {"type": "text", "text": f"Action {i} completed."}, - {"type": "text", "text": f"[hint {i}: before the image]"}, - {"type": "text", "text": "--- Current Screenshot ---"}, - {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,IMG_{i}"}}, - {"type": "text", "text": f"--- UI Element List ---\nTree {i}"}, - {"type": "text", "text": f"[reminder {i}: after the image]"}, - {"type": "text", "text": f"Body note {i}"}, - ], - ) - mark_ephemeral(msg, [1, 5]) - return msg - - -def test_image_edge_before_text_edge_remaps_ephemeral_indices_to_the_summary_layout( - mock_context, -): - """K=3 with xml_scrub_depth=4: the summary swap at depth 3 removes the - label block, so the notice after the image moves up by one. The stored - indices must follow it; the later text pass then deletes both notices and - leaves the body block intact.""" - from artemis.memory.transcript import EPHEMERAL_BLOCKS_KEY - - summarizer = VisualStepSummarizer(mock_context) - for i in range(1, 6): - summarizer._summaries[f"tc{i}"] = f"Summary {i}." - messages: list = [] - compressor = ScrubEdgeCompressor(summarizer=summarizer, image_scrub_depth=3, xml_scrub_depth=4) - _simulate_turns(compressor, messages, [_step_with_notices(i) for i in range(1, 4)]) - - # Image pass (depth 3): label + image -> summary; notices still present. - assert messages[0].content == [ - {"type": "text", "text": "Action 1 completed."}, - {"type": "text", "text": "[hint 1: before the image]"}, - {"type": "text", "text": "--- Historical Visual Transition ---\nSummary 1."}, - {"type": "text", "text": "--- UI Element List ---\nTree 1"}, - {"type": "text", "text": "[reminder 1: after the image]"}, - {"type": "text", "text": "Body note 1"}, - ] - assert messages[0].additional_kwargs[EPHEMERAL_BLOCKS_KEY] == [1, 4] - - _simulate_turns(compressor, messages, [_step_with_notices(4), _step_with_notices(5)]) - # Text pass (depth 5 > 4): notices and UI list gone, body block intact. - assert messages[0].content == [ - {"type": "text", "text": "Action 1 completed."}, - {"type": "text", "text": "--- Historical Visual Transition ---\nSummary 1."}, - {"type": "text", "text": "Body note 1"}, - ] - assert EPHEMERAL_BLOCKS_KEY not in messages[0].additional_kwargs - assert 0 in compressor._frozen - - -def test_image_edge_before_text_edge_remaps_when_the_image_is_dropped(mock_context): - """Same ordering with no summary job at all: label and image both leave - (two blocks removed), the notice after the image moves up by two.""" - from artemis.memory.transcript import EPHEMERAL_BLOCKS_KEY - - messages: list = [] - compressor = ScrubEdgeCompressor(summarizer=None, image_scrub_depth=3, xml_scrub_depth=4) - _simulate_turns(compressor, messages, [_step_with_notices(i) for i in range(1, 4)]) - - assert messages[0].content == [ - {"type": "text", "text": "Action 1 completed."}, - {"type": "text", "text": "[hint 1: before the image]"}, - {"type": "text", "text": "--- UI Element List ---\nTree 1"}, - {"type": "text", "text": "[reminder 1: after the image]"}, - {"type": "text", "text": "Body note 1"}, - ] - assert messages[0].additional_kwargs[EPHEMERAL_BLOCKS_KEY] == [1, 3] - - _simulate_turns(compressor, messages, [_step_with_notices(4), _step_with_notices(5)]) - assert messages[0].content == [ - {"type": "text", "text": "Action 1 completed."}, - {"type": "text", "text": "Body note 1"}, - ] - assert EPHEMERAL_BLOCKS_KEY not in messages[0].additional_kwargs - assert 0 in compressor._frozen - - -def test_grace_swap_before_the_text_edge_remaps_ephemeral_indices(mock_context): - """Pending summary inside the grace window with the text edge still ahead: - the keep-image revisit changes nothing (indices untouched); the late swap - removes the label and remaps; the text pass then deletes the right blocks.""" - from artemis.memory.transcript import EPHEMERAL_BLOCKS_KEY - - summarizer = VisualStepSummarizer(mock_context) - summarizer._step_inputs["tc1"] = {"step_number": 1} # pending - messages: list = [] - compressor = ScrubEdgeCompressor( - summarizer=summarizer, image_scrub_depth=3, xml_scrub_depth=5, pending_grace_steps=3 - ) - _simulate_turns(compressor, messages, [_step_with_notices(i) for i in range(1, 4)]) - # Depth 3, pending, in grace: byte-identical, indices untouched. - assert _has_image(messages[0]) - assert "--- Current Screenshot ---" in str(messages[0].content) - assert messages[0].additional_kwargs[EPHEMERAL_BLOCKS_KEY] == [1, 5] - - summarizer._summaries["tc1"] = "Late but in time." - _simulate_turns(compressor, messages, [_step_with_notices(4)]) # depth 4: swap - assert messages[0].content[2] == { - "type": "text", - "text": "--- Historical Visual Transition ---\nLate but in time.", - } - assert messages[0].additional_kwargs[EPHEMERAL_BLOCKS_KEY] == [1, 4] - - _simulate_turns(compressor, messages, [_step_with_notices(5), _step_with_notices(6)]) - # Depth 6 > 5: text pass on the remapped layout. - assert messages[0].content == [ - {"type": "text", "text": "Action 1 completed."}, - {"type": "text", "text": "--- Historical Visual Transition ---\nLate but in time."}, - {"type": "text", "text": "Body note 1"}, - ] - assert EPHEMERAL_BLOCKS_KEY not in messages[0].additional_kwargs - assert 0 in compressor._frozen diff --git a/tests/unit/agents/test_flash_step_summarizer.py b/tests/unit/agents/test_flash_step_summarizer.py deleted file mode 100644 index a26bc57b0..000000000 --- a/tests/unit/agents/test_flash_step_summarizer.py +++ /dev/null @@ -1,929 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for VisualStepSummarizer and Context Compressor in Flash profile.""" - -from unittest.mock import ANY, AsyncMock, Mock, call -from uuid import uuid4 -import pytest -from langchain_core.messages import AIMessage, HumanMessage, ToolMessage - -from artemis.agents.flash.context_compressor import ScrubEdgeCompressor -from artemis.agents.flash.summarizer import ( - FOCUS_BLOCK_HEADER, - FOCUS_INTENT_MAX_CHARS, - VisualStepSummarizer, - build_focus_context, - render_focus_block, -) -from artemis.context import ArtemisContext -from artemis.sdk.builders import Builders - - -@pytest.fixture -def mock_context(): - ctx = Mock(spec=ArtemisContext) - ctx.data_engine = None - return ctx - - -@pytest.mark.asyncio -async def test_summarizer_dispatch_and_caching(mock_context): - """Verify non-blocking dispatch and summary generation.""" - summarizer = VisualStepSummarizer(mock_context) - - mock_llm_response = AIMessage( - content="[Step 1 State: Tapped 'Login' button. Home view opened.]" - ) - summarizer._llm = Mock() - summarizer._llm.ainvoke = AsyncMock(return_value=mock_llm_response) - - summarizer.dispatch( - step_number=1, - action_name="click", - action_args={"target": [500, 600]}, - pre_img_bytes=b"fake_pre", - post_img_bytes=b"fake_post", - exec_outcome="Clicked successfully", - ) - - # Await background task flush - await summarizer.flush() - - assert summarizer.has_summary(1) - summary = summarizer.get_summary(1) - assert "[Step 1 State: Tapped 'Login' button. Home view opened.]" in summary - - # Verify memory optimization: image buffers are released - assert summarizer._step_inputs[1]["pre_img_bytes"] is None - assert summarizer._step_inputs[1]["post_img_bytes"] is None - - -@pytest.mark.asyncio -async def test_summarizer_retries_independently(mock_context): - """A failed summary retries without waiting for another action to arrive.""" - summarizer = VisualStepSummarizer(mock_context) - attempt_count = 0 - - async def mock_ainvoke(messages): - nonlocal attempt_count - attempt_count += 1 - # Fail the first attempt, then return the requested step summary. - if attempt_count == 1: - raise TimeoutError("Simulated timeout") - msg_str = str(messages) - if "Step 1" in msg_str: - return AIMessage(content="[Step 1 State: Recovered Step 1 summary]") - raise AssertionError("Unexpected step") - - summarizer._llm = Mock() - summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke) - - # No Step 2 dispatch is needed to trigger the retry. - summarizer.dispatch( - step_number=1, - action_name="click", - action_args={"target": [100, 100]}, - pre_img_bytes=b"pre1", - post_img_bytes=b"post1", - exec_outcome="Outcome 1", - ) - await summarizer.flush() - - assert summarizer.has_summary(1) - assert "Recovered Step 1" in summarizer.get_summary(1) - assert attempt_count == 2 - - -@pytest.mark.asyncio -async def test_summarizer_bounded_retry_marks_failed(mock_context): - """Persistent failures exit after 1 + retry_limit attempts with an explicit failed state.""" - mock_context.data_engine = Mock() - summarizer = VisualStepSummarizer(mock_context, retry_limit=2) - summarizer._retry_delays = (0.0,) - attempt_count = 0 - - async def mock_ainvoke(messages): - nonlocal attempt_count - attempt_count += 1 - raise TimeoutError("Simulated permanent failure") - - summarizer._llm = Mock() - summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke) - - step_id = uuid4() - summarizer.dispatch( - step_number=1, - action_name="click", - action_args={"target": [100, 100]}, - pre_img_bytes=b"pre1", - post_img_bytes=b"post1", - exec_outcome="Outcome 1", - action_key="tc-fail", - data_engine_step_id=step_id, - ) - await summarizer.flush() - - # Bounded exit: initial attempt + retry_limit retries, then explicit failure. - assert attempt_count == 3 - assert summarizer.has_failed("tc-fail") - assert not summarizer.has_summary("tc-fail") - # Lossless semantics: a failed step still reads as pending so the - # compressor keeps its source image instead of dropping evidence. - assert summarizer.is_pending("tc-fail") - - # DataEngine observability: pending on dispatch, failed on exhaustion. - mock_context.data_engine.update_step_summary.assert_has_calls( - [ - call(step_id, None, status="pending", source="visual_transition", model=ANY), - call(step_id, None, status="failed", source="visual_transition", model=ANY), - ] - ) - - -@pytest.mark.asyncio -async def test_summarizer_success_within_retry_budget_not_failed(mock_context): - """A summary that succeeds on a retry within the budget never enters failed state.""" - summarizer = VisualStepSummarizer(mock_context, retry_limit=3) - summarizer._retry_delays = (0.0,) - attempt_count = 0 - - async def mock_ainvoke(messages): - nonlocal attempt_count - attempt_count += 1 - if attempt_count < 3: - raise TimeoutError("Transient failure") - return AIMessage(content="[Step 1 State: Finally worked.]") - - summarizer._llm = Mock() - summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke) - - summarizer.dispatch( - step_number=1, - action_name="click", - action_args={"target": [100, 100]}, - pre_img_bytes=b"pre1", - post_img_bytes=b"post1", - exec_outcome="Outcome 1", - action_key="tc-ok", - ) - await summarizer.flush() - - assert attempt_count == 3 - assert summarizer.has_summary("tc-ok") - assert not summarizer.has_failed("tc-ok") - - -def test_context_compressor_with_ready_summaries(mock_context): - """Ready summaries replace past image blocks and outdated XML is pruned. - - Rewritten against ``ScrubEdgeCompressor`` (M5; the legacy - ``compress_flash_messages`` was removed) with ``image_scrub_depth=1`` so - every historical message is scrubbed immediately, mirroring the legacy - timing this test was written for. Summaries are keyed by legacy ordinals - to keep the older-caller fallback path covered. - """ - summarizer = VisualStepSummarizer(mock_context) - summarizer._summaries[1] = "[Step 1 State: Tapped Search bar. Keyboard appeared.]" - summarizer._summaries[2] = "[Step 2 State: Typed 'flights'. Suggestions displayed.]" - - messages = [ - HumanMessage( - content=[ - {"type": "text", "text": "Task: Search flights"}, - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_1"}}, - {"type": "text", "text": "--- UI Element List ---\nOld Tree 1"}, - ] - ), - ToolMessage( - tool_call_id="tc1", - name="click", - content=[ - {"type": "text", "text": "Action 'click' completed."}, - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_2"}}, - {"type": "text", "text": "--- UI Element List ---\nOld Tree 2"}, - ], - ), - ToolMessage( - tool_call_id="tc2", - name="input_text", - content=[ - {"type": "text", "text": "Action 'input_text' completed."}, - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG_3"}}, - {"type": "text", "text": "--- UI Element List ---\nLive Latest Tree 3"}, - ], - ), - ] - - compressor = ScrubEdgeCompressor( - summarizer=summarizer, - prune_history_xml=True, - image_scrub_depth=1, - pending_grace_steps=0, - ) - compressor.compress(messages) - - # Initial HumanMessage: Historical initial image pruned, task text preserved - assert len(messages[0].content) == 1 - assert messages[0].content[0]["text"] == "Task: Search flights" - - # Step 1 (ToolMessage 1): Image replaced with Step 1 summary, old XML pruned - assert len(messages[1].content) == 2 - assert messages[1].content[0]["text"] == "Action 'click' completed." - assert messages[1].content[1]["text"] == ( - "--- Historical Visual Transition ---\n" - "[Step 1 State: Tapped Search bar. Keyboard appeared.]" - ) - - # Step 2 (ToolMessage 2 - Latest live screen): Image and latest live XML MUST be preserved - assert len(messages[2].content) == 3 - assert messages[2].content[0]["text"] == "Action 'input_text' completed." - assert messages[2].content[1]["type"] == "image_url" - assert "Live Latest Tree 3" in messages[2].content[2]["text"] - - -def test_context_compressor_uses_tool_call_id_over_message_ordinal(mock_context): - """Generic tool messages cannot shift action summaries onto the wrong result. - - (Rewritten against ``ScrubEdgeCompressor`` in M5. The legacy tests for - pending-summary fallback and late-summary backfill were removed with - ``compress_flash_messages``: the scrub edge covers pending handling in - ``test_flash_scrub_edge.py``, and late backfill is now forbidden by the - freeze invariant rather than supported.) - """ - summarizer = VisualStepSummarizer(mock_context) - summarizer._summaries["click-id"] = "Click-specific visual memory." - summarizer._summaries["type-id"] = "Typing-specific visual memory." - - messages = [ - ToolMessage( - tool_call_id="click-id", - name="click", - content=[ - {"type": "text", "text": "Clicked."}, - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,A"}}, - ], - ), - ToolMessage( - tool_call_id="explorer-id", - name="ask_explorer", - content=[{"type": "text", "text": "Explorer result."}], - ), - ToolMessage( - tool_call_id="type-id", - name="input_text", - content=[ - {"type": "text", "text": "Typed."}, - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,B"}}, - ], - ), - ] - - compressor = ScrubEdgeCompressor( - summarizer=summarizer, - image_scrub_depth=1, - pending_grace_steps=0, - ) - compressor.compress(messages) - - assert "Click-specific visual memory." in str(messages[0].content) - assert "Typing-specific visual memory." not in str(messages[0].content) - assert "Visual Transition" not in str(messages[1].content) - # The latest live observation remains uncompressed until a newer screen exists. - assert messages[2].content[-1]["type"] == "image_url" - - -@pytest.mark.asyncio -async def test_summarizer_keys_actions_independently_of_turn_number(mock_context): - """Multiple actions from one LLM turn keep independent inputs and summaries.""" - mock_context.data_engine = Mock() - summarizer = VisualStepSummarizer(mock_context) - - async def mock_ainvoke(messages): - prompt = str(messages) - if "first action" in prompt: - return AIMessage(content="First action memory.") - return AIMessage(content="Second action memory.") - - summarizer._llm = Mock() - summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke) - first_step_id = uuid4() - second_step_id = uuid4() - - summarizer.dispatch( - step_number=1, - action_name="click", - action_args={"label": "first action"}, - pre_img_bytes=None, - post_img_bytes=None, - exec_outcome="one", - action_key="tc-1", - data_engine_step_id=first_step_id, - ) - summarizer.dispatch( - step_number=2, - action_name="click", - action_args={"label": "second action"}, - pre_img_bytes=None, - post_img_bytes=None, - exec_outcome="two", - action_key="tc-2", - data_engine_step_id=second_step_id, - ) - await summarizer.flush() - - assert summarizer.get_summary("tc-1") == "First action memory." - assert summarizer.get_summary("tc-2") == "Second action memory." - # M1 keying: jobs are canonical under the DataEngine step id; the - # tool_call_id stays queryable as an alias. - assert summarizer.get_job_payload("tc-1")["data_engine_step_id"] == first_step_id - assert summarizer.get_job_payload("tc-2")["data_engine_step_id"] == second_step_id - assert summarizer.resolve_key("tc-1") == str(first_step_id) - mock_context.data_engine.update_step_summary.assert_has_calls( - [ - call( - first_step_id, - "First action memory.", - status="ready", - source="visual_transition", - model=ANY, - ), - call( - second_step_id, - "Second action memory.", - status="ready", - source="visual_transition", - model=ANY, - ), - ], - any_order=True, - ) - - -@pytest.mark.asyncio -async def test_single_frame_dispatch_uses_single_image_prompt_variant(mock_context): - """A missing after-frame (every Pro step, by design) selects the - single-image prompt: decision frame + red marker, no AFTER ACTION section.""" - summarizer = VisualStepSummarizer(mock_context) - captured: list = [] - - async def mock_ainvoke(messages): - captured.append(messages) - return AIMessage( - content=( - "In Step 1, I tapped the 'Battery' row marked in red in the" - " Settings list, which showed 'Network & internet' and 'Display'." - ) - ) - - summarizer._llm = Mock() - summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke) - - summarizer.dispatch( - step_number=1, - action_name="click", - action_args={"target": [100, 200]}, - pre_img_bytes=b"decision_frame", - post_img_bytes=None, - exec_outcome="executed", - ) - await summarizer.flush() - - assert summarizer.has_summary(1) - system_text = str(captured[0][0].content) - human_text = str(captured[0][1].content) - assert "Exactly ONE screenshot" in system_text - assert "no independent post-action evidence" in system_text - assert "DECISION FRAME" in human_text - assert "AFTER ACTION SCREEN" not in human_text - assert "--- [2]" not in human_text - - -@pytest.mark.asyncio -async def test_dual_frame_dispatch_keeps_transition_prompt(mock_context): - """Both frames present (Flash path): the classic BEFORE/AFTER transition - prompt and section labels are unchanged.""" - summarizer = VisualStepSummarizer(mock_context) - captured: list = [] - - async def mock_ainvoke(messages): - captured.append(messages) - return AIMessage( - content=( - "In Step 1, I tapped the 'Settings' gear icon marked in red;" - " the main Settings menu opened showing 'Apps'." - ) - ) - - summarizer._llm = Mock() - summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke) - - summarizer.dispatch( - step_number=1, - action_name="click", - action_args={"target": [100, 200]}, - pre_img_bytes=b"pre", - post_img_bytes=b"post", - exec_outcome="executed", - ) - await summarizer.flush() - - assert summarizer.has_summary(1) - system_text = str(captured[0][0].content) - human_text = str(captured[0][1].content) - assert "Before/After screenshots" in system_text - assert "--- [1] BEFORE ACTION SCREEN (Action Marked Visually in Red) ---" in human_text - assert "--- [2] AFTER ACTION SCREEN ---" in human_text - assert "DECISION FRAME" not in human_text - - -@pytest.mark.asyncio -async def test_echo_output_fails_attempt_then_recovers_on_retry(mock_context): - """An input-marker echo is a failed attempt (not a summary): the bounded - retry regenerates and the recovered text lands normally.""" - summarizer = VisualStepSummarizer(mock_context) - summarizer._retry_delays = (0.0,) - attempts = 0 - - async def mock_ainvoke(messages): - nonlocal attempts - attempts += 1 - if attempts == 1: - return AIMessage(content="--- [2] AFTER ACTION SCREEN ---") - return AIMessage( - content=( - "In Step 1, I tapped the search field marked in red; the field" - " showed placeholder text 'Search here'." - ) - ) - - summarizer._llm = Mock() - summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke) - - summarizer.dispatch( - step_number=1, - action_name="click", - action_args={"target": [1, 2]}, - pre_img_bytes=b"pre", - post_img_bytes=b"post", - exec_outcome="executed", - ) - await summarizer.flush() - - assert attempts == 2 - assert summarizer.has_summary(1) - assert "Search here" in summarizer.get_summary(1) - - -@pytest.mark.asyncio -async def test_degenerate_outputs_exhaust_to_failed(mock_context): - """Echo/too-short/too-long outputs never land as summaries; persistent - degeneration exhausts the bounded retries into the explicit failed state.""" - summarizer = VisualStepSummarizer(mock_context, retry_limit=1) - summarizer._retry_delays = (0.0,) - - async def mock_ainvoke(messages): - text = str(messages) - if "Step 1" in text: - return AIMessage(content="--- [1] DECISION FRAME: SCREEN AT ACTION TIME ---") - if "Step 2" in text: - return AIMessage(content="x" * 2000) - return AIMessage(content="ok.") - - summarizer._llm = Mock() - summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke) - - for step in (1, 2, 3): - summarizer.dispatch( - step_number=step, - action_name="click", - action_args={"target": [1, 2]}, - pre_img_bytes=b"pre", - post_img_bytes=None, - exec_outcome="executed", - ) - await summarizer.flush() - - for step in (1, 2, 3): - assert summarizer.has_failed(step), f"step {step} should have failed" - assert not summarizer.has_summary(step) - - -@pytest.mark.asyncio -async def test_raw_lens_call_meters_llm_usage_without_touching_context_base(mock_context): - """The raw-model lens bypass records an llm_usage trace, but its tiny - prompt never overwrites the session's last_prompt_tokens (the compaction - thresholds' live context base).""" - from artemis.services.token_meter import get_meter - - engine = Mock() - engine.current_session_id = "lens-meter-session" - engine.current_step_id = None - mock_context.data_engine = engine - meter = get_meter("lens-meter-session") - meter.last_prompt_tokens = 777 - - summarizer = VisualStepSummarizer(mock_context) - summarizer._llm = Mock() - summarizer._llm.ainvoke = AsyncMock( - return_value=AIMessage( - content=( - "In Step 1, I tapped the 'Battery' row marked in red; the list" - " showed 'Display' and 'Battery'." - ), - usage_metadata={"input_tokens": 120, "output_tokens": 30, "total_tokens": 150}, - ) - ) - - summarizer.dispatch( - step_number=1, - action_name="click", - action_args={"target": [1, 2]}, - pre_img_bytes=b"pre", - post_img_bytes=b"post", - exec_outcome="executed", - ) - await summarizer.flush() - - usage_calls = [ - c for c in engine.record_trace.call_args_list if c.kwargs.get("name") == "llm_usage" - ] - assert usage_calls, "the raw lens bypass must record an llm_usage trace" - payload = usage_calls[0].kwargs["payload"] - assert payload["source"].startswith("lens:visual_transition:") - assert payload["prompt_tokens"] == 120 - assert meter.last_prompt_tokens == 777 - - -@pytest.mark.asyncio -async def test_focus_block_and_provenance_marked_action_phrase_reach_the_lens(mock_context): - """The lens input opens with the action as every history reader sees it - (self-described target marked), the outcome, and the operator focus - block; the phrase already carries every argument, so no verbatim - argument line repeats them. The system prompt teaches how to use it.""" - summarizer = VisualStepSummarizer(mock_context) - captured: list = [] - - async def mock_ainvoke(messages): - captured.append(messages) - return AIMessage( - content=( - "In Step 3, I tapped the switch marked in red on the 'Wi-Fi' row, which" - " read 'Off' before; afterwards it showed 'On' and the list stayed empty." - ) - ) - - summarizer._llm = Mock() - summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke) - - summarizer.dispatch( - step_number=3, - action_name="click", - action_args={"target": [880, 410], "target_description": "Wi-Fi toggle"}, - pre_img_bytes=b"pre", - post_img_bytes=b"post", - exec_outcome="Tapped at [880, 410] (normalized).", - focus=build_focus_context( - intent="The Wi-Fi row shows Off. I will tap its switch and expect it to read On.", - goal="Turn on Wi-Fi", - subgoal="Enable Wi-Fi > Tap the Wi-Fi switch", - injected_instruction="Do not join any network yet.", - ), - ) - await summarizer.flush() - - assert summarizer.has_summary(3) - system_text = str(captured[0][0].content) - lead_text = captured[0][1].content[0]["text"] - assert "# OPERATOR FOCUS" in system_text - assert "Absence is a fact" in system_text - assert lead_text.startswith( - "Step 3 Physical Action: Tapped 'Wi-Fi toggle' (self-described) at [880, 410]\n" - "Controller Outcome: Tapped at [880, 410] (normalized)." - ) - assert "Action arguments (verbatim)" not in lead_text - assert FOCUS_BLOCK_HEADER in lead_text - assert "Task goal: Turn on Wi-Fi" in lead_text - assert "Active sub-goal: Enable Wi-Fi > Tap the Wi-Fi switch" in lead_text - assert "User instruction at this step: Do not join any network yet." in lead_text - assert "expect it to read On." in lead_text - # The focus precedes the frames: context first, evidence second. - assert captured[0][1].content[1]["text"].startswith("--- [1] BEFORE ACTION SCREEN") - - -@pytest.mark.asyncio -async def test_dispatch_without_focus_carries_no_focus_block(mock_context): - """Callers without operator context (older call sites, tests) get the - plain lead block: no focus header, observed target rendered unmarked.""" - summarizer = VisualStepSummarizer(mock_context) - captured: list = [] - - async def mock_ainvoke(messages): - captured.append(messages) - return AIMessage( - content="In Step 1, I tapped the 'Search' field marked in red; the keyboard opened." - ) - - summarizer._llm = Mock() - summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke) - - summarizer.dispatch( - step_number=1, - action_name="tap", - action_args={"coordinates": [500, 600], "target_text": "Search"}, - pre_img_bytes=b"pre", - post_img_bytes=None, - exec_outcome="dispatched", - ) - await summarizer.flush() - - lead_text = captured[0][1].content[0]["text"] - assert lead_text.startswith("Step 1 Physical Action: Tapped 'Search' at [500, 600]\n") - assert "(self-described)" not in lead_text - assert FOCUS_BLOCK_HEADER not in lead_text - assert summarizer.get_job_payload(1)["focus"] is None - - -@pytest.mark.asyncio -async def test_single_frame_focus_label_forbids_describing_the_outcome(mock_context): - """With one decision frame (every Pro step) the reasoning is labelled as the - operator's aim only, never as an expected screen change to narrate.""" - summarizer = VisualStepSummarizer(mock_context) - captured: list = [] - - async def mock_ainvoke(messages): - captured.append(messages) - return AIMessage( - content="In Step 2, I tapped the 'Pay' button marked in red on a checkout screen totalling '$42.00'." - ) - - summarizer._llm = Mock() - summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke) - - summarizer.dispatch( - step_number=2, - action_name="tap", - action_args={"coordinates": [500, 900], "target_text": "Pay"}, - pre_img_bytes=b"decision_frame", - post_img_bytes=None, - exec_outcome="dispatched", - focus=build_focus_context(intent="Tap Pay; I expect a confirmation dialog."), - ) - await summarizer.flush() - - lead_text = captured[0][1].content[0]["text"] - assert "the expected outcome is NOT in this frame and must not be described" in lead_text - assert "what screen change it expected" not in lead_text - # The focus text is released once the summary landed, like the frames. - assert summarizer.get_job_payload(2)["focus"] is None - - -def test_action_phrase_action_name_wins_over_an_action_argument(): - """``manage_app``'s own ``action="launch"`` argument is the intent the - renderer reads, never the verb: the phrase is ``Launched app 'X'`` for - the Flash record shape, the Pro record shape and a raw argument dict.""" - flash_record = { - "coordinates": None, - "coordinate_space": "normalized", - "args": {"action": "launch", "app_name": "Settings"}, - } - assert VisualStepSummarizer._action_phrase("manage_app", flash_record) == ( - "Launched app 'Settings'" - ) - pro_record = {"intent": "stop", "app_name": "com.android.settings"} - assert VisualStepSummarizer._action_phrase("manage_app", pro_record) == ( - "Stopped app 'com.android.settings'" - ) - raw = {"action": "launch", "app_name": "Settings"} - assert VisualStepSummarizer._action_phrase("manage_app", raw) == "Launched app 'Settings'" - assert "Action: launch" not in VisualStepSummarizer._action_phrase("manage_app", raw) - - # The Flash record shape renders every other action through the same path. - key = {"coordinates": None, "args": {"key": "BACK"}} - assert VisualStepSummarizer._action_phrase("press_key", key) == "Pressed key 'BACK'" - swipe = { - "coordinates": [600, 700, 600, 300], - "normalized_start_coordinates": [600, 700], - "normalized_end_coordinates": [600, 300], - "args": {"direction": "up"}, - } - assert VisualStepSummarizer._action_phrase("swipe", swipe) == ( - "Swiped up (from [600, 700] to [600, 300])" - ) - index_click = { - "coordinates": [500, 520], - "normalized_coordinates": [500, 520], - "target_text": "Wi-Fi", - "args": {"target": 3}, - } - assert VisualStepSummarizer._action_phrase("click", index_click) == ( - "Tapped 'Wi-Fi' at [500, 520]" - ) - - -@pytest.mark.asyncio -async def test_verbatim_argument_line_appears_only_when_the_phrase_folds_arguments( - mock_context, -): - """``Launched app 'Settings'`` carries its arguments: no verbatim line. An - index target (``target=3``) is folded into the resolved point, so the - verbatim line keeps the index the model actually named.""" - summarizer = VisualStepSummarizer(mock_context) - captured: list = [] - - async def mock_ainvoke(messages): - captured.append(messages) - return AIMessage(content="In this step, I acted and the screen showed the result.") - - summarizer._llm = Mock() - summarizer._llm.ainvoke = AsyncMock(side_effect=mock_ainvoke) - - summarizer.dispatch( - step_number=1, - action_name="manage_app", - action_args={"coordinates": None, "args": {"action": "launch", "app_name": "Settings"}}, - pre_img_bytes=b"pre", - post_img_bytes=b"post", - exec_outcome="Launched app 'Settings' (com.android.settings); foreground confirmed.", - ) - summarizer.dispatch( - step_number=2, - action_name="click", - action_args={ - "coordinates": [500, 520], - "normalized_coordinates": [500, 520], - "target_text": "Wi-Fi", - "args": {"target": 3}, - }, - pre_img_bytes=b"pre", - post_img_bytes=b"post", - exec_outcome="Tapped at [500, 520] (normalized).", - ) - await summarizer.flush() - - lead_by_step = {} - for messages in captured: - text = messages[1].content[0]["text"] - lead_by_step[text.split(" ")[1]] = text - launch = lead_by_step["1"] - assert launch.startswith("Step 1 Physical Action: Launched app 'Settings'\nController Outcome:") - assert "Action arguments (verbatim)" not in launch - assert "Action: launch" not in launch - click = lead_by_step["2"] - assert click.startswith("Step 2 Physical Action: Tapped 'Wi-Fi' at [500, 520]\n") - assert "Action arguments (verbatim): click({'target': 3})" in click - - -def test_action_phrase_renders_flash_swipe_points_and_bursts(): - """Flash's ``start``/``end`` swipe points and a Pro burst's additional - actions render through the shared history phrase, never as None.""" - phrase = VisualStepSummarizer._action_phrase( - "swipe", {"start": [500, 800], "end": [500, 200], "target_description": "video list"} - ) - assert phrase == "Swiped 'video list' (self-described) from [500, 800] to [500, 200]" - assert VisualStepSummarizer._action_phrase("swipe", {"direction": "up"}) == "Swiped up" - - burst = VisualStepSummarizer._action_phrase( - "tap", - { - "coordinates": [100, 200], - "target_description": "Skip ad", - "additional_actions": [ - {"action": "tap", "coordinates": [300, 400], "target_text": "Play"} - ], - }, - ) - assert burst.startswith("Fast-action burst (2 actions, unvetted): ") - assert "Tapped 'Skip ad' (self-described) at [100, 200] -> Tapped 'Play' at [300, 400]" in burst - - -def test_build_focus_context_drops_blank_fields_and_caps_reasoning_tail_intact(): - """Blank/None fields never appear; an over-long reasoning keeps its tail - (where the operator states the expected screen change) and announces - the cut in place.""" - assert ( - build_focus_context(intent=" ", goal=None, subgoal="", injected_instruction=None) is None - ) - - head = "H" * 500 - tail = "I expect the toggle to read On." - filler = "x" * (FOCUS_INTENT_MAX_CHARS * 2) - focus = build_focus_context(intent=head + filler + tail, goal="Turn on Wi-Fi") - assert set(focus) == {"goal", "intent"} - assert focus["intent"].startswith("HHHH") - assert focus["intent"].endswith(tail) - assert "characters cut here" in focus["intent"] - assert len(focus["intent"]) < FOCUS_INTENT_MAX_CHARS + 80 - - block = render_focus_block(focus) - assert block.splitlines()[0] == FOCUS_BLOCK_HEADER - assert "Task goal: Turn on Wi-Fi" in block - assert "Active sub-goal" not in block - - -def test_flash_config_and_builder(): - """Verify Flash profile configuration model and SDK builder fluent API.""" - cfg = Builders.AgentConfig.with_flash_config( - max_turns=25, - explorer_mode="flash", - step_summarizer=True, - step_summarizer_model="gemini-2.5-flash-lite", - prune_history_xml=True, - ).build() - - assert cfg.flash.max_turns == 25 - assert cfg.flash.explorer_mode == "flash" - assert cfg.flash.step_summarizer.enabled is True - assert cfg.flash.step_summarizer.model == "gemini-2.5-flash-lite" - assert cfg.flash.step_summarizer.prune_history_xml is True - - -@pytest.mark.asyncio -async def test_data_engine_integration_with_step_summarizer(mock_context): - """Verify that generated summaries sync into DataEngine and are visible to get_agent_friendly_steps.""" - mock_de = Mock() - mock_de.update_step_summary = Mock() - mock_context.data_engine = mock_de - - summarizer = VisualStepSummarizer(mock_context) - summarizer._llm = Mock() - summarizer._llm.ainvoke = AsyncMock( - return_value=AIMessage(content="[Step 1 State: Input text confirmed.]") - ) - - summarizer.dispatch( - step_number=1, - action_name="input_text", - action_args={"text": "hello"}, - pre_img_bytes=b"pre", - post_img_bytes=b"post", - exec_outcome="Executed typing", - ) - await summarizer.flush() - - mock_de.update_step_summary.assert_called_once_with( - 1, - "[Step 1 State: Input text confirmed.]", - status="ready", - source="visual_transition", - model=ANY, - ) - - -def test_draw_action_overlay_rendering(): - """Verify that draw_action_overlay_on_image correctly renders markers on images.""" - import io - from PIL import Image - from artemis.utils.visualization import draw_action_overlay_on_image - - # Create a blank RGB image in memory - img = Image.new("RGB", (1080, 2400), color="white") - buf = io.BytesIO() - img.save(buf, format="JPEG") - raw_bytes = buf.getvalue() - - # 1. Click overlay - annotated_click = draw_action_overlay_on_image( - image_bytes=raw_bytes, - action_name="click", - action_args={"target": [540, 1200]}, - ) - assert len(annotated_click) > 0 - assert annotated_click != raw_bytes - - # 2. Swipe overlay - annotated_swipe = draw_action_overlay_on_image( - image_bytes=raw_bytes, - action_name="swipe", - action_args={"start": [500, 1800], "end": [500, 600]}, - ) - assert len(annotated_swipe) > 0 - assert annotated_swipe != raw_bytes - - # 3. Click Sequence overlay - annotated_seq = draw_action_overlay_on_image( - image_bytes=raw_bytes, - action_name="click_sequence", - action_args={"sequence": [[200, 300], [400, 500], [600, 700]]}, - ) - assert len(annotated_seq) > 0 - assert annotated_seq != raw_bytes - - # 4. Fail-safe on corrupt bytes - corrupt_result = draw_action_overlay_on_image( - image_bytes=b"invalid_bytes", - action_name="click", - action_args={"target": [100, 100]}, - ) - assert corrupt_result == b"invalid_bytes" diff --git a/tests/unit/agents/test_historical_state_hint.py b/tests/unit/agents/test_historical_state_hint.py deleted file mode 100644 index 8a302489f..000000000 --- a/tests/unit/agents/test_historical_state_hint.py +++ /dev/null @@ -1,199 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""HistoricalStateHintPromptComponent (M4): perceptual-hash hint + silence rules. - -Division of labor under test: the hint fires only for matches *older* than the -recent window (returned-to-an-early-state regime); any match within the last 3 -steps silences it entirely — that regime belongs to the pixel-level -ScreenshotSimilarityPromptComponent. -""" - -import base64 -import io -from types import SimpleNamespace -from unittest.mock import MagicMock, patch - -import pytest -from PIL import Image, ImageDraw - -from artemis.agents.operator.prompts import HistoricalStateHintPromptComponent, PromptBuilder -from artemis.utils.image_hash import dhash_hex - - -def _jpeg(draw_fn) -> bytes: - img = Image.new("RGB", (270, 600), "white") - draw_fn(ImageDraw.Draw(img)) - buf = io.BytesIO() - img.save(buf, format="JPEG", quality=85) - return buf.getvalue() - - -def _screen_a(d): - d.rectangle([20, 40, 250, 90], fill="#3355ff") - d.rectangle([60, 450, 210, 510], fill="#22aa66") - - -def _screen_b(d): - for y in range(60, 560, 80): - d.rectangle([10, y, 260, y + 50], fill="#dddddd") - - -SCREEN_A = _jpeg(_screen_a) -SCREEN_B = _jpeg(_screen_b) -HASH_A = dhash_hex(SCREEN_A) -HASH_B = dhash_hex(SCREEN_B) -B64_A = base64.b64encode(SCREEN_A).decode("utf-8") - - -def _step(number: int, post_hash: str | None) -> dict: - meta = {"post_image_dhash": post_hash} if post_hash else {} - return {"step_number": number, "extra_metadata": meta} - - -def _cfg(similarity_hint=True, max_distance=8): - return SimpleNamespace( - memory=SimpleNamespace( - transcript=SimpleNamespace( - similarity_hint=similarity_hint, - similarity_max_distance=max_distance, - ) - ) - ) - - -async def _run(steps, b64=B64_A, cfg=None): - builder = PromptBuilder() - component = HistoricalStateHintPromptComponent() - with patch("artemis.config.load_agent_config", return_value=cfg or _cfg()): - await component(builder, MagicMock(), MagicMock(), latest_screenshot_b64=b64, steps=steps) - return [p for p in builder.human_parts if isinstance(p, str)] - - -@pytest.mark.asyncio -async def test_hint_fires_on_old_matching_step(): - steps = [ - _step(1, HASH_B), - _step(2, HASH_A), # old match - _step(3, HASH_B), - _step(4, HASH_B), - _step(5, HASH_B), - _step(6, HASH_B), - ] - parts = await _run(steps) - assert len(parts) == 1 - assert "Historical state hint" in parts[0] - assert "Step 2" in parts[0] - assert "search_history" in parts[0] - - -@pytest.mark.asyncio -async def test_silent_when_recent_step_also_matches(): - steps = [ - _step(1, HASH_A), # old match exists... - _step(2, HASH_B), - _step(3, HASH_B), - _step(4, HASH_B), - _step(5, HASH_A), # ...but a recent (last-3) match silences the hint - _step(6, HASH_B), - ] - assert await _run(steps) == [] - - -@pytest.mark.asyncio -async def test_silent_without_any_match(): - steps = [_step(n, HASH_B) for n in range(1, 7)] - assert await _run(steps) == [] - - -@pytest.mark.asyncio -async def test_silent_when_config_disabled(): - steps = [ - _step(1, HASH_A), - _step(2, HASH_B), - _step(3, HASH_B), - _step(4, HASH_B), - _step(5, HASH_B), - _step(6, HASH_B), - ] - assert await _run(steps, cfg=_cfg(similarity_hint=False)) == [] - - -@pytest.mark.asyncio -async def test_silent_with_too_few_steps(): - # Everything within the recent window belongs to the pixel-level note. - assert await _run([_step(1, HASH_A), _step(2, HASH_A)]) == [] - - -@pytest.mark.asyncio -async def test_tie_prefers_most_recent_old_step(): - steps = [ - _step(1, HASH_A), - _step(2, HASH_A), # same distance, more recent - _step(3, HASH_B), - _step(4, HASH_B), - _step(5, HASH_B), - _step(6, HASH_B), - ] - parts = await _run(steps) - assert len(parts) == 1 and "Step 2" in parts[0] - - -@pytest.mark.asyncio -async def test_steps_without_hashes_are_skipped(): - steps = [ - _step(1, None), - _step(2, None), - _step(3, None), - _step(4, None), - _step(5, None), - _step(6, None), - ] - assert await _run(steps) == [] - - -def test_record_step_stamps_perceptual_hashes(tmp_path): - from artemis.context import ArtemisContext - from artemis.data_engine.engine import DataEngine - - mock_ctx = MagicMock(spec=ArtemisContext) - setup = MagicMock() - setup.traces_path = str(tmp_path) - mock_ctx.execution_setup = setup - mock_ctx.device = None - engine = DataEngine(mock_ctx) - engine.start_session("dhash stamping test") - - engine.record_step( - pre_screenshot_bytes=SCREEN_A, - post_screenshot_bytes=SCREEN_B, - summary="two distinct screens", - ) - # Same pre/post bytes: post_image_name is nulled but the post-action - # screen IS the pre screen, so the post hash mirrors the pre hash. - engine.record_step( - pre_screenshot_bytes=SCREEN_A, - post_screenshot_bytes=SCREEN_A, - summary="screen unchanged", - ) - for t in list(engine._pending_threads): - t.join() - - steps = engine.storage.get_steps(engine.current_session_id) - meta1 = steps[0].extra_metadata - assert meta1["pre_image_dhash"] == HASH_A - assert meta1["post_image_dhash"] == HASH_B - meta2 = steps[1].extra_metadata - assert meta2["pre_image_dhash"] == HASH_A - assert meta2["post_image_dhash"] == HASH_A diff --git a/tests/unit/agents/test_history_analyzer.py b/tests/unit/agents/test_history_analyzer.py deleted file mode 100644 index 201615d96..000000000 --- a/tests/unit/agents/test_history_analyzer.py +++ /dev/null @@ -1,437 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from pathlib import Path -from unittest.mock import MagicMock, patch - -from langchain_core.messages import ToolMessage -from langchain_core.tools import StructuredTool -from artemis.agents.history_analyzer.history_analyzer import HistoryAnalyzer -from artemis.context import ArtemisContext -from artemis.core.tool_failure import ToolFailure -import pytest - - -@pytest.mark.asyncio -async def test_history_analyzer_no_history(): - # Mock context with a data engine that has no steps - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = MagicMock() - mock_ctx.data_engine.get_agent_friendly_steps.return_value = [] - - analyzer = HistoryAnalyzer(mock_ctx) - result = await analyzer.run("What happened?") - assert result == "No history recorded for this session yet." - - -@pytest.mark.asyncio -async def test_history_analyzer_simple_query_no_tool_call(): - # Mock context and data engine - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = MagicMock() - mock_ctx.data_engine.base_dir = "/tmp/fake_traces" - - steps = [ - { - "step_number": 1, - "relative_time": "1.2s", - "summary": "Opened the settings app", - }, - { - "step_number": 2, - "relative_time": "4.5s", - "summary": "Toggled the wifi switch", - }, - ] - mock_ctx.data_engine.get_agent_friendly_steps.return_value = steps - - # Mock LLM to return a direct text response (no tool calls) - mock_response = MagicMock() - mock_response.content = "First you opened settings, then you toggled wifi." - mock_response.tool_calls = [] - - mock_llm = MagicMock() - mock_llm.bind_tools.return_value = mock_llm - - async def mock_astream(*args, **kwargs): - yield mock_response - - mock_llm.astream.side_effect = mock_astream - - with ( - patch( - "artemis.agents.history_analyzer.history_analyzer.get_llm", - return_value=mock_llm, - ), - patch("pathlib.Path.exists", return_value=False), # Force system prompt fallback - ): - analyzer = HistoryAnalyzer(mock_ctx) - result = await analyzer.run("Summarize the session") - - assert result == "First you opened settings, then you toggled wifi." - assert mock_llm.astream.called - - -@pytest.mark.asyncio -async def test_history_analyzer_detailed_query_with_tool_call(): - # Mock context and data engine with full details - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = MagicMock() - mock_ctx.data_engine.base_dir = "/tmp/fake_traces" - - steps = [ - { - "step_number": 1, - "relative_time": "1.2s", - "summary": "Opened the settings app", - "action_taken": {"action": "tap", "coordinates": [500, 600]}, - "operator_raw_thinking": "Need to open settings to configure wifi", - "last_execution_result": {"status": "success"}, - }, - { - "step_number": 2, - "relative_time": "4.5s", - "summary": "Toggled the wifi switch", - "action_taken": {"action": "tap", "coordinates": [100, 200]}, - "operator_raw_thinking": "Toggle the switch to turn wifi on", - "last_execution_result": {"status": "success"}, - }, - ] - mock_ctx.data_engine.get_agent_friendly_steps.return_value = steps - mock_ctx.data_engine.get_agent_friendly_steps_in_range.side_effect = lambda s, e: [ - st for st in steps if s <= st["step_number"] <= e - ] - - # Turn 1 LLM response: Tool call to replay_steps for step 2 - mock_response_turn_1 = MagicMock() - mock_response_turn_1.content = "" - mock_tool_call = { - "name": "replay_steps", - "args": {"start_step": 2, "end_step": 2}, - "id": "call_123456", - } - mock_response_turn_1.tool_calls = [mock_tool_call] - - # Turn 2 LLM response: Final answer using the details - mock_response_turn_2 = MagicMock() - mock_response_turn_2.content = ( - "In step 2, the operator was thinking: 'Toggle the switch to turn wifi on'." - ) - mock_response_turn_2.tool_calls = [] - - mock_llm = MagicMock() - mock_llm.bind_tools.return_value = mock_llm - responses = [mock_response_turn_1, mock_response_turn_2] - call_count = 0 - - async def mock_astream(*args, **kwargs): - nonlocal call_count - if call_count < len(responses): - yield responses[call_count] - call_count += 1 - - mock_llm.astream.side_effect = mock_astream - - with ( - patch( - "artemis.agents.history_analyzer.history_analyzer.get_llm", - return_value=mock_llm, - ), - patch("pathlib.Path.exists", return_value=False), # Force fallback prompt - ): - analyzer = HistoryAnalyzer(mock_ctx) - result = await analyzer.run("What was the operator thinking in step 2?") - - assert ( - result == "In step 2, the operator was thinking: 'Toggle the switch to turn wifi on'." - ) - assert mock_llm.astream.call_count == 2 - - # Verify the tool was invoked with correct parameters internally - tool_msg = mock_llm.astream.call_args_list[1][0][0][ - -2 - ] # ToolMessage is second to last after final response is appended - assert isinstance(tool_msg, ToolMessage) - assert tool_msg.tool_call_id == "call_123456" - assert tool_msg.name == "replay_steps" - - # The shared replay renderer: step header, screen description, thinking. - mock_ctx.data_engine.get_agent_friendly_steps_in_range.assert_called_once_with(2, 2) - assert "- **Step 2 (Start: 4.5s)**" in tool_msg.content - assert "[Screen]: Toggled the wifi switch" in tool_msg.content - assert "Toggle the switch to turn wifi on" in tool_msg.content - assert "**Step 1" not in tool_msg.content - - -@pytest.mark.asyncio -async def test_history_analyzer_read_note_tool_call(): - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = MagicMock() - mock_ctx.data_engine.base_dir = "/tmp/fake_traces" - - steps = [{"step_number": 1, "relative_time": "1.0s", "summary": "Initiated"}] - mock_ctx.data_engine.get_agent_friendly_steps.return_value = steps - - # Turn 1 LLM response: Tool call to list_notes - mock_response_turn_1 = MagicMock() - mock_response_turn_1.content = "" - mock_tool_call_list = {"name": "list_notes", "args": {}, "id": "call_list"} - mock_response_turn_1.tool_calls = [mock_tool_call_list] - - # Turn 2 LLM response: Tool call to read_note for tactical_plan - mock_response_turn_2 = MagicMock() - mock_response_turn_2.content = "" - mock_tool_call_read = { - "name": "read_note", - "args": {"key": "tactical_plan"}, - "id": "call_read", - } - mock_response_turn_2.tool_calls = [mock_tool_call_read] - - # Turn 3 LLM response: Final answer - mock_response_turn_3 = MagicMock() - mock_response_turn_3.content = "The plan was to toggle wifi." - mock_response_turn_3.tool_calls = [] - - mock_llm = MagicMock() - mock_llm.bind_tools.return_value = mock_llm - responses = [ - mock_response_turn_1, - mock_response_turn_2, - mock_response_turn_3, - ] - call_count = 0 - - async def mock_astream(*args, **kwargs): - nonlocal call_count - if call_count < len(responses): - yield responses[call_count] - call_count += 1 - - mock_llm.astream.side_effect = mock_astream - - # Mock the file system read using patch - fake_plan_content = "Goal: Toggle wifi" - - def mock_exists(path): - # Let Path("/tmp/fake_traces/notes").exists() and notes/tactical_plan.md exist - return True - - def mock_read_text(self, encoding="utf-8"): - return fake_plan_content - - def mock_glob(self, pattern): - return [Path("/tmp/fake_traces/notes/tactical_plan.md")] - - with ( - patch( - "artemis.agents.history_analyzer.history_analyzer.get_llm", - return_value=mock_llm, - ), - patch("pathlib.Path.exists", mock_exists), - patch("pathlib.Path.read_text", mock_read_text), - patch("pathlib.Path.glob", mock_glob), - ): - analyzer = HistoryAnalyzer(mock_ctx) - result = await analyzer.run("What is the saved tactical plan?") - - assert result == "The plan was to toggle wifi." - assert mock_llm.astream.call_count == 3 - - # Verify list_notes response - list_msg = mock_llm.astream.call_args_list[1][0][0][ - 3 - ] # Index 3 contains the ToolMessage for list_notes - assert isinstance(list_msg, ToolMessage) - assert list_msg.tool_call_id == "call_list" - assert list_msg.content == "Here are all the notes:\n- tactical_plan (1 lines)" - - # Verify read_note response - read_msg = mock_llm.astream.call_args_list[2][0][0][ - 5 - ] # Index 5 contains the ToolMessage for read_note - assert isinstance(read_msg, ToolMessage) - assert read_msg.tool_call_id == "call_read" - assert read_msg.content == ( - f"Note 'tactical_plan' ({len(fake_plan_content.splitlines())} lines):" - f"\n{fake_plan_content}" - ) - - -def test_history_analyzer_robust_tools_behavior(): - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = MagicMock() - mock_ctx.data_engine.base_dir = "/tmp/fake_traces" - - steps = [ - { - "step_number": 1, - "relative_time": "1.0s", - "summary": "Action 1", - "action_taken": {"action": "tap"}, - } - ] - - analyzer = HistoryAnalyzer(mock_ctx) - mock_ctx.data_engine.get_agent_friendly_steps_in_range.return_value = steps - - # 1. The shared replay tool coerces string step numbers - replay_tool = next(t for t in analyzer._build_tools() if t.name == "replay_steps") - result_details = replay_tool.invoke({"start_step": "1", "end_step": "1"}) - mock_ctx.data_engine.get_agent_friendly_steps_in_range.assert_called_once_with(1, 1) - assert "- **Step 1 (Start: 1.0s)**" in result_details - assert "[Screen]: Action 1" in result_details - - # 2. Test read_note with .md suffix - from artemis.tools.scratchpad import get_read_note_tool_pure - - read_tool = get_read_note_tool_pure(analyzer.ctx) - - def mock_exists(path): - return "tactical_plan.md" in str(path) - - with ( - patch("pathlib.Path.exists", mock_exists), - patch("pathlib.Path.read_text", return_value="Tactical plan content"), - ): - result_read = read_tool.invoke({"key": "tactical_plan.md"}) - assert result_read == "Note 'tactical_plan.md' (1 lines):\nTactical plan content" - - -@pytest.mark.asyncio -async def test_history_analyzer_integration_with_task_tree(): - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = MagicMock() - mock_ctx.data_engine.base_dir = "/tmp/fake_traces" - - steps = [ - { - "step_id": "step_1", - "step_number": 1, - "relative_time": "1.0s", - "summary": "Step 1", - }, - ] - mock_ctx.data_engine.get_agent_friendly_steps.return_value = steps - - mock_response = MagicMock() - mock_response.content = "Answer" - mock_response.tool_calls = [] - - mock_llm = MagicMock() - mock_llm.bind_tools.return_value = mock_llm - - async def mock_astream(*args, **kwargs): - yield mock_response - - mock_llm.astream.side_effect = mock_astream - - fake_plan = """- [/] Active Subgoal -- [ ] Pending Subgoal""" - - mock_path = MagicMock() - mock_path.exists.return_value = True - mock_path.read_text.return_value = fake_plan - - # Mock the policy-table compiled-history renderer (M4) - with ( - patch( - "artemis.agents.history_analyzer.history_analyzer.get_llm", - return_value=mock_llm, - ), - patch( - "artemis.agents.history_analyzer.history_analyzer.get_note_file_path", - return_value=mock_path, - ), - patch("artemis.agents.history_analyzer.history_analyzer.build_history_for") as mock_build, - ): - mock_build.return_value = "Mocked operation history" - analyzer = HistoryAnalyzer(mock_ctx) - await analyzer.run("query") - - # Verify build_history_for call args - mock_build.assert_called_once() - args, kwargs = mock_build.call_args - assert args[0] == "history_analyzer" - assert args[1] == fake_plan - assert args[2] == steps - - import hashlib - - expected_hash = hashlib.md5(b"Active Subgoal").hexdigest() - assert args[3] == expected_hash - - assert kwargs.get("engine") is mock_ctx.data_engine - - -# --- tool results: status is structural, never sniffed from the words ----------------- - - -# A helper tool reports failure structurally (``ToolFailure``); free-form text -# that merely starts with "Error" is an ordinary answer. -_STATUS_CASES = [ - pytest.param(ToolFailure("Error: note 'progress' not found"), "error", id="tool_failure"), - pytest.param("Error 404 was typed into the search box", "success", id="plain_error_text"), -] - - -def _text_tool(name: str, result): - async def _run(key: str) -> str: - return result - - return StructuredTool.from_function(coroutine=_run, name=name, description=name) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("result, expected_status", _STATUS_CASES) -async def test_history_analyzer_tool_message_status_is_structural(result, expected_status): - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = MagicMock() - mock_ctx.data_engine.base_dir = "/tmp/fake_traces" - mock_ctx.data_engine.get_agent_friendly_steps.return_value = [ - {"step_number": 1, "relative_time": "1.2s", "summary": "Opened the settings app"} - ] - - tool_turn = MagicMock(content="") - tool_turn.tool_calls = [{"name": "read_note", "args": {"key": "progress"}, "id": "c1"}] - final_turn = MagicMock(content="done") - final_turn.tool_calls = [] - responses = [tool_turn, final_turn] - - async def mock_astream(*args, **kwargs): - yield responses.pop(0) - - mock_llm = MagicMock() - mock_llm.bind_tools.return_value = mock_llm - mock_llm.astream.side_effect = mock_astream - - with ( - patch( - "artemis.agents.history_analyzer.history_analyzer.get_llm", - return_value=mock_llm, - ), - patch.object( - HistoryAnalyzer, "_build_tools", return_value=[_text_tool("read_note", result)] - ), - patch("pathlib.Path.exists", return_value=False), - ): - answer = await HistoryAnalyzer(mock_ctx).run("What did the note say?") - - assert answer == "done" - second_turn_messages = mock_llm.astream.call_args_list[1][0][0] - tool_msgs = [m for m in second_turn_messages if isinstance(m, ToolMessage)] - assert len(tool_msgs) == 1 - assert tool_msgs[0].tool_call_id == "c1" - assert tool_msgs[0].status == expected_status - assert tool_msgs[0].content == str(result) diff --git a/tests/unit/agents/test_log_analyzer.py b/tests/unit/agents/test_log_analyzer.py deleted file mode 100644 index a24386ad5..000000000 --- a/tests/unit/agents/test_log_analyzer.py +++ /dev/null @@ -1,78 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""LogAnalyzerNode's ReAct loop: a helper tool's failure is structural -(``ToolFailure``), never sniffed from the words of its text.""" - -from unittest.mock import AsyncMock, MagicMock, patch - -from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage -from langchain_core.tools import StructuredTool -import pytest - -from artemis.agents.log_analyzer.log_analyzer import LogAnalyzerNode -from artemis.context import ArtemisContext -from artemis.core.tool_failure import ToolFailure - -_STATUS_CASES = [ - pytest.param(ToolFailure("Error searching logs: device gone"), "error", id="tool_failure"), - pytest.param( - "Error: 12 matching log lines\nE/Foo: Error opening file", "success", id="plain_error_text" - ), -] - - -def _text_tool(name: str, result): - async def _run(keyword: str) -> str: - return result - - return StructuredTool.from_function(coroutine=_run, name=name, description=name) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("result, expected_status", _STATUS_CASES) -async def test_agent_loop_tool_message_status_is_structural(result, expected_status): - ctx = MagicMock(spec=ArtemisContext) - ctx.data_engine = None - node = LogAnalyzerNode(ctx=ctx) - tool = _text_tool("search_logs", result) - - base_llm = MagicMock() - base_llm.bind_tools.return_value = base_llm - tool_turn = AIMessage( - content="", tool_calls=[{"name": "search_logs", "args": {"keyword": "Error"}, "id": "c1"}] - ) - final_turn = AIMessage(content="Summary.", tool_calls=[]) - messages = [SystemMessage(content="s"), HumanMessage(content="h")] - - with patch( - "artemis.agents.log_analyzer.log_analyzer.acomplete", - new=AsyncMock(side_effect=[tool_turn, final_turn]), - ): - outcome = await node._run_agent_loop( - base_llm=base_llm, - tools_for_binding=[tool], - tools=[tool], - current_messages=messages, - state=MagicMock(), - max_iterations=5, - agent_name="Log Analyzer", - ) - - assert outcome == "Summary." - tool_msgs = [m for m in messages if isinstance(m, ToolMessage)] - assert len(tool_msgs) == 1 - assert tool_msgs[0].tool_call_id == "c1" - assert tool_msgs[0].status == expected_status - assert tool_msgs[0].content == str(result) diff --git a/tests/unit/agents/test_operator.py b/tests/unit/agents/test_operator.py deleted file mode 100644 index 5fcfb7c21..000000000 --- a/tests/unit/agents/test_operator.py +++ /dev/null @@ -1,1657 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json -from unittest.mock import AsyncMock, MagicMock, Mock, patch - -from artemis.agents.operator.operator import OperatorNode -from artemis.config.agent import MemoryTranscriptConfig -from artemis.context import ArtemisContext -from artemis.core.tool_failure import ToolFailure -import pytest - -# These tests exercise the legacy 2-message prompt path. Since M5 the -# transcript path is the default, so flag-off semantics must be requested -# explicitly (never via the config default). -LEGACY_TRANSCRIPT = MemoryTranscriptConfig(enabled=False) - - -@pytest.mark.asyncio -async def test_operator_node_fast_path(): - # Mock context and state - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.execution_setup = None - mock_ctx.data_engine = None - mock_ctx.trace_id = "test-trace" - - mock_state = MagicMock() - mock_state.subagent_calls = [] - mock_state.initial_goal = "Test goal" - - # Mock controller and data - mock_controller = MagicMock() - mock_controller.take_screenshot = AsyncMock( - return_value="YmFzZTY0X3NjcmVlbnNob3Q=" - ) # "base64_screenshot" in base64 - - mock_device_data = MagicMock() - mock_device_data.elements = [{"text": "Item 1", "bounds": "[0,0][100,100]"}] - mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) - - # Mock LLM response - mock_llm = MagicMock() - mock_response = MagicMock() - mock_response.tool_calls = [ - { - "name": "click", - "args": { - "target": [50, 50], - "target_description": "button", - "reasoning": "Test reasoning", - }, - "id": "call_123", - } - ] - - mock_llm.ainvoke = AsyncMock(return_value=mock_response) - mock_llm.bind_tools.return_value = mock_llm # Allow chaining - - # Patch dependencies - with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm): - node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT) - node_update = await node(mock_state) - - # Verify state was updated with action - update_dict = node_update - - assert "structured_decisions" in update_dict - decisions = json.loads(update_dict["structured_decisions"]) - assert decisions[0]["action"] == "tap" - assert decisions[0]["coordinates"] == [54, 120] - - -@pytest.mark.asyncio -async def test_perform_action_validation(): - from artemis.agents.operator.operator import OperatorNode - from unittest.mock import MagicMock - from artemis.graph.state import State - - mock_ctx = MagicMock() - node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT) - - mock_state = MagicMock(spec=State) - mock_state.indexed_points = [[200, 300], [400, 500]] - mock_state.indexed_elements = [ - { - "center": [200, 300], - "text": "Item 1", - "bounds": "[100,100][300,500]", - "class": "android.widget.Button", - "resource_id": "btn_1", - }, - { - "center": [400, 500], - "text": "hello", - "bounds": "[300,400][500,600]", - "class": "android.widget.EditText", - "resource_id": "input_1", - }, - ] - - # Test click with valid target index - actions, err = node._translate_and_validate_tool( - {"name": "click", "args": {"target": 1}}, mock_state - ) - assert err is None - assert actions == [ - { - "action": "tap", - "coordinates": [200, 300], - "coordinate_space": "pixel", - "normalized_coordinates": [185, 125], - "times": 1, - "delay_ms": 100, - "target_text": "Item 1", - "target_bounds": "[100,100][300,500]", - "target_resource_id": "btn_1", - "target_class": "android.widget.Button", - } - ] - - # Test click with invalid index - actions, err = node._translate_and_validate_tool( - {"name": "click", "args": {"target": 5}}, mock_state - ) - assert "Error" in err - - # Test input_text with clear_exist - actions, err = node._translate_and_validate_tool( - { - "name": "input_text", - "args": {"text": "hello", "target": 2, "clear_exist": True}, - }, - mock_state, - ) - assert err is None - assert len(actions) == 1 - assert actions[0] == { - "action": "focus_and_input_text", - "coordinates": [400, 500], - "coordinate_space": "pixel", - "normalized_coordinates": [370, 208], - "text": "hello", - "clear_before_input": True, - "target_text": "hello", - "target_bounds": "[300,400][500,600]", - "target_resource_id": "input_1", - "target_class": "android.widget.EditText", - } - - # Test swipe direction - actions, err = node._translate_and_validate_tool( - {"name": "swipe", "args": {"gesture": "up"}}, mock_state - ) - assert err is None - assert actions[0]["action"] == "swipe" - assert len(actions[0]["coordinates"]) == 4 - assert actions[0]["duration"] == 800 - - # Test click with float target index (coercion) - actions, err = node._translate_and_validate_tool( - {"name": "click", "args": {"target": 2.0}}, mock_state - ) - assert err is None - assert actions == [ - { - "action": "tap", - "coordinates": [400, 500], - "coordinate_space": "pixel", - "normalized_coordinates": [370, 208], - "times": 1, - "delay_ms": 100, - "target_text": "hello", - "target_bounds": "[300,400][500,600]", - "target_resource_id": "input_1", - "target_class": "android.widget.EditText", - } - ] - - # Test input_text with missing text argument - actions, err = node._translate_and_validate_tool( - {"name": "input_text", "args": {"target": 2}}, mock_state - ) - assert "Error" in err - assert "Missing required argument 'text'" in err - - # Test input_text with non-string text argument - actions, err = node._translate_and_validate_tool( - {"name": "input_text", "args": {"text": 123, "target": 2}}, mock_state - ) - assert "Error" in err - assert "text' must be a string" in err - - # Test swipe with invalid duration - actions, err = node._translate_and_validate_tool( - {"name": "swipe", "args": {"gesture": "up", "duration": "fast"}}, - mock_state, - ) - assert "Error" in err - assert "duration' must be a number" in err - - # Test swipe with float duration (coercion) - actions, err = node._translate_and_validate_tool( - {"name": "swipe", "args": {"gesture": "up", "duration": 500.0}}, - mock_state, - ) - assert err is None - assert actions[0]["duration"] == 500 - - # Test manage_app with missing app_name - actions, err = node._translate_and_validate_tool( - {"name": "manage_app", "args": {"action": "launch"}}, mock_state - ) - assert "Error" in err - assert "app_name' must be a valid non-empty string" in err - - # Test manage_app with invalid action - actions, err = node._translate_and_validate_tool( - { - "name": "manage_app", - "args": {"action": "restart", "app_name": "Settings"}, - }, - mock_state, - ) - assert "Error" in err - assert "Unsupported app management action" in err - - -@pytest.mark.asyncio -async def test_coordinate_click_requires_and_records_the_models_description(): - """A coordinate target carries the model's own description, never an inferred label. - - Even when an indexed element sits right under the point, nothing is hit - tested on the model's behalf: the recorded semantics are exactly what the - model declared, kept apart from the observed ``target_*`` fields. - """ - from artemis.agents.operator.operator import OperatorNode - from unittest.mock import MagicMock - from artemis.graph.state import State - - mock_ctx = MagicMock() - node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT) - - mock_state = MagicMock(spec=State) - mock_state.indexed_elements = [ - { - "center": [550, 1440], - "text": "Confirm", - "bounds": [500, 1400, 600, 1480], - "class": "android.widget.Button", - "resource_id": "btn_confirm", - "is_ocr": False, - } - ] - - # Coordinates without a description are refused with a retryable error. - for args in ( - {"target": [500, 600]}, - {"target": [500, 600], "target_description": " "}, - {"target": [500, 600], "target_description": 12}, - ): - actions, err = node._translate_and_validate_tool( - {"name": "click", "args": args}, mock_state - ) - assert actions == [] - assert err.startswith("Error:") - assert "target_description" in err - - actions, err = node._translate_and_validate_tool( - {"name": "click", "args": {"target": [500, 600], "target_description": " play button "}}, - mock_state, - ) - assert err is None - assert actions == [ - { - "action": "tap", - "coordinates": [540, 1440], - "coordinate_space": "pixel", - "normalized_coordinates": [500, 600], - "times": 1, - "delay_ms": 100, - "target_description": "play button", - } - ] - - # The same rule applies to input_text and the description is ignored for an index. - actions, err = node._translate_and_validate_tool( - {"name": "input_text", "args": {"text": "hi", "target": [500, 600]}}, mock_state - ) - assert actions == [] and "target_description" in err - actions, err = node._translate_and_validate_tool( - { - "name": "input_text", - "args": {"text": "hi", "target": 1, "target_description": "ignored"}, - }, - mock_state, - ) - assert err is None - assert actions[0]["target_text"] == "Confirm" - assert "target_description" not in actions[0] - - -@pytest.mark.asyncio -async def test_coordinate_swipe_requires_description_directional_does_not(): - from artemis.agents.operator.operator import OperatorNode - from unittest.mock import MagicMock - from artemis.graph.state import State - - node = OperatorNode(MagicMock(), transcript_config=LEGACY_TRANSCRIPT) - mock_state = MagicMock(spec=State) - mock_state.indexed_elements = [] - mock_state.latest_ui_hierarchy = None - - actions, err = node._translate_and_validate_tool( - {"name": "swipe", "args": {"start": [200, 600], "end": [800, 600]}}, mock_state - ) - assert actions == [] and "target_description" in err - - actions, err = node._translate_and_validate_tool( - { - "name": "swipe", - "args": {"start": [200, 600], "end": [800, 600], "target_description": "volume knob"}, - }, - mock_state, - ) - assert err is None - assert actions[0]["action"] == "swipe" - assert actions[0]["target_description"] == "volume knob" - - actions, err = node._translate_and_validate_tool( - {"name": "swipe", "args": {"direction": "up"}}, mock_state - ) - assert err is None - assert "target_description" not in actions[0] - - -@pytest.mark.asyncio -async def test_operator_node_multiple_actions(): - from artemis.agents.operator.operator import OperatorNode - from artemis.context import ArtemisContext - from unittest.mock import AsyncMock, MagicMock, patch - import json - - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.execution_setup = None - mock_ctx.data_engine = None - - mock_state = MagicMock() - mock_state.subagent_calls = [] - mock_state.initial_goal = "Test goal" - - mock_controller = MagicMock() - mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") - mock_device_data = MagicMock() - mock_device_data.elements = [] - mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) - - mock_llm = MagicMock() - mock_response = MagicMock() - mock_response.tool_calls = [ - { - "name": "input_text", - "args": {"text": "hello", "target": [50, 50], "target_description": "field"}, - "id": "call_1", - }, - { - "name": "click", - "args": {"target": [50, 50], "target_description": "button"}, - "id": "call_2", - }, - ] - - mock_llm.ainvoke = AsyncMock(return_value=mock_response) - mock_llm.bind_tools.return_value = mock_llm - - with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm): - node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT) - node_update = await node(mock_state) - - update_dict = node_update - - assert "structured_decisions" in update_dict - decisions = json.loads(update_dict["structured_decisions"]) - assert len(decisions) == 2 - assert decisions[0]["action"] == "focus_and_input_text" - assert decisions[0]["text"] == "hello" - assert decisions[0]["coordinates"] == [54, 120] - assert decisions[0]["clear_before_input"] == True - assert decisions[1]["action"] == "tap" - assert decisions[1]["coordinates"] == [54, 120] - - # Each action's ToolMessage names its tool, like helper-tool results do - # (the loop appends them to the same list it sent to the model). - from langchain_core.messages import ToolMessage - - sent = mock_llm.ainvoke.call_args[0][0] - recorded = [(m.name, m.content) for m in sent if isinstance(m, ToolMessage)] - assert recorded == [("input_text", "Action Recorded"), ("click", "Action Recorded")] - - -@pytest.mark.asyncio -async def test_operator_node_no_record_step(): - from artemis.agents.operator.operator import OperatorNode - from artemis.context import ArtemisContext - from unittest.mock import AsyncMock, MagicMock, patch - - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = MagicMock() - mock_ctx.data_engine.current_session_id = "test-session" - - mock_state = MagicMock() - mock_state.subagent_calls = [] - mock_state.initial_goal = "Test goal" - - mock_controller = MagicMock() - mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") - mock_device_data = MagicMock() - mock_device_data.elements = [] - mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) - - mock_llm = MagicMock() - mock_response = MagicMock() - mock_response.tool_calls = [] - - mock_llm.ainvoke = AsyncMock(return_value=mock_response) - mock_llm.bind_tools.return_value = mock_llm - - with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm): - node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT) - node_update = await node(mock_state) - - mock_ctx.data_engine.record_step.assert_not_called() - - update_dict = node_update - assert "structured_decisions" in update_dict - - -@pytest.mark.asyncio -async def test_operator_dynamic_prompt(): - from artemis.agents.operator.operator import OperatorNode, PromptComponent - from artemis.context import ArtemisContext - from unittest.mock import AsyncMock, MagicMock, patch - - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = None - - mock_state = MagicMock() - mock_state.subagent_calls = [] - mock_state.initial_goal = "Test goal" - - class MockComponent(PromptComponent): - async def __call__(self, builder, state, ctx, **kwargs): - builder.add_system_text("System part.") - builder.add_human_content("Human part.") - builder.set_human_footer("Footer part.") - - mock_controller = MagicMock() - mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") - mock_device_data = MagicMock() - mock_device_data.elements = [] - mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) - - mock_llm = MagicMock() - mock_response = MagicMock() - mock_response.tool_calls = [] - - async def mock_ainvoke(*args, **kwargs): - messages = args[0] - assert len(messages) == 2 - assert messages[0].content == "System part." - assert messages[1].content[0]["text"] == "Human part." - assert messages[1].content[-1]["text"] == "Footer part." - return mock_response - - mock_llm.ainvoke.side_effect = mock_ainvoke - mock_llm.bind_tools.return_value = mock_llm - - with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm): - node = OperatorNode( - mock_ctx, prompt_components=[MockComponent()], transcript_config=LEGACY_TRANSCRIPT - ) - node_update = await node(mock_state) - - -@pytest.mark.asyncio -async def test_operator_history_compression(): - from artemis.agents.operator.operator import OperatorNode - from artemis.context import ArtemisContext - from unittest.mock import AsyncMock, MagicMock, patch - import json - import hashlib - - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = MagicMock() - - subgoal_old_hash = hashlib.md5(b"subgoal_old").hexdigest() - subgoal_current_hash = hashlib.md5(b"subgoal_current").hexdigest() - - # Create mock steps spanning multiple subgoals - # We need at least 5 summaries to trigger folding of older subgoals - mock_steps = [ - { - "step_id": "step_1", - "step_number": 1, - "summary": "Step 1 summary", - "relative_time": "1.0s", - "extra_metadata": {"subgoal_hash": subgoal_old_hash}, - }, - { - "step_id": "step_2", - "step_number": 2, - "summary": "Step 2 summary", - "relative_time": "2.0s", - "extra_metadata": {"subgoal_hash": subgoal_current_hash}, - }, - { - "step_id": "step_3", - "step_number": 3, - "summary": "Step 3 summary", - "relative_time": "3.0s", - "extra_metadata": {"subgoal_hash": subgoal_current_hash}, - }, - { - "step_id": "step_4", - "step_number": 4, - "summary": "Step 4 summary", - "relative_time": "4.0s", - "extra_metadata": {"subgoal_hash": subgoal_current_hash}, - }, - { - "step_id": "step_5", - "step_number": 5, - "summary": "Step 5 summary", - "relative_time": "5.0s", - "extra_metadata": {"subgoal_hash": subgoal_current_hash}, - }, - { - "step_id": "step_6", - "step_number": 6, - "summary": "Step 6 summary", - "relative_time": "6.0s", - "extra_metadata": {"subgoal_hash": subgoal_current_hash}, - }, - { - "step_id": "step_7", - "step_number": 7, - "summary": "Step 7 summary", - "relative_time": "7.0s", - "extra_metadata": {"subgoal_hash": subgoal_current_hash}, - "action_taken": {"action": "tap"}, - }, - ] - mock_ctx.data_engine.get_agent_friendly_steps.return_value = mock_steps - - mock_state = MagicMock() - mock_state.subagent_calls = [] - mock_state.initial_goal = "Test goal" - - mock_controller = MagicMock() - mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") - mock_device_data = MagicMock() - mock_device_data.elements = [] - mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) - - mock_llm = MagicMock() - mock_response = MagicMock() - mock_response.tool_calls = [] - - # Smarter file reading mock - def mock_read_text(path_self, encoding=None): - path_str = str(path_self) - if "operator.json" in path_str: - return json.dumps( - { - "main_template": ( - "Goals: {{ initial_goal }}\nPlan:\n{{ plan_and_history" - " }}\n# CURRENT OBSERVATION" - ), - } - ) - elif "task_plan.md" in path_str: - return """- [x] subgoal_old -- [/] subgoal_current""" - return "" - - async def mock_ainvoke(*args, **kwargs): - messages = args[0] - full_text = "".join([str(m.content) for m in messages]) - - # Check that old subgoal line is present - assert "- [x] subgoal_old" in full_text - # Check that step 1 (old) is NOT present (folded) - assert "Step 1 summary" not in full_text - - # Check that current subgoal line is present - assert "- [/] subgoal_current" in full_text - # Check that current subgoal steps are present as summaries - assert "Step 2 summary" in full_text - assert "Step 6 summary" not in full_text - assert "Step 6" in full_text - # Check that last step has full details - assert "Step 7" in full_text - return mock_response - - mock_llm.ainvoke.side_effect = mock_ainvoke - mock_llm.bind_tools.return_value = mock_llm - - with ( - patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm), - patch( - "artemis.agents.operator.operator.OperatorNode._get_active_subgoal_hash", - return_value=subgoal_current_hash, - ), - patch("pathlib.Path.exists", return_value=True), - patch("pathlib.Path.read_text", mock_read_text), - ): - node = OperatorNode(mock_ctx, last_n_detailed=5, transcript_config=LEGACY_TRANSCRIPT) - node_update = await node(mock_state) - - -@pytest.mark.asyncio -async def test_operator_tracks_subagent_calls(): - from artemis.agents.operator.operator import OperatorNode - from artemis.context import ArtemisContext - from unittest.mock import AsyncMock, MagicMock, patch - - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.execution_setup = None - mock_ctx.data_engine = None - - mock_state = MagicMock() - mock_state.subagent_calls = ["ask_explorer"] - mock_state.initial_goal = "Test goal" - - mock_controller = MagicMock() - mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") - mock_device_data = MagicMock() - mock_device_data.elements = [] - mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) - - mock_llm = MagicMock() - mock_response = MagicMock() - mock_response.tool_calls = [ - {"name": "ask_diagnoser", "args": {"query": "test"}, "id": "call_1"} - ] - - mock_response_2 = MagicMock() - mock_response_2.tool_calls = [ - { - "name": "click", - "args": {"target": [10, 20], "target_description": "button"}, - "id": "call_2", - } - ] - - responses = [mock_response, mock_response_2] - call_count = 0 - - async def mock_ainvoke(*args, **kwargs): - nonlocal call_count - if call_count < len(responses): - res = responses[call_count] - call_count += 1 - return res - - mock_llm.ainvoke.side_effect = mock_ainvoke - mock_llm.bind_tools.return_value = mock_llm - - mock_diagnoser_tool = MagicMock() - mock_diagnoser_tool.name = "ask_diagnoser" - mock_diagnoser_tool.args = {"state": None} - mock_diagnoser_tool.ainvoke = AsyncMock(return_value="Diagnosis result") - - with ( - patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm), - patch( - "artemis.agents.operator.operator.trace_langchain_tool", - side_effect=lambda t, ctx: t, - ), - ): - node = OperatorNode( - mock_ctx, tools=[mock_diagnoser_tool], transcript_config=LEGACY_TRANSCRIPT - ) - node_update = await node(mock_state) - - update_dict = node_update - - assert "subagent_calls" in update_dict - assert update_dict["subagent_calls"] == [ - "ask_explorer", - "ask_diagnoser", - ] - - -@pytest.mark.asyncio -async def test_operator_defer_action_for_gathering(): - from artemis.agents.operator.operator import OperatorNode - from artemis.context import ArtemisContext - from unittest.mock import AsyncMock, MagicMock, patch - import json - - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.execution_setup = None - mock_ctx.data_engine = None - - mock_state = MagicMock() - mock_state.subagent_calls = [] - mock_state.initial_goal = "Test goal" - - mock_controller = MagicMock() - mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") - mock_device_data = MagicMock() - mock_device_data.elements = [] - mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) - - mock_llm = MagicMock() - - # First response calls both ask_diagnoser (gathering) and click (action) - mock_response_1 = MagicMock() - mock_response_1.tool_calls = [ - { - "name": "ask_diagnoser", - "args": {"query": "check something"}, - "id": "call_diagnose", - }, - { - "name": "click", - "args": {"target": [50, 50], "target_description": "button"}, - "id": "call_click", - }, - ] - - # Second response only calls click - mock_response_2 = MagicMock() - mock_response_2.tool_calls = [ - { - "name": "click", - "args": {"target": [50, 50], "target_description": "button"}, - "id": "call_click_final", - } - ] - - responses = [mock_response_1, mock_response_2] - call_count = 0 - - async def mock_ainvoke(*args, **kwargs): - nonlocal call_count - if call_count < len(responses): - res = responses[call_count] - call_count += 1 - return res - - mock_llm.ainvoke.side_effect = mock_ainvoke - mock_llm.bind_tools.return_value = mock_llm - - from langgraph.prebuilt import InjectedState - from typing import Annotated - - mock_diagnoser_tool = MagicMock() - mock_diagnoser_tool.name = "ask_diagnoser" - mock_diagnoser_tool.args = {"state": None} - - mock_call_receiver = Mock() - - async def dummy_diagnoser_func(query: str, state: Annotated[MagicMock, InjectedState]): - mock_call_receiver(query=query, state=state) - return "Diagnostics OK" - - mock_diagnoser_tool.coroutine = dummy_diagnoser_func - mock_diagnoser_tool.func = None - - with ( - patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm), - patch( - "artemis.agents.operator.operator.trace_langchain_tool", - side_effect=lambda t, ctx: t, - ), - ): - node = OperatorNode( - mock_ctx, tools=[mock_diagnoser_tool], transcript_config=LEGACY_TRANSCRIPT - ) - node_update = await node(mock_state) - - # Subagent tool should have been executed - mock_call_receiver.assert_called_once_with(query="check something", state=mock_state) - - # Verify final state has the action execution from the second response - update_dict = node_update - - assert "structured_decisions" in update_dict - decisions = json.loads(update_dict["structured_decisions"]) - assert len(decisions) == 1 - assert decisions[0]["action"] == "tap" - assert decisions[0]["coordinates"] == [54, 120] - - -@pytest.mark.asyncio -async def test_wait_actions_translation(): - from artemis.agents.operator.operator import OperatorNode - from unittest.mock import MagicMock - from artemis.graph.state import State - - mock_ctx = MagicMock() - node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT) - mock_state = MagicMock(spec=State) - - # 1. Test wait_for_delay translation - actions, err = node._translate_and_validate_tool( - {"name": "wait_for_delay", "args": {"time_in_ms": 2500}}, mock_state - ) - assert err is None - assert actions == [{"action": "wait_for_delay", "time_in_ms": 2500}] - - # Test wait_for_delay invalid type coercion - actions, err = node._translate_and_validate_tool( - {"name": "wait_for_delay", "args": {"time_in_ms": "3000"}}, mock_state - ) - assert err is None - assert actions == [{"action": "wait_for_delay", "time_in_ms": 3000}] - - -@pytest.mark.asyncio -async def test_long_press_action_translation(): - from artemis.agents.operator.operator import OperatorNode - from unittest.mock import MagicMock - from artemis.graph.state import State - - mock_ctx = MagicMock() - node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT) - mock_state = MagicMock(spec=State) - mock_state.indexed_points = [[200, 300], [400, 500]] - mock_state.indexed_elements = [ - { - "center": [200, 300], - "text": "Item 1", - "bounds": "[100,100][300,500]", - "class": "android.widget.Button", - "resource_id": "btn_1", - }, - { - "center": [400, 500], - "text": "hello", - "bounds": "[300,400][500,600]", - "class": "android.widget.EditText", - "resource_id": "input_1", - }, - ] - - # 1. Test long_press with element index target - actions, err = node._translate_and_validate_tool( - {"name": "long_press", "args": {"target": 1, "duration": 1500}}, - mock_state, - ) - assert err is None - assert actions == [ - { - "action": "long_press_on", - "coordinates": [200, 300], - "coordinate_space": "pixel", - "normalized_coordinates": [185, 125], - "duration": 1500, - "target_text": "Item 1", - "target_bounds": "[100,100][300,500]", - "target_resource_id": "btn_1", - "target_class": "android.widget.Button", - } - ] - - # 2. Test long_press with custom coordinates and default duration - actions, err = node._translate_and_validate_tool( - {"name": "long_press", "args": {"target": [500, 600]}}, mock_state - ) - assert actions == [] and "target_description" in err - actions, err = node._translate_and_validate_tool( - { - "name": "long_press", - "args": {"target": [500, 600], "target_description": "app icon"}, - }, - mock_state, - ) - assert err is None - # Since coordinates are in 0-1000 scale, they should be converted using device width (default 1080) and height (default 2400) - # x = 500 * 1080 / 1000 = 540 - # y = 600 * 2400 / 1000 = 1440 - assert actions == [ - { - "action": "long_press_on", - "coordinates": [540, 1440], - "coordinate_space": "pixel", - "normalized_coordinates": [500, 600], - "duration": 1000, - "target_description": "app icon", - } - ] - - # 3. Test long_press with invalid index - actions, err = node._translate_and_validate_tool( - {"name": "long_press", "args": {"target": 5}}, mock_state - ) - assert "Error" in err - - -@pytest.mark.asyncio -async def test_operator_no_defer_for_note_updating(): - from artemis.agents.operator.operator import OperatorNode - from artemis.context import ArtemisContext - from unittest.mock import AsyncMock, MagicMock, patch - import json - - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.execution_setup = None - mock_ctx.data_engine = None - - mock_state = MagicMock() - mock_state.subagent_calls = [] - mock_state.initial_goal = "Test goal" - - mock_controller = MagicMock() - mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") - mock_device_data = MagicMock() - mock_device_data.elements = [] - mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) - - mock_llm = MagicMock() - - # LLM calls both update_note (writing, non-deferring) and click (action) in 1st turn - mock_response_1 = MagicMock() - mock_response_1.tool_calls = [ - { - "name": "update_note", - "args": {"key": "progress", "content": "step 1"}, - "id": "call_save", - }, - { - "name": "click", - "args": {"target": [50, 50], "target_description": "button"}, - "id": "call_click", - }, - ] - - # We only expect 1 LLM call (astream should only yield once) - responses = [mock_response_1] - call_count = 0 - - async def mock_ainvoke(*args, **kwargs): - nonlocal call_count - if call_count < len(responses): - res = responses[call_count] - call_count += 1 - return res - - mock_llm.ainvoke.side_effect = mock_ainvoke - mock_llm.bind_tools.return_value = mock_llm - - # Mock the update_note tool - mock_update_note_tool = MagicMock() - mock_update_note_tool.name = "update_note" - mock_update_note_tool.args = {} - - mock_call_receiver = MagicMock() - - async def dummy_update_note(key: str, content: str, **kwargs): - mock_call_receiver(key=key, content=content) - return "Note updated" - - mock_update_note_tool.coroutine = dummy_update_note - mock_update_note_tool.func = None - - with ( - patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm), - patch( - "artemis.agents.operator.operator.trace_langchain_tool", - side_effect=lambda t, ctx: t, - ), - ): - node = OperatorNode( - mock_ctx, tools=[mock_update_note_tool], transcript_config=LEGACY_TRANSCRIPT - ) - node_update = await node(mock_state) - - # The update_note tool should have been executed immediately - mock_call_receiver.assert_called_once_with(key="progress", content="step 1") - - # The click action should NOT be deferred, so it should be in structured_decisions - update_dict = node_update - - assert "structured_decisions" in update_dict - decisions = json.loads(update_dict["structured_decisions"]) - assert len(decisions) == 1 - assert decisions[0]["action"] == "tap" - assert decisions[0]["coordinates"] == [54, 120] - - -@pytest.mark.asyncio -async def test_operator_defer_for_note_reading(): - from artemis.agents.operator.operator import OperatorNode - from artemis.context import ArtemisContext - from unittest.mock import AsyncMock, MagicMock, patch - import json - - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.execution_setup = None - mock_ctx.data_engine = None - - mock_state = MagicMock() - mock_state.subagent_calls = [] - mock_state.initial_goal = "Test goal" - - mock_controller = MagicMock() - mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") - mock_device_data = MagicMock() - mock_device_data.elements = [] - mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) - - mock_llm = MagicMock() - - # 1st turn: LLM calls both read_note (reading, deferring) and click (action) - mock_response_1 = MagicMock() - mock_response_1.tool_calls = [ - {"name": "read_note", "args": {"key": "progress"}, "id": "call_read"}, - { - "name": "click", - "args": {"target": [50, 50], "target_description": "button"}, - "id": "call_click", - }, - ] - - # 2nd turn: LLM calls only click after receiving note content - mock_response_2 = MagicMock() - mock_response_2.tool_calls = [ - { - "name": "click", - "args": {"target": [50, 50], "target_description": "button"}, - "id": "call_click_final", - } - ] - - responses = [mock_response_1, mock_response_2] - call_count = 0 - - async def mock_ainvoke(*args, **kwargs): - nonlocal call_count - if call_count < len(responses): - res = responses[call_count] - call_count += 1 - return res - - mock_llm.ainvoke.side_effect = mock_ainvoke - mock_llm.bind_tools.return_value = mock_llm - - # Mock the read_note tool - mock_read_note_tool = MagicMock() - mock_read_note_tool.name = "read_note" - mock_read_note_tool.args = {} - - mock_call_receiver = MagicMock() - - async def dummy_read_note(key: str, **kwargs): - mock_call_receiver(key=key) - return "Note content" - - mock_read_note_tool.coroutine = dummy_read_note - mock_read_note_tool.func = None - - with ( - patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm), - patch( - "artemis.agents.operator.operator.trace_langchain_tool", - side_effect=lambda t, ctx: t, - ), - ): - node = OperatorNode( - mock_ctx, tools=[mock_read_note_tool], transcript_config=LEGACY_TRANSCRIPT - ) - node_update = await node(mock_state) - - # The read_note tool should have been executed - mock_call_receiver.assert_called_once_with(key="progress") - - # The click action from the first response should have been deferred, - # so we should have needed the second response to get the final click. - # Total call_count should be 2. - assert call_count == 2 - - update_dict = node_update - - assert "structured_decisions" in update_dict - decisions = json.loads(update_dict["structured_decisions"]) - assert len(decisions) == 1 - assert decisions[0]["action"] == "tap" - assert decisions[0]["coordinates"] == [54, 120] - - -@pytest.mark.asyncio -async def test_operator_no_defer_for_note_appending(): - from artemis.agents.operator.operator import OperatorNode - from artemis.context import ArtemisContext - from unittest.mock import AsyncMock, MagicMock, patch - import json - - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.execution_setup = None - mock_ctx.data_engine = None - - mock_state = MagicMock() - mock_state.subagent_calls = [] - mock_state.initial_goal = "Test goal" - - mock_controller = MagicMock() - mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") - mock_device_data = MagicMock() - mock_device_data.elements = [] - mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) - - mock_llm = MagicMock() - - # LLM calls both append_note (writing, non-deferring) and click (action) in 1st turn - mock_response_1 = MagicMock() - mock_response_1.tool_calls = [ - { - "name": "append_note", - "args": {"key": "progress", "content": "step 1"}, - "id": "call_append", - }, - { - "name": "click", - "args": {"target": [50, 50], "target_description": "button"}, - "id": "call_click", - }, - ] - - responses = [mock_response_1] - call_count = 0 - - async def mock_ainvoke(*args, **kwargs): - nonlocal call_count - if call_count < len(responses): - res = responses[call_count] - call_count += 1 - return res - - mock_llm.ainvoke.side_effect = mock_ainvoke - mock_llm.bind_tools.return_value = mock_llm - - # Mock the append_note tool - mock_append_note_tool = MagicMock() - mock_append_note_tool.name = "append_note" - mock_append_note_tool.args = {} - - mock_call_receiver = MagicMock() - - async def dummy_append_note(key: str, content: str, **kwargs): - mock_call_receiver(key=key, content=content) - return "Note appended" - - mock_append_note_tool.coroutine = dummy_append_note - mock_append_note_tool.func = None - - with ( - patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm), - patch( - "artemis.agents.operator.operator.trace_langchain_tool", - side_effect=lambda t, ctx: t, - ), - ): - node = OperatorNode( - mock_ctx, tools=[mock_append_note_tool], transcript_config=LEGACY_TRANSCRIPT - ) - node_update = await node(mock_state) - - # The append_note tool should have been executed immediately - mock_call_receiver.assert_called_once_with(key="progress", content="step 1") - - # The click action should NOT be deferred, so it should be in structured_decisions - update_dict = node_update - - assert "structured_decisions" in update_dict - decisions = json.loads(update_dict["structured_decisions"]) - assert len(decisions) == 1 - assert decisions[0]["action"] == "tap" - assert decisions[0]["coordinates"] == [54, 120] - - -@pytest.mark.asyncio -async def test_operator_background_tasks_prompt_injection(): - from artemis.agents.operator.operator import OperatorNode - from artemis.context import ArtemisContext - from artemis.tools.command_tool import BackgroundTask, get_adb_task_registry - from unittest.mock import AsyncMock, MagicMock, patch - - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.execution_setup = None - mock_ctx.data_engine = None - - mock_state = MagicMock() - mock_state.subagent_calls = [] - mock_state.initial_goal = "Test goal" - - mock_controller = MagicMock() - mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") - mock_device_data = MagicMock() - mock_device_data.elements = [] - mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) - - mock_llm = MagicMock() - mock_response = MagicMock() - mock_response.tool_calls = [] - - mock_llm.ainvoke = AsyncMock(return_value=mock_response) - mock_llm.bind_tools.return_value = mock_llm - - # Setup a mock background task - mock_proc = MagicMock() - bg_task = BackgroundTask( - task_id="task_99999", - command="ping google.com", - process=mock_proc, - terminal_id="term_xyz", - cwd="/data/local/tmp", - ) - - adb_registry = get_adb_task_registry(mock_ctx) - adb_registry.background["task_99999"] = bg_task - - try: - captured_messages = [] - - async def mock_invoke_llm_loop(*args, **kwargs): - active_background_tasks = [ - { - "task_id": "task_99999", - "command": "ping google.com", - "cwd": "/data/local/tmp", - "terminal_id": "term_xyz", - "output_line_count": 0, - } - ] - messages = await node._build_prompt( - state=mock_state, - latest_screenshot_b64="YmFzZTY0X3NjcmVlbnNob3Q=", - fused_xml=[], - minimal_list="", - current_step_num=1, - steps=[], - task_plan="", - active_background_tasks=active_background_tasks, - ) - captured_messages.extend(messages) - return [], None, None, False - - with ( - patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm), - patch( - "artemis.agents.operator.operator.OperatorNode._invoke_llm_loop", - side_effect=mock_invoke_llm_loop, - ), - ): - node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT) - node_update = await node(mock_state) - - # Assert that the captured messages (prompts) contain the background tasks section - full_content = "" - for m in captured_messages: - if isinstance(m.content, list): - for part in m.content: - if isinstance(part, dict) and "text" in part: - full_content += part["text"] + "\n" - elif isinstance(m.content, str): - full_content += m.content + "\n" - - assert "--- Active Background ADB Tasks ---" in full_content - assert "task_99999" in full_content - assert "ping google.com" in full_content - assert "term_xyz" in full_content - assert "Accumulated Output: 0 lines of logs" in full_content - finally: - adb_registry.background.pop("task_99999", None) - - -@pytest.mark.asyncio -async def test_operator_tool_limit_exceeded_warning(): - from artemis.agents.operator.operator import OperatorNode - from artemis.context import ArtemisContext - from unittest.mock import AsyncMock, MagicMock, patch - - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.execution_setup = None - mock_ctx.data_engine = None - - mock_state = MagicMock() - mock_state.subagent_calls = [] - # Set the flag to True to simulate tool call limit exceeded in previous turn - mock_state.operator_tool_limit_exceeded = True - mock_state.initial_goal = "Test goal" - - mock_controller = MagicMock() - mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") - mock_device_data = MagicMock() - mock_device_data.elements = [] - mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) - - mock_llm = MagicMock() - mock_response = MagicMock() - mock_response.tool_calls = [] - - captured_messages = [] - - async def mock_invoke_llm_loop(*args, **kwargs): - messages = await node._build_prompt( - state=mock_state, - latest_screenshot_b64="YmFzZTY0X3NjcmVlbnNob3Q=", - fused_xml=[], - minimal_list="", - current_step_num=1, - steps=[], - task_plan="", - ) - captured_messages.extend(messages) - # Return 4 elements: action_result, raw_thinking, native_thinking, tool_limit_exceeded - return [], None, None, False - - with ( - patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm), - patch( - "artemis.agents.operator.operator.OperatorNode._invoke_llm_loop", - side_effect=mock_invoke_llm_loop, - ), - ): - node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT) - node_update = await node(mock_state) - - full_content = "" - for m in captured_messages: - if isinstance(m.content, list): - for part in m.content: - if isinstance(part, dict) and "text" in part: - full_content += part["text"] + "\n" - elif isinstance(m.content, str): - full_content += m.content + "\n" - - assert ( - "your last turn used up its tool-call budget without a Turn-Ending Action" - in full_content - ) - - assert node_update.get("operator_tool_limit_exceeded") is False - - -@pytest.mark.asyncio -async def test_operator_fallback_function_call_parsing(): - from artemis.agents.operator.operator import OperatorNode - from artemis.context import ArtemisContext - from unittest.mock import AsyncMock, MagicMock, patch - from langchain_core.messages import AIMessage - import json - - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.execution_setup = None - mock_ctx.data_engine = None - - mock_state = MagicMock() - mock_state.subagent_calls = [] - mock_state.initial_goal = "Test goal" - - mock_controller = MagicMock() - mock_controller.take_screenshot = AsyncMock(return_value="YmFzZTY0X3NjcmVlbnNob3Q=") - mock_device_data = MagicMock() - mock_device_data.elements = [] - mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) - - # Mock response with empty tool_calls but populated additional_kwargs['function_call'] - mock_response = AIMessage( - content="I intend to click the button.", - additional_kwargs={ - "function_call": { - "name": "click", - "arguments": '{"target": [123, 456], "target_description": "button"}', - } - }, - tool_calls=[], - ) - - mock_llm = MagicMock() - mock_llm.ainvoke = AsyncMock(return_value=mock_response) - mock_llm.bind_tools.return_value = mock_llm - - with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm): - node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT) - node_update = await node(mock_state) - - # Verify state was updated with parsed click action - update_dict = node_update - - assert "structured_decisions" in update_dict - decisions = json.loads(update_dict["structured_decisions"]) - assert len(decisions) == 1 - assert decisions[0]["action"] == "tap" - assert decisions[0]["coordinates"] == [132, 1094] - - -@pytest.mark.asyncio -async def test_operator_swipe_translation(): - from artemis.agents.operator.operator import OperatorNode - from artemis.context import ArtemisContext - from unittest.mock import MagicMock - - mock_ctx = MagicMock(spec=ArtemisContext) - mock_state = MagicMock() - mock_state.operator_raw_data = {"width": 1080, "height": 2400} - node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT) - - # 1. Directional swipe - actions, err = node._translate_and_validate_tool( - {"name": "swipe", "args": {"direction": "up"}}, mock_state - ) - assert err is None - assert len(actions) == 1 - assert actions[0]["action"] == "swipe" - assert actions[0]["coordinates"] == [648, 1680, 648, 720] - - # 2. Start & End coordinate swipe (drag) - actions, err = node._translate_and_validate_tool( - { - "name": "swipe", - "args": { - "start": [100, 200], - "end": [500, 600], - "duration": 1500, - "target_description": "list item", - }, - }, - mock_state, - ) - assert err is None - assert len(actions) == 1 - assert actions[0]["action"] == "swipe" - assert actions[0]["coordinates"] == [108, 480, 540, 1440] - assert actions[0]["duration"] == 1500 - assert actions[0]["target_description"] == "list item" - - # 3. Coordinates alias - actions, err = node._translate_and_validate_tool( - { - "name": "swipe", - "args": {"coordinates": [200, 300, 400, 500], "target_description": "slider"}, - }, - mock_state, - ) - assert err is None - assert len(actions) == 1 - assert actions[0]["action"] == "swipe" - assert actions[0]["coordinates"] == [216, 720, 432, 1200] - - # 4. Backward compatible gesture parameter - actions, err = node._translate_and_validate_tool( - { - "name": "swipe", - "args": {"gesture": "down"}, - }, - mock_state, - ) - assert err is None - assert len(actions) == 1 - assert actions[0]["action"] == "swipe" - assert actions[0]["coordinates"] == [648, 720, 648, 1680] - - -@pytest.mark.asyncio -async def test_operator_press_key_translation_is_case_insensitive(): - from artemis.agents.operator.operator import OperatorNode - from artemis.context import ArtemisContext - from unittest.mock import MagicMock - - mock_ctx = MagicMock(spec=ArtemisContext) - mock_state = MagicMock() - mock_state.operator_raw_data = {"width": 1080, "height": 2400} - node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT) - - for spelling in ("back", "Back", "BACK", "KEYCODE_BACK", "keycode_back", " back "): - actions, err = node._translate_and_validate_tool( - {"name": "press_key", "args": {"key": spelling}}, mock_state - ) - assert err is None, spelling - assert actions == [{"action": "press_key", "keycode": "KEYCODE_BACK"}], spelling - - actions, err = node._translate_and_validate_tool( - {"name": "press_key", "args": {"key": "app_switch"}}, mock_state - ) - assert err is None - assert actions == [{"action": "press_key", "keycode": "KEYCODE_APP_SWITCH"}] - - # Unsupported keys get a self-explanatory error that lists the accepted names. - actions, err = node._translate_and_validate_tool( - {"name": "press_key", "args": {"key": "volume_up"}}, mock_state - ) - assert actions == [] - assert err == ( - "Error: Unsupported key 'volume_up'. Supported keys: ENTER, BACK, HOME, APP_SWITCH." - ) - - -# --- helper tool results: status is structural, never sniffed from the words ---------- - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "result, expected_status", - [ - pytest.param(ToolFailure("Error: note 'progress' not found"), "error", id="tool_failure"), - pytest.param("Error 404 was typed into the search box", "success", id="plain_error_text"), - ], -) -async def test_operator_helper_tool_message_status_is_structural(result, expected_status): - from langchain_core.messages import ToolMessage - - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.execution_setup = None - mock_ctx.data_engine = None - - mock_state = MagicMock() - mock_state.subagent_calls = [] - mock_state.initial_goal = "Test goal" - - # Turn 1: read_note next to a click. A failed helper rejects the click; a - # deferring helper defers it. Either way turn 2 carries the tool message. - turn_1 = MagicMock() - turn_1.tool_calls = [ - {"name": "read_note", "args": {"key": "progress"}, "id": "call_read"}, - { - "name": "click", - "args": {"target": [50, 50], "target_description": "button"}, - "id": "call_click", - }, - ] - turn_2 = MagicMock() - turn_2.tool_calls = [ - { - "name": "click", - "args": {"target": [50, 50], "target_description": "button"}, - "id": "call_click_final", - } - ] - mock_llm = MagicMock() - mock_llm.ainvoke = AsyncMock(side_effect=[turn_1, turn_2]) - mock_llm.bind_tools.return_value = mock_llm - - read_note_tool = MagicMock() - read_note_tool.name = "read_note" - read_note_tool.args = {} - - async def dummy_read_note(key: str, **kwargs): - return result - - read_note_tool.coroutine = dummy_read_note - read_note_tool.func = None - - with ( - patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm), - patch( - "artemis.agents.operator.operator.trace_langchain_tool", - side_effect=lambda t, ctx: t, - ), - ): - node = OperatorNode(mock_ctx, tools=[read_note_tool], transcript_config=LEGACY_TRANSCRIPT) - await node(mock_state) - - assert mock_llm.ainvoke.await_count == 2 - second_turn_messages = mock_llm.ainvoke.call_args_list[1].args[0] - tool_msgs = {m.tool_call_id: m for m in second_turn_messages if isinstance(m, ToolMessage)} - assert tool_msgs["call_read"].status == expected_status - assert tool_msgs["call_read"].content == str(result) - # The accompanying click never ran: rejected when the helper failed, deferred - # otherwise. Both are reported as errors and never claim success. - assert tool_msgs["call_click"].status == "error" - assert tool_msgs["call_click"].content.startswith("Not executed:") - assert "success" not in tool_msgs["call_click"].content.lower() - if expected_status == "error": - assert str(result) in tool_msgs["call_click"].content - else: - assert "deferred" in tool_msgs["call_click"].content - - -@pytest.mark.asyncio -async def test_operator_burst_limit_error_text_is_not_repeated_per_call(): - """An over-long burst answers every tool_call id, but the full burst-limit - paragraph travels once; the remaining ToolMessages just point at it.""" - from langchain_core.messages import ToolMessage - - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.execution_setup = None - mock_ctx.data_engine = None - - mock_state = MagicMock() - mock_state.subagent_calls = [] - mock_state.initial_goal = "Test goal" - mock_state.open_incident = None - - turn_1 = MagicMock() - turn_1.tool_calls = [ - { - "name": "click", - "args": {"target": [50, 50], "target_description": f"button {i}"}, - "id": f"call_click_{i}", - } - for i in range(6) - ] - turn_2 = MagicMock() - turn_2.tool_calls = [ - { - "name": "click", - "args": {"target": [50, 50], "target_description": "button"}, - "id": "call_click_final", - } - ] - mock_llm = MagicMock() - mock_llm.ainvoke = AsyncMock(side_effect=[turn_1, turn_2]) - mock_llm.bind_tools.return_value = mock_llm - - with ( - patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm), - patch.object(OperatorNode, "_max_burst_actions", return_value=4), - ): - node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT) - node_update = await node(mock_state) - - assert mock_llm.ainvoke.await_count == 2 - second_turn_messages = mock_llm.ainvoke.call_args_list[1].args[0] - burst_ids = {f"call_click_{i}" for i in range(6)} - tool_msgs = { - m.tool_call_id: m - for m in second_turn_messages - if isinstance(m, ToolMessage) and m.tool_call_id in burst_ids - } - assert set(tool_msgs) == burst_ids - assert all(m.status == "error" for m in tool_msgs.values()) - - first = tool_msgs["call_click_0"].content - assert first.startswith("Error: 6 Turn-Ending Actions in one turn exceed") - assert "burst limit of 4" in first - for i in range(1, 6): - assert tool_msgs[f"call_click_{i}"].content == ( - "Not executed: see the burst-limit error above." - ) - - # Nothing from the rejected burst ran; turn 2's single click did. - decisions = json.loads(node_update["structured_decisions"]) - assert len(decisions) == 1 and decisions[0]["action"] == "tap" diff --git a/tests/unit/agents/test_operator_plan_ledger.py b/tests/unit/agents/test_operator_plan_ledger.py deleted file mode 100644 index 4de96863e..000000000 --- a/tests/unit/agents/test_operator_plan_ledger.py +++ /dev/null @@ -1,282 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Tests for operator plan-ledger handling.""" - -import json -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from artemis.agents.operator.operator import OperatorNode -from artemis.agents.operator.prompts import ( - apply_operator_prompt_contract, - load_operator_prompts, - render_plan_ledger_bounce, - unwritten_action_streak, -) -from artemis.config.agent import MemoryTranscriptConfig -from artemis.context import ArtemisContext -from artemis.utils.plan_grammar import parse_plan, render_plan_grammar_spec - -LEGACY_TRANSCRIPT = MemoryTranscriptConfig(enabled=False) - -PLAN_NO_LEAF = """- [x] Open Google Maps and search for SFO -- [/] Read the commute duration and record it into note `commute_eta_info` - - verify: duration recorded -- [ ] Draft the ETA message -""" - -PLAN_WITH_LEAF = """- [x] Open Google Maps and search for SFO -- [/] Read the commute duration and record it into note `commute_eta_info` - - [x] Open route options for SFO - - [/] Read the driving duration - - [/] Tap the "Driving" tab so the fastest route is highlighted - - verify: duration recorded -- [ ] Draft the ETA message -""" - - -def test_grammar_spec_teaches_deeper_nesting(): - spec = render_plan_grammar_spec(include_checks=False) - assert "4 spaces for a sub-sub-goal" in spec - assert "Only zero-indent lines count as milestones" in spec - - -def test_active_milestone_and_leaf(): - snap = parse_plan(PLAN_WITH_LEAF) - milestone = snap.active_milestone() - assert milestone is not None and milestone.text.startswith("Read the commute duration") - children = snap.children_of(milestone) - assert [c.text for c in children] == [ - "Open route options for SFO", - "Read the driving duration", - 'Tap the "Driving" tab so the fastest route is highlighted', - ] - leaf = snap.active_leaf(milestone) - assert leaf is not None and leaf.text.startswith('Tap the "Driving" tab') - assert leaf.indent == 4 - - snap = parse_plan(PLAN_NO_LEAF) - milestone = snap.active_milestone() - assert milestone is not None - assert snap.active_leaf(milestone) is None - assert snap.children_of(milestone) == () - - -def test_active_milestone_fallbacks(): - all_pending = parse_plan("- [ ] First\n- [ ] Second\n") - assert all_pending.active_milestone().text == "First" - nested_only = parse_plan("- [ ] First\n - [/] Doing it\n- [ ] Second\n") - assert nested_only.active_milestone().text == "First" - assert parse_plan("- [x] First\n- [x] Second\n").active_milestone() is None - - -def _action_step(n, wrote_plan=False, action=True): - tool_calls = [] - if wrote_plan: - tool_calls.append( - {"name": "update_note", "args": {"key": "task_plan", "target": "a", "replacement": "b"}} - ) - step = {"step_id": f"s{n}", "step_number": n, "tool_calls": tool_calls} - if action: - step["action_taken"] = [{"action": "tap"}] - return step - - -def test_streak_counts_trailing_action_turns_without_plan_write(): - assert unwritten_action_streak([]) == 0 - assert unwritten_action_streak([_action_step(1, wrote_plan=True)]) == 0 - assert unwritten_action_streak([_action_step(1)]) == 1 - steps = [ - _action_step(1), - _action_step(2, wrote_plan=True), - _action_step(3), - _action_step(4, action=False), - _action_step(5), - ] - assert unwritten_action_streak(steps) == 2 - - -def test_bounce_only_when_ledger_is_broken_or_stale(): - assert render_plan_ledger_bounce(PLAN_WITH_LEAF, streak=0, stale_turns=4) is None - assert render_plan_ledger_bounce(PLAN_WITH_LEAF, streak=2, stale_turns=4) is None - stale = render_plan_ledger_bounce(PLAN_WITH_LEAF, streak=3, stale_turns=4) - assert stale and "stayed in progress for 4 action turns" in stale - assert 'Tap the "Driving" tab' in stale - assert render_plan_ledger_bounce(PLAN_WITH_LEAF, streak=9, stale_turns=0) is None - - broken = render_plan_ledger_bounce(PLAN_NO_LEAF, streak=0, stale_turns=4) - assert broken and "has no in-progress `[/]` sub-goal beneath it" in broken - assert "Read the commute duration" in broken - - assert render_plan_ledger_bounce("No task plan yet.", 0, 4) is None - assert render_plan_ledger_bounce("- [x] Done\n- [x] Also done\n", 0, 4) is None - - -def test_prompt_teaches_the_ledger_once_without_per_turn_reminder(): - prompt = apply_operator_prompt_contract(load_operator_prompts()["main_template"]) - assert prompt.count("**Live Sub-goal Ledger**") == 1 - assert "mandatory every turn" not in prompt - assert "need no plan write" in prompt - assert "sub-sub-goal (4 spaces)" in prompt - assert "structurally broken" in prompt - - -def _make_node(tmp_path, plan_text, steps=None): - notes = tmp_path / "notes" - notes.mkdir(parents=True) - (notes / "task_plan.md").write_text(plan_text, encoding="utf-8") - - ctx = MagicMock(spec=ArtemisContext) - ctx.execution_setup = None - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = str(tmp_path) - ctx.data_engine.current_session_id = "s" - ctx.data_engine.get_agent_friendly_steps.return_value = steps or [] - - state = MagicMock() - state.subagent_calls = [] - state.initial_goal = "goal" - state.open_incident = None - node = OperatorNode(ctx, transcript_config=LEGACY_TRANSCRIPT) - return node, state, notes / "task_plan.md" - - -def _click_response(n_actions=1): - response = MagicMock() - response.content = "" - response.tool_calls = [ - { - "name": "click", - "args": {"target": [50, 50], "target_description": "button"}, - "id": f"call_{i}", - } - for i in range(n_actions) - ] - return response - - -def _llm(side_effect=None, return_value=None): - llm = MagicMock() - llm.ainvoke = AsyncMock(side_effect=side_effect, return_value=return_value) - llm.bind_tools.return_value = llm - return llm - - -@pytest.mark.asyncio -async def test_gate_bounces_broken_ledger_once_then_executes(tmp_path): - node, state, _ = _make_node(tmp_path, PLAN_NO_LEAF) - seen = [] - - async def ainvoke(messages, *args, **kwargs): - seen.append(list(messages)) - return _click_response() - - with patch("artemis.agents.operator.operator.get_llm", return_value=_llm(ainvoke)): - update = await node(state) - - assert len(seen) == 2, "first submission bounced, second executed" - bounce = [ - m - for m in seen[1] - if isinstance(getattr(m, "content", None), str) - and m.content.startswith("Not executed: the active milestone") - ] - assert bounce and bounce[0].status == "error" - assert json.loads(update["structured_decisions"])[0]["action"] == "tap" - - -@pytest.mark.asyncio -async def test_gate_lets_healthy_ledger_through_without_a_write(tmp_path): - node, state, _ = _make_node(tmp_path, PLAN_WITH_LEAF, steps=[_action_step(1)]) - llm = _llm(return_value=_click_response()) - with patch("artemis.agents.operator.operator.get_llm", return_value=llm): - update = await node(state) - assert llm.ainvoke.await_count == 1 - assert update["structured_decisions"] is not None - - -@pytest.mark.asyncio -async def test_gate_bounces_stale_ledger(tmp_path): - stale_history = [_action_step(1), _action_step(2), _action_step(3)] - node, state, _ = _make_node(tmp_path, PLAN_WITH_LEAF, steps=stale_history) - seen = [] - - async def ainvoke(messages, *args, **kwargs): - seen.append(list(messages)) - return _click_response() - - with ( - patch("artemis.agents.operator.operator.get_llm", return_value=_llm(ainvoke)), - patch.object(OperatorNode, "_plan_ledger_stale_turns", return_value=4), - ): - await node(state) - assert len(seen) == 2 - assert any( - isinstance(getattr(m, "content", None), str) - and "stayed in progress for 4 action turns" in m.content - for m in seen[1] - ) - - -@pytest.mark.asyncio -async def test_gate_passes_when_plan_changed_in_turn(tmp_path): - node, state, plan_path = _make_node(tmp_path, PLAN_NO_LEAF) - - async def ainvoke(messages, *args, **kwargs): - plan_path.write_text(PLAN_WITH_LEAF, encoding="utf-8") - return _click_response() - - llm = _llm(ainvoke) - with patch("artemis.agents.operator.operator.get_llm", return_value=llm): - update = await node(state) - assert llm.ainvoke.await_count == 1 - assert update["structured_decisions"] is not None - - -@pytest.mark.asyncio -async def test_gate_exempts_bursts_and_open_incidents(tmp_path): - node, state, _ = _make_node(tmp_path / "burst", PLAN_NO_LEAF) - llm = _llm(return_value=_click_response(n_actions=2)) - with patch("artemis.agents.operator.operator.get_llm", return_value=llm): - update = await node(state) - assert llm.ainvoke.await_count == 1 - assert len(json.loads(update["structured_decisions"])) == 2 - - node, state, _ = _make_node(tmp_path / "incident", PLAN_NO_LEAF) - state.open_incident = {"category": "intercepted"} - llm = _llm(return_value=_click_response()) - with patch("artemis.agents.operator.operator.get_llm", return_value=llm): - await node(state) - assert llm.ainvoke.await_count == 1 - - -@pytest.mark.asyncio -async def test_gate_inactive_without_plan_or_when_disabled(tmp_path): - llm = _llm(return_value=_click_response()) - - node, state, _ = _make_node(tmp_path / "a", "No task plan yet.") - with patch("artemis.agents.operator.operator.get_llm", return_value=llm): - await node(state) - assert llm.ainvoke.await_count == 1 - - llm.ainvoke.reset_mock() - node, state, _ = _make_node(tmp_path / "b", PLAN_NO_LEAF) - with ( - patch("artemis.agents.operator.operator.get_llm", return_value=llm), - patch.object(OperatorNode, "_plan_ledger_gate_enabled", return_value=False), - ): - await node(state) - assert llm.ainvoke.await_count == 1 diff --git a/tests/unit/agents/test_operator_prompt_estimate.py b/tests/unit/agents/test_operator_prompt_estimate.py deleted file mode 100644 index 5332d497e..000000000 --- a/tests/unit/agents/test_operator_prompt_estimate.py +++ /dev/null @@ -1,122 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""The Pro operator's context base on a provider without usage metadata. - -The transcript ledger's start gate and soft/hard thresholds read the -operator's own measured prompt size; when the provider reports no usage the -operator records a provider-less estimate instead (Flash already does), so -the thresholds still see a base. A measured call also hands the ledger the -messages it was measured on, for the chars-per-token calibration. -""" - -import base64 -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from artemis.agents.operator.operator import OperatorNode -from artemis.config.agent import MemoryTranscriptConfig -from artemis.context import ArtemisContext -from artemis.memory.step_memory import StepMemoryService -from artemis.memory.transcript import TranscriptLedger, estimate_prompt_tokens - -SCREENSHOT_B64 = base64.b64encode(b"fake-jpeg-bytes").decode("utf-8") - - -def _ctx(): - ctx = MagicMock(spec=ArtemisContext) - ctx.execution_setup = None - ctx.actuator = None - ctx.data_engine = None - ctx.step_memory = StepMemoryService(ctx=None) - ctx.transcript_ledger = None - return ctx - - -def _state(): - state = MagicMock() - state.subagent_calls = [] - state.initial_goal = "Estimate goal" - state.operator_feedback = None - state.injected_instruction = None - state.operator_tool_limit_exceeded = False - state.structured_decisions = None - state.last_execution_result = None - state.current_step_id = None - state.latest_ui_hierarchy = [] - state.operator_raw_data = { - "screenshot_b64": SCREENSHOT_B64, - "xml_hierarchy": [], - "ocr_results": None, - "width": 1080, - "height": 2400, - } - return state - - -def _llm(captured: list, usage_metadata=None): - mock_llm = MagicMock() - response = MagicMock() - response.tool_calls = [] - response.content = "no action this turn" - # usage_from_message only trusts a non-empty dict; a MagicMock attribute - # (the default) reads as "no usage reported". - if usage_metadata is not None: - response.usage_metadata = usage_metadata - - async def ainvoke(*args, **kwargs): - captured.append(list(args[0])) - return response - - mock_llm.ainvoke = AsyncMock(side_effect=ainvoke) - mock_llm.bind_tools.return_value = mock_llm - return mock_llm - - -@pytest.mark.asyncio -async def test_operator_records_an_estimate_when_the_provider_reports_no_usage(): - ctx = _ctx() - captured: list = [] - with patch("artemis.agents.operator.operator.get_llm", return_value=_llm(captured)): - node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True)) - await node(_state()) - - ledger = ctx.transcript_ledger - assert isinstance(ledger, TranscriptLedger) - assert captured, "the operator made no model call" - assert ledger.last_prompt_tokens == estimate_prompt_tokens(captured[-1]) - assert ledger.last_prompt_tokens > 0 - # No measurement, no calibration. - assert ledger.chars_per_token == 4.0 - - -@pytest.mark.asyncio -async def test_operator_measured_usage_calibrates_the_ledger_ratio(): - ctx = _ctx() - captured: list = [] - llm = _llm( - captured, - usage_metadata={"input_tokens": 50_000, "output_tokens": 10, "total_tokens": 50_010}, - ) - with patch("artemis.agents.operator.operator.get_llm", return_value=llm): - node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True)) - await node(_state()) - - ledger = ctx.transcript_ledger - assert ledger.last_prompt_tokens == 50_000 - # The prompt is a few thousand chars of English plus one image; measured - # at 50k tokens the ratio clamps to the lower bound — proof the messages - # reached the calibration, not just the token figure. - assert ledger.chars_per_token == 1.0 diff --git a/tests/unit/agents/test_operator_prompts.py b/tests/unit/agents/test_operator_prompts.py deleted file mode 100644 index 0e092c16c..000000000 --- a/tests/unit/agents/test_operator_prompts.py +++ /dev/null @@ -1,78 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from artemis.agents.operator.prompts import ( - apply_operator_prompt_contract, - load_operator_prompts, -) - - -def test_operator_prompt_contract_matches_note_runtime_semantics(): - prompt = apply_operator_prompt_contract(load_operator_prompts()["main_template"]) - - assert "**Write-Through Memory Tools** (`save_note`, `update_note`, `append_note`)" in prompt - assert "May run alongside the turn's Turn-Ending Action (or Fast-Action Burst)" in prompt - # The two execution tiers and the incident workflow are advertised. - assert "**Vetted Single Action (default)**" in prompt - # The burst ceiling is a context variable filled in by the later template render. - assert ( - "**Fast-Action Burst (time-critical only)**: two to {{ max_burst_actions }}" - " Turn-Ending Actions" in prompt - ) - assert "--- Execution Incident (OPEN) ---" in prompt - assert "Failure Analyzer" not in prompt - assert ( - "(`read_note`, `list_notes`, `search_history`, `replay_steps`, `get_step_screenshot`)" - in prompt - ) - assert "recall_history" not in prompt - assert "`save_note`, and `search_history`" not in prompt - assert "Do NOT submit a Turn-Ending Action at the same time" not in prompt - assert "memory note tools (`read_note`, `list_notes`, `save_note`, `update_note`" not in prompt - - -def test_operator_prompt_is_single_template_without_checker_dialogue(): - """The prompt is never switched by verification results: main_template is - the only template, and no checker-dialogue machinery is advertised.""" - prompts = load_operator_prompts() - assert set(prompts) == {"main_template"} - prompt = apply_operator_prompt_contract(prompts["main_template"]) - assert "reply_to_checker" not in prompt - assert "Checker" not in prompt - - -def test_operator_prompt_omits_environment_trust_and_explorer_directives(): - for template in load_operator_prompts().values(): - prompt = apply_operator_prompt_contract(template) - assert "Untrusted Screen Content & Instruction Priority" not in prompt - assert "Visual Explorer Rule" not in prompt - - -def test_operator_prompt_keeps_large_list_traversal_single_pass_until_boundary(): - for template in load_operator_prompts().values(): - prompt = apply_operator_prompt_contract(template) - - assert "do not advance to another milestone or mark it complete" in prompt - assert "A single scroll that reveals no new content is not sufficient evidence" in prompt - assert "one additional successful swipe in the same direction" in prompt - assert "never reverse direction merely to prove completion" in prompt - assert "even if older execution history has been pruned" in prompt - assert "Terminate exploration when a definitive boundary is reached" not in prompt - - -def test_contract_strips_legacy_tool_literals(): - expected = apply_operator_prompt_contract(load_operator_prompts()["main_template"]) - - assert "wait_for_delay(seconds=" not in expected - assert 'press_key(key="home"' not in expected diff --git a/tests/unit/agents/test_operator_reasoning_reminder.py b/tests/unit/agents/test_operator_reasoning_reminder.py deleted file mode 100644 index f9b965ec3..000000000 --- a/tests/unit/agents/test_operator_reasoning_reminder.py +++ /dev/null @@ -1,147 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Tests for the Operator reasoning reminder. - -Thinking-mode models satisfy "reason before acting" inside their thought channel -and emit bare tool calls. After such a silent turn the next observation tail -carries a one-line reminder; nothing is bounced and no extra model call is made. -""" - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from artemis.agents.operator.operator import OperatorNode -from artemis.agents.operator.prompts import ( - REASONING_REMINDER, - apply_operator_prompt_contract, - load_operator_prompts, -) -from artemis.config.agent import MemoryTranscriptConfig -from artemis.context import ArtemisContext - -LEGACY_TRANSCRIPT = MemoryTranscriptConfig(enabled=False) - -PLAN_WITH_LEAF = """- [x] Open Google Maps and search for SFO -- [/] Read the commute duration and record it into note `commute_eta_info` - - [/] Read the driving duration - - [/] Tap the "Driving" tab so the fastest route is highlighted -- [ ] Draft the ETA message -""" - - -def _make_node(tmp_path): - notes = tmp_path / "notes" - notes.mkdir(parents=True) - (notes / "task_plan.md").write_text(PLAN_WITH_LEAF, encoding="utf-8") - - ctx = MagicMock(spec=ArtemisContext) - ctx.execution_setup = None - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = str(tmp_path) - ctx.data_engine.current_session_id = "s" - ctx.data_engine.get_agent_friendly_steps.return_value = [] - - state = MagicMock() - state.subagent_calls = [] - state.initial_goal = "goal" - state.open_incident = None - return OperatorNode(ctx, transcript_config=LEGACY_TRANSCRIPT), state - - -def _click_response(content): - response = MagicMock() - response.content = content - response.tool_calls = [ - { - "name": "click", - "args": {"target": [50, 50], "target_description": "button"}, - "id": "call_0", - } - ] - return response - - -def _texts(messages): - out = [] - for m in messages: - content = getattr(m, "content", None) - if isinstance(content, str): - out.append(content) - elif isinstance(content, list): - out.extend(b.get("text", "") for b in content if isinstance(b, dict)) - return "\n".join(out) - - -@pytest.mark.asyncio -async def test_reminder_follows_a_silent_turn_and_costs_no_extra_call(tmp_path): - node, state = _make_node(tmp_path) - seen = [] - replies = iter( - [ - _click_response(""), # turn 1: silent - _click_response("Tapping the Driving tab."), # turn 2: reminded, speaks - _click_response([{"type": "thinking", "thinking": "internal"}]), # turn 3: silent - _click_response("Done."), # turn 4 - ] - ) - - async def ainvoke(messages, *args, **kwargs): - seen.append(list(messages)) - return next(replies) - - llm = MagicMock() - llm.ainvoke = AsyncMock(side_effect=ainvoke) - llm.bind_tools.return_value = llm - with patch("artemis.agents.operator.operator.get_llm", return_value=llm): - for _ in range(4): - await node(state) - - assert llm.ainvoke.await_count == 4, "one model call per turn, never a bounce" - assert REASONING_REMINDER not in _texts(seen[0]) # nothing to remind on turn 1 - assert REASONING_REMINDER in _texts(seen[1]) # turn 1 was silent - assert REASONING_REMINDER not in _texts(seen[2]) # turn 2 spoke - assert REASONING_REMINDER in _texts(seen[3]) # thinking-only turn 3 counts as silent - assert _texts(seen[3]).count(REASONING_REMINDER) == 1 - - -def test_ledger_silence_verdict_outranks_the_local_flag(tmp_path): - """With a transcript ledger, the ledger's own judgment of the previous - turn is authoritative; the local flag only serves the legacy path.""" - from artemis.memory import TranscriptLedger - - node, _ = _make_node(tmp_path) - node._previous_turn_silent = True - assert node._previous_turn_was_silent() is True # no ledger: local flag - - ledger = MagicMock(spec=TranscriptLedger) - ledger.last_turn_silent = False - node.ctx.transcript_ledger = ledger - assert node._previous_turn_was_silent() is False - - ledger.last_turn_silent = True - node._previous_turn_silent = False - assert node._previous_turn_was_silent() is True - - -def test_prompt_asks_for_one_paragraph_not_phase_sections(): - prompt = apply_operator_prompt_contract(load_operator_prompts()["main_template"]) - assert prompt.count("one short paragraph of connected prose reflecting these three phases") == 1 - assert "in your raw thinking" not in prompt - assert "in your written reply" not in prompt - assert ( - "bounced" - not in prompt.split("# THE THREE-PHASE COGNITIVE PROTOCOL")[1].split("## Phase 1")[0] - ) diff --git a/tests/unit/agents/test_operator_transcript.py b/tests/unit/agents/test_operator_transcript.py deleted file mode 100644 index 0fa9b5b78..000000000 --- a/tests/unit/agents/test_operator_transcript.py +++ /dev/null @@ -1,350 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Operator prompt snapshots and transcript mode. - -Flag off (``agent.memory.transcript.enabled=false``): the legacy 2-message -build must stay byte-for-byte identical — pinned by SHA-256 goldens recorded -for the current template. - -Flag on: ``_build_prompt`` renders ``S + F + A + tail`` from the session -transcript ledger; the turn's messages are staged after the tool loop and -committed (with step key and validator result) at the next build. -""" - -import base64 -import hashlib -import re -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from langchain_core.messages import HumanMessage, SystemMessage - -from artemis.agents.operator.operator import OperatorNode -from artemis.agents.operator.prompts import load_operator_prompts -from artemis.agents.operator.prompts import ( - PLAN_HISTORY_STATIC_POINTER, - PLAN_HISTORY_TEMPLATE_SECTION, - PromptBuilder, - TemplatePromptComponent, - render_transcript_static_system, -) -from artemis.config.agent import MemoryTranscriptConfig -from artemis.context import ArtemisContext -from artemis.memory.step_memory import StepMemoryService -from artemis.memory.transcript import ( - EPHEMERAL_BLOCKS_KEY, - EXECUTION_RESULT_MARKER, - PLAN_RECITATION_MARKER, - RESTORED_HISTORY_HEADER, - TranscriptLedger, -) - -# SHA-256 snapshots of the legacy system message with the fixed inputs below. -# Update these when an intentional template change alters the rendered prompt. -GOLDEN_EMPTY_PLAN = "513e0341014b9a2342f608ad4dd173b7dcf5704ce53ee028ba302a9a1b696f96" -GOLDEN_SENTINEL_PLAN = "012fe1b08645266d5a7e518023e20fc6fbfa65d4f2d985847ebb06e0184c67ee" - -SCREENSHOT_B64 = base64.b64encode(b"fake-jpeg-bytes").decode("utf-8") - - -async def _render_legacy_system(plan_and_history: str) -> str: - builder = PromptBuilder() - component = TemplatePromptComponent() - ctx = SimpleNamespace(execution_setup=None, actuator=None, data_engine=None) - state = SimpleNamespace(initial_goal="GOLDEN_FIXED_GOAL") - await component( - builder, - state, - ctx, - prompts=load_operator_prompts(), - template_name="main_template", - plan_and_history=plan_and_history, - ) - return "".join(builder.system_parts) - - -@pytest.mark.asyncio -async def test_legacy_prompt_bytes_match_pre_split_golden(): - empty = await _render_legacy_system("No plan or history yet.") - sentinel = await _render_legacy_system("PLAN_AND_HISTORY_SENTINEL\nline2") - assert hashlib.sha256(empty.encode("utf-8")).hexdigest() == GOLDEN_EMPTY_PLAN - assert hashlib.sha256(sentinel.encode("utf-8")).hexdigest() == GOLDEN_SENTINEL_PLAN - - -def test_main_template_contains_exactly_one_plan_history_section(): - template = load_operator_prompts()["main_template"] - assert template.count(PLAN_HISTORY_TEMPLATE_SECTION) == 1 - - -def test_transcript_static_system_is_stable_and_carries_no_history(): - ctx = SimpleNamespace(execution_setup=None, actuator=None, data_engine=None) - state = SimpleNamespace(initial_goal="STATIC_GOAL") - prompts = load_operator_prompts() - - first = render_transcript_static_system(prompts, ctx, state) - second = render_transcript_static_system(prompts, ctx, state) - assert first == second # byte-stable S region - - assert "STATIC_GOAL" in first - # The volatile section is replaced by the static pointer... - assert PLAN_HISTORY_STATIC_POINTER.splitlines()[1] in first - # ...and no unrendered slot or trailing observation header survives. - assert "{{" not in first - assert not first.endswith("# CURRENT OBSERVATION\n") - # The history guide names the marker a resolved screenshot renders as. - assert "--- Historical Visual Transition ---" in first - - -def _transcript_ctx(): - ctx = MagicMock(spec=ArtemisContext) - ctx.execution_setup = None - ctx.actuator = None - ctx.data_engine = None - ctx.step_memory = StepMemoryService(ctx=None) - ctx.transcript_ledger = None - return ctx - - -def _transcript_state(**overrides): - state = MagicMock() - state.subagent_calls = [] - state.initial_goal = "Transcript goal" - state.operator_feedback = None - state.injected_instruction = None - state.operator_tool_limit_exceeded = False - state.structured_decisions = None - state.last_execution_result = None - state.current_step_id = None - state.latest_ui_hierarchy = [] - state.operator_raw_data = { - "screenshot_b64": SCREENSHOT_B64, - "xml_hierarchy": [], - "ocr_results": None, - "width": 1080, - "height": 2400, - } - for key, value in overrides.items(): - setattr(state, key, value) - return state - - -def _no_action_llm(captured: list): - mock_llm = MagicMock() - mock_response = MagicMock() - mock_response.tool_calls = [] - mock_response.content = "no action this turn" - - async def mock_ainvoke(*args, **kwargs): - captured.append(list(args[0])) - return mock_response - - mock_llm.ainvoke = AsyncMock(side_effect=mock_ainvoke) - mock_llm.bind_tools.return_value = mock_llm - return mock_llm - - -@pytest.mark.asyncio -async def test_transcript_mode_two_turns_build_four_regions(tmp_path): - ctx = _transcript_ctx() - # Turn 2 has a recorded step but no committed ledger turn yet. This is - # an active session, so it must not trigger cold-start history seeding. - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = str(tmp_path) - ctx.data_engine.global_base_dir = str(tmp_path) - ctx.data_engine.get_agent_friendly_steps.return_value = [] - captured: list = [] - mock_llm = _no_action_llm(captured) - - with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm): - node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True)) - - # Turn 1 - await node(_transcript_state()) - ledger = ctx.transcript_ledger - assert isinstance(ledger, TranscriptLedger) - assert ledger.has_static_prefix - assert ledger.has_staged_turn - assert ledger.turn_count == 0 - - turn1_messages = captured[0] - assert isinstance(turn1_messages[0], SystemMessage) - assert isinstance(turn1_messages[-1], HumanMessage) - tail1_text = str(turn1_messages[-1].content) - assert "# CURRENT OBSERVATION [T+" in tail1_text - assert PLAN_RECITATION_MARKER in tail1_text - - # Turn 2: step 1 is now recorded, and the previous turn is committed - # with its step id and result. - ctx.data_engine.get_agent_friendly_steps.return_value = [ - { - "step_id": "step-1", - "step_number": 1, - "summary": "Step 1 summary", - "relative_time": "1.0s", - "extra_metadata": {"subgoal_hash": "default"}, - } - ] - await node( - _transcript_state( - current_step_id="step-1", - structured_decisions='[{"action": "tap"}]', - last_execution_result={"status": "dispatched"}, - ) - ) - - # The staged-but-uncommitted turn 2 state never triggers cold-start - # seeding (the transcript path stayed live instead of falling back). - assert not ledger.has_restored_history - assert ledger.turn_count == 1 - turn2_messages = captured[1] - # S region is the same object with the same bytes. - assert turn2_messages[0] is turn1_messages[0] - - active = list(ledger.active_messages) - # Committed turn-1 observation followed by its validator result. - assert any(EXECUTION_RESULT_MARKER in str(m.content) for m in active) - committed_obs = active[0] - # Text edge at depth 1: the committed observation has lost its plan - # recitation as soon as the next tail exists (its screenshot stays - # until the screenshot edge). - assert PLAN_RECITATION_MARKER not in str(committed_obs.content) - # The live tail still recites the plan. - assert PLAN_RECITATION_MARKER in str(turn2_messages[-1].content) - # Message layout is S + A + tail. - assert turn2_messages[-1] is not turn1_messages[-1] - assert re.search(r"T\+\d{2,}:\d{2}", str(turn2_messages[-1].content)) - - -@pytest.mark.asyncio -async def test_transcript_tail_marks_per_turn_notices_ephemeral(): - """The hand-built tail carries the builder's ephemeral indices: per-turn - notices are deleted by the scrub edge; header, plan recitation and the - observation itself are not.""" - from artemis.agents.operator.prompts import USER_GUIDANCE_MARKER - - ctx = _transcript_ctx() - captured: list = [] - mock_llm = _no_action_llm(captured) - with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm): - node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True)) - await node( - _transcript_state( - injected_instruction="Skip the login step", - user_stop_requested=False, - operator_tool_limit_exceeded=True, - ) - ) - - tail = captured[0][-1] - assert isinstance(tail, HumanMessage) - texts = { - i: block.get("text", "") - for i, block in enumerate(tail.content) - if isinstance(block, dict) and block.get("type") == "text" - } - guidance = next(i for i, t in texts.items() if t.startswith(USER_GUIDANCE_MARKER)) - warning = next(i for i, t in texts.items() if "tool-call budget" in t) - assert set(tail.additional_kwargs[EPHEMERAL_BLOCKS_KEY]) == {guidance, warning} - # The user's stop bit is relayed on the guidance block itself. - assert "authorized stopping" not in texts[guidance] - # The verbatim instruction is its own persistent block right after the - # wrapper (stamped with the ledger's session offset), never ephemeral: - # a standing instruction must outlive the turn it arrived on. - body = guidance + 1 - assert re.fullmatch(r'User instruction \(T\+\d{2,}:\d{2}\): "Skip the login step"', texts[body]) - assert body not in tail.additional_kwargs[EPHEMERAL_BLOCKS_KEY] - assert "Skip the login step" not in texts[guidance] - # Header, plan recitation and the observation stay in the history. - assert texts[0].startswith("# CURRENT OBSERVATION") - plan = next(i for i, t in texts.items() if t.startswith(PLAN_RECITATION_MARKER)) - screenshot = next(i for i, t in texts.items() if t == "--- Current Screenshot ---") - assert not {0, plan, screenshot} & set(tail.additional_kwargs[EPHEMERAL_BLOCKS_KEY]) - - -@pytest.mark.asyncio -async def test_transcript_actionless_turn_commits_without_validator_message(): - ctx = _transcript_ctx() - captured: list = [] - mock_llm = _no_action_llm(captured) - - with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm): - node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True)) - await node(_transcript_state()) - # Planner-rejected turns clear structured_decisions: no validator ran. - await node( - _transcript_state( - current_step_id="step-1", - structured_decisions="", - last_execution_result={"status": "dispatched"}, - ) - ) - - ledger = ctx.transcript_ledger - assert ledger.turn_count == 1 - assert not any(EXECUTION_RESULT_MARKER in str(m.content) for m in ledger.active_messages) - - -@pytest.mark.asyncio -async def test_transcript_cold_start_builds_restored_history_block(tmp_path): - ctx = _transcript_ctx() - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = str(tmp_path) - ctx.data_engine.global_base_dir = str(tmp_path) - ctx.data_engine.get_agent_friendly_steps.return_value = [ - { - "step_id": f"step_{i}", - "step_number": i, - "summary": f"Step {i} summary", - "relative_time": f"{i}.0s", - "extra_metadata": {"subgoal_hash": "default"}, - } - for i in range(1, 4) - ] - - captured: list = [] - mock_llm = _no_action_llm(captured) - - with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm): - node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True)) - await node(_transcript_state()) - - ledger = ctx.transcript_ledger - assert ledger.has_restored_history - - messages = captured[0] - restored_text = str(messages[1].content) - assert RESTORED_HISTORY_HEADER in restored_text - assert "--- Execution History ---" in restored_text - assert "Step 1 summary" in restored_text - # The live tail still follows normally and A stays appendable. - assert "# CURRENT OBSERVATION [T+" in str(messages[-1].content) - assert ledger.has_staged_turn - - -@pytest.mark.asyncio -async def test_flag_off_keeps_legacy_two_message_build_and_no_ledger(): - ctx = _transcript_ctx() - captured: list = [] - mock_llm = _no_action_llm(captured) - - with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm): - node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=False)) - await node(_transcript_state()) - - assert len(captured[0]) == 2 - assert isinstance(captured[0][0], SystemMessage) - assert isinstance(captured[0][1], HumanMessage) - assert ctx.transcript_ledger is None diff --git a/tests/unit/agents/test_outputter.py b/tests/unit/agents/test_outputter.py deleted file mode 100644 index 8def19fa9..000000000 --- a/tests/unit/agents/test_outputter.py +++ /dev/null @@ -1,746 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -from unittest.mock import AsyncMock, Mock, patch - -from pydantic import BaseModel -import pytest - -from artemis.agents.outputter.outputter import outputter -from artemis.config import LLM, OutputConfig # noqa: E402 -from artemis.context import ArtemisContext # noqa: E402 -from artemis.core.tool_failure import ToolFailure # noqa: E402 -from third_party.mobile_use import testing as agent_fixtures -from third_party.mobile_use.utils.logger import get_logger # noqa: E402 - -logger = get_logger(__name__) - -mock_context = agent_fixtures.mock_context -mock_state = agent_fixtures.mock_state - - -def setup_mock_llm(mock_get_llm, react_response_content="Paris", structured_response=None): - mock_llm = Mock() - mock_llm_fallback = Mock() - - # Mock bind_tools - mock_llm_with_tools = Mock() - mock_llm_with_tools.ainvoke = AsyncMock() - mock_react_response = Mock() - mock_react_response.content = react_response_content - mock_react_response.tool_calls = [] - mock_llm_with_tools.ainvoke.return_value = mock_react_response - - mock_llm.bind_tools.return_value = mock_llm_with_tools - mock_llm_fallback.bind_tools.return_value = mock_llm_with_tools - - # Mock with_structured_output - mock_structured_llm = Mock() - mock_structured_llm.ainvoke = AsyncMock() - if structured_response is not None: - mock_structured_llm.ainvoke.return_value = structured_response - mock_llm.with_structured_output.return_value = mock_structured_llm - mock_llm_fallback.with_structured_output.return_value = mock_structured_llm - - def get_llm_side_effect(ctx, name, is_utils=False, use_fallback=False): - if use_fallback: - return mock_llm_fallback - return mock_llm - - mock_get_llm.side_effect = get_llm_side_effect - - return mock_llm, mock_llm_with_tools, mock_structured_llm - - -class _Verdict(BaseModel): - achieved: bool - evidence: str - - -# A Pydantic model or a JSON schema dict; the result is a plain dict in both cases. -_FORMAT_CASES = [ - pytest.param(_Verdict, _Verdict(achieved=True, evidence="Order placed"), id="pydantic_model"), - pytest.param( - _Verdict.model_json_schema(), - {"achieved": True, "evidence": "Order placed"}, - id="json_schema", - ), -] - - -@patch("artemis.agents.outputter.outputter.get_llm") -@pytest.mark.asyncio -@pytest.mark.parametrize("schema, formatted_reply", _FORMAT_CASES) -async def test_outputter_applies_schema_in_separate_formatting_pass( - mock_get_llm, schema, formatted_reply, mock_context, mock_state -): - """The ReAct answer is re-shaped by a second, schema-bound call that sees the goal.""" - react_answer = "The confirmation screen reads 'Order placed'." - mock_llm, _, mock_structured_llm = setup_mock_llm( - mock_get_llm, react_response_content=react_answer, structured_response=formatted_reply - ) - - result = await outputter( - ctx=mock_context, - output_config=OutputConfig(structured_output=schema), - graph_output=mock_state, - ) - - assert result == {"achieved": True, "evidence": "Order placed"} - mock_llm.with_structured_output.assert_called_once_with(schema) - format_request = mock_structured_llm.ainvoke.call_args.args[0][-1].content - assert mock_state.initial_goal in format_request - assert react_answer in format_request - - -@patch("artemis.agents.outputter.outputter.get_llm") -@pytest.mark.asyncio -async def test_outputter_with_history_and_image(mock_get_llm, mock_context): - """Test outputter with explicit history and image.""" - expected_json = '{"answer": "Paris"}' - _, mock_llm_with_tools, _ = setup_mock_llm(mock_get_llm, react_response_content=expected_json) - - config = OutputConfig( - structured_output=None, - output_description="The capital of France", - ) - - class StateWithRawData: - def __init__(self, messages, initial_goal, operator_raw_data): - self.messages = messages - self.initial_goal = initial_goal - self.operator_raw_data = operator_raw_data - - state = StateWithRawData( - messages=[], - initial_goal="What is the capital of France?", - operator_raw_data={"screenshot_b64": "mock_b64_data"}, - ) - - await outputter( - ctx=mock_context, - output_config=config, - graph_output=state, - plan_and_history="Plan: Find capital\nStep 1: Opened browser", - ) - - call_args = mock_llm_with_tools.ainvoke.call_args - assert call_args is not None - messages = call_args[0][0] - - assert ( - "Your sole objective is to verify whether the user's initial goal was" - " achieved" in messages[0].content - ) - - human_content = messages[1].content - assert isinstance(human_content, list) - assert human_content[0]["type"] == "text" - assert "Step 1: Opened browser" in human_content[0]["text"] - assert "Plan: Find capital" in human_content[0]["text"] - assert human_content[1]["type"] == "image_url" - assert human_content[1]["image_url"]["url"] == "data:image/jpeg;base64,mock_b64_data" - - -@patch("artemis.agents.outputter.outputter.get_llm") -@pytest.mark.asyncio -async def test_outputter_builds_concise_history(mock_get_llm): - """Test that the outputter dynamically builds clean Summary ➡️ Action history.""" - expected_json = '{"answer": "Success"}' - _, mock_llm_with_tools, _ = setup_mock_llm(mock_get_llm, react_response_content=expected_json) - - config = OutputConfig( - structured_output=None, - output_description="Test output", - ) - - ctx = Mock(spec=ArtemisContext) - ctx.llm_config = { - "planner": LLM(provider="openai", model="gpt-5-nano"), - "operator": LLM(provider="openai", model="gpt-5-nano"), - "validator": LLM(provider="openai", model="gpt-5-nano"), - } - ctx.device = Mock() - - mock_data_engine = Mock() - steps = [ - { - "step_id": "step_1", - "step_number": 1, - "relative_time": "2.5s", - "summary": "Tapped search button", - "action_taken": [{"action": "click", "target_text": "Search"}], - "last_execution_result": {"status": "success"}, - } - ] - mock_data_engine.get_agent_friendly_steps.return_value = steps - # The transcript flag defaults on, so the outputter's compiled view - # consults the chunk store; no persisted chunks keeps the classic path. - mock_data_engine.get_history_chunks.return_value = [] - mock_data_engine.base_dir = "/tmp/mock_session" - ctx.data_engine = mock_data_engine - - class StateWithRawData: - def __init__(self, messages, initial_goal, operator_raw_data): - self.messages = messages - self.initial_goal = initial_goal - self.operator_raw_data = operator_raw_data - - state = StateWithRawData( - messages=[], - initial_goal="Search for something", - operator_raw_data=None, - ) - - # Test Case 1: Fallback path (no plan file) - from pathlib import Path - - original_exists = Path.exists - - def mock_exists_fallback(self, *args, **kwargs): - if self.name == "task_plan.md": - return False - return original_exists(self, *args, **kwargs) - - with patch("pathlib.Path.exists", mock_exists_fallback): - await outputter( - ctx=ctx, - output_config=config, - graph_output=state, - ) - - call_args = mock_llm_with_tools.ainvoke.call_args - assert call_args is not None - messages = call_args[0][0] - human_content_fallback = messages[1].content[0]["text"] - - assert "--- Execution History ---" in human_content_fallback - assert "- *Step 1 (Start: 2.5s): Tapped search button*" in human_content_fallback - assert "*Action*: Tapped 'Search' at None" in human_content_fallback - - # Reset mock call history - mock_llm_with_tools.ainvoke.reset_mock() - - # Test Case 2: Standard path (plan file exists) - def mock_exists_standard(self, *args, **kwargs): - if self.name == "task_plan.md": - return True - return original_exists(self, *args, **kwargs) - - original_read_text = Path.read_text - - def mock_read_text(self, *args, **kwargs): - if self.name == "task_plan.md": - return "- [ ] Goal 1\n - [/] Subgoal 1.1" - return original_read_text(self, *args, **kwargs) - - with ( - patch("pathlib.Path.exists", mock_exists_standard), - patch("pathlib.Path.read_text", mock_read_text), - ): - await outputter( - ctx=ctx, - output_config=config, - graph_output=state, - ) - - call_args = mock_llm_with_tools.ainvoke.call_args - assert call_args is not None - messages = call_args[0][0] - human_content_standard = messages[1].content[0]["text"] - - assert "--- Execution History ---" in human_content_standard - assert "- *Step 1 (Start: 2.5s): Tapped search button*" in human_content_standard - assert "*Action*: Tapped 'Search' at None" in human_content_standard - - -@patch("artemis.tools.video_tool.VideoAnalyzer") -@patch("artemis.agents.outputter.outputter.get_llm") -@pytest.mark.asyncio -async def test_outputter_executes_video_analyzer_tool( - mock_get_llm, mock_video_analyzer, mock_context, mock_state -): - """Test that the outputter ReAct loop can successfully invoke the video_analyzer tool.""" - # Setup mock VideoAnalyzer - mock_agent_instance = Mock() - mock_agent_instance.run = AsyncMock( - return_value=("The user played a video for 5 seconds.", "success") - ) - mock_video_analyzer.return_value = mock_agent_instance - - # Setup mock LLM with two turns - mock_llm = Mock() - mock_llm_fallback = Mock() - - mock_llm_with_tools = Mock() - mock_llm_with_tools.ainvoke = AsyncMock() - - # Turn 1: LLM calls video_analyzer tool - tool_call = { - "name": "video_analyzer", - "args": { - "time_description": "from 5s to 10s", - "purpose": "verify video played", - }, - "id": "call_123", - } - msg_turn1 = Mock() - msg_turn1.content = "" - msg_turn1.tool_calls = [tool_call] - - # Turn 2: LLM returns final answer - msg_turn2 = Mock() - msg_turn2.content = "Video played successfully." - msg_turn2.tool_calls = [] - - mock_llm_with_tools.ainvoke.side_effect = [msg_turn1, msg_turn2] - - mock_llm.bind_tools.return_value = mock_llm_with_tools - mock_llm_fallback.bind_tools.return_value = mock_llm_with_tools - - def get_llm_side_effect(ctx, name, is_utils=False, use_fallback=False): - if use_fallback: - return mock_llm_fallback - return mock_llm - - mock_get_llm.side_effect = get_llm_side_effect - - config = OutputConfig( - structured_output=None, - output_description="Verify video", - ) - - result = await outputter(ctx=mock_context, output_config=config, graph_output=mock_state) - - # Assert VideoAnalyzer was called correctly - mock_video_analyzer.assert_called_once_with(mock_context) - mock_agent_instance.run.assert_called_once_with("from 5s to 10s", "verify video played") - - # Assert LLM's ainvoke was called twice - assert mock_llm_with_tools.ainvoke.call_count == 2 - - # Assert tool message was appended (messages list is mutated in place, - # so it eventually has 5 elements) - sent_messages = mock_llm_with_tools.ainvoke.call_args_list[1][0][0] - assert len(sent_messages) == 5 - - # Verify the sequence of messages - assert sent_messages[2] == msg_turn1 - - tool_message = sent_messages[3] - from langchain_core.messages import ToolMessage - - assert isinstance(tool_message, ToolMessage) - assert tool_message.content == "The user played a video for 5 seconds." - assert tool_message.status == "success" - - assert sent_messages[4] == msg_turn2 - - # Assert final result - assert result == "Video played successfully." - - -@patch("artemis.agents.outputter.outputter.get_llm") -@pytest.mark.asyncio -async def test_outputter_executes_save_note_tool(mock_get_llm, mock_context, mock_state): - """Test that the outputter ReAct loop can successfully invoke the save_note tool.""" - # Setup mock LLM with two turns - mock_llm = Mock() - mock_llm_fallback = Mock() - - mock_llm_with_tools = Mock() - mock_llm_with_tools.ainvoke = AsyncMock() - - # Turn 1: LLM calls save_note tool - tool_call = { - "name": "save_note", - "args": {"key": "verification_code", "content": "123456"}, - "id": "call_save_123", - } - msg_turn1 = Mock() - msg_turn1.content = "" - msg_turn1.tool_calls = [tool_call] - - # Turn 2: LLM returns final answer - msg_turn2 = Mock() - msg_turn2.content = "Verification code is 123456." - msg_turn2.tool_calls = [] - - mock_llm_with_tools.ainvoke.side_effect = [msg_turn1, msg_turn2] - - mock_llm.bind_tools.return_value = mock_llm_with_tools - mock_llm_fallback.bind_tools.return_value = mock_llm_with_tools - - def get_llm_side_effect(ctx, name, is_utils=False, use_fallback=False): - if use_fallback: - return mock_llm_fallback - return mock_llm - - mock_get_llm.side_effect = get_llm_side_effect - - config = OutputConfig( - structured_output=None, - output_description="Save verification code to note", - ) - - # Setup mock data_engine on context to support saving notes - mock_data_engine = Mock() - mock_data_engine.base_dir = "/tmp/mock_session" - mock_context.data_engine = mock_data_engine - - # Patch save_note_content utility to avoid writing to disk - with patch("artemis.tools.scratchpad.save_note_content") as mock_save_content: - result = await outputter(ctx=mock_context, output_config=config, graph_output=mock_state) - mock_save_content.assert_called_once_with( - "/tmp/mock_session", "verification_code", "123456" - ) - - # Assert final result - assert result == "Verification code is 123456." - - -# --- tool results: status is structural, never sniffed from the words ----------------- - - -# A helper tool reports failure structurally (``ToolFailure``); free-form text -# that merely starts with "Error" is an ordinary answer. -_STATUS_CASES = [ - pytest.param(ToolFailure("Error: note 'progress' not found"), "error", id="tool_failure"), - pytest.param("Error 404 was typed into the search box", "success", id="plain_error_text"), -] - - -def _text_tool(name: str, result): # noqa: D103 - from langchain_core.tools import StructuredTool - - async def _run(key: str) -> str: - return result - - return StructuredTool.from_function(coroutine=_run, name=name, description=name) - - -@patch("artemis.agents.outputter.outputter.get_read_note_tool_pure") -@patch("artemis.agents.outputter.outputter.get_llm") -@pytest.mark.asyncio -@pytest.mark.parametrize("result, expected_status", _STATUS_CASES) -async def test_outputter_tool_message_status_is_structural( - mock_get_llm, mock_get_read_note, result, expected_status, mock_context, mock_state -): - from langchain_core.messages import AIMessage, ToolMessage - - mock_get_read_note.return_value = _text_tool("read_note", result) - _, mock_llm_with_tools, _ = setup_mock_llm(mock_get_llm) - tool_turn = AIMessage( - content="", tool_calls=[{"name": "read_note", "args": {"key": "progress"}, "id": "c1"}] - ) - final_turn = AIMessage(content="The answer.", tool_calls=[]) - mock_llm_with_tools.ainvoke.side_effect = [tool_turn, final_turn] - - config = OutputConfig(structured_output=None, output_description=None) - answer = await outputter(ctx=mock_context, output_config=config, graph_output=mock_state) - - assert answer == "The answer." - second_turn_messages = mock_llm_with_tools.ainvoke.call_args_list[1].args[0] - tool_msgs = [m for m in second_turn_messages if isinstance(m, ToolMessage)] - assert len(tool_msgs) == 1 - assert tool_msgs[0].tool_call_id == "c1" - assert tool_msgs[0].status == expected_status - assert tool_msgs[0].content == str(result) - - -# Runs that end without a normal answer. - - -def _tool_call(name: str, call_id: str, **args): # noqa: D103 - return {"name": name, "args": args, "id": call_id} - - -def _recording_tool(name: str, result, events: list, barrier=None): # noqa: D103 - from langchain_core.tools import StructuredTool - - async def _run(**_kwargs): - events.append(f"start:{name}") - if barrier is not None: - await asyncio.wait_for(barrier.wait(), timeout=2) - await asyncio.sleep(0) - events.append(f"end:{name}") - return result - - return StructuredTool.from_function(coroutine=_run, name=name, description=name) - - -@patch("artemis.agents.outputter.outputter.get_llm") -@pytest.mark.asyncio -async def test_outputter_returns_text_for_content_block_replies( - mock_get_llm, mock_context, mock_state -): - """Gemini 3+ replies arrive as content blocks; the caller gets their text.""" - from langchain_core.messages import AIMessage - - _, bound, _ = setup_mock_llm(mock_get_llm) - bound.ainvoke.return_value = AIMessage( - content=[ - {"type": "thinking", "thinking": "internal"}, - {"type": "text", "text": "The order was placed."}, - ] - ) - - result = await outputter( - ctx=mock_context, output_config=OutputConfig(), graph_output=mock_state - ) - - assert result == "The order was placed." - - -@patch("artemis.agents.outputter.outputter.get_llm") -@pytest.mark.asyncio -async def test_outputter_nudges_once_after_empty_reply(mock_get_llm, mock_context, mock_state): - from langchain_core.messages import AIMessage, HumanMessage - - _, bound, _ = setup_mock_llm(mock_get_llm) - bound.ainvoke.side_effect = [AIMessage(content=""), AIMessage(content="Done.")] - - result = await outputter( - ctx=mock_context, output_config=OutputConfig(), graph_output=mock_state - ) - - assert result == "Done." - assert bound.ainvoke.call_count == 2 - sent = bound.ainvoke.call_args_list[1].args[0] - assert isinstance(sent[2], HumanMessage) # the nudge, not the empty AI turn - assert not any(isinstance(m, AIMessage) and not m.content for m in sent) - - -@patch("artemis.agents.outputter.outputter.get_llm") -@pytest.mark.asyncio -async def test_outputter_nudges_only_once(mock_get_llm, mock_context, mock_state): - from langchain_core.messages import AIMessage - - _, bound, _ = setup_mock_llm(mock_get_llm) - bound.ainvoke.side_effect = [AIMessage(content=""), AIMessage(content=""), AssertionError()] - - result = await outputter( - ctx=mock_context, output_config=OutputConfig(), graph_output=mock_state - ) - - assert bound.ainvoke.call_count == 2 - assert result.startswith("Error: Outputter failed") - - -@patch("artemis.agents.outputter.outputter.MAX_TURNS", 2) -@patch("artemis.agents.outputter.outputter.get_read_note_tool_pure") -@patch("artemis.agents.outputter.outputter.get_llm") -@pytest.mark.asyncio -async def test_outputter_final_turn_runs_no_tools_and_returns_text( - mock_get_llm, mock_get_read_note, mock_context, mock_state -): - from langchain_core.messages import AIMessage, HumanMessage - - events: list[str] = [] - mock_get_read_note.return_value = _recording_tool("read_note", "note body", events) - _, bound, _ = setup_mock_llm(mock_get_llm) - bound.ainvoke.side_effect = [ - AIMessage(content="", tool_calls=[_tool_call("read_note", "c1", key="a")]), - AIMessage(content="Best answer so far.", tool_calls=[_tool_call("read_note", "c2")]), - ] - - result = await outputter( - ctx=mock_context, output_config=OutputConfig(), graph_output=mock_state - ) - - assert result == "Best answer so far." - assert events == ["start:read_note", "end:read_note"] # the final turn's call never ran - final_request = bound.ainvoke.call_args_list[1].args[0] - assert any( - isinstance(m, HumanMessage) and "final turn" in str(m.content) for m in final_request - ) - - -@patch("artemis.agents.outputter.outputter.MAX_TURNS", 2) -@patch("artemis.agents.outputter.outputter.get_llm") -@pytest.mark.asyncio -async def test_outputter_falls_back_to_saved_report( - mock_get_llm, mock_context, mock_state, tmp_path -): - """Without a final answer, the report written this run is returned, not an error.""" - from langchain_core.messages import AIMessage - - mock_context.data_engine = Mock(base_dir=str(tmp_path)) - _, bound, _ = setup_mock_llm(mock_get_llm) - report = "# Report\nStatus: SUCCESS" - bound.ainvoke.side_effect = [ - AIMessage( - content="", tool_calls=[_tool_call("save_note", "c1", key="output", content=report)] - ), - AIMessage(content=""), - ] - - result = await outputter( - ctx=mock_context, - output_config=OutputConfig(), - graph_output=mock_state, - plan_and_history="Step 1: placed order", - ) - - assert result == report - - -@patch("artemis.agents.outputter.outputter.get_llm") -@pytest.mark.asyncio -async def test_outputter_formatting_pass_sees_saved_report( - mock_get_llm, mock_context, mock_state, tmp_path -): - from langchain_core.messages import AIMessage - - mock_context.data_engine = Mock(base_dir=str(tmp_path)) - _, bound, structured = setup_mock_llm( - mock_get_llm, structured_response={"achieved": True, "evidence": "e"} - ) - bound.ainvoke.side_effect = [ - AIMessage( - content="", tool_calls=[_tool_call("save_note", "c1", key="output", content="REPORT")] - ), - AIMessage(content="Order placed."), - ] - - await outputter( - ctx=mock_context, - output_config=OutputConfig(structured_output=_Verdict), - graph_output=mock_state, - plan_and_history="Step 1", - ) - - format_messages = structured.ainvoke.call_args.args[0] - assert "REPORT" in format_messages[1].content - assert "Order placed." in format_messages[-1].content - assert mock_state.initial_goal in format_messages[-1].content - - -@patch("artemis.agents.outputter.outputter.get_save_note_tool_pure") -@patch("artemis.agents.outputter.outputter.get_history_tools") -@patch("artemis.agents.outputter.outputter.get_llm") -@pytest.mark.asyncio -async def test_outputter_runs_reads_concurrently_and_writes_in_order( - mock_get_llm, mock_history_tools, mock_get_save_note, mock_context, mock_state -): - from langchain_core.messages import AIMessage, ToolMessage - - events: list[str] = [] - barrier = asyncio.Barrier(2) # only passable if both reads are in flight together - mock_history_tools.return_value = ( - _recording_tool("search_history", "hit", events, barrier), - _recording_tool("replay_steps", "replay", events, barrier), - _recording_tool("get_step_screenshot", "no image", events), - ) - mock_get_save_note.return_value = _recording_tool("save_note", "saved", events) - _, bound, _ = setup_mock_llm(mock_get_llm) - calls = [ - _tool_call("search_history", "a"), - _tool_call("replay_steps", "b"), - _tool_call("save_note", "c", key="k", content="v"), - _tool_call("get_step_screenshot", "d"), - ] - bound.ainvoke.side_effect = [AIMessage(content="", tool_calls=calls), AIMessage("ok")] - - await outputter(ctx=mock_context, output_config=OutputConfig(), graph_output=mock_state) - - save_start = events.index("start:save_note") - assert {"end:search_history", "end:replay_steps"} <= set(events[:save_start]) - assert events.index("end:save_note") < events.index("start:get_step_screenshot") - sent = bound.ainvoke.call_args_list[1].args[0] - assert [m.tool_call_id for m in sent if isinstance(m, ToolMessage)] == ["a", "b", "c", "d"] - - -@patch("artemis.agents.outputter.outputter.get_history_tools") -@patch("artemis.agents.outputter.outputter.get_llm") -@pytest.mark.asyncio -async def test_outputter_puts_image_messages_after_all_tool_results( - mock_get_llm, mock_history_tools, mock_context, mock_state -): - """Providers that carry images in a HumanMessage need tool results contiguous.""" - from langchain_core.messages import AIMessage, HumanMessage, ToolMessage - - shot = [ - {"type": "text", "text": "Step 3 screenshot"}, - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,AAAA"}}, - ] - events: list[str] = [] - mock_history_tools.return_value = ( - _recording_tool("search_history", "hit at step 3", events), - _recording_tool("replay_steps", "replay", events), - _recording_tool("get_step_screenshot", shot, events), - ) - mock_llm, bound, _ = setup_mock_llm(mock_get_llm) - mock_llm.endpoint.provider = "openai" - turn = AIMessage( - content="", - tool_calls=[_tool_call("get_step_screenshot", "a"), _tool_call("search_history", "b")], - ) - bound.ainvoke.side_effect = [turn, AIMessage("done")] - - await outputter(ctx=mock_context, output_config=OutputConfig(), graph_output=mock_state) - - sent = bound.ainvoke.call_args_list[1].args[0] - after = sent[sent.index(turn) + 1 : -1] - assert [type(m) for m in after] == [ToolMessage, ToolMessage, HumanMessage] - assert [m.tool_call_id for m in after[:2]] == ["a", "b"] - - -_DEGRADE_CASES = [ - pytest.param( - '{"achieved": true, "evidence": "Order placed"}', - {"achieved": True, "evidence": "Order placed"}, - id="parsed_locally", - ), - pytest.param("The order was placed.", "The order was placed.", id="text_answer"), -] - - -@patch("artemis.agents.outputter.outputter.get_llm") -@pytest.mark.asyncio -@pytest.mark.parametrize("answer, expected", _DEGRADE_CASES) -async def test_outputter_formatting_failure_degrades( - mock_get_llm, answer, expected, mock_context, mock_state -): - _, _, structured = setup_mock_llm(mock_get_llm, react_response_content=answer) - structured.ainvoke.side_effect = RuntimeError("schema rejected") - - result = await outputter( - ctx=mock_context, - output_config=OutputConfig(structured_output=_Verdict), - graph_output=mock_state, - ) - - assert result == expected - - -@patch("artemis.agents.outputter.outputter.get_llm") -@pytest.mark.asyncio -async def test_outputter_shows_target_schema_while_investigating( - mock_get_llm, mock_context, mock_state -): - _, bound, _ = setup_mock_llm( - mock_get_llm, structured_response={"achieved": True, "evidence": "e"} - ) - - await outputter( - ctx=mock_context, - output_config=OutputConfig(structured_output=_Verdict), - graph_output=mock_state, - ) - - prompt = bound.ainvoke.call_args.args[0][1].content[0]["text"] - assert "## Target Output Fields" in prompt - assert '"achieved"' in prompt and '"evidence"' in prompt diff --git a/tests/unit/agents/test_plan_mutation_guardrails.py b/tests/unit/agents/test_plan_mutation_guardrails.py deleted file mode 100644 index 73918d90e..000000000 --- a/tests/unit/agents/test_plan_mutation_guardrails.py +++ /dev/null @@ -1,302 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from artemis.context import ArtemisContext, ExecutionSetup -from artemis.graph.graph import ( - check_plan_mutation_rejections, - check_unintended_rewrite, - wrap_note_tool, -) -from artemis.graph.state import State - -CONTINUOUS_PLAN = ( - "- [x] Open app\n" - "- [/] [Loop:continuous] Periodically monitor for new emails " - "(Interval: every 5 minutes)\n" - " - [x] Polling Check #1: Done" -) - - -def test_check_plan_mutation_incomplete_nested(): - content_before = "- [/] Main task\n - [ ] Subtask 1" - content_after = "- [x] Main task\n - [ ] Subtask 1" - res = check_plan_mutation_rejections(content_before, content_after) - assert res is not None - assert "goals in the task plan that have not been marked as completed" in res - - -def test_check_plan_mutation_delete_continuous_loop(): - content_after = ( - "- [x] Open app\n" - "- [x] Monitor for new emails\n" - " - [x] Baseline established\n" - " - [x] Polling Check #1: Done" - ) - res = check_plan_mutation_rejections(CONTINUOUS_PLAN, content_after) - assert res is not None - assert "cannot delete an active [Loop:continuous]" in res - - -def test_check_plan_mutation_mark_continuous_loop_completed(): - content_after = CONTINUOUS_PLAN.replace("- [/] [Loop:continuous]", "- [x] [Loop:continuous]") - res = check_plan_mutation_rejections(CONTINUOUS_PLAN, content_after) - assert res is not None - assert "cannot be" in res and "marked as completed [x]" in res - - -def test_check_plan_mutation_user_stopped_allowed(): - content_after = CONTINUOUS_PLAN.replace("- [/] [Loop:continuous]", "- [x] [Loop:continuous]") - state = MagicMock(spec=State) - state.user_stop_requested = True - res = check_plan_mutation_rejections(CONTINUOUS_PLAN, content_after, state=state) - assert res is None - - -def test_check_plan_mutation_stop_wording_is_not_a_signal(): - """Natural-language 'stop' phrasing must NOT unlock the continuous loop — - only the explicit release_loop signal (user_stop_requested) does.""" - content_after = CONTINUOUS_PLAN.replace("- [/] [Loop:continuous]", "- [x] [Loop:continuous]") - state = MagicMock(spec=State) - state.user_stop_requested = False - state.injected_instruction = "Please stop the task now." - res = check_plan_mutation_rejections(CONTINUOUS_PLAN, content_after, state=state) - assert res is not None - - -def test_check_plan_mutation_bounded_loop_completion_allowed(): - """Plain [Loop] milestones are bounded: the model may complete them.""" - content_before = ( - "- [x] Open app\n" - "- [/] [Loop] Inspect candidate emails (Exit: all 5 candidates inspected)\n" - " - [x] Candidate #5: Done" - ) - content_after = content_before.replace("- [/] [Loop]", "- [x] [Loop]") - res = check_plan_mutation_rejections(content_before, content_after) - assert res is None - - -def test_check_plan_mutation_normal_bounded_task(): - content_before = "- [ ] Open Settings\n- [ ] Toggle WiFi" - content_after = "- [x] Open Settings\n- [x] Toggle WiFi" - res = check_plan_mutation_rejections(content_before, content_after) - assert res is None - - -def test_check_unintended_rewrite_detects_hand_slip(): - content_before = "- [x] Open Settings app\n- [/] Toggle the WiFi switch" - # Full rewrite: milestone 1's status unchanged but text drifted - content_after = "- [x] Open the Settings application\n- [/] Toggle the WiFi switch" - res = check_unintended_rewrite(content_before, content_after) - assert res is not None - assert "Open Settings app" in res - assert "update_note" in res - - -def test_check_unintended_rewrite_allows_status_flips_and_appends(): - content_before = "- [x] Open Settings\n- [/] Toggle WiFi" - content_after = "- [x] Open Settings\n- [x] Toggle WiFi\n - [x] Confirmed enabled" - assert check_unintended_rewrite(content_before, content_after) is None - - -def test_check_unintended_rewrite_ignores_structural_replans(): - # Milestone count changes are declared replans, judged by planner validation - content_before = "- [x] Open Settings\n- [/] Toggle WiFi" - content_after = "- [x] Open Settings\n- [/] Toggle WiFi\n- [ ] Verify connectivity" - assert check_unintended_rewrite(content_before, content_after) is None - - -# --- §4.3 deterministic check-line guard (independent of planner validation) --------- - -GUARDED_PLAN = ( - "- [/] Create the alarm\n" - " - verify: alarm exists in the list\n" - " - assert: a toast appeared\n" - "- [ ] Next milestone\n" -) - - -def _guard_ctx(tmp_path, **setup_kwargs): - # Planner validation OFF by default: the guard must hold on its own. - setup_kwargs.setdefault("disable_planner_validation", True) - setup_kwargs.setdefault("disable_checker", False) - ctx = MagicMock(spec=ArtemisContext) - ctx.execution_setup = ExecutionSetup(**setup_kwargs) - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = str(tmp_path) - ctx.planner_task = None - ctx.last_validated_plan = None - ctx.pending_validated_plan = None - ctx.pending_checkpoints = [] - ctx.checkpoint_tasks = {} - return ctx - - -def _guard_plan_path(tmp_path, content=GUARDED_PLAN): - notes_dir = tmp_path / "notes" - notes_dir.mkdir(parents=True, exist_ok=True) - path = notes_dir / "task_plan.md" - path.write_text(content, encoding="utf-8") - return path - - -def _save_invoke(path): - async def fake(tool, args, tool_call_id, state, record_trace=None): - path.write_text(args["content"], encoding="utf-8") - return "Success" - - return fake - - -def _save_tool(): - tool = MagicMock() - tool.name = "save_note" - tool.description = "save_note tool" - return tool - - -@pytest.mark.asyncio -async def test_operator_deleting_check_line_is_restored(tmp_path): - path = _guard_plan_path(tmp_path) - ctx = _guard_ctx(tmp_path) - - rewritten = "- [/] Create the alarm\n - assert: a toast appeared\n- [ ] Next milestone\n" - with patch( - "artemis.graph.graph.invoke_tool_with_injection", - side_effect=_save_invoke(path), - ): - wrapped = wrap_note_tool(ctx, _save_tool()) - await wrapped.ainvoke({"key": "task_plan", "content": rewritten, "tool_call_id": "t"}) - - content = path.read_text(encoding="utf-8") - assert "- verify: alarm exists in the list" in content - assert "- assert: a toast appeared" in content - # No planner validation was needed or spawned (guard is deterministic) - assert ctx.planner_task is None - - -@pytest.mark.asyncio -async def test_operator_rewording_check_line_restores_original_keeps_new(tmp_path): - path = _guard_plan_path(tmp_path) - ctx = _guard_ctx(tmp_path) - - rewritten = GUARDED_PLAN.replace( - " - verify: alarm exists in the list\n", - " - verify: alarm roughly configured\n", - ) - with patch( - "artemis.graph.graph.invoke_tool_with_injection", - side_effect=_save_invoke(path), - ): - wrapped = wrap_note_tool(ctx, _save_tool()) - await wrapped.ainvoke({"key": "task_plan", "content": rewritten, "tool_call_id": "t"}) - - content = path.read_text(encoding="utf-8") - # Original standard restored; the Operator's new line survives as an addition - assert "- verify: alarm exists in the list" in content - assert "- verify: alarm roughly configured" in content - - -@pytest.mark.asyncio -async def test_deleted_parent_turns_check_into_task_level_at_end(tmp_path): - path = _guard_plan_path(tmp_path) - ctx = _guard_ctx(tmp_path) - - # Whole parent subgoal removed - rewritten = "- [ ] Next milestone\n" - with patch( - "artemis.graph.graph.invoke_tool_with_injection", - side_effect=_save_invoke(path), - ): - wrapped = wrap_note_tool(ctx, _save_tool()) - await wrapped.ainvoke({"key": "task_plan", "content": rewritten, "tool_call_id": "t"}) - - content = path.read_text(encoding="utf-8") - assert "- verify@end: alarm exists in the list" in content - assert "- assert@end: a toast appeared" in content - - -@pytest.mark.asyncio -async def test_operator_adding_check_lines_is_allowed(tmp_path): - path = _guard_plan_path(tmp_path) - ctx = _guard_ctx(tmp_path) - - extended = GUARDED_PLAN + " - verify: extra criterion\n" - with patch( - "artemis.graph.graph.invoke_tool_with_injection", - side_effect=_save_invoke(path), - ): - wrapped = wrap_note_tool(ctx, _save_tool()) - result = await wrapped.ainvoke( - {"key": "task_plan", "content": extended, "tool_call_id": "t"} - ) - - assert result == "Success" - assert "- verify: extra criterion" in path.read_text(encoding="utf-8") - - -@pytest.mark.asyncio -async def test_guard_holds_with_planner_validation_disabled_and_checks_off(tmp_path): - """The guard is content-driven: it protects check lines even when every - check switch AND planner validation are disabled.""" - path = _guard_plan_path(tmp_path) - ctx = _guard_ctx(tmp_path, disable_checker=True, disable_planner_validation=True) - - with patch( - "artemis.graph.graph.invoke_tool_with_injection", - side_effect=_save_invoke(path), - ): - wrapped = wrap_note_tool(ctx, _save_tool()) - await wrapped.ainvoke( - { - "key": "task_plan", - "content": "- [/] Create the alarm\n- [ ] Next milestone\n", - "tool_call_id": "t", - } - ) - - content = path.read_text(encoding="utf-8") - assert "- verify: alarm exists in the list" in content - assert "- assert: a toast appeared" in content - - -@pytest.mark.asyncio -async def test_planner_node_writes_are_not_guarded(): - """The Planner keeps revision authority over check standards: its node is - wired with the UNWRAPPED note tools, so the guard never applies to it.""" - from artemis.context import DeviceContext, DevicePlatform - from artemis.graph.graph import get_graph - - device = DeviceContext( - host_platform="LINUX", - mobile_platform=DevicePlatform.ANDROID, - device_id="dummy", - device_width=1080, - device_height=2400, - ) - ctx = ArtemisContext(device=device, execution_setup=ExecutionSetup()) - graph = await get_graph(ctx) - - planner_tools = {t.name: t for t in graph.nodes["planner"].bound.afunc.tools} - operator_tools = {t.name: t for t in graph.nodes["operator"].bound.afunc.tools} - - # Operator's plan-writing tools go through the guard wrapper... - assert operator_tools["save_note"].coroutine.__name__ == "wrapped_note_tool" - assert operator_tools["update_note"].coroutine.__name__ == "wrapped_update_note" - # ...the Planner's do not. - assert getattr(planner_tools["save_note"].coroutine, "__name__", "") != "wrapped_note_tool" - assert getattr(planner_tools["update_note"].coroutine, "__name__", "") != "wrapped_update_note" diff --git a/tests/unit/agents/test_planner_node.py b/tests/unit/agents/test_planner_node.py deleted file mode 100644 index 9fabb1143..000000000 --- a/tests/unit/agents/test_planner_node.py +++ /dev/null @@ -1,112 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import Mock, patch - -from artemis.agents.planner.planner import PlannerNode -from artemis.context import DevicePlatform, ArtemisContext -import pytest - - -class DummyState: - def __init__( - self, - initial_goal, - needs_replan=False, - operator_raw_data=None, - operator_replan_reason=None, - ): - self.initial_goal = initial_goal - self.needs_replan = needs_replan - self.operator_raw_data = operator_raw_data - self.operator_replan_reason = operator_replan_reason - - -@pytest.fixture -def mock_context(): - ctx = Mock(spec=ArtemisContext) - ctx.device = Mock() - ctx.device.mobile_platform = DevicePlatform.ANDROID - ctx.adb_client = Mock() - ctx.ui_adb_client = Mock() - mock_screen_data = Mock() - mock_screen_data.base64 = "YmFzZTY0" - mock_screen_data.elements = [] - mock_screen_data.width = 1080 - mock_screen_data.height = 2400 - ctx.ui_adb_client.get_screen_data.return_value = mock_screen_data - ctx.data_engine = None - - return ctx - - -@pytest.mark.asyncio -async def test_planner_initial_plan(mock_context): - state = DummyState( - initial_goal="Test Goal", - operator_raw_data={"screenshot_b64": "dummy_base64_string"}, - ) - node = PlannerNode(mock_context) - - mock_llm = Mock() - mock_response = Mock() - mock_response.content = "- [ ] Subgoal 1" - mock_response.tool_calls = [] - - async def mock_astream(*args, **kwargs): - yield mock_response - - mock_llm.astream.side_effect = mock_astream - mock_llm.bind_tools = Mock(return_value=mock_llm) - - with patch("artemis.agents.planner.planner.get_llm", return_value=mock_llm): - await node(state) - - assert mock_llm.astream.called - args, kwargs = mock_llm.astream.call_args - messages = args[0] - - system_msg = messages[0].content - assert "## Role" in system_msg - - human_msg_content = messages[1].content - assert isinstance(human_msg_content, list) - assert human_msg_content[0]["type"] == "text" - assert "Goal: Test Goal" in human_msg_content[0]["text"] - - -def test_validate_plan_format_single_sourced_status_alphabet(): - """Regression: the Planner's format gate is single-sourced from - plan_grammar — a legal in-progress '[/]' line must never be rejected.""" - from artemis.agents.planner.planner import validate_plan_format - - valid, err = validate_plan_format( - "- [x] Done milestone\n- [/] In-progress milestone\n - [ ] Sub\n- [!] Blocked\n" - ) - assert valid is True and err == "" - - # Every grammar status char is accepted; unknown ones are still rejected. - from artemis.utils.plan_grammar import STATUS_CHARS - - for c in STATUS_CHARS: - ok, _ = validate_plan_format(f"- [{c}] Milestone\n") - assert ok is True - - bad, message = validate_plan_format("- [z] Bogus status\n") - assert bad is False - assert "Invalid task status" in message - - empty, message = validate_plan_format("# just prose\n") - assert empty is False - assert "at least one subgoal" in message diff --git a/tests/unit/agents/test_precondition_pixel_target.py b/tests/unit/agents/test_precondition_pixel_target.py deleted file mode 100644 index 99ddfa20a..000000000 --- a/tests/unit/agents/test_precondition_pixel_target.py +++ /dev/null @@ -1,127 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Tests for pixel safety-net target descriptions.""" - -from pathlib import Path -from unittest.mock import MagicMock - -from artemis.agents.validator import precondition_pixel as pp -from langchain_core.messages import HumanMessage, SystemMessage - -_PNG = b"fake-image-bytes" - - -def _texts(messages: list) -> list[str]: - human = messages[1] - return [part["text"] for part in human.content if part["type"] == "text"] - - -def test_named_control_target_block_precedes_images(): - item = { - "action": "tap", - "coordinates": [1001, 718], - "target_text": "进入全屏模式", - "target_resource_id": "com.google.android.youtube:id/fullscreen_button", - "target_class": "android.widget.ImageView", - "target_bounds": [922, 655, 1080, 781], - } - state = MagicMock() - state.operator_native_thinking = "identified 进入全屏模式" - state.operator_raw_thinking = None - - messages = pp._build_messages("SYSTEM", _PNG, _PNG, item, state) - - assert isinstance(messages[0], SystemMessage) - assert isinstance(messages[1], HumanMessage) - first_text = messages[1].content[0]["text"] - assert first_text.startswith("[Target]") - assert "Kind: specific UI control" in first_text - assert "Label: 进入全屏模式" in first_text - assert "Resource ID: com.google.android.youtube:id/fullscreen_button" in first_text - assert "Class: android.widget.ImageView" in first_text - assert "[922, 655, 1080, 781]" in first_text - assert messages[1].content[1]["text"] == "[Image 1 (Reference)]" - assert any("Original Thinking" in t for t in _texts(messages)) - assert any("Action: tap" in t for t in _texts(messages)) - - -def test_coordinates_only_target_is_labelled_as_surface(): - item = {"action": "tap", "coordinates": [540, 400]} - - messages = pp._build_messages("SYSTEM", _PNG, _PNG, item, state=None) - - first_text = messages[1].content[0]["text"] - assert "Kind: coordinates only" in first_text - # No hit test runs any more: the block states that nothing was recorded, it - # does not claim the point was looked up in the UI hierarchy. - assert "no target metadata recorded" in first_text - assert "UI hierarchy" not in first_text - assert "Label:" not in first_text - assert "Resource ID:" not in first_text - assert not any("Original Thinking" in t for t in _texts(messages)) - - -def test_label_alone_counts_as_named_control(): - item = {"action": "tap", "coordinates": [10, 10], "target_text": "Submit"} - text = pp._describe_target(item) - assert "Kind: specific UI control" in text - assert "Label: Submit" in text - assert "Resource ID" not in text - - -def test_unlabelled_indexed_element_is_still_a_control(): - item = { - "action": "tap", - "coordinates": [1001, 718], - "target_class": "android.widget.ImageView", - "target_bounds": [922, 655, 1080, 781], - } - text = pp._describe_target(item) - assert "Kind: specific UI control" in text - assert "unlabelled" in text - assert "Class: android.widget.ImageView" in text - assert "[922, 655, 1080, 781]" in text - - -def test_described_coordinate_target_is_its_own_kind(): - """The model's description is presented as its belief, not as observed metadata.""" - item = {"action": "tap", "coordinates": [540, 400], "target_description": "play button"} - text = pp._describe_target(item) - assert "Kind: described target" in text - assert "Operator's description: play button" in text - assert "Kind: specific UI control" not in text - assert "Label:" not in text - - -def test_observed_metadata_outranks_description(): - item = { - "action": "tap", - "coordinates": [10, 10], - "target_text": "Submit", - "target_description": "ignored", - } - text = pp._describe_target(item) - assert "Kind: specific UI control" in text - assert "Label: Submit" in text - assert "described target" not in text - - -def test_prompt_rules_are_keyed_on_target_kind(): - prompt = Path(pp.__file__).with_name("pixel_safety_net.md").read_text(encoding="utf-8") - assert "`Kind: specific UI control`" in prompt - assert "`Kind: described target`" in prompt - assert "`Kind: coordinates only`" in prompt - assert "hidden" in prompt - assert "Identify what appears under the red dot" in prompt diff --git a/tests/unit/agents/test_prompt_assembly.py b/tests/unit/agents/test_prompt_assembly.py deleted file mode 100644 index f924f3d9c..000000000 --- a/tests/unit/agents/test_prompt_assembly.py +++ /dev/null @@ -1,195 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Prompt assembly tests: full-set output is byte-stable, absent tools leave no trace.""" - -from pathlib import Path -import re - -from jinja2 import Template -import pytest - -from artemis.agents.operator.prompts import load_operator_prompts -from artemis.agents.operator.prompts import ( - OPERATOR_PROMPT_TOOLSET, - apply_operator_prompt_contract, -) -from artemis.agents.prompt_assembly import gate_segment, render_tool_enum - -REPO_ROOT = Path(__file__).resolve().parents[3] - -OPERATOR_DEVICE_TOOLS = ( - "click", - "input_text", - "swipe", - "press_key", - "manage_app", - "wait_for_delay", - "long_press", -) - - -def _tool_refs(text: str, tool: str) -> list[str]: - """Finds real tool references (backticked or call form), ignoring prose.""" - return re.findall(rf"(`{tool}`|\b{tool}\()", text) - - -# --- render_tool_enum ---------------------------------------------------------------- - - -def test_enum_plain_list(): - out = render_tool_enum(("a", "b", "c"), {"a", "b", "c"}) - assert out == "`a`, `b`, `c`" - - -def test_enum_oxford_or_and(): - tools = ("a", "b", "c") - assert render_tool_enum(tools, set(tools), final_sep="or") == "`a`, `b`, or `c`" - assert render_tool_enum(tools, set(tools), final_sep="and") == "`a`, `b`, and `c`" - - -def test_enum_two_items_no_oxford_comma(): - assert render_tool_enum(("a", "b"), {"a", "b"}, final_sep="or") == "`a` or `b`" - - -def test_enum_single_and_empty(): - assert render_tool_enum(("a", "b"), {"a"}) == "`a`" - assert render_tool_enum(("a", "b"), set()) == "" - - -def test_gate_segment(): - assert gate_segment("teach", {"swipe"}, "swipe") == "teach" - assert gate_segment("teach", set(), "swipe") == "" - assert gate_segment("teach", {"a"}, "a", "b") == "" - - -# --- Operator prompt assembly -------------------------------------------------------- - - -def test_full_toolset_is_the_default(): - """``available_tools=None`` must equal the explicit full set, byte for byte.""" - for template in load_operator_prompts().values(): - assert apply_operator_prompt_contract(template) == apply_operator_prompt_contract( - template, available_tools=OPERATOR_PROMPT_TOOLSET - ) - - -@pytest.mark.parametrize( - "tool", - OPERATOR_DEVICE_TOOLS - + ("video_analyzer", "search_history", "replay_steps", "get_step_screenshot"), -) -def test_removed_tool_leaves_no_reference(tool): - """The executable definition of 'an absent tool costs the model nothing'.""" - for name, template in load_operator_prompts().items(): - out = apply_operator_prompt_contract( - template, available_tools=OPERATOR_PROMPT_TOOLSET - {tool} - ) - assert not _tool_refs(out, tool), f"'{tool}' still referenced in {name}" - - -def test_removed_swipe_takes_its_teaching_along(): - """Gating removes the drag/slider teaching, not just the backticked name.""" - template = load_operator_prompts()["main_template"] - out = apply_operator_prompt_contract( - template, available_tools=OPERATOR_PROMPT_TOOLSET - {"swipe"} - ) - assert "swipe/drag" not in out - assert "sliders/SeekBars" not in out - # The generic gesture-scope sentences survive. - assert "Action Scope & Interaction Precision" in out - - -def test_removed_wait_for_delay_keeps_interval_line_coherent(): - """Only the wait_for_delay parenthetical goes; the SOP pacing step stays.""" - template = load_operator_prompts()["main_template"] - out = apply_operator_prompt_contract( - template, available_tools=OPERATOR_PROMPT_TOOLSET - {"wait_for_delay"} - ) - assert ( - "Adhere strictly to the exact duration declared in ``, then execute" - " a refresh action" in out - ) - assert "Loading & Transitions" not in out - - -def test_reduced_enums_stay_well_formed(): - template = load_operator_prompts()["main_template"] - out = apply_operator_prompt_contract( - template, - available_tools=OPERATOR_PROMPT_TOOLSET - {"manage_app", "wait_for_delay"}, - ) - assert ( - "Physical device actions (`click`, `input_text`, `swipe`, `press_key`, `long_press`)" in out - ) - assert ( - "Turn-Ending Action (`click`, `swipe`, `input_text`, `long_press`, or `press_key`)" in out - ) - - -def test_full_set_enum_slots_render_verbatim(): - """With every tool present the historical enumeration wording is reproduced.""" - template = load_operator_prompts()["main_template"] - out = apply_operator_prompt_contract(template) - assert ( - "Physical device actions (`click`, `input_text`, `swipe`, `press_key`," - " `manage_app`, `wait_for_delay`, `long_press`)" in out - ) - assert ( - "Turn-Ending Action (`click`, `swipe`, `input_text`, `long_press`," - " `press_key`, `manage_app`, or `wait_for_delay`)" in out - ) - assert "Helper/Subagent tools (`ask_explorer`, `ask_diagnoser`, `video_analyzer`)" in out - assert "ADB/task tools (`run_adb_command`, `manage_task`)" in out - assert ( - "(`read_note`, `list_notes`, `search_history`, `replay_steps`, `get_step_screenshot`)" - in out - ) - # save_note is a write-through tool, never a result-dependent one; the on-demand - # analyze_task_output is not advertised. - assert "`save_note`, and" not in out - assert "analyze_task_output" not in out - - -# --- Flash prompt assembly ----------------------------------------------------------- - - -def _render_flash(available_tools: frozenset[str]) -> str: - template_text = (REPO_ROOT / "artemis/agents/flash/flash_runner.md").read_text(encoding="utf-8") - return Template(template_text).render(goal="test goal", available_tools=available_tools) - - -FLASH_FULL = frozenset({"manage_app", "wait_for_delay", "click_sequence", "click", "swipe"}) - - -def test_flash_full_set_keeps_both_segments(): - out = _render_flash(FLASH_FULL) - assert "**App Launching**" in out - assert "**Timed Waiting & Transitions**" in out - assert "{%" not in out - - -def test_flash_without_manage_app_drops_app_launching(): - out = _render_flash(FLASH_FULL - {"manage_app"}) - assert "**App Launching**" not in out - assert not _tool_refs(out, "manage_app") - # The neighbouring numbered rule survives. - assert "**Do not determine element coordinates based on guesswork.**" in out - - -def test_flash_without_wait_for_delay_drops_timed_waiting(): - out = _render_flash(FLASH_FULL - {"wait_for_delay"}) - assert "**Timed Waiting & Transitions**" not in out - assert not _tool_refs(out, "wait_for_delay") - assert "**State Drift & Progression Tolerance**" in out diff --git a/tests/unit/agents/test_summarizer.py b/tests/unit/agents/test_summarizer.py deleted file mode 100644 index 9166b748f..000000000 --- a/tests/unit/agents/test_summarizer.py +++ /dev/null @@ -1,219 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Tests for SummarizerNode visual-transition dispatch. - -Per the history redesign §5 the Pro SummarizerNode no longer runs its own LLM -call to produce a text capsule: it dispatches the step to the shared -step-memory service (``ctx.step_memory``) with the pre/post screenshot bytes -pulled from the DataEngine, and degrades gracefully when images are missing. -""" - -from types import SimpleNamespace -from unittest.mock import MagicMock, Mock - -import pytest - -from artemis.agents.summarizer.summarizer import SummarizerNode -from artemis.context import ArtemisContext - - -class DummyState: - def __init__( - self, - structured_decisions=None, - operator_raw_thinking=None, - operator_native_thinking=None, - last_execution_result=None, - current_step_id=None, - initial_goal=None, - injected_instruction=None, - ): - self.structured_decisions = structured_decisions - self.operator_raw_thinking = operator_raw_thinking - self.operator_native_thinking = operator_native_thinking - self.last_execution_result = last_execution_result - self.current_step_id = current_step_id - self.initial_goal = initial_goal - self.injected_instruction = injected_instruction - - -@pytest.fixture -def mock_context(tmp_path): - ctx = Mock(spec=ArtemisContext) - ctx.device = None - ctx.background_tasks = [] - ctx.step_memory = MagicMock() - - pre_path = tmp_path / "pre.jpg" - post_path = tmp_path / "post.jpg" - pre_path.write_bytes(b"PRE_IMAGE") - post_path.write_bytes(b"POST_IMAGE") - - engine = Mock() - engine.base_dir = None # no task_plan on disk: no sub-goal in the focus - engine.get_step_number.return_value = 7 - engine.get_step_record.return_value = SimpleNamespace( - action_taken=[ - { - "action": "tap", - "coordinates": [540, 1440], - "coordinate_space": "pixel", - "target_text": "Search", - } - ] - ) - engine.get_step_image_path.side_effect = lambda step_number, which="pre": ( - pre_path if which == "pre" else post_path - ) - ctx.data_engine = engine - return ctx - - -@pytest.mark.asyncio -async def test_summarizer_dispatches_visual_lens(mock_context): - """The node dispatches to the shared service instead of calling an LLM.""" - state = DummyState( - last_execution_result={"status": "dispatched"}, - current_step_id="12345678-1234-5678-1234-567812345678", - ) - - node = SummarizerNode(mock_context) - result = await node(state) - - assert result == {} - mock_context.step_memory.dispatch.assert_called_once_with( - step_number=7, - action_name="tap", - action_args={ - "coordinates": [500, 600], - "coordinate_space": "normalized", - "target_text": "Search", - }, - pre_img_bytes=b"PRE_IMAGE", - post_img_bytes=b"POST_IMAGE", - exec_outcome="dispatched", - data_engine_step_id="12345678-1234-5678-1234-567812345678", - focus=None, - ) - # No direct capsule write from the node: the lens owns the versioned - # summary write when its background job completes. - mock_context.data_engine.update_step_summary.assert_not_called() - - -@pytest.mark.asyncio -async def test_summarizer_degrades_gracefully_without_images(mock_context): - """Missing DataEngine screenshots dispatch with None bytes, no exception.""" - mock_context.data_engine.get_step_image_path.side_effect = None - mock_context.data_engine.get_step_image_path.return_value = None - - state = DummyState( - last_execution_result={"status": "failed", "error": "tap missed"}, - current_step_id="12345678-1234-5678-1234-567812345678", - ) - - node = SummarizerNode(mock_context) - await node(state) - - kwargs = mock_context.step_memory.dispatch.call_args.kwargs - assert kwargs["pre_img_bytes"] is None - assert kwargs["post_img_bytes"] is None - assert kwargs["exec_outcome"] == "Error: tap missed" - - -@pytest.mark.asyncio -async def test_summarizer_falls_back_to_structured_decisions(mock_context): - """Without a step record the action comes from structured_decisions, and - extra actions ride along as additional_actions.""" - mock_context.data_engine.get_step_record.return_value = None - - state = DummyState( - structured_decisions=( - '[{"action": "swipe", "coordinates": [540, 1800, 540, 600],' - ' "coordinate_space": "pixel"},' - ' {"action": "tap", "coordinates": [540, 1200], "coordinate_space": "pixel"}]' - ), - last_execution_result={"status": "dispatched"}, - current_step_id="12345678-1234-5678-1234-567812345678", - ) - - node = SummarizerNode(mock_context) - await node(state) - - kwargs = mock_context.step_memory.dispatch.call_args.kwargs - assert kwargs["action_name"] == "swipe" - assert kwargs["action_args"]["coordinates"] == [500, 750, 500, 250] - assert kwargs["action_args"]["additional_actions"] == [ - {"action": "tap", "coordinates": [500, 500], "coordinate_space": "normalized"} - ] - - -@pytest.mark.asyncio -async def test_summarizer_passes_operator_focus(mock_context, tmp_path): - """The operator's reasoning, the goal, the injected instruction and the live - sub-goal leaf ride along as the lens's focus context; the record's - reasoning is the fallback when the state carries none.""" - notes = tmp_path / "notes" - notes.mkdir() - (notes / "task_plan.md").write_text( - "- [/] Enable Wi-Fi\n - [/] Tap the Wi-Fi switch so it reads On\n- [ ] Join network\n", - encoding="utf-8", - ) - mock_context.data_engine.base_dir = str(tmp_path) - mock_context.data_engine.get_step_record.return_value = SimpleNamespace( - action_taken=[ - { - "action": "tap", - "coordinates": [950, 984], - "coordinate_space": "pixel", - "target_description": "Wi-Fi switch", - } - ], - operator_raw_thinking="Recorded reasoning: the switch reads Off; tapping it should read On.", - ) - state = DummyState( - last_execution_result={"status": "dispatched"}, - current_step_id="12345678-1234-5678-1234-567812345678", - initial_goal="Turn on Wi-Fi", - injected_instruction="Do not join any network yet.", - ) - - await SummarizerNode(mock_context)(state) - - kwargs = mock_context.step_memory.dispatch.call_args.kwargs - assert kwargs["action_args"]["target_description"] == "Wi-Fi switch" - assert kwargs["focus"] == { - "goal": "Turn on Wi-Fi", - "subgoal": "Enable Wi-Fi > Tap the Wi-Fi switch so it reads On", - "injected_instruction": "Do not join any network yet.", - "intent": "Recorded reasoning: the switch reads Off; tapping it should read On.", - } - - # Live state reasoning outranks the record's copy. - mock_context.step_memory.dispatch.reset_mock() - state.operator_raw_thinking = "Live reasoning wins." - await SummarizerNode(mock_context)(state) - focus = mock_context.step_memory.dispatch.call_args.kwargs["focus"] - assert focus["intent"] == "Live reasoning wins." - - -@pytest.mark.asyncio -async def test_summarizer_noop_without_step_id(mock_context): - state = DummyState(current_step_id=None) - - node = SummarizerNode(mock_context) - result = await node(state) - - assert result == {} - mock_context.step_memory.dispatch.assert_not_called() diff --git a/tests/unit/agents/test_validator.py b/tests/unit/agents/test_validator.py deleted file mode 100644 index c7a445d86..000000000 --- a/tests/unit/agents/test_validator.py +++ /dev/null @@ -1,888 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""ValidatorNode tests over the in-process unified action session. - -The Validator no longer spawns a stdio MCP subprocess; it calls a shared -``ActionSession`` whose device actions return structured ``ActionResult`` objects. -``FakeActionSession`` below stands in for it: ``action_handler`` decides each -action's ``(ok, message)``, ``screenshot`` feeds the polling loop, and ``hierarchy`` -feeds the safety-net XML validation. -""" - -import json -from unittest.mock import AsyncMock, MagicMock, Mock, patch - -from langchain_core.messages import AIMessage -import pytest - -from artemis.agents.validator.categories import ValidationErrorCategory -from artemis.agents.validator.validator import ValidatorNode -from artemis.context import ArtemisContext -from artemis.mcp.action_types import ActionCode, ActionResult - - -class DummyState: - def __init__( - self, - structured_decisions, - current_step_id=None, - latest_screenshot=None, - open_incident=None, - ): - self.structured_decisions = structured_decisions - self.current_step_id = current_step_id - self.latest_screenshot = latest_screenshot - self.open_incident = open_incident - - -class FakeActionSession: - """Test double for artemis.mcp.action_session.ActionSession.""" - - def __init__(self): - self.started = True - self.calls: list[tuple[str, dict]] = [] - # (name, args) -> (ok, message) - self.action_handler = lambda name, args: (True, "") - # str | callable | Exception - self.screenshot = "ZHVtbXlfZGF0YQ==" # b64("dummy_data") - self.hierarchy: list | Exception = [] - - async def call(self, name, args): - self.calls.append((name, args)) - ok, msg = self.action_handler(name, args) - return ActionResult( - ok=ok, - code=ActionCode.OK if ok else ActionCode.DEVICE_ERROR, - action=name, - message=msg, - ) - - async def screenshot_b64(self, timeout=None): - shot = self.screenshot - if isinstance(shot, Exception): - raise shot - return shot() if callable(shot) else shot - - async def ui_hierarchy(self, timeout=None): - if isinstance(self.hierarchy, Exception): - raise self.hierarchy - return self.hierarchy - - async def aclose(self): - pass - - def calls_for(self, name: str) -> list[dict]: - return [args for n, args in self.calls if n == name] - - -@pytest.fixture -def mock_context(tmp_path): - ctx = Mock(spec=ArtemisContext) - ctx.llm_config = Mock() - ctx.data_engine = Mock() - ctx.data_engine.base_dir = tmp_path - ctx.data_engine.get_relative_time.return_value = 1.0 - ctx.device = Mock() - ctx.device.device_width = 1080 - ctx.device.device_height = 2400 - return ctx - - -@pytest.fixture -def temp_screenshot(tmp_path): - p = tmp_path / "screenshot.png" - p.write_bytes(b"dummy_data") - return str(p) - - -@pytest.fixture -def mock_mcp(): - fake = FakeActionSession() - - async def _get_session(ctx, actuator=None): - return fake - - with patch("artemis.agents.validator.validator.get_action_session", _get_session): - yield fake - - -@pytest.mark.asyncio -async def test_validator_success(mock_mcp, mock_context, temp_screenshot): - """Test that ValidatorNode executes actions successfully.""" - decisions = json.dumps([{"action": "tap", "coordinates": [105, 205]}]) - state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) - - with patch("artemis.utils.image_diff.check_ui_change", return_value=True): - node = ValidatorNode(mock_context) - result = await node(state) - - assert "last_execution_result" in result - report = result["last_execution_result"] - assert "execution" in report - assert len(report["execution"]) == 1 - assert report["execution"][0]["action"] == "tap" - assert "attempts" not in report["execution"][0] - # The Operator's tap verb was translated to the canonical click tool. - assert len(mock_mcp.calls_for("click")) == 1 - - -# NOTE: upstream's test_failed_mcp_handshake_exits_contexts_and_disables_child_awake_policy -# guarded the stdio-subprocess handshake teardown, machinery this migration removed -# entirely. Its hazard class (leaked AnyIO cancel scopes) is now pinned by -# tests/unit/mcp/test_action_session.py against the in-process ActionSession. - - -@pytest.mark.asyncio -async def test_validator_exec_error_opens_incident(mock_mcp, mock_context, temp_screenshot): - """A vetted single action that the device rejects is retried once, then an - execution incident is opened for the Operator (no repair agent).""" - mock_mcp.action_handler = lambda name, args: ( - (False, "Error: Element not found") if name == "click" else (True, "") - ) - - decisions = json.dumps([{"action": "tap", "coordinates": [105, 205]}]) - state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) - - node = ValidatorNode(mock_context) - with patch("artemis.utils.image_diff.check_ui_change", return_value=False): - result = await node(state) - - report = result["last_execution_result"] - assert report["status"] == "failed" - assert report["burst"] is False - assert len(report["execution"]) == 1 - assert report["execution"][0]["attempts"] == [ - "Error: Element not found", - "Error: Element not found", - ] - assert "repair" not in report["execution"][0] - - incident = report["incident"] - assert incident["kind"] == "exec_error" - assert incident["category"] == "general" - assert incident["reason"] == "Error: Element not found" - assert incident["consecutive_failures"] == 1 - assert incident["burst_size"] == 1 - # The Operator reads the incident: the pixel target [105, 205] is rendered - # in its own 0-1000 space (1080x2400 frame). - assert incident["action_description"] == "Tapped element at [97, 85]" - # The same record rides in graph state for the Operator prompt. - assert result["open_incident"] == incident - - -@pytest.mark.asyncio -async def test_validator_burst_skips_safety_net_and_aborts_on_first_failure( - mock_mcp, mock_context, temp_screenshot -): - """Two or more actions form a fast-action burst: no precondition gate, a - single dispatch per member, and the first failure aborts the rest.""" - calls = [] - - def handler(name, args): - calls.append(name) - if name == "click" and len([c for c in calls if c == "click"]) == 2: - return False, "Error: tap rejected" - return True, "" - - mock_mcp.action_handler = handler - - decisions = json.dumps( - [ - {"action": "tap", "coordinates": [105, 205]}, - {"action": "tap", "coordinates": [205, 305]}, - {"action": "press_key", "keycode": "BACK"}, - ] - ) - state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) - - node = ValidatorNode(mock_context) - with ( - patch( - "artemis.agents.validator.execution_loop._run_precondition_gate", - new_callable=AsyncMock, - ) as mock_gate, - patch("artemis.utils.image_diff.check_ui_change", return_value=False), - ): - result = await node(state) - - mock_gate.assert_not_called() - - report = result["last_execution_result"] - assert report["burst"] is True - assert report["status"] == "failed" - assert len(report["execution"]) == 3 - # First member executed cleanly. - assert "attempts" not in report["execution"][0] - # Second member failed after exactly one dispatch (bursts never retry). - assert report["execution"][1]["attempts"] == ["Error: tap rejected"] - # Third member never fired. - assert report["execution"][2]["attempts"] == ["Skipped (burst aborted)"] - assert len(mock_mcp.calls_for("click")) == 2 - assert mock_mcp.calls_for("press_key") == [] - - incident = report["incident"] - assert incident["kind"] == "exec_error" - assert incident["action_index"] == 1 - assert incident["burst_size"] == 3 - assert result["open_incident"] == incident - - -@pytest.mark.asyncio -async def test_validator_burst_success_executes_every_member( - mock_mcp, mock_context, temp_screenshot -): - decisions = json.dumps( - [ - {"action": "tap", "coordinates": [105, 205]}, - {"action": "wait_for_delay", "time_in_ms": 1}, - {"action": "tap", "coordinates": [205, 305]}, - ] - ) - state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) - - node = ValidatorNode(mock_context) - with patch("artemis.utils.image_diff.check_ui_change", return_value=True): - result = await node(state) - - report = result["last_execution_result"] - assert report["status"] == "dispatched" - assert report["burst"] is True - assert report["incident"] is None - assert result["open_incident"] is None - assert result["last_closed_incident"] is None - assert len(mock_mcp.calls_for("click")) == 2 - - -@pytest.mark.asyncio -async def test_validator_success_closes_open_incident(mock_mcp, mock_context, temp_screenshot): - """A successful turn closes whatever incident was still open.""" - previous = { - "kind": "safety_net", - "category": "target_disappeared", - "reason": "gone", - "action": {"action": "tap"}, - "action_description": "Tapped element", - "consecutive_failures": 2, - } - decisions = json.dumps([{"action": "tap", "coordinates": [105, 205]}]) - state = DummyState( - structured_decisions=decisions, - latest_screenshot=temp_screenshot, - open_incident=previous, - ) - - node = ValidatorNode(mock_context) - with patch("artemis.utils.image_diff.check_ui_change", return_value=True): - result = await node(state) - - assert result["last_execution_result"]["status"] == "dispatched" - assert result["open_incident"] is None - # The closed record is handed over once so the Operator settles the intent. - assert result["last_closed_incident"]["kind"] == "safety_net" - assert "closed_at_step" in result["last_closed_incident"] - - -@pytest.mark.asyncio -async def test_validator_consecutive_failures_escalate_incident( - mock_mcp, mock_context, temp_screenshot -): - """Failing again while an incident is open continues its failure count.""" - previous = { - "kind": "exec_error", - "category": "general", - "reason": "Error: Element not found", - "action": {"action": "tap"}, - "action_description": "Tapped element", - "consecutive_failures": 2, - } - mock_mcp.action_handler = lambda name, args: (False, "Error: Element not found") - decisions = json.dumps([{"action": "tap", "coordinates": [105, 205]}]) - state = DummyState( - structured_decisions=decisions, - latest_screenshot=temp_screenshot, - open_incident=previous, - ) - - node = ValidatorNode(mock_context) - with patch("artemis.utils.image_diff.check_ui_change", return_value=False): - result = await node(state) - - assert result["open_incident"]["consecutive_failures"] == 3 - - -@pytest.mark.asyncio -async def test_validator_wait_for_delay(mock_mcp, mock_context, temp_screenshot): - """Test that wait_for_delay executes successfully.""" - decisions = json.dumps([{"action": "wait_for_delay", "time_in_ms": 10}]) - state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) - - with patch("artemis.utils.image_diff.check_ui_change", return_value=False): - node = ValidatorNode(mock_context) - result = await node(state) - - assert "last_execution_result" in result - report = result["last_execution_result"] - assert "execution" in report - assert len(report["execution"]) == 1 - assert report["execution"][0]["action"] == "wait_for_delay" - # wait_for_delay is a pure client-side sleep, never a session call. - assert mock_mcp.calls_for("wait_for_delay") == [] - - -@pytest.mark.asyncio -async def test_validator_focus_and_clear_text_no_ui_change(mock_mcp, mock_context, temp_screenshot): - """Test that focus_and_clear_text executes successfully even if no UI change is detected.""" - decisions = json.dumps([{"action": "focus_and_clear_text", "coordinates": [400, 500]}]) - state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) - - with patch("artemis.utils.image_diff.check_ui_change", return_value=False): - node = ValidatorNode(mock_context) - result = await node(state) - - assert "last_execution_result" in result - report = result["last_execution_result"] - assert "execution" in report - assert len(report["execution"]) == 1 - assert report["execution"][0]["action"] == "focus_and_clear_text" - assert len(mock_mcp.calls_for("focus_and_clear_text")) == 1 - - -@pytest.mark.asyncio -async def test_validator_silent_failure_treated_as_success(mock_mcp, mock_context, temp_screenshot): - """Test that silent failure (exec succeeds but no UI change) is treated as - success and does not open an execution incident. - """ - decisions = json.dumps([{"action": "tap", "coordinates": [105, 205]}]) - state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) - - node = ValidatorNode(mock_context) - - with ( - patch("artemis.agents.validator.validator.VALIDATOR_POLL_TIMEOUT", 0.1), - patch("artemis.agents.validator.validator.VALIDATOR_POLL_INTERVAL", 0.01), - patch("artemis.utils.image_diff.check_ui_change", return_value=False), # No UI change - ): - result = await node(state) - - # No incident is opened for a silent (no-UI-change) success. - assert result["open_incident"] is None - - # Assert the action is considered executed successfully - assert "last_execution_result" in result - report = result["last_execution_result"] - assert "execution" in report - assert len(report["execution"]) == 1 - assert report["execution"][0]["action"] == "tap" - assert "attempts" not in report["execution"][0] - - -@pytest.mark.asyncio -async def test_validator_burst_first_member_failure_marks_rest_skipped( - mock_mcp, mock_context, temp_screenshot -): - mock_mcp.action_handler = lambda name, args: (False, "Error: Connection lost") - - decisions = json.dumps( - [ - {"action": "tap", "coordinates": [100, 200]}, - {"action": "press_key", "keycode": "ENTER"}, - ] - ) - state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) - - node = ValidatorNode(mock_context) - with patch("artemis.utils.image_diff.check_ui_change", return_value=False): - result = await node(state) - - report = result["last_execution_result"] - assert len(report["execution"]) == 2 - assert report["execution"][0]["action"] == "tap" - # A burst member is dispatched exactly once. - assert report["execution"][0]["attempts"] == ["Error: Connection lost"] - assert "repair" not in report["execution"][0] - assert report["execution"][1]["action"] == "press_key" - assert report["execution"][1]["attempts"] == ["Skipped (burst aborted)"] - assert report["incident"]["action_index"] == 0 - assert report["incident"]["burst_size"] == 2 - - -@pytest.mark.asyncio -async def test_validator_pre_execution_validation_opens_incident( - mock_mcp, mock_context, temp_screenshot -): - """Test that pre-execution validation fails when the element changes, and the - interception is handed to the Operator as an execution incident. - """ - mock_mcp.hierarchy = [ - { - "text": "Sign Up", - "bounds": "[100,200][300,300]", - "resource-id": "btn_signup", - } - ] - - decisions = json.dumps( - [ - { - "action": "tap", - "coordinates": [200, 250], - "target_text": "Login", - "target_bounds": [100, 200, 300, 300], - "target_resource_id": "btn_login", - } - ] - ) - - state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) - node = ValidatorNode(mock_context) - - with ( - patch("artemis.utils.image_diff.check_ui_change", return_value=True), - patch.object( - ValidatorNode, - "_validate_action_precondition_pixel", - new_callable=AsyncMock, - ) as mock_pixel, - ): - mock_pixel.return_value = ( - False, - ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED, - "Pixel check failed", - ) - result = await node(state) - - incident = result["last_execution_result"]["incident"] - assert "Pre-execution validation failed" in incident["reason"] - assert "Login" in incident["reason"] - assert incident["category"] == ValidationErrorCategory.TARGET_OCCUPIED.value - - assert "last_execution_result" in result - report = result["last_execution_result"] - assert len(report["execution"]) == 1 - assert report["execution"][0]["action"] == "tap" - assert "Pre-execution validation failed" in report["execution"][0]["attempts"][0] - assert report["incident"]["kind"] == "safety_net" - assert report["status"] == "failed" - - -@pytest.mark.asyncio -async def test_validator_pre_execution_validation_self_healing( - mock_mcp, mock_context, temp_screenshot -): - """Test that pre-execution validation succeeds and self-heals when element shifts slightly.""" - mock_mcp.hierarchy = [ - { - "text": "Login", - "bounds": "[100,220][300,320]", - "resource-id": "btn_login", - } - ] - - decisions = json.dumps( - [ - { - "action": "tap", - "coordinates": [200, 250], - "target_text": "Login", - "target_bounds": [100, 200, 300, 300], - "target_resource_id": "btn_login", - } - ] - ) - - state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) - node = ValidatorNode(mock_context) - - with patch("artemis.utils.image_diff.check_ui_change", return_value=True): - result = await node(state) - - # Center of [100, 220][300, 320] is pixel [200, 270]; the healed coordinates - # travel to the canonical click tool normalized to 0-1000 on a 1080x2400 screen: - # round(200 * 1000 / 1080) = 185, round(270 * 1000 / 2400) = 112. - clicks = mock_mcp.calls_for("click") - assert len(clicks) == 1 - assert clicks[0]["target"] == [185, 112] - - -@pytest.mark.asyncio -async def test_validator_pre_execution_validation_anonymous_occupant( - mock_mcp, mock_context, temp_screenshot -): - """Test that pre-execution validation correctly flags TARGET_OCCUPIED - when coordinates are blocked by an anonymous clickable view. - """ - # The live screen contains an anonymous clickable view covering - # target coordinates [200, 250] - mock_mcp.hierarchy = [ - { - "text": "", - "bounds": "[150,200][250,300]", - "clickable": True, - "resource-id": "", - } - ] - - decisions = json.dumps( - [ - { - "action": "tap", - "coordinates": [200, 250], - "target_text": "Login", - "target_bounds": [100, 200, 300, 300], - "target_resource_id": "btn_login", - } - ] - ) - - state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) - node = ValidatorNode(mock_context) - - with ( - patch("artemis.utils.image_diff.check_ui_change", return_value=True), - patch.object( - ValidatorNode, - "_validate_action_precondition_pixel", - new_callable=AsyncMock, - ) as mock_pixel, - ): - mock_pixel.return_value = ( - False, - ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED, - "Pixel check failed", - ) - result = await node(state) - - # Assert that the validator correctly classified this as TARGET_OCCUPIED - # (due to the clickable anonymous view blocking the click) - incident = result["last_execution_result"]["incident"] - assert incident["category"] == ValidationErrorCategory.TARGET_OCCUPIED.value - assert ( - "occupied/intercepted by a different element: interactive anonymous element" - in incident["reason"] - ) - - -@pytest.mark.asyncio -async def test_validator_pixel_validation_success(mock_mcp, mock_context, temp_screenshot): - """Test that the pixel safety net succeeds when Gemini reports target is present.""" - decisions = json.dumps([{"action": "tap", "coordinates": [100, 200]}]) - state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) - - mock_agent_cfg = Mock() - mock_agent_cfg.provider = "google" - mock_agent_cfg.model = "gemini-3.5-flash-lite" - mock_context.llm_config.get_agent.return_value = mock_agent_cfg - - mock_response = Mock() - mock_response.text = json.dumps( - { - "is_present": True, - "reason": "Button is clearly visible and clickable.", - "confidence": 0.95, - } - ) - - mock_llm = MagicMock() - mock_llm.ainvoke = AsyncMock(return_value=AIMessage(content=mock_response.text)) - - # Mock crop and draw helpers to return dummy bytes - with ( - patch( - "artemis.utils.visualization.crop_and_annotate_target", - return_value=b"dummy_bytes", - ), - patch("artemis.agents.validator.validator.get_llm", return_value=mock_llm), - patch("artemis.utils.image_diff.check_ui_change", return_value=True), - ): - node = ValidatorNode(mock_context) - result = await node(state) - - assert "last_execution_result" in result - report = result["last_execution_result"] - assert len(report["execution"]) == 1 - assert "attempts" not in report["execution"][0] - - -@pytest.mark.asyncio -async def test_validator_pixel_validation_failure(mock_mcp, mock_context, temp_screenshot): - """Test that the pixel safety net fails and opens an execution incident - when Gemini reports target is missing. - """ - decisions = json.dumps([{"action": "tap", "coordinates": [100, 200]}]) - state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) - - mock_agent_cfg = Mock() - mock_agent_cfg.provider = "google" - mock_agent_cfg.model = "gemini-3.5-flash-lite" - mock_context.llm_config.get_agent.return_value = mock_agent_cfg - - mock_response = Mock() - mock_response.text = json.dumps( - { - "is_present": False, - "reason": "The button has completely disappeared from the screen.", - "confidence": 0.90, - } - ) - - mock_llm = MagicMock() - mock_llm.ainvoke = AsyncMock(return_value=AIMessage(content=mock_response.text)) - - with ( - patch( - "artemis.utils.visualization.crop_and_annotate_target", - return_value=b"dummy_bytes", - ), - patch("artemis.agents.validator.validator.get_llm", return_value=mock_llm), - patch("artemis.utils.image_diff.check_ui_change", return_value=False), - ): - node = ValidatorNode(mock_context) - result = await node(state) - - incident = result["last_execution_result"]["incident"] - assert "Pixel-level validation failed" in incident["reason"] - assert incident["category"] == ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED.value - - -@pytest.mark.asyncio -async def test_validator_launch_app_routes_through_manage_app( - mock_mcp, mock_context, temp_screenshot -): - """Test that a launch_app action item routes to the canonical manage_app tool. - - Package resolution (find_package with use_fallback=False) now happens inside the - actuator's manage_app implementation and is covered by the failure-analyzer tool - tests; the Validator's contract is the translation and single dispatch. - """ - decisions = json.dumps([{"action": "launch_app", "app_name": "My App"}]) - state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) - - with patch("artemis.utils.image_diff.check_ui_change", return_value=True): - node = ValidatorNode(mock_context) - result = await node(state) - - manage_calls = mock_mcp.calls_for("manage_app") - assert manage_calls == [{"action": "launch", "app_name": "My App"}] - assert "last_execution_result" in result - - -@pytest.mark.asyncio -async def test_validator_launch_app_failure_no_retry(mock_mcp, mock_context, temp_screenshot): - """Test that ValidatorNode does NOT retry launch_app action on failure.""" - mock_mcp.action_handler = lambda name, args: ( - (False, "Error: Force close") if name == "manage_app" else (True, "") - ) - - decisions = json.dumps([{"action": "launch_app", "app_name": "My App"}]) - state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) - - with ( - patch("artemis.agents.validator.validator.VALIDATOR_POLL_TIMEOUT", 0.05), - patch("artemis.agents.validator.validator.VALIDATOR_POLL_INTERVAL", 0.01), - patch("artemis.utils.image_diff.check_ui_change", return_value=False), - ): - node = ValidatorNode(mock_context) - result = await node(state) - - # launch_app allows a single attempt -- no validator-level retry. - assert len(mock_mcp.calls_for("manage_app")) == 1 - - assert "last_execution_result" in result - report = result["last_execution_result"] - assert len(report["execution"]) == 1 - assert report["execution"][0]["action"] == "launch_app" - assert report["execution"][0]["attempts"] == ["Error: Force close"] - - -@pytest.mark.asyncio -async def test_validator_pre_execution_validation_disappeared_not_shifted( - mock_mcp, mock_context, temp_screenshot -): - """Test that a disappeared small button matching text in a giant container - is classified as TARGET_DISAPPEARED, not TARGET_SHIFTED. - """ - # Live hierarchy only contains a giant background/container - # containing the text "Sign Up" - mock_mcp.hierarchy = [ - { - "text": "Sign Up", - "bounds": "[0,0][1080,2400]", - "resource-id": "root_container", - } - ] - - decisions = json.dumps( - [ - { - "action": "tap", - "coordinates": [200, 250], - "target_text": "Sign Up", - "target_bounds": [100, 200, 300, 300], - "target_resource_id": "btn_signup", - } - ] - ) - - state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) - node = ValidatorNode(mock_context) - - with ( - patch("artemis.utils.image_diff.check_ui_change", return_value=True), - patch.object( - ValidatorNode, - "_validate_action_precondition_pixel", - new_callable=AsyncMock, - ) as mock_pixel, - ): - mock_pixel.return_value = ( - False, - ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED, - "Pixel check failed", - ) - result = await node(state) - - # Assert that the validator correctly classified this as TARGET_DISAPPEARED - # (since size mismatch prevents shift matching) - incident = result["last_execution_result"]["incident"] - assert incident["category"] == ValidationErrorCategory.TARGET_DISAPPEARED.value - - -@pytest.mark.asyncio -async def test_validator_pre_execution_validation_ocr_direct_to_pixel( - mock_mcp, mock_context, temp_screenshot -): - """Test that OCR-derived elements bypass XML validation and - directly use pixel-based validation. - """ - decisions = json.dumps( - [ - { - "action": "tap", - "coordinates": [200, 250], - "target_text": "Login", - "target_bounds": [100, 200, 300, 300], - "target_class": "android.view.View [OCR]", - } - ] - ) - - state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) - node = ValidatorNode(mock_context) - - with ( - patch.object( - ValidatorNode, - "_validate_action_precondition", - new_callable=AsyncMock, - ) as mock_xml, - patch.object( - ValidatorNode, - "_validate_action_precondition_pixel", - new_callable=AsyncMock, - ) as mock_pixel, - patch("artemis.utils.image_diff.check_ui_change", return_value=True), - ): - mock_pixel.return_value = (True, ValidationErrorCategory.NONE, "") - - result = await node(state) - - # Assert XML validation was NOT called - mock_xml.assert_not_called() - # Assert Pixel validation WAS called - mock_pixel.assert_called_once() - - assert "last_execution_result" in result - report = result["last_execution_result"] - assert report["status"] == "dispatched" - - -@pytest.mark.asyncio -async def test_validator_pre_execution_xml_failure_not_overridden_when_pixel_bypassed( - mock_mcp, mock_context, temp_screenshot -): - """Test that when XML validation fails AND Pixel validation is bypassed - (PIXEL_BYPASSED), the XML failure is NOT overridden. - """ - mock_mcp.hierarchy = [ - { - "text": "Sign Up", - "bounds": "[100,200][300,300]", - "resource-id": "btn_signup", - } - ] - - decisions = json.dumps( - [ - { - "action": "tap", - "coordinates": [200, 250], - "target_text": "Login", - "target_bounds": [100, 200, 300, 300], - "target_resource_id": "btn_login", - } - ] - ) - - state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) - node = ValidatorNode(mock_context) - - with ( - patch("artemis.utils.image_diff.check_ui_change", return_value=True), - patch.object( - ValidatorNode, - "_validate_action_precondition_pixel", - new_callable=AsyncMock, - ) as mock_pixel, - ): - # Pixel check was bypassed due to VLM error or low confidence - mock_pixel.return_value = ( - True, - ValidationErrorCategory.PIXEL_BYPASSED, - "", - ) - result = await node(state) - - # An incident SHOULD be opened because XML validation failed - # and PIXEL_BYPASSED did not override it - incident = result["last_execution_result"]["incident"] - assert "Pre-execution validation failed" in incident["reason"] - - -@pytest.mark.asyncio -async def test_validator_failure_screenshot_mcp_error_handled_safely( - mock_mcp, mock_context, temp_screenshot -): - """Test that when take_screenshot fails on the session, the ValidatorNode does - not crash -- the failure screenshot is simply absent. - """ - mock_mcp.screenshot = RuntimeError("Error: ADB connection lost") - mock_mcp.action_handler = lambda name, args: ( - (False, "Error: Target not found") if name == "click" else (True, "") - ) - - decisions = json.dumps([{"action": "tap", "coordinates": [105, 205]}]) - state = DummyState(structured_decisions=decisions, latest_screenshot=temp_screenshot) - node = ValidatorNode(mock_context) - - with ( - patch("artemis.utils.image_diff.check_ui_change", return_value=False), - ): - result = await node(state) - - assert "last_execution_result" in result - report = result["last_execution_result"] - assert report["status"] == "failed" - assert len(report["execution"]) == 1 - assert report["execution"][0]["action"] == "tap" diff --git a/tests/unit/agents/test_video_agentic.py b/tests/unit/agents/test_video_agentic.py deleted file mode 100644 index ad28bb715..000000000 --- a/tests/unit/agents/test_video_agentic.py +++ /dev/null @@ -1,1086 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Agentic video understanding sub-agent (Gemini Interactions API).""" - -import json -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch - -from artemis.agents.video_analyzer import chunk_agentic -from artemis.agents.video_analyzer.chunk_agentic import ( - _drive_agentic_loop, - build_interactions_submit_tool, - run_agentic_chunk_conversation, -) -from artemis.agents.video_analyzer.chunk_conversation import ( - _MISSING_SUMMARY_ERROR, - _NO_TOOL_CALL_ERROR, - _WRONG_TOOL_ERROR, - SubAgentAnswerExhausted, - _ChunkMedia, -) -from artemis.agents.video_analyzer.reliability import ( - AgenticVideoDegraded, - VideoFailureCategory, - classify_video_failure, - is_agentic_rejection, -) -from artemis.agents.video_analyzer.video_analyzer import VideoAnalyzer -from artemis.context import ArtemisContext -from pydantic import SecretStr -import pytest - -# --------------------------------------------------------------------- fakes - - -def _context(model: str = "gemini-3.8-flash", **video_cfg) -> MagicMock: - ctx = MagicMock(spec=ArtemisContext) - ctx._video_blackboard = None - ctx._video_circuit_breaker = None - ctx._mobile_controller = None - ctx._genai_client = None - ctx.execution_setup = None - ctx.data_engine = None - ctx.agent_config = SimpleNamespace( - video_analyzer=SimpleNamespace( - enable_ledger=True, - chunk_size_seconds=60.0, - min_chunk_seconds=4.0, - max_split_depth=4, - circuit_breaker_threshold=2, - circuit_breaker_cooldown_seconds=30.0, - action_window_seconds=2.0, - dense_action_fps=4.0, - max_dense_action_frames=24, - **video_cfg, - ) - ) - ctx.device = SimpleNamespace(device_id="agentic-device") - ctx.llm_config = MagicMock() - ctx.llm_config.utils.video_analyzer.model = model - ctx.llm_config.utils.video_analyzer.temperature = 0.2 - return ctx - - -def _function_call(call_id: str, arguments: dict, name: str = "submit_answer"): - return SimpleNamespace(type="function_call", id=call_id, name=name, arguments=arguments) - - -def _model_output(text: str): - return SimpleNamespace(type="model_output", content=[SimpleNamespace(type="text", text=text)]) - - -_USAGE = SimpleNamespace( - total_input_tokens=191, - total_output_tokens=655, - total_tokens=10571, - total_cached_tokens=0, - total_thought_tokens=0, - total_tool_use_tokens=9725, -) - - -def _interaction(interaction_id: str, steps: list, status: str = "requires_action"): - return SimpleNamespace(id=interaction_id, status=status, steps=steps, usage=_USAGE) - - -def _events(interaction, processing_calls: int = 2, *, argument_chunks: int = 1): - """Scripts the SSE stream the real API emits for ``interaction``. - - Mirrors production: processing steps arrive as start/delta/stop triples, - function-call arguments arrive only as ``arguments_delta`` JSON text, and - ``interaction.completed`` carries no steps. - """ - events = [ - SimpleNamespace( - event_type="interaction.created", - interaction=SimpleNamespace(id=interaction.id, status="in_progress"), - ) - ] - index = 0 - for _ in range(processing_calls): - events.append( - SimpleNamespace( - event_type="step.start", - index=index, - step=SimpleNamespace(type="processing_call", id=f"proc_{index}"), - ) - ) - events.append(SimpleNamespace(event_type="step.stop", index=index, usage=None)) - index += 1 - for step in interaction.steps: - if step.type == "function_call": - events.append( - SimpleNamespace( - event_type="step.start", - index=index, - step=SimpleNamespace( - type="function_call", id=step.id, name=step.name, arguments={} - ), - ) - ) - payload = json.dumps(step.arguments) - size = max(1, len(payload) // argument_chunks) - for start in range(0, len(payload), size): - events.append( - SimpleNamespace( - event_type="step.delta", - index=index, - delta=SimpleNamespace( - type="arguments_delta", arguments=payload[start : start + size] - ), - ) - ) - elif step.type == "model_output": - events.append( - SimpleNamespace( - event_type="step.start", - index=index, - step=SimpleNamespace(type="model_output", content=[]), - ) - ) - for content in step.content: - events.append( - SimpleNamespace( - event_type="step.delta", - index=index, - delta=SimpleNamespace(type="text", text=content.text), - ) - ) - events.append(SimpleNamespace(event_type="step.stop", index=index, usage=None)) - index += 1 - events.append( - SimpleNamespace( - event_type="interaction.completed", - interaction=SimpleNamespace( - id=interaction.id, status=interaction.status, steps=None, usage=interaction.usage - ), - ) - ) - return events - - -class FakeInteractions: - """Replays scripted event streams and records every request.""" - - def __init__(self, turns, get_results=()): - self.turns = list(turns) - self.get_results = list(get_results) - self.requests: list[dict] = [] - self.get_calls: list[str] = [] - - async def create(self, **request): - self.requests.append(request) - events = self.turns.pop(0) - if isinstance(events, Exception): - raise events - - async def gen(): - for event in events: - yield event - - return gen() - - async def get(self, interaction_id): - self.get_calls.append(interaction_id) - if not self.get_results: - raise AssertionError(f"unexpected interactions.get({interaction_id})") - return self.get_results.pop(0) - - -def _fake_client(turns, get_results=()): - client = MagicMock() - client.aio.interactions = FakeInteractions(turns, get_results) - client.aio.files.delete = AsyncMock() - return client - - -def _valid_event(start=1.0, end=2.0): - return { - "start_time": start, - "end_time": end, - "transcription": "Battery Saver toggled on", - "confidence_score": 0.9, - "verification_timestamp_secs": (start + end) / 2, - } - - -def _media(tmp_path): - media = _ChunkMedia() - media.path = tmp_path / "segment.mp4" - media.path.write_bytes(b"video") - media.compressed_path = media.path - media.actual_start = 0.0 - media.prompt_with_context = "Watch the toggle." - return media - - -def _stub_analyzer(client): - analyzer = MagicMock() - analyzer.client = client - analyzer.model_name = "gemini-3.8-flash" - analyzer.sub_system_prompt = "system" - analyzer.agentic_call_timeout_seconds = 30.0 - analyzer.ctx.data_engine = None - return analyzer - - -_UPLOADED = SimpleNamespace(name="files/abc", uri="https://files/abc", mime_type="video/mp4") - - -def _extraction(tmp_path): - raw_video = tmp_path / "segment.mp4" - raw_video.write_bytes(b"video") - extracted = SimpleNamespace( - success=True, - video_path=raw_video, - actual_start_relative_time=0.0, - duration_seconds=5.0, - warning=None, - ) - controller = SimpleNamespace(extract_segment_metadata=AsyncMock(return_value=extracted)) - return raw_video, controller - - -# ------------------------------------------------------------------- tools - - -def test_submit_tool_is_flat_json_schema_with_required_timeline(): - tool = build_interactions_submit_tool() - assert tool["type"] == "function" - assert tool["name"] == "submit_answer" - assert "function" not in tool - assert "timeline_events" in tool["parameters"]["required"] - assert tool["parameters"]["properties"]["timeline_events"]["type"] == "array" - - -# --------------------------------------------------------- mode resolution - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "model, video_cfg, expected_mode, expected_chunk", - [ - ("gemini-3.8-flash", {}, "agentic", 600.0), - ("gemini-3.8-flash", {"processing": "static"}, "static", 60.0), - ("gemini-3.8-flash", {"agentic_chunk_size_seconds": 240.0}, "agentic", 240.0), - ("gemini-3.5-flash-lite", {}, "static", 60.0), - ("gemini-3.5-flash-lite", {"processing": "agentic"}, "agentic", 600.0), - ("gemini-2.5-flash", {"processing": "agentic"}, "static", 60.0), - ], -) -async def test_engine_resolves_video_processing(model, video_cfg, expected_mode, expected_chunk): - with ( - patch( - "artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", - SecretStr("test-key"), - ), - patch( - "artemis.agents.video_analyzer.video_analyzer.genai.Client", - return_value=MagicMock(), - ), - patch( - "artemis.agents.video_analyzer.video_analyzer.cleanup_abandoned_gemini_files", - AsyncMock(), - ), - ): - analyzer = VideoAnalyzer(_context(model, **video_cfg)) - - assert analyzer.use_native_gemini is True - assert analyzer.video_processing == expected_mode - assert analyzer.chunk_size_seconds == expected_chunk - - -def test_universal_engine_never_uses_agentic_processing(): - with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None): - analyzer = VideoAnalyzer(_context("gemini-3.8-flash")) - assert analyzer.use_native_gemini is False - assert analyzer.video_processing == "static" - assert analyzer.chunk_size_seconds == 60.0 - - -# ------------------------------------------------------------ conversation - - -@pytest.mark.asyncio -async def test_agentic_chunk_commits_answer_and_records_usage(tmp_path): - with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None): - analyzer = VideoAnalyzer(_context()) - interaction = _interaction( - "v1_first", - [ - _function_call( - "call_1", - { - "summary": "Toggle enabled", - "analysis": "Switch turned on at 1.5s", - "timeline_events": [_valid_event()], - }, - ) - ], - ) - client = _fake_client([_events(interaction, argument_chunks=3)]) - analyzer.client = client - analyzer.use_native_gemini = True - analyzer.video_processing = "agentic" - analyzer.chunk_size_seconds = analyzer.agentic_chunk_size_seconds - raw_video, controller = _extraction(tmp_path) - - spans = [] - - class RecordingSpan: - def __init__(self, name, **_): - self.name = name - self.payload = {} - self.result = None - spans.append(self) - - def __enter__(self): - return self - - def __exit__(self, *exc): - return False - - with ( - patch( - "artemis.agents.video_analyzer.video_analyzer.get_controller", - return_value=controller, - ), - patch( - "artemis.agents.video_analyzer.video_analyzer.compress_video_for_api", - new=AsyncMock(return_value=raw_video), - ), - patch.object(analyzer, "upload_and_poll_file", new=AsyncMock(return_value=_UPLOADED)), - patch.object(chunk_agentic, "_persist_timeline_events", new=AsyncMock()) as persist, - patch.object(chunk_agentic, "TraceSpan", RecordingSpan), - ): - result = await analyzer._exec_single_chunk(0.0, 5.0, "verify toggle") - - assert ( - result == "[from 0.0s to 5.0s] Summary: Toggle enabled Analysis: Switch turned on at 1.5s" - ) - assert analyzer.blackboard.missing_intervals(0.0, 5.0, "verify toggle") == [] - persist.assert_awaited_once() - assert persist.await_args.args[1] == [_valid_event()] - - request = client.aio.interactions.requests[0] - assert request["model"] == "gemini-3.8-flash" - assert request["stream"] is True - assert request["input"][0] == { - "type": "video", - "uri": "https://files/abc", - "mime_type": "video/mp4", - "processing": "agentic", - } - assert request["input"][1]["type"] == "text" - assert request["tools"][0]["name"] == "submit_answer" - assert "previous_interaction_id" not in request - assert client.aio.interactions.get_calls == [] - client.aio.files.delete.assert_awaited_once_with(name="files/abc") - - call_span = next(s for s in spans if s.name == "gemini_agentic_video_call") - assert call_span.payload["usage_metadata"]["tool_use_tokens"] == 9725 - assert call_span.payload["processing_calls"] == 2 - assert call_span.result == "Function call: submit_answer" - - -@pytest.mark.asyncio -async def test_rejected_answer_is_sent_back_as_stateful_function_result(tmp_path): - bad_event = dict(_valid_event(), confidence_score=1.7) - first = _interaction( - "v1_first", - [ - _function_call( - "call_1", {"summary": "s1", "analysis": "a1", "timeline_events": [bad_event]} - ) - ], - ) - second = _interaction( - "v1_second", - [ - _function_call( - "call_2", {"summary": "s2", "analysis": "a2", "timeline_events": [_valid_event()]} - ) - ], - ) - client = _fake_client([_events(first), _events(second, processing_calls=0)]) - analyzer = _stub_analyzer(client) - - with patch.object(chunk_agentic, "_persist_timeline_events", new=AsyncMock()) as persist: - summary, analysis = await _drive_agentic_loop( - analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle" - ) - - assert (summary, analysis) == ("s2", "a2") - persist.assert_awaited_once() - follow_up = client.aio.interactions.requests[1] - assert follow_up["previous_interaction_id"] == "v1_first" - assert len(follow_up["input"]) == 1 - result_step = follow_up["input"][0] - assert result_step["type"] == "function_result" - assert result_step["call_id"] == "call_1" - assert result_step["name"] == "submit_answer" - assert "confidence_score" in result_step["result"]["error"] - # system_instruction and tools are interaction-scoped: they are not - # inherited through previous_interaction_id and must be re-sent. - assert follow_up["system_instruction"] == analyzer.sub_system_prompt - assert follow_up["tools"][0]["name"] == "submit_answer" - - -@pytest.mark.asyncio -async def test_wrong_tool_is_rejected_then_accepted(tmp_path): - first = _interaction("v1_first", [_function_call("call_1", {}, name="ns:other_tool")]) - second = _interaction( - "v1_second", - [_function_call("call_2", {"summary": "s2", "analysis": "a2", "timeline_events": []})], - ) - client = _fake_client([_events(first, 0), _events(second, 0)]) - analyzer = _stub_analyzer(client) - - with patch.object(chunk_agentic, "_persist_timeline_events", new=AsyncMock()): - summary, analysis = await _drive_agentic_loop( - analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle" - ) - - assert (summary, analysis) == ("s2", "a2") - follow_up = client.aio.interactions.requests[1] - assert follow_up["input"][0]["call_id"] == "call_1" - assert "submit_answer" in follow_up["input"][0]["result"]["error"] - assert follow_up["system_instruction"] == analyzer.sub_system_prompt - - -@pytest.mark.asyncio -async def test_missing_tool_call_gets_one_reminder_then_raises(tmp_path): - first = _interaction( - "v1_first", [_model_output("I looked but did not call the tool.")], "completed" - ) - second = _interaction("v1_second", [_model_output("Still just text.")], "completed") - client = _fake_client([_events(first, 0), _events(second, 0)]) - analyzer = _stub_analyzer(client) - - with pytest.raises(SubAgentAnswerExhausted) as raised: - await _drive_agentic_loop( - analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle" - ) - - assert raised.value.reason == _NO_TOOL_CALL_ERROR - assert "never called submit_answer" in str(raised.value) - assert len(client.aio.interactions.requests) == 2 - reminder = client.aio.interactions.requests[1] - assert reminder["previous_interaction_id"] == "v1_first" - assert reminder["input"][0]["type"] == "text" - assert "submit_answer" in reminder["input"][0]["text"] - assert reminder["system_instruction"] == analyzer.sub_system_prompt - assert client.aio.interactions.get_calls == [] - - -@pytest.mark.asyncio -async def test_wrong_tool_on_every_turn_raises(tmp_path): - first = _interaction("v1_first", [_function_call("call_1", {}, name="ns:other_tool")]) - second = _interaction("v1_second", [_function_call("call_2", {}, name="other_tool")]) - client = _fake_client([_events(first, 0), _events(second, 0)]) - analyzer = _stub_analyzer(client) - - with ( - patch.object(chunk_agentic, "_persist_timeline_events", new=AsyncMock()) as persist, - pytest.raises(SubAgentAnswerExhausted) as raised, - ): - await _drive_agentic_loop( - analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle" - ) - - assert raised.value.reason == _WRONG_TOOL_ERROR - persist.assert_not_awaited() - assert len(client.aio.interactions.requests) == 2 - - -@pytest.mark.asyncio -async def test_unparsed_call_on_requires_action_is_reread_by_id(tmp_path): - """A stream that ends in requires_action without a parsable call is re-read via get().""" - events = [ - SimpleNamespace(event_type="interaction.created", interaction=SimpleNamespace(id="v1_x")), - SimpleNamespace( - event_type="interaction.completed", - interaction=SimpleNamespace( - id="v1_x", status="requires_action", steps=None, usage=_USAGE - ), - ), - ] - full = _interaction( - "v1_x", - [_function_call("call_9", {"summary": "s9", "analysis": "a9", "timeline_events": []})], - ) - client = _fake_client([events], get_results=[full]) - analyzer = _stub_analyzer(client) - - with patch.object(chunk_agentic, "_persist_timeline_events", new=AsyncMock()): - summary, analysis = await _drive_agentic_loop( - analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle" - ) - - assert (summary, analysis) == ("s9", "a9") - assert client.aio.interactions.get_calls == ["v1_x"] - - -@pytest.mark.asyncio -async def test_unparsable_arguments_drop_the_step_and_reread_by_id(tmp_path): - """Malformed streamed arguments must not stand in as an empty answer.""" - events = [ - SimpleNamespace(event_type="interaction.created", interaction=SimpleNamespace(id="v1_x")), - SimpleNamespace( - event_type="step.start", - index=0, - step=SimpleNamespace( - type="function_call", id="call_7", name="submit_answer", arguments={} - ), - ), - SimpleNamespace( - event_type="step.delta", - index=0, - delta=SimpleNamespace(type="arguments_delta", arguments='{"summary": "trunc'), - ), - SimpleNamespace(event_type="step.stop", index=0, usage=None), - SimpleNamespace( - event_type="interaction.completed", - interaction=SimpleNamespace( - id="v1_x", status="requires_action", steps=None, usage=_USAGE - ), - ), - ] - full = _interaction( - "v1_x", - [_function_call("call_7", {"summary": "s7", "analysis": "a7", "timeline_events": []})], - ) - client = _fake_client([events], get_results=[full]) - analyzer = _stub_analyzer(client) - - with patch.object(chunk_agentic, "_persist_timeline_events", new=AsyncMock()) as persist: - summary, analysis = await _drive_agentic_loop( - analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle" - ) - - assert (summary, analysis) == ("s7", "a7") - assert client.aio.interactions.get_calls == ["v1_x"] - assert len(client.aio.interactions.requests) == 1 - persist.assert_awaited_once() - - -def test_pending_step_with_invalid_arguments_json_is_dropped(): - step = chunk_agentic._PendingStep(kind="function_call", call_id="c", name="submit_answer") - step.arguments_json.append("{not json") - assert step.finish() is None - - intact = chunk_agentic._PendingStep(kind="function_call", call_id="c", name="submit_answer") - intact.arguments_json.extend(['{"summary": ', '"ok"}']) - assert intact.finish().arguments == {"summary": "ok"} - - -@pytest.mark.asyncio -async def test_submit_answer_without_summary_is_sent_back_not_committed(tmp_path): - first = _interaction( - "v1_first", - [_function_call("call_1", {"timeline_events": [_valid_event()]})], - ) - second = _interaction( - "v1_second", - [ - _function_call( - "call_2", {"summary": "s2", "analysis": "a2", "timeline_events": [_valid_event()]} - ) - ], - ) - client = _fake_client([_events(first), _events(second, processing_calls=0)]) - analyzer = _stub_analyzer(client) - - with patch.object(chunk_agentic, "_persist_timeline_events", new=AsyncMock()) as persist: - summary, analysis = await _drive_agentic_loop( - analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle" - ) - - assert (summary, analysis) == ("s2", "a2") - # Only the accepted answer reaches persistence. - persist.assert_awaited_once() - follow_up = client.aio.interactions.requests[1] - assert follow_up["previous_interaction_id"] == "v1_first" - result_step = follow_up["input"][0] - assert result_step["type"] == "function_result" - assert result_step["call_id"] == "call_1" - assert "summary" in result_step["result"]["error"] - - -@pytest.mark.asyncio -async def test_answer_never_given_a_summary_raises_and_persists_nothing(tmp_path): - first = _interaction("v1_first", [_function_call("call_1", {"timeline_events": []})]) - second = _interaction("v1_second", [_function_call("call_2", {"summary": " "})]) - client = _fake_client([_events(first, 0), _events(second, 0)]) - analyzer = _stub_analyzer(client) - - with ( - patch.object(chunk_agentic, "_persist_timeline_events", new=AsyncMock()) as persist, - pytest.raises(SubAgentAnswerExhausted) as raised, - ): - await _drive_agentic_loop( - analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle" - ) - - assert raised.value.reason == _MISSING_SUMMARY_ERROR - persist.assert_not_awaited() - assert len(client.aio.interactions.requests) == 2 - - -@pytest.mark.asyncio -async def test_last_turn_timeline_validation_failure_raises_and_persists_nothing(tmp_path): - """A summary whose timeline still fails validation on the last turn is never accepted.""" - bad_event = dict(_valid_event(), confidence_score=1.7) - first = _interaction( - "v1_first", - [ - _function_call( - "call_1", {"summary": "s1", "analysis": "a1", "timeline_events": [bad_event]} - ) - ], - ) - out_of_range = dict(_valid_event(), verification_timestamp_secs=42.0) - second = _interaction( - "v1_second", - [ - _function_call( - "call_2", - {"summary": "s2", "analysis": "a2", "timeline_events": [out_of_range]}, - ) - ], - ) - client = _fake_client([_events(first, 0), _events(second, 0)]) - analyzer = _stub_analyzer(client) - - with ( - patch.object(chunk_agentic, "_persist_timeline_events", new=AsyncMock()) as persist, - pytest.raises(SubAgentAnswerExhausted) as raised, - ): - await _drive_agentic_loop( - analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle" - ) - - # The message carries the last rejection the model received. - assert "verification_timestamp_secs" in raised.value.reason - assert "42.0" in str(raised.value) - persist.assert_not_awaited() - assert len(client.aio.interactions.requests) == 2 - assert client.aio.interactions.requests[1]["input"][0]["call_id"] == "call_1" - - -@pytest.mark.asyncio -async def test_exhausted_agentic_chunk_is_not_committed_to_the_blackboard(tmp_path): - first = _interaction("v1_first", [_model_output("text only")], "completed") - second = _interaction("v1_second", [_model_output("still text")], "completed") - client = _fake_client([_events(first, 0), _events(second, 0)]) - analyzer = _stub_analyzer(client) - analyzer.upload_and_poll_file = AsyncMock(return_value=_UPLOADED) - analyzer.cloud_files_to_cleanup = set() - - with pytest.raises(SubAgentAnswerExhausted): - await run_agentic_chunk_conversation( - analyzer, _media(tmp_path), 0.0, 5.0, 0.0, 5.0, "verify toggle", "lease-1" - ) - - analyzer.blackboard.complete_segment.assert_not_called() - # The uploaded clip is still cleaned up on the way out. - client.aio.files.delete.assert_awaited_once_with(name="files/abc") - - -@pytest.mark.parametrize( - "reason", - [ - _NO_TOOL_CALL_ERROR, - _WRONG_TOOL_ERROR, - _MISSING_SUMMARY_ERROR, - "Error: 'verification_timestamp_secs' 42.0 must fall within the start_time (0.0)" - " and end_time (5.0) boundaries. Fix the payload and call submit_answer again.", - "Error: 'confidence_score' is missing or out of bounds in one of the" - " timeline_events. It must be a float between 0.0 and 1.0 inclusive.", - ], -) -def test_exhaustion_is_classified_as_retryable_unknown(reason): - """No rejection text may accidentally match a transport / media marker.""" - failure = classify_video_failure(SubAgentAnswerExhausted(2, reason)) - assert failure.category is VideoFailureCategory.UNKNOWN - assert failure.retryable - assert failure.should_split - assert failure.should_fallback - - -@pytest.mark.asyncio -async def test_exhausted_chunk_leaves_the_interval_unanswered_for_re_analysis(tmp_path): - """End to end: exhaustion fails the segment instead of caching an empty success.""" - with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None): - analyzer = VideoAnalyzer(_context()) - - def text_only_turns(): - first = _interaction("v1_first", [_model_output("text only")], "completed") - second = _interaction("v1_second", [_model_output("still text")], "completed") - return [_events(first, 0), _events(second, 0)] - - # UNKNOWN is retried once from scratch, so two full conversations are scripted. - analyzer.client = _fake_client(text_only_turns() + text_only_turns()) - analyzer.use_native_gemini = True - analyzer.video_processing = "agentic" - raw_video, controller = _extraction(tmp_path) - - with ( - patch( - "artemis.agents.video_analyzer.video_analyzer.get_controller", - return_value=controller, - ), - patch( - "artemis.agents.video_analyzer.video_analyzer.compress_video_for_api", - new=AsyncMock(return_value=raw_video), - ), - patch.object(analyzer, "upload_and_poll_file", new=AsyncMock(return_value=_UPLOADED)), - patch.object( - analyzer, - "_exec_single_chunk_universal", - new=AsyncMock(side_effect=RuntimeError("fallback engine declined")), - ) as universal, - patch("artemis.agents.video_analyzer.video_analyzer._record_llm_event"), - patch("artemis.agents.video_analyzer.video_analyzer.asyncio.sleep", new=AsyncMock()), - pytest.raises(RuntimeError, match="fallback engine declined"), - ): - await analyzer._exec_single_chunk(0.0, 5.0, "verify toggle") - - assert len(analyzer.client.aio.interactions.requests) == 4 - universal.assert_awaited_once() - # Nothing succeeded, so the next claim re-analyses instead of hitting a cache. - assert analyzer.blackboard.missing_intervals(0.0, 5.0, "verify toggle") == [(0.0, 5.0)] - assert analyzer.blackboard.claim_segment(0.0, 5.0, "verify toggle").state == "claimed" - - -@pytest.mark.asyncio -@pytest.mark.parametrize("status", ["failed", "cancelled", "incomplete", "budget_exceeded"]) -async def test_completed_with_terminal_failure_status_raises(tmp_path, status): - events = [ - SimpleNamespace(event_type="interaction.created", interaction=SimpleNamespace(id="v1")), - SimpleNamespace( - event_type="interaction.completed", - interaction=SimpleNamespace( - id="v1", - status=status, - steps=None, - usage=None, - errors=[SimpleNamespace(code="internal", message="video worker died")], - ), - ), - ] - client = _fake_client([events]) - analyzer = _stub_analyzer(client) - - with pytest.raises(RuntimeError, match=f"{status}.*video worker died"): - await _drive_agentic_loop( - analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle" - ) - - # No follow-up is chained onto the failed interaction. - assert len(client.aio.interactions.requests) == 1 - assert client.aio.interactions.get_calls == [] - - -@pytest.mark.asyncio -async def test_completed_with_errors_raises_even_when_status_is_completed(tmp_path): - events = [ - SimpleNamespace(event_type="interaction.created", interaction=SimpleNamespace(id="v1")), - SimpleNamespace( - event_type="interaction.completed", - interaction=SimpleNamespace( - id="v1", - status="completed", - steps=None, - usage=None, - errors=[SimpleNamespace(code="unavailable", message="overloaded")], - ), - ), - ] - client = _fake_client([events]) - analyzer = _stub_analyzer(client) - - with pytest.raises(RuntimeError, match="overloaded"): - await _drive_agentic_loop( - analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle" - ) - - -@pytest.mark.asyncio -async def test_stream_error_event_raises(tmp_path): - events = [ - SimpleNamespace(event_type="interaction.created", interaction=SimpleNamespace(id="v1")), - SimpleNamespace( - event_type="error", error=SimpleNamespace(code="unavailable", message="overloaded") - ), - ] - client = _fake_client([events]) - analyzer = _stub_analyzer(client) - - with pytest.raises(RuntimeError, match="overloaded"): - await _drive_agentic_loop( - analyzer, _UPLOADED, None, _media(tmp_path), 0.0, 5.0, "verify toggle" - ) - - -@pytest.mark.asyncio -async def test_progress_is_streamed_into_the_trace(tmp_path): - interaction = _interaction( - "v1_first", - [ - _model_output("Toggle appears at 74s."), - _function_call("call_1", {"summary": "s", "analysis": "a", "timeline_events": []}), - ], - ) - client = _fake_client([_events(interaction, processing_calls=3)]) - analyzer = _stub_analyzer(client) - lines = [] - analyzer.ctx.data_engine = SimpleNamespace( - stream_output=lambda trace_id, text, is_thinking=None: lines.append((is_thinking, text)), - record_trace=lambda **kw: None, - ) - - with patch.object(chunk_agentic, "_persist_timeline_events", new=AsyncMock()): - await _drive_agentic_loop( - analyzer, _UPLOADED, "trace-1", _media(tmp_path), 0.0, 5.0, "verify toggle" - ) - - thinking = [text for is_thinking, text in lines if is_thinking] - text = [text for is_thinking, text in lines if not is_thinking] - assert len(thinking) == 3 - assert "video lookup 3" in thinking[-1] - assert text == ["Toggle appears at 74s."] - - -# ---------------------------------------------------------------- degrade - - -class _BadRequest(Exception): - status_code = 400 - - -class _PayloadTooLarge(Exception): - status_code = 413 - - -def test_agentic_rejection_is_only_a_400_about_processing(): - rejected = _BadRequest("400 INVALID_ARGUMENT: processing 'agentic' is not supported") - assert is_agentic_rejection(classify_video_failure(rejected), rejected) - - other_400 = _BadRequest("400 INVALID_ARGUMENT: unknown field 'tools[0].foo'") - assert not is_agentic_rejection(classify_video_failure(other_400), other_400) - - too_large = _PayloadTooLarge("413 request entity too large: processing") - failure = classify_video_failure(too_large) - assert failure.category is VideoFailureCategory.BAD_REQUEST - assert failure.should_split - assert not is_agentic_rejection(failure, too_large) - - outage = RuntimeError("503 service unavailable while processing") - assert not is_agentic_rejection(classify_video_failure(outage), outage) - - -def test_degraded_marker_replans_without_fallback(): - failure = classify_video_failure(AgenticVideoDegraded("rejected")) - assert failure.should_split - assert not failure.should_fallback - assert not failure.retryable - - -def _agentic_analyzer(tmp_path, first_turn): - with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None): - analyzer = VideoAnalyzer(_context()) - analyzer.client = _fake_client([first_turn]) - analyzer.use_native_gemini = True - analyzer.video_processing = "agentic" - analyzer.chunk_size_seconds = analyzer.agentic_chunk_size_seconds - raw_video, controller = _extraction(tmp_path) - return analyzer, raw_video, controller - - -@pytest.mark.asyncio -async def test_rejected_agentic_request_degrades_run_and_hands_chunk_back(tmp_path): - analyzer, raw_video, controller = _agentic_analyzer( - tmp_path, _BadRequest("processing agentic is not supported") - ) - - with ( - patch( - "artemis.agents.video_analyzer.video_analyzer.get_controller", - return_value=controller, - ), - patch( - "artemis.agents.video_analyzer.video_analyzer.compress_video_for_api", - new=AsyncMock(return_value=raw_video), - ), - patch.object(analyzer, "upload_and_poll_file", new=AsyncMock(return_value=_UPLOADED)), - patch( - "artemis.agents.video_analyzer.chunk_native._run_native_chunk_conversation", - new=AsyncMock(), - ) as static_path, - patch.object(analyzer, "_exec_single_chunk_universal", new=AsyncMock()) as universal, - patch("artemis.agents.video_analyzer.video_analyzer._record_llm_event") as record_event, - pytest.raises(AgenticVideoDegraded), - ): - await analyzer._exec_single_chunk(0.0, 300.0, "verify toggle") - - # The 300s clip is neither re-run statically nor handed to the fallback. - static_path.assert_not_awaited() - universal.assert_not_awaited() - assert analyzer.video_processing == "static" - assert analyzer.chunk_size_seconds == 60.0 - assert record_event.call_args.args[0] == "llm_fallback" - assert record_event.call_args.args[1]["reason"] == "agentic_video_rejected" - # The lease is released so the re-planned pieces can be claimed. - assert analyzer.blackboard.missing_intervals(0.0, 300.0, "verify toggle") == [(0.0, 300.0)] - - -@pytest.mark.asyncio -async def test_degraded_chunk_is_replanned_at_the_static_chunk_size(tmp_path): - analyzer, raw_video, controller = _agentic_analyzer( - tmp_path, _BadRequest("processing agentic is not supported") - ) - - async def static_answer(analyzer_, media, cs, ce, *rest): - return f"[from {cs:.1f}s to {ce:.1f}s] Summary: static Analysis: ok" - - with ( - patch( - "artemis.agents.video_analyzer.video_analyzer.get_controller", - return_value=controller, - ), - patch( - "artemis.agents.video_analyzer.video_analyzer.compress_video_for_api", - new=AsyncMock(return_value=raw_video), - ), - patch.object(analyzer, "upload_and_poll_file", new=AsyncMock(return_value=_UPLOADED)), - patch( - "artemis.agents.video_analyzer.chunk_native._run_native_chunk_conversation", - new=AsyncMock(side_effect=static_answer), - ) as static_path, - patch("artemis.agents.video_analyzer.video_analyzer._record_llm_event"), - ): - result = await analyzer.exec_spawn_sub_agent(0.0, 150.0, "verify toggle") - - # One agentic attempt on the 150s clip, then 60s static pieces. - assert len(analyzer.client.aio.interactions.requests) == 1 - segments = [(call.args[2], call.args[3]) for call in static_path.await_args_list] - assert sorted(segments) == [(0.0, 60.0), (60.0, 120.0), (120.0, 150.0)] - assert all(ce - cs <= 60.0 for cs, ce in segments) - assert "PARTIAL" not in result - assert result.count("Summary: static") == 3 - assert analyzer.video_processing == "static" - - -@pytest.mark.asyncio -async def test_413_in_agentic_mode_splits_without_degrading(tmp_path): - analyzer, raw_video, controller = _agentic_analyzer( - tmp_path, _PayloadTooLarge("413 request entity too large") - ) - - with ( - patch( - "artemis.agents.video_analyzer.video_analyzer.get_controller", - return_value=controller, - ), - patch( - "artemis.agents.video_analyzer.video_analyzer.compress_video_for_api", - new=AsyncMock(return_value=raw_video), - ), - patch.object(analyzer, "upload_and_poll_file", new=AsyncMock(return_value=_UPLOADED)), - patch.object( - analyzer, - "_exec_single_chunk_universal", - new=AsyncMock(side_effect=_PayloadTooLarge("413 still too large")), - ), - patch("artemis.agents.video_analyzer.video_analyzer._record_llm_event") as record_event, - pytest.raises(_PayloadTooLarge) as raised, - ): - await analyzer._exec_single_chunk(0.0, 300.0, "verify toggle") - - assert classify_video_failure(raised.value).should_split - assert analyzer.video_processing == "agentic" - assert analyzer.chunk_size_seconds == analyzer.agentic_chunk_size_seconds - reasons = [call.args[1].get("reason") for call in record_event.call_args_list] - assert "agentic_video_rejected" not in reasons - - -@pytest.mark.asyncio -async def test_unrelated_400_in_agentic_mode_does_not_degrade(tmp_path): - analyzer, raw_video, controller = _agentic_analyzer( - tmp_path, _BadRequest("400 INVALID_ARGUMENT: unknown field 'tools[0].foo'") - ) - - with ( - patch( - "artemis.agents.video_analyzer.video_analyzer.get_controller", - return_value=controller, - ), - patch( - "artemis.agents.video_analyzer.video_analyzer.compress_video_for_api", - new=AsyncMock(return_value=raw_video), - ), - patch.object(analyzer, "upload_and_poll_file", new=AsyncMock(return_value=_UPLOADED)), - patch.object( - analyzer, - "_exec_single_chunk_universal", - new=AsyncMock(side_effect=_BadRequest("400 unknown field")), - ), - patch("artemis.agents.video_analyzer.video_analyzer._record_llm_event") as record_event, - pytest.raises(_BadRequest), - ): - await analyzer._exec_single_chunk(0.0, 300.0, "verify toggle") - - assert analyzer.video_processing == "agentic" - reasons = [call.args[1].get("reason") for call in record_event.call_args_list] - assert "agentic_video_rejected" not in reasons - - -@pytest.mark.asyncio -async def test_transient_agentic_failure_flows_through_shared_retry(tmp_path): - with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None): - analyzer = VideoAnalyzer(_context()) - interaction = _interaction( - "v1_retry", - [_function_call("call_1", {"summary": "s", "analysis": "a", "timeline_events": []})], - ) - analyzer.client = _fake_client( - [RuntimeError("503 provider unavailable"), _events(interaction, 1)] - ) - analyzer.use_native_gemini = True - analyzer.video_processing = "agentic" - raw_video, controller = _extraction(tmp_path) - - with ( - patch( - "artemis.agents.video_analyzer.video_analyzer.get_controller", - return_value=controller, - ), - patch( - "artemis.agents.video_analyzer.video_analyzer.compress_video_for_api", - new=AsyncMock(return_value=raw_video), - ), - patch.object(analyzer, "upload_and_poll_file", new=AsyncMock(return_value=_UPLOADED)), - patch( - "artemis.agents.video_analyzer.video_analyzer.asyncio.sleep", - new=AsyncMock(), - ), - ): - result = await analyzer._exec_single_chunk(0.0, 5.0, "verify toggle") - - assert result == "[from 0.0s to 5.0s] Summary: s Analysis: a" - assert analyzer.video_processing == "agentic" - assert len(analyzer.client.aio.interactions.requests) == 2 diff --git a/tests/unit/agents/test_video_analyzer.py b/tests/unit/agents/test_video_analyzer.py deleted file mode 100644 index 6a1bab0d3..000000000 --- a/tests/unit/agents/test_video_analyzer.py +++ /dev/null @@ -1,616 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch - -from artemis.agents.video_analyzer import chunk_conversation -from artemis.agents.video_analyzer.chunk_conversation import ( - _MISSING_SUMMARY_ERROR, - _NO_TOOL_CALL_ERROR, - SubAgentAnswerExhausted, - _ChunkMedia, - _drive_sub_agent_loop, - _run_native_chunk_conversation, -) -from artemis.agents.video_analyzer.video_analyzer import VideoAnalyzer -from artemis.context import ArtemisContext -from google.genai import types -import pytest - - -@pytest.mark.asyncio -async def test_video_analyzer_run(): - # Mock context - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = None - mock_ctx.llm_config = MagicMock() - mock_ctx.llm_config.utils.video_analyzer = MagicMock() - mock_ctx.llm_config.utils.video_analyzer.model = "gemini-3.7-flash" - mock_ctx.agent_config = SimpleNamespace(video_analyzer=SimpleNamespace(processing="static")) - - # Mock Gemini Client - mock_client = MagicMock() - mock_client.aio = MagicMock() - mock_client.aio.models = MagicMock() - mock_chunk = MagicMock() - mock_chunk.text = "Final analysis: The action succeeded." - mock_chunk.function_calls = [] - - async def mock_stream(): - yield mock_chunk - - mock_client.aio.models.generate_content_stream = AsyncMock(return_value=mock_stream()) - - # Mock file reading for prompts - mock_prompt = "# Dynamic Video Analyzer..." - - with ( - patch( - "artemis.agents.video_analyzer.video_analyzer.genai.Client", - return_value=mock_client, - ), - patch( - "pathlib.Path.exists", - autospec=True, - side_effect=lambda self: not str(self).endswith(".jpg"), - ), - patch("pathlib.Path.read_text", return_value=mock_prompt), - patch( - "artemis.agents.video_analyzer.video_analyzer.cleanup_abandoned_gemini_files", - AsyncMock(), - ), - ): - analyzer = VideoAnalyzer(mock_ctx) - result, status = await analyzer.run( - time_description="from 10s to 15s", purpose="Verify action" - ) - - assert result == "Final analysis: The action succeeded." - assert status == "success" - assert mock_client.aio.models.generate_content_stream.called - - -@pytest.mark.asyncio -async def test_video_analyzer_preserves_thought_signature(): - # Mock context - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = None - mock_ctx.llm_config = MagicMock() - mock_ctx.llm_config.utils.video_analyzer = MagicMock() - mock_ctx.llm_config.utils.video_analyzer.model = "gemini-3.7-flash" - mock_ctx.agent_config = SimpleNamespace(video_analyzer=SimpleNamespace(processing="static")) - - # Mock MobileDeviceController - mock_controller = MagicMock() - mock_controller.extract_segment_metadata = AsyncMock( - return_value=MagicMock( - success=True, - video_path="/tmp/video.mp4", - actual_start_relative_time=10.0, - ) - ) - - from google.genai import types - - dummy_signature = b"dummy_thought_signature_bytes_56789" - - # Turn 1: Model calls spawn_sub_agent - spawn_call = types.FunctionCall( - name="spawn_sub_agent", - args={ - "start_time": 10.0, - "end_time": 15.0, - "specific_query": "Verify login screen", - }, - ) - spawn_part = types.Part( - function_call=spawn_call, - thought_signature=dummy_signature, - ) - - mock_chunk_1 = MagicMock() - mock_chunk_1.text = "" - mock_chunk_1.function_calls = [spawn_call] - mock_chunk_1.candidates = [ - types.Candidate(content=types.Content(role="model", parts=[spawn_part])) - ] - - # Turn 2: Model returns final answer - mock_chunk_2 = MagicMock() - mock_chunk_2.text = "Final analysis: The spawn succeeded and login button is visible." - mock_chunk_2.function_calls = [] - mock_chunk_2.candidates = [ - types.Candidate( - content=types.Content( - role="model", - parts=[types.Part.from_text(text=mock_chunk_2.text)], - ) - ) - ] - - # We mock generate_content_stream - call_count = 0 - captured_contents = [] - - async def mock_stream_1(): - yield mock_chunk_1 - - async def mock_stream_2(): - yield mock_chunk_2 - - async def mock_generate_content_stream(model, contents, config): - nonlocal call_count - has_submit_answer = False - if config.tools: - for t in config.tools: - fd_list = getattr(t, "function_declarations", None) - if fd_list: - for fd in fd_list: - if fd.name == "submit_answer": - has_submit_answer = True - break - if not has_submit_answer: - captured_contents.append(list(contents)) # copy history contents passed to call - call_count += 1 - if call_count == 1: - return mock_stream_1() - else: - return mock_stream_2() - else: - submit_call = types.FunctionCall( - name="submit_answer", - args={ - "confidence_score": 0.9, - "summary": "Sub-agent verification finished", - "analysis": "Analysis here", - "verification_timestamp_secs": 10.0, - }, - ) - mock_sub_chunk = MagicMock() - mock_sub_chunk.text = "" - mock_sub_chunk.function_calls = [submit_call] - - async def sub_stream(): - yield mock_sub_chunk - - return sub_stream() - - # Mock Gemini Client - mock_client = MagicMock() - mock_client.aio = MagicMock() - mock_client.aio.files = MagicMock() - - mock_file = MagicMock() - mock_file.name = "dummy_cloud_file_123" - mock_file.uri = "https://gemini-file-api/dummy_cloud_file_123" - mock_file.mime_type = "video/mp4" - mock_client.aio.files.upload = AsyncMock(return_value=mock_file) - - mock_f_state = MagicMock() - mock_f_state.state.name = "ACTIVE" - mock_client.aio.files.get = AsyncMock(return_value=mock_f_state) - mock_client.aio.files.delete = AsyncMock() - - mock_client.aio.models = MagicMock() - mock_client.aio.models.generate_content_stream = AsyncMock( - side_effect=mock_generate_content_stream - ) - - # Mock file reading for prompts - mock_prompt = "# Dynamic Video Analyzer..." - - with ( - patch( - "artemis.agents.video_analyzer.video_analyzer.genai.Client", - return_value=mock_client, - ), - patch( - "artemis.agents.video_analyzer.video_analyzer.get_controller", - return_value=mock_controller, - ), - patch( - "pathlib.Path.exists", - autospec=True, - side_effect=lambda self: not str(self).endswith(".jpg"), - ), - patch( - "pathlib.Path.stat", - return_value=os.stat_result((0o100644, 0, 0, 0, 0, 0, 1024 * 1024, 0, 0, 0)), - ), - patch("pathlib.Path.read_text", return_value=mock_prompt), - patch( - "artemis.agents.video_analyzer.video_analyzer.cleanup_abandoned_gemini_files", - AsyncMock(), - ), - patch( - "artemis.agents.video_analyzer.video_analyzer.compress_video_for_api", - AsyncMock(return_value=Path("/tmp/video.mp4")), - ), - ): - analyzer = VideoAnalyzer(mock_ctx) - result, status = await analyzer.run( - time_description="from 10s to 15s", purpose="Verify login" - ) - - assert status == "success" - assert "spawn succeeded" in result - assert call_count == 2 - - # Verify that the second model call's history contains the model turn with the thought_signature - second_call_history = captured_contents[1] - assert len(second_call_history) >= 4 - - model_turn = second_call_history[1] - assert model_turn.role == "model" - assert len(model_turn.parts) == 1 - assert model_turn.parts[0].function_call.name == "spawn_sub_agent" - assert model_turn.parts[0].thought_signature == dummy_signature - - -@pytest.mark.asyncio -async def test_video_analyzer_sub_agent_confidence_validation(): - # Mock context - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = None - mock_ctx.llm_config = MagicMock() - mock_ctx.llm_config.utils.video_analyzer = MagicMock() - mock_ctx.llm_config.utils.video_analyzer.model = "gemini-3.7-flash" - mock_ctx.agent_config = SimpleNamespace(video_analyzer=SimpleNamespace(processing="static")) - - # Mock MobileDeviceController - mock_controller = MagicMock() - mock_controller.extract_segment_metadata = AsyncMock( - return_value=MagicMock( - success=True, - video_path="/tmp/video.mp4", - actual_start_relative_time=10.0, - ) - ) - - from google.genai import types - - # Main agent responses - # Turn 1: Calls spawn_sub_agent - spawn_call = types.FunctionCall( - name="spawn_sub_agent", - args={ - "start_time": 10.0, - "end_time": 15.0, - "specific_query": "Verify login screen", - }, - ) - mock_main_chunk_1 = MagicMock() - mock_main_chunk_1.text = "" - mock_main_chunk_1.function_calls = [spawn_call] - - # Turn 2: Returns final answer - mock_main_chunk_2 = MagicMock() - mock_main_chunk_2.text = "Final blackboard status checked." - mock_main_chunk_2.function_calls = [] - - # Sub-agent responses - # Turn 1: Calls submit_answer with invalid confidence_score (string) - invalid_submit_call = types.FunctionCall( - name="submit_answer", - args={ - "timeline_events": [ - { - "confidence_score": "HIGH", - "transcription": "Invalid confidence score call", - "verification_timestamp_secs": 10.0, - } - ], - "summary": "Invalid confidence score call", - "analysis": "Some analysis", - }, - ) - sub_dummy_signature = b"sub_dummy_signature_9999" - sub_part_1 = types.Part( - function_call=invalid_submit_call, - thought_signature=sub_dummy_signature, - ) - mock_sub_chunk_1 = MagicMock() - mock_sub_chunk_1.text = "" - mock_sub_chunk_1.function_calls = [invalid_submit_call] - mock_sub_chunk_1.candidates = [ - types.Candidate(content=types.Content(role="model", parts=[sub_part_1])) - ] - - # Turn 2: Calls submit_answer with valid confidence_score - valid_submit_call = types.FunctionCall( - name="submit_answer", - args={ - "timeline_events": [ - { - "confidence_score": 0.8, - "transcription": "Valid confidence score call", - "verification_timestamp_secs": 10.0, - } - ], - "summary": "Valid confidence score call", - "analysis": "Some analysis", - }, - ) - mock_sub_chunk_2 = MagicMock() - mock_sub_chunk_2.text = "" - mock_sub_chunk_2.function_calls = [valid_submit_call] - mock_sub_chunk_2.candidates = [ - types.Candidate( - content=types.Content(role="model", parts=[types.Part(function_call=valid_submit_call)]) - ) - ] - - # Stream generator mocks - main_calls = 0 - sub_calls = 0 - captured_sub_contents = [] - - async def mock_main_stream(): - nonlocal main_calls - if main_calls == 0: - main_calls += 1 - yield mock_main_chunk_1 - else: - yield mock_main_chunk_2 - - async def mock_sub_stream(): - nonlocal sub_calls - if sub_calls == 0: - sub_calls += 1 - yield mock_sub_chunk_1 - else: - yield mock_sub_chunk_2 - - async def mock_generate_content_stream(model, contents, config): - system_instruction = config.system_instruction - if "Dynamic Video Analyzer" in str(system_instruction): - return mock_main_stream() - else: - captured_sub_contents.append(list(contents)) - return mock_sub_stream() - - # Mock Gemini Client - mock_client = MagicMock() - mock_client.aio = MagicMock() - mock_client.aio.files = MagicMock() - - mock_file = MagicMock() - mock_file.name = "dummy_cloud_file_123" - mock_file.uri = "https://gemini-file-api/dummy_cloud_file_123" - mock_file.mime_type = "video/mp4" - mock_client.aio.files.upload = AsyncMock(return_value=mock_file) - - mock_f_state = MagicMock() - mock_f_state.state.name = "ACTIVE" - mock_client.aio.files.get = AsyncMock(return_value=mock_f_state) - mock_client.aio.files.delete = AsyncMock() - - mock_client.aio.models = MagicMock() - mock_client.aio.models.generate_content_stream = AsyncMock( - side_effect=mock_generate_content_stream - ) - - # Mock file reading for prompts - mock_main_prompt = "# Dynamic Video Analyzer..." - mock_sub_prompt = "# Sub Agent..." - - def mock_read_text(self, encoding="utf-8"): - if "video_analyzer.md" in str(self): - return mock_main_prompt - elif "video_sub_agent.md" in str(self): - return mock_sub_prompt - return "" - - with ( - patch( - "artemis.agents.video_analyzer.video_analyzer.genai.Client", - return_value=mock_client, - ), - patch( - "artemis.agents.video_analyzer.video_analyzer.get_controller", - return_value=mock_controller, - ), - patch( - "pathlib.Path.exists", - autospec=True, - side_effect=lambda self: not str(self).endswith(".jpg"), - ), - patch( - "pathlib.Path.stat", - return_value=os.stat_result((0o100644, 0, 0, 0, 0, 0, 1024 * 1024, 0, 0, 0)), - ), - patch("pathlib.Path.read_text", mock_read_text), - patch("pathlib.Path.mkdir"), - patch("pathlib.Path.touch"), - patch("pathlib.Path.is_dir", return_value=True), - patch( - "artemis.agents.video_analyzer.video_analyzer.cleanup_abandoned_gemini_files", - AsyncMock(), - ), - patch( - "artemis.agents.video_analyzer.video_analyzer.compress_video_for_api", - AsyncMock(return_value=Path("/tmp/video.mp4")), - ), - patch( - "asyncio.create_subprocess_exec", - AsyncMock(return_value=MagicMock(communicate=AsyncMock(return_value=(b"", b"")))), - ), - ): - analyzer = VideoAnalyzer(mock_ctx) - result, status = await analyzer.run( - time_description="from 10s to 15s", purpose="Verify login" - ) - - assert status == "success" - assert sub_calls == 1 - assert len(analyzer.blackboard_entries) == 1 - entry = analyzer.blackboard_entries[0] - assert entry["confidence_score"] == 0.8 - assert entry["summary"] == "Valid confidence score call" - - # Verify that sub-agent turn 2 received the model turn with the thought_signature - assert len(captured_sub_contents) >= 2 - sub_turn_2_contents = captured_sub_contents[1] - model_turns = [c for c in sub_turn_2_contents if c.role == "model"] - assert len(model_turns) == 1 - assert model_turns[0].parts[0].thought_signature == sub_dummy_signature - - -# ------------------------------------------------- static sub-agent loop - - -def _static_media(tmp_path) -> _ChunkMedia: - media = _ChunkMedia() - media.path = tmp_path / "segment.mp4" - media.path.write_bytes(b"video") - media.compressed_path = media.path - media.actual_start = 0.0 - media.prompt_with_context = "Watch the toggle." - return media - - -def _valid_static_event() -> dict: - return { - "start_time": 1.0, - "end_time": 2.0, - "transcription": "Battery Saver toggled on", - "confidence_score": 0.9, - "verification_timestamp_secs": 1.5, - } - - -def _scripted_static_turns(*turns): - """Fakes ``_stream_sub_agent_turn`` with ``(text, function_calls)`` per turn.""" - scripted = list(turns) - - async def fake_turn(analyzer, sub_agent_contents, trace_id): - text, function_calls = scripted.pop(0) - return text, function_calls, [] - - return fake_turn - - -def _static_analyzer() -> MagicMock: - analyzer = MagicMock() - analyzer.model_name = "gemini-3.7-flash" - analyzer.sub_system_prompt = "system" - analyzer.ctx.data_engine = None - return analyzer - - -@pytest.mark.asyncio -async def test_static_submit_answer_without_summary_is_sent_back_then_accepted(tmp_path): - first = types.FunctionCall( - name="submit_answer", args={"timeline_events": [_valid_static_event()]} - ) - second = types.FunctionCall( - name="submit_answer", - args={"summary": "s2", "analysis": "a2", "timeline_events": []}, - ) - contents: list = [] - - with ( - patch.object( - chunk_conversation, - "_stream_sub_agent_turn", - new=_scripted_static_turns(("", [first]), ("", [second])), - ), - patch.object(chunk_conversation, "_persist_timeline_events", new=AsyncMock()) as persist, - ): - answer = await _drive_sub_agent_loop( - _static_analyzer(), contents, None, _static_media(tmp_path), 0.0, 5.0, "verify" - ) - - assert answer == ("s2", "a2") - # Only the accepted answer reaches persistence. - persist.assert_awaited_once() - assert persist.await_args.args[1] == [] - rejection = contents[-1] - assert rejection.role == "user" - assert rejection.parts[0].function_response.response["error"] == _MISSING_SUMMARY_ERROR - - -@pytest.mark.asyncio -async def test_static_loop_raises_when_no_turn_calls_submit_answer(tmp_path): - with ( - patch.object( - chunk_conversation, - "_stream_sub_agent_turn", - new=_scripted_static_turns(("text only", []), ("still text", [])), - ), - patch.object(chunk_conversation, "_persist_timeline_events", new=AsyncMock()) as persist, - pytest.raises(SubAgentAnswerExhausted) as raised, - ): - await _drive_sub_agent_loop( - _static_analyzer(), [], None, _static_media(tmp_path), 0.0, 5.0, "verify" - ) - - assert raised.value.reason == _NO_TOOL_CALL_ERROR - persist.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_static_last_turn_timeline_failure_raises_and_persists_nothing(tmp_path): - bad = dict(_valid_static_event(), confidence_score="HIGH") - out_of_range = dict(_valid_static_event(), verification_timestamp_secs=42.0) - first = types.FunctionCall( - name="submit_answer", args={"summary": "s1", "timeline_events": [bad]} - ) - second = types.FunctionCall( - name="submit_answer", args={"summary": "s2", "timeline_events": [out_of_range]} - ) - - with ( - patch.object( - chunk_conversation, - "_stream_sub_agent_turn", - new=_scripted_static_turns(("", [first]), ("", [second])), - ), - patch.object(chunk_conversation, "_persist_timeline_events", new=AsyncMock()) as persist, - pytest.raises(SubAgentAnswerExhausted) as raised, - ): - await _drive_sub_agent_loop( - _static_analyzer(), [], None, _static_media(tmp_path), 0.0, 5.0, "verify" - ) - - assert "verification_timestamp_secs" in raised.value.reason - assert "42.0" in str(raised.value) - persist.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_exhausted_static_chunk_is_not_committed_to_the_blackboard(tmp_path): - analyzer = _static_analyzer() - uploaded = SimpleNamespace(name="files/abc", uri="https://files/abc", mime_type="video/mp4") - analyzer.upload_and_poll_file = AsyncMock(return_value=uploaded) - analyzer.client.aio.files.delete = AsyncMock() - analyzer.cloud_files_to_cleanup = set() - - with ( - patch.object( - chunk_conversation, - "_stream_sub_agent_turn", - new=_scripted_static_turns(("text only", []), ("still text", [])), - ), - pytest.raises(SubAgentAnswerExhausted), - ): - await _run_native_chunk_conversation( - analyzer, _static_media(tmp_path), 0.0, 5.0, 0.0, 5.0, "verify", "lease-1" - ) - - analyzer.blackboard.complete_segment.assert_not_called() - analyzer.client.aio.files.delete.assert_awaited_once_with(name="files/abc") diff --git a/tests/unit/agents/test_video_analyzer_universal.py b/tests/unit/agents/test_video_analyzer_universal.py deleted file mode 100644 index a718aec49..000000000 --- a/tests/unit/agents/test_video_analyzer_universal.py +++ /dev/null @@ -1,449 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for Universal VideoAnalyzer dual-path execution engine.""" - -from unittest.mock import AsyncMock, MagicMock, Mock, patch - -import pytest -from langchain_core.messages import AIMessage - -from artemis.agents.video_analyzer.video_analyzer import ( - UNIVERSAL_MAIN_TOOLS, - UNIVERSAL_SUBMIT_ANSWER_TOOL, - VideoAnalyzer, -) -from artemis.context import ArtemisContext - - -@pytest.fixture -def mock_context(tmp_path): - ctx = Mock(spec=ArtemisContext) - ctx.llm_config = Mock() - mock_llm_cfg = Mock() - mock_llm_cfg.model = "claude-3-7-sonnet" - mock_llm_cfg.temperature = 0.2 - ctx.llm_config.utils = Mock() - ctx.llm_config.utils.video_analyzer = mock_llm_cfg - - ctx.device = Mock() - ctx.device.device_width = 1080 - ctx.device.device_height = 2400 - - ctx.data_engine = Mock() - ctx.data_engine.base_dir = tmp_path - return ctx - - -def test_video_analyzer_universal_engine_detection(mock_context): - """Test that VideoAnalyzer routes to Universal Engine when non-Gemini model is set.""" - from artemis.config import settings - - with patch.object(settings, "GOOGLE_API_KEY", None): - agent = VideoAnalyzer(mock_context) - assert agent.use_native_gemini is False - assert agent.model_name == "claude-3-7-sonnet" - - -def test_universal_tool_schemas(): - """Verify schema structures for Universal tools.""" - assert UNIVERSAL_SUBMIT_ANSWER_TOOL["type"] == "function" - assert UNIVERSAL_SUBMIT_ANSWER_TOOL["function"]["name"] == "submit_answer" - assert len(UNIVERSAL_MAIN_TOOLS) == 3 - tool_names = [t["function"]["name"] for t in UNIVERSAL_MAIN_TOOLS] - assert "extract_segment_metadata" in tool_names - assert "spawn_sub_agent" in tool_names - assert "analyze_audio_only" in tool_names - - -@pytest.mark.asyncio -async def test_exec_single_chunk_universal(mock_context, tmp_path): - """Test keyframe extraction and LangChain tool invocation in universal sub-agent.""" - agent = VideoAnalyzer(mock_context) - agent.use_native_gemini = False - - fake_video = tmp_path / "compressed.mp4" - fake_video.write_bytes(b"video_bytes") - raw_video = tmp_path / "raw.mp4" - raw_video.write_bytes(b"raw_bytes") - - fake_keyframes = [ - (0.0, b"frame0"), - (1.0, b"frame1"), - (2.0, b"frame2"), - ] - - mock_llm = MagicMock() - mock_response = AIMessage( - content="Sub-agent analyzed frames.", - tool_calls=[ - { - "name": "submit_answer", - "args": { - "summary": "Detected settings button tap.", - "analysis": "At 1.0s user clicked Settings icon.", - "timeline_events": [ - { - "start_time": 0.5, - "end_time": 1.5, - "transcription": "Tap on Settings", - "confidence_score": 0.95, - "verification_timestamp_secs": 1.0, - } - ], - }, - "id": "sub_call_1", - } - ], - ) - mock_bound = MagicMock() - mock_bound.ainvoke = AsyncMock(return_value=mock_response) - mock_llm.bind_tools.return_value = mock_bound - - with ( - patch( - "artemis.agents.video_analyzer.video_analyzer.extract_keyframes_from_video", - return_value=fake_keyframes, - ), - patch( - "artemis.agents.video_analyzer.video_analyzer.get_llm", - return_value=mock_llm, - ), - ): - result = await agent._exec_single_chunk_universal( - compressed_path=fake_video, - raw_path=raw_video, - start_time=0.0, - end_time=3.0, - actual_start=0.0, - prompt_with_context="Find when settings was opened.", - specific_query="settings icon", - ) - - assert "Detected settings button tap" in result - assert "At 1.0s user clicked Settings icon" in result - assert len(agent.blackboard_entries) == 1 - assert agent.blackboard_entries[0]["summary"] == "Tap on Settings" - assert agent.blackboard_entries[0]["confidence_score"] == 0.95 - - -@pytest.mark.asyncio -async def test_exec_analyze_audio_universal(mock_context, tmp_path): - """Test audio analysis in universal mode.""" - agent = VideoAnalyzer(mock_context) - agent.use_native_gemini = False - - fake_audio = tmp_path / "audio.aac" - fake_audio.write_bytes(b"audio_bytes") - - mock_llm = MagicMock() - mock_response = AIMessage( - content="", - tool_calls=[ - { - "name": "submit_answer", - "args": { - "summary": "Heard notification sound.", - "analysis": "Notification bell sounded at 2.0s.", - }, - "id": "audio_call_1", - } - ], - ) - mock_bound = MagicMock() - mock_bound.ainvoke = AsyncMock(return_value=mock_response) - mock_llm.bind_tools.return_value = mock_bound - - with patch( - "artemis.agents.video_analyzer.video_analyzer.get_llm", - return_value=mock_llm, - ): - result = await agent._exec_analyze_audio_universal( - audio_path=fake_audio, - start_time=1.0, - end_time=4.0, - actual_start=1.0, - prompt_with_context="Listen for notification sound.", - specific_query="notification bell", - ) - - assert "Heard notification sound" in result - assert len(agent.blackboard_entries) == 1 - assert agent.blackboard_entries[0]["summary"] == "Heard notification sound." - sent_content = mock_bound.ainvoke.await_args.args[0][1].content - assert sent_content[1]["type"] == "audio" - assert sent_content[1]["base64"] == "YXVkaW9fYnl0ZXM=" - - -@pytest.mark.asyncio -async def test_run_universal_multi_turn_reasoning(mock_context): - """Test main reasoning loop of VideoAnalyzer in universal mode with tool dispatch.""" - agent = VideoAnalyzer(mock_context) - agent.use_native_gemini = False - - # Turn 1: Call extract_segment_metadata - msg_turn1 = AIMessage( - content="Let me check the video metadata first.", - tool_calls=[ - { - "name": "extract_segment_metadata", - "args": {"start_time": 0.0, "end_time": 10.0}, - "id": "main_call_1", - } - ], - ) - # Turn 2: Final response - msg_turn2 = AIMessage( - content="The video shows the app launched successfully at 3.5s.", - tool_calls=[], - ) - - mock_llm = MagicMock() - mock_bound = MagicMock() - mock_bound.ainvoke = AsyncMock(side_effect=[msg_turn1, msg_turn2]) - mock_llm.bind_tools.return_value = mock_bound - - with ( - patch( - "artemis.agents.video_analyzer.video_analyzer.get_llm", - return_value=mock_llm, - ), - patch.object( - agent, - "exec_extract_segment_metadata", - new_callable=AsyncMock, - return_value='{"duration_seconds": 10.0, "file_size_mb": 2.5}', - ), - ): - outcome, status = await agent._run_universal( - time_description="from 0s to 10s", - purpose="Verify app launch", - system_prompt="You are a video analyzer.", - ) - - assert status == "success" - assert "launched successfully at 3.5s" in outcome - assert agent.exec_extract_segment_metadata.call_count == 1 - - -# --- No-usable-answer handling ------------------------------------------- -# -# The universal engine is single-shot: when the model neither calls -# submit_answer with a summary nor replies with text, it must raise -# SubAgentAnswerExhausted so the caller fails the blackboard segment instead -# of committing placeholder text as a succeeded entry. - -_BLANK_SUBMIT = AIMessage( - content="", - tool_calls=[ - { - "name": "submit_answer", - "args": {"summary": " ", "analysis": "", "timeline_events": []}, - "id": "blank_call", - } - ], -) -_EMPTY_RESPONSE = AIMessage(content="", tool_calls=[]) -_TEXT_ONLY = AIMessage(content="Prose answer: the toast appeared at 2.0s.", tool_calls=[]) - - -def _universal_agent(mock_context) -> VideoAnalyzer: - agent = VideoAnalyzer(mock_context) - agent.use_native_gemini = False - return agent - - -async def _run_chunk_with_response(agent, tmp_path, response): - """Drives the universal chunk path with a stubbed model reply. - - Returns ``(result_or_exception, persist_mock)`` so tests can assert both - the outcome and whether the blackboard persistence step was reached. - """ - raw_video = tmp_path / "raw.mp4" - raw_video.write_bytes(b"raw_bytes") - persist = AsyncMock() - with ( - patch( - "artemis.agents.video_analyzer.video_analyzer.extract_keyframes_from_video", - return_value=[(0.0, b"frame0")], - ), - patch( - "artemis.agents.video_analyzer.video_analyzer.extract_frames_at_timestamps", - return_value=[], - ), - patch( - "artemis.agents.video_analyzer.video_analyzer.extract_audio_from_video", - new_callable=AsyncMock, - side_effect=RuntimeError("no audio track"), - ), - patch( - "artemis.agents.video_analyzer.universal_engine._persist_universal_events", - persist, - ), - patch.object( - agent, - "_invoke_universal_model", - new_callable=AsyncMock, - return_value=response, - ), - ): - try: - result = await agent._exec_single_chunk_universal( - compressed_path=raw_video, - raw_path=raw_video, - start_time=0.0, - end_time=3.0, - actual_start=0.0, - prompt_with_context="Find the toast.", - specific_query="toast", - ) - except Exception as exc: # noqa: BLE001 - the exception type is the assertion - return exc, persist - return result, persist - - -@pytest.mark.asyncio -@pytest.mark.parametrize("response", [_EMPTY_RESPONSE, _BLANK_SUBMIT]) -async def test_exec_single_chunk_universal_raises_without_usable_answer( - mock_context, tmp_path, response -): - """No tool call + no text, or blank-summary tool call + no text -> raise, no persistence.""" - from artemis.agents.video_analyzer.reliability import SubAgentAnswerExhausted - - agent = _universal_agent(mock_context) - outcome, persist = await _run_chunk_with_response(agent, tmp_path, response) - - assert isinstance(outcome, SubAgentAnswerExhausted) - assert outcome.turns == 1 - assert "submit_answer" in outcome.reason - persist.assert_not_awaited() - assert agent.blackboard_entries == [] - - -@pytest.mark.asyncio -async def test_exec_single_chunk_universal_text_only_is_degraded_answer(mock_context, tmp_path): - """Prose instead of the tool is a real (degraded) answer, not a placeholder.""" - agent = _universal_agent(mock_context) - outcome, persist = await _run_chunk_with_response(agent, tmp_path, _TEXT_ONLY) - - assert isinstance(outcome, str) - assert "Summary: Prose answer: the toast appeared at 2.0s." in outcome - assert "Analysis: Prose answer: the toast appeared at 2.0s." in outcome - persist.assert_awaited_once() - # A degraded text answer carries no timeline events. - assert persist.await_args.args[1] == [] - - -@pytest.mark.asyncio -async def test_exec_single_chunk_universal_blank_summary_falls_through_to_text( - mock_context, tmp_path -): - """submit_answer with a blank summary is ignored; the text content becomes the answer.""" - response = AIMessage( - content="Fallback prose from the model.", - tool_calls=[ - { - "name": "submit_answer", - "args": { - "summary": "", - "analysis": "Orphaned analysis.", - "timeline_events": [{"start_time": 0.0, "end_time": 1.0}], - }, - "id": "blank_call", - } - ], - ) - agent = _universal_agent(mock_context) - outcome, persist = await _run_chunk_with_response(agent, tmp_path, response) - - assert isinstance(outcome, str) - assert "Summary: Fallback prose from the model." in outcome - assert "Orphaned analysis" not in outcome - # The blank tool call is dropped wholesale, events included. - assert persist.await_args.args[1] == [] - - -async def _run_audio_with_response(agent, tmp_path, response): - """Drives the universal audio path with a stubbed model reply.""" - fake_audio = tmp_path / "audio.aac" - fake_audio.write_bytes(b"audio_bytes") - with ( - patch.object( - agent, - "_invoke_universal_model", - new_callable=AsyncMock, - return_value=response, - ), - patch.object( - agent, "_record_blackboard_entry", wraps=agent._record_blackboard_entry - ) as record, - ): - try: - result = await agent._exec_analyze_audio_universal( - audio_path=fake_audio, - start_time=1.0, - end_time=4.0, - actual_start=1.0, - prompt_with_context="Listen for the toast.", - specific_query="toast sound", - ) - except Exception as exc: # noqa: BLE001 - the exception type is the assertion - return exc, record - return result, record - - -@pytest.mark.asyncio -@pytest.mark.parametrize("response", [_EMPTY_RESPONSE, _BLANK_SUBMIT]) -async def test_exec_analyze_audio_universal_raises_without_usable_answer( - mock_context, tmp_path, response -): - """Audio path: no usable answer -> raise before anything reaches the blackboard.""" - from artemis.agents.video_analyzer.reliability import SubAgentAnswerExhausted - - agent = _universal_agent(mock_context) - outcome, record = await _run_audio_with_response(agent, tmp_path, response) - - assert isinstance(outcome, SubAgentAnswerExhausted) - assert outcome.turns == 1 - record.assert_not_called() - assert agent.blackboard_entries == [] - - -@pytest.mark.asyncio -async def test_exec_analyze_audio_universal_text_only_is_degraded_answer(mock_context, tmp_path): - """Audio path: prose instead of the tool is still committed as a degraded answer.""" - agent = _universal_agent(mock_context) - outcome, record = await _run_audio_with_response(agent, tmp_path, _TEXT_ONLY) - - assert isinstance(outcome, str) - assert "Audio Summary: Prose answer: the toast appeared at 2.0s." in outcome - record.assert_called_once() - assert len(agent.blackboard_entries) == 1 - assert agent.blackboard_entries[0]["summary"] == "Prose answer: the toast appeared at 2.0s." - - -def test_sub_agent_answer_exhausted_classifies_as_unknown(): - """The raise must land in the retryable / split / fallback bucket of the callers.""" - from artemis.agents.video_analyzer.reliability import ( - SubAgentAnswerExhausted, - VideoFailureCategory, - classify_video_failure, - ) - - failure = classify_video_failure(SubAgentAnswerExhausted(1, "x")) - - assert failure.category is VideoFailureCategory.UNKNOWN - assert failure.retryable is True - assert failure.should_split is True - assert failure.should_fallback is True diff --git a/tests/unit/agents/test_video_audio_native.py b/tests/unit/agents/test_video_audio_native.py deleted file mode 100644 index 56db4cf19..000000000 --- a/tests/unit/agents/test_video_audio_native.py +++ /dev/null @@ -1,157 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Native Gemini audio agent loop: only accepted answers reach the blackboard.""" - -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock - -from artemis.agents.video_analyzer.audio_native import ( - _BAD_CONFIDENCE_ERROR, - _AudioMedia, - _drive_audio_agent_loop, - _run_native_audio_conversation, -) -from artemis.agents.video_analyzer.chunk_conversation import ( - _MISSING_SUMMARY_ERROR, - _NO_TOOL_CALL_ERROR, - SUB_AGENT_MAX_TURNS, -) -from artemis.agents.video_analyzer.reliability import ( - SubAgentAnswerExhausted, - VideoFailureCategory, - classify_video_failure, -) -from artemis.agents.video_analyzer.universal_tools import build_submit_answer_declaration -from google.genai import types -import pytest - - -def _audio_response(text: str, function_calls: list) -> SimpleNamespace: - """Fakes a non-streamed ``generate_content`` response for one audio turn.""" - parts = [types.Part(function_call=fc) for fc in function_calls] - if text: - parts.append(types.Part.from_text(text=text)) - return SimpleNamespace( - text=text, - function_calls=function_calls, - candidates=[SimpleNamespace(content=SimpleNamespace(parts=parts))], - ) - - -def _audio_analyzer(*turns) -> MagicMock: - """Analyzer stub whose Gemini client answers the scripted ``(text, calls)`` turns.""" - analyzer = MagicMock() - analyzer.model_name = "gemini-3.7-flash" - analyzer.audio_system_prompt = "system" - analyzer.submit_answer_tool = build_submit_answer_declaration() - analyzer.model_call_timeout_seconds = 5.0 - analyzer.ctx.data_engine = None - analyzer.client.aio.models.generate_content = AsyncMock( - side_effect=[_audio_response(text, calls) for text, calls in turns] - ) - return analyzer - - -def _audio_media(tmp_path) -> _AudioMedia: - media = _AudioMedia() - media.audio_path = tmp_path / "segment.wav" - media.audio_path.write_bytes(b"audio") - media.actual_start = 0.0 - media.prompt_with_context = "Listen for the chime." - return media - - -@pytest.mark.asyncio -async def test_audio_submit_answer_without_summary_is_sent_back_then_accepted(): - first = types.FunctionCall( - name="submit_answer", args={"summary": " ", "confidence_score": 0.9} - ) - second = types.FunctionCall( - name="submit_answer", - args={"summary": "chime\nheard", "analysis": "a2", "confidence_score": 0.7}, - ) - analyzer = _audio_analyzer(("", [first]), ("", [second])) - contents: list = [] - - answer = await _drive_audio_agent_loop(analyzer, contents) - - assert answer == ("chime heard", "a2", 0.7) - assert analyzer.client.aio.models.generate_content.await_count == 2 - # The blank summary went back to the model as a function_response error. - rejections = [ - c - for c in contents - if c.role == "user" and c.parts and c.parts[0].function_response is not None - ] - assert len(rejections) == 1 - assert rejections[0].parts[0].function_response.response["error"] == _MISSING_SUMMARY_ERROR - - -@pytest.mark.asyncio -async def test_audio_loop_raises_when_no_turn_calls_submit_answer(): - analyzer = _audio_analyzer(("text only", []), ("still text", [])) - - with pytest.raises(SubAgentAnswerExhausted) as raised: - await _drive_audio_agent_loop(analyzer, []) - - assert raised.value.turns == SUB_AGENT_MAX_TURNS - assert raised.value.reason == _NO_TOOL_CALL_ERROR - assert "No summary provided" not in str(raised.value) - - -@pytest.mark.asyncio -async def test_audio_loop_raises_when_confidence_score_is_never_valid(): - first = types.FunctionCall( - name="submit_answer", args={"summary": "s1", "confidence_score": "HIGH"} - ) - second = types.FunctionCall( - name="submit_answer", args={"summary": "s2", "confidence_score": 1.5} - ) - analyzer = _audio_analyzer(("", [first]), ("", [second])) - contents: list = [] - - with pytest.raises(SubAgentAnswerExhausted) as raised: - await _drive_audio_agent_loop(analyzer, contents) - - assert raised.value.reason == _BAD_CONFIDENCE_ERROR - assert contents[-1].role == "user" - assert contents[-1].parts[0].function_response.response["error"] == _BAD_CONFIDENCE_ERROR - - -@pytest.mark.asyncio -async def test_exhausted_audio_segment_is_not_committed_and_cloud_file_is_deleted(tmp_path): - analyzer = _audio_analyzer(("text only", []), ("still text", [])) - uploaded = SimpleNamespace(name="files/abc", uri="https://files/abc", mime_type="audio/wav") - analyzer.upload_and_poll_file = AsyncMock(return_value=uploaded) - analyzer.client.aio.files.delete = AsyncMock() - analyzer.cloud_files_to_cleanup = {"files/abc"} - analyzer._record_blackboard_entry = MagicMock() - analyzer.blackboard = MagicMock() - - with pytest.raises(SubAgentAnswerExhausted): - await _run_native_audio_conversation( - analyzer, _audio_media(tmp_path), 0.0, 5.0, 0.0, 5.0, "chime", "lease-1" - ) - - analyzer.blackboard.complete_segment.assert_not_called() - analyzer._record_blackboard_entry.assert_not_called() - analyzer.client.aio.files.delete.assert_awaited_once_with(name="files/abc") - assert analyzer.cloud_files_to_cleanup == set() - - -def test_sub_agent_exhaustion_is_classified_unknown(): - failure = classify_video_failure(SubAgentAnswerExhausted(2, "x")) - - assert failure.category is VideoFailureCategory.UNKNOWN diff --git a/tests/unit/agents/test_video_blackboard.py b/tests/unit/agents/test_video_blackboard.py deleted file mode 100644 index 3afa363ba..000000000 --- a/tests/unit/agents/test_video_blackboard.py +++ /dev/null @@ -1,275 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import time -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from artemis.agents.video_analyzer.blackboard import ( - VideoBlackboard, - get_video_blackboard, -) -from artemis.agents.video_analyzer.video_analyzer import VideoAnalyzer, _invoke_with_retry -from artemis.context import ArtemisContext - - -def test_blackboard_persists_segments_observations_and_evidence(tmp_path): - db_path = tmp_path / "trace.db" - evidence_dir = tmp_path / "video_blackboard" / "evidence" - source_image = tmp_path / "temporary.jpg" - source_image.write_bytes(b"jpeg evidence") - - board = VideoBlackboard("video:recording-1", db_path=db_path, evidence_dir=evidence_dir) - stored = board.add_observation( - { - "start": 1.0, - "end": 1.5, - "target": "verify login", - "summary": "Login screen became visible.", - "confidence_score": 0.95, - "screenshot": str(source_image), - } - ) - # A duplicate commit is idempotent. - board.add_observation(stored) - - claim = board.claim_segment(0.0, 5.0, "verify login") - assert claim.state == "claimed" - board.complete_segment( - 0.0, - 5.0, - "verify login", - claim.lease_owner, - "Login succeeded.", - "The authenticated home screen appeared.", - ) - - reopened = VideoBlackboard("video:recording-1", db_path=db_path, evidence_dir=evidence_dir) - observations = reopened.list_observations() - assert len(observations) == 1 - persisted_evidence = observations[0]["screenshot"] - assert persisted_evidence != str(source_image) - assert (evidence_dir / f"{stored['observation_id']}.jpg").read_bytes() == b"jpeg evidence" - - assert reopened.missing_intervals(0.0, 5.0, "verify login") == [] - cached = reopened.claim_segment(0.0, 5.0, "verify login") - assert cached.state == "cached" - assert cached.summary == "Login succeeded." - assert any(entry.get("kind") == "segment_result" for entry in reopened.list_ledger_entries()) - - -def test_blackboard_calculates_only_uncovered_intervals(tmp_path): - board = VideoBlackboard("video:coverage", db_path=tmp_path / "coverage.db") - for start, end in ((0.0, 10.0), (8.0, 20.0), (30.0, 40.0)): - claim = board.claim_segment(start, end, "find spinner") - board.complete_segment( - start, end, "find spinner", claim.lease_owner, f"Analyzed {start}-{end}" - ) - - assert board.missing_intervals(0.0, 50.0, "find spinner") == [ - (20.0, 30.0), - (40.0, 50.0), - ] - # Different questions can use the observations as context but do not share - # query-specific completion coverage. - assert board.missing_intervals(0.0, 10.0, "count spinner frames") == [(0.0, 10.0)] - - -def test_blackboard_lease_suppresses_duplicates_and_is_reclaimable(): - board = VideoBlackboard("video:lease", lease_seconds=0.02) - first = board.claim_segment(0.0, 5.0, "verify tap") - assert first.state == "claimed" - assert board.claim_segment(0.0, 5.0, "verify tap").state == "in_progress" - - time.sleep(0.03) - reclaimed = board.claim_segment(0.0, 5.0, "verify tap") - assert reclaimed.state == "claimed" - assert reclaimed.lease_owner != first.lease_owner - - board.fail_segment(0.0, 5.0, "verify tap", reclaimed.lease_owner, "temporary provider outage") - assert board.claim_segment(0.0, 5.0, "verify tap").state == "claimed" - - -def test_get_video_blackboard_is_shared_by_context(): - ctx = MagicMock(spec=ArtemisContext) - ctx._video_blackboard = None - ctx.data_engine = None - ctx.execution_setup = None - ctx.device = MagicMock() - ctx.device.device_id = "device-1" - - first = get_video_blackboard(ctx) - first.add_observation( - { - "start": 0.0, - "end": 1.0, - "target": "shared", - "summary": "Persisted in context.", - } - ) - second = get_video_blackboard(ctx) - - assert first is second - assert second.list_observations()[0]["summary"] == "Persisted in context." - - -@pytest.fixture -def analyzer_context(): - ctx = MagicMock(spec=ArtemisContext) - ctx._video_blackboard = None - ctx._mobile_controller = None - ctx.data_engine = None - ctx.execution_setup = None - ctx.agent_config = None - ctx.device = MagicMock() - ctx.device.device_id = "device-analyzer" - ctx.llm_config = MagicMock() - ctx.llm_config.utils.video_analyzer.model = "universal-test-model" - ctx.llm_config.utils.video_analyzer.temperature = 0.2 - return ctx - - -@pytest.mark.asyncio -async def test_spawn_sub_agent_reuses_complete_persistent_coverage(analyzer_context): - with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None): - analyzer = VideoAnalyzer(analyzer_context) - claim = analyzer.blackboard.claim_segment(10.0, 20.0, "verify login") - analyzer.blackboard.complete_segment( - 10.0, - 20.0, - "verify login", - claim.lease_owner, - "Login succeeded.", - "Home screen appeared.", - ) - - with patch.object(analyzer, "_exec_single_chunk", new_callable=AsyncMock) as child: - result = await analyzer.exec_spawn_sub_agent(10.0, 20.0, "verify login") - - child.assert_not_awaited() - assert result.startswith("CACHED VIDEO ANALYSIS:") - assert "Login succeeded" in result - - -@pytest.mark.asyncio -async def test_spawn_sub_agent_returns_partial_and_keeps_prior_success(analyzer_context): - with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None): - analyzer = VideoAnalyzer(analyzer_context) - claim = analyzer.blackboard.claim_segment(0.0, 60.0, "summarize flow") - analyzer.blackboard.complete_segment( - 0.0, - 60.0, - "summarize flow", - claim.lease_owner, - "First minute completed.", - ) - - with patch.object( - analyzer, - "_exec_single_chunk", - new_callable=AsyncMock, - side_effect=RuntimeError("provider unavailable"), - ) as child: - result = await analyzer.exec_spawn_sub_agent(0.0, 120.0, "summarize flow") - - child.assert_awaited_once_with(60.0, 120.0, "summarize flow") - assert result.startswith("PARTIAL VIDEO ANALYSIS") - assert "First minute completed" in result - assert analyzer.blackboard.missing_intervals(0.0, 120.0, "summarize flow") == [(60.0, 120.0)] - - -@pytest.mark.asyncio -async def test_video_coordinator_retries_transient_failures(): - operation = AsyncMock( - side_effect=[TimeoutError("timeout one"), ConnectionError("reset"), "completed"] - ) - with patch( - "artemis.agents.video_analyzer.video_analyzer.asyncio.sleep", new_callable=AsyncMock - ) as sleep: - result = await _invoke_with_retry(operation, "test coordinator") - - assert result == "completed" - assert operation.await_count == 3 - assert sleep.await_count == 2 - - -@pytest.mark.asyncio -async def test_audio_analysis_reuses_exact_completed_coverage(analyzer_context): - with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None): - analyzer = VideoAnalyzer(analyzer_context) - claim = analyzer.blackboard.claim_segment(2.0, 6.0, "hear notification", modality="audio") - analyzer.blackboard.complete_segment( - 2.0, - 6.0, - "hear notification", - claim.lease_owner, - "A notification sounded.", - "One short bell was audible.", - modality="audio", - ) - - with patch("artemis.agents.video_analyzer.video_analyzer.get_controller") as controller: - result = await analyzer.exec_analyze_audio_only(2.0, 6.0, "hear notification") - - controller.assert_not_called() - assert result.startswith("CACHED AUDIO ANALYSIS:") - assert "notification sounded" in result - - -def test_blackboard_metrics_include_structured_failures(tmp_path): - board = VideoBlackboard("video:metrics", db_path=tmp_path / "metrics.db") - claim = board.claim_segment( - 0.0, - 5.0, - "inspect", - model_name="vlm-primary", - source_generation=2, - ) - board.fail_segment( - 0.0, - 5.0, - "inspect", - claim.lease_owner, - "timeout", - error_category="timeout", - ) - - assert board.metrics()["segments"] == {"retryable_failed": 1} - assert board.metrics()["failure_categories"] == {"timeout": 1} - - -def test_session_deletion_removes_video_blackboard_rows(tmp_path): - from uuid import uuid4 - - from artemis.data_engine.engine import DataEngine - - ctx = MagicMock(spec=ArtemisContext) - ctx.execution_setup = MagicMock(traces_path=str(tmp_path / "traces")) - ctx.device = None - engine = DataEngine(ctx) - session_id = engine.start_session(goal="blackboard cleanup") - video_id = uuid4() - recording = tmp_path / "recording.mp4" - recording.write_bytes(b"video") - engine.record_video_start(video_id, "device", recording) - - board = VideoBlackboard(f"video:{video_id}", db_path=engine.storage.db_path) - board.add_observation({"start": 0.0, "end": 1.0, "target": "cleanup", "summary": "evidence"}) - assert board.list_observations() - - engine.storage.delete_session(session_id) - - reopened = VideoBlackboard(f"video:{video_id}", db_path=engine.storage.db_path) - assert reopened.list_observations() == [] diff --git a/tests/unit/agents/test_video_reliability.py b/tests/unit/agents/test_video_reliability.py deleted file mode 100644 index e8877281d..000000000 --- a/tests/unit/agents/test_video_reliability.py +++ /dev/null @@ -1,454 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. - -import asyncio -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch - -from langchain_core.messages import AIMessage, HumanMessage -import pytest - -from artemis.agents.video_analyzer.reliability import ( - AgenticVideoDegraded, - VideoCircuitBreaker, - VideoFailureCategory, - classify_video_failure, -) -from artemis.agents.video_analyzer.video_analyzer import VideoAnalyzer -from artemis.context import ArtemisContext - - -def _context() -> MagicMock: - ctx = MagicMock(spec=ArtemisContext) - ctx._video_blackboard = None - ctx._video_circuit_breaker = None - ctx._mobile_controller = None - ctx.execution_setup = None - ctx.data_engine = None - ctx.agent_config = SimpleNamespace( - video_analyzer=SimpleNamespace( - enable_ledger=True, - chunk_size_seconds=60.0, - min_chunk_seconds=4.0, - max_split_depth=4, - circuit_breaker_threshold=2, - circuit_breaker_cooldown_seconds=30.0, - action_window_seconds=2.0, - dense_action_fps=4.0, - max_dense_action_frames=24, - ) - ) - ctx.device = SimpleNamespace(device_id="reliability-device") - ctx.llm_config = MagicMock() - ctx.llm_config.utils.video_analyzer.model = "primary-video-model" - ctx.llm_config.utils.video_analyzer.temperature = 0.2 - return ctx - - -def test_failure_classifier_chooses_safe_recovery(): - outage = classify_video_failure(RuntimeError("503 service unavailable")) - assert outage.category == VideoFailureCategory.PROVIDER_UNAVAILABLE - assert outage.retryable and outage.should_fallback - assert not outage.should_split - - timeout = classify_video_failure(TimeoutError("clip timed out")) - assert timeout.category == VideoFailureCategory.TIMEOUT - assert timeout.should_split and timeout.should_fallback - - invalid = SimpleNamespace(code=400) - invalid_error = RuntimeError("bad request") - invalid_error.code = invalid.code - classified = classify_video_failure(invalid_error) - assert classified.category == VideoFailureCategory.BAD_REQUEST - assert not classified.retryable - - -def test_circuit_breaker_opens_and_half_opens(): - breaker = VideoCircuitBreaker(threshold=2, cooldown_seconds=10.0) - failure = classify_video_failure(TimeoutError("timeout")) - breaker.record_failure("model", failure, now=10.0) - assert breaker.allow("model", now=10.1) - breaker.record_failure("model", failure, now=11.0) - assert not breaker.allow("model", now=15.0) - assert breaker.allow("model", now=21.1) - assert not breaker.allow("model", now=21.2) - breaker.record_success("model") - assert breaker.snapshot() == {} - - -@pytest.mark.asyncio -async def test_timeout_chunk_is_bisected_and_successful_leaves_survive(): - with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None): - analyzer = VideoAnalyzer(_context()) - - async def analyze(start: float, end: float, query: str) -> str: - if end - start > 4.0: - raise TimeoutError("large clip timed out") - return f"[from {start:.1f}s to {end:.1f}s] Summary: recovered {query}" - - with patch.object(analyzer, "_exec_single_chunk", side_effect=analyze) as child: - result = await analyzer.exec_spawn_sub_agent(0.0, 16.0, "find transition") - - assert child.await_count == 7 - assert result.count("recovered find transition") == 4 - assert "PARTIAL" not in result - - -@pytest.mark.asyncio -async def test_universal_primary_failure_uses_configured_fallback(): - with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None): - analyzer = VideoAnalyzer(_context()) - - primary = MagicMock() - primary_bound = MagicMock() - primary_bound.ainvoke = AsyncMock(side_effect=TimeoutError("primary timeout")) - primary.bind_tools.return_value = primary_bound - fallback = MagicMock() - fallback_bound = MagicMock() - fallback_bound.ainvoke = AsyncMock(return_value=AIMessage(content="fallback answer")) - fallback.bind_tools.return_value = fallback_bound - - with patch( - "artemis.agents.video_analyzer.video_analyzer.get_llm", - side_effect=[primary, fallback], - ) as get_model: - response = await analyzer._invoke_universal_model( - [HumanMessage(content="inspect")], - [], - timeout=1.0, - label="test", - ) - - assert response.content == "fallback answer" - assert get_model.call_args_list[0].kwargs["is_utils"] is True - assert get_model.call_args_list[1].kwargs["use_fallback"] is True - - -@pytest.mark.asyncio -async def test_native_chunk_exhaustion_commits_universal_fallback(tmp_path): - with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None): - analyzer = VideoAnalyzer(_context()) - analyzer.use_native_gemini = True - raw_video = tmp_path / "segment.mp4" - raw_video.write_bytes(b"video") - extracted = SimpleNamespace( - success=True, - video_path=raw_video, - actual_start_relative_time=0.0, - duration_seconds=5.0, - warning=None, - ) - controller = SimpleNamespace(extract_segment_metadata=AsyncMock(return_value=extracted)) - fallback_text = "[from 0.0s to 5.0s] Summary: fallback recovered Analysis: grounded" - - with ( - patch( - "artemis.agents.video_analyzer.video_analyzer.get_controller", - return_value=controller, - ), - patch( - "artemis.agents.video_analyzer.video_analyzer.compress_video_for_api", - new=AsyncMock(return_value=raw_video), - ), - patch.object( - analyzer, - "upload_and_poll_file", - new=AsyncMock(side_effect=RuntimeError("503 provider unavailable")), - ) as upload, - patch.object( - analyzer, - "_exec_single_chunk_universal", - new=AsyncMock(return_value=fallback_text), - ) as fallback, - patch( - "artemis.agents.video_analyzer.video_analyzer.asyncio.sleep", - new=AsyncMock(), - ), - ): - result = await analyzer._exec_single_chunk(0.0, 5.0, "verify transition") - - assert result == fallback_text - assert upload.await_count == 2 - fallback.assert_awaited_once() - assert analyzer.blackboard.missing_intervals(0.0, 5.0, "verify transition") == [] - - -def test_action_timestamps_drive_dense_sampling(): - with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None): - analyzer = VideoAnalyzer(_context()) - step = SimpleNamespace(timestamp=104.0, action_taken={"name": "tap"}) - storage = SimpleNamespace(get_steps=lambda _session: [step]) - analyzer.ctx.data_engine = SimpleNamespace( - current_session_id="session-1", - session_start_time=100.0, - storage=storage, - ) - - offsets = analyzer._dense_action_offsets(0.0, 10.0, actual_start=0.0) - - assert offsets[0] == 2.0 - assert 4.0 in offsets - assert offsets[-1] == 6.0 - - -class _FakeGenaiError(Exception): - """Mimics google.genai.errors.APIError's shape (a ``code`` attribute).""" - - def __init__(self, message: str, code: int | None = None): - super().__init__(message) - self.code = code - - -@pytest.mark.asyncio -async def test_invoke_with_retry_raises_non_retryable_immediately(): - from artemis.agents.video_analyzer.video_analyzer import _invoke_with_retry - - calls = 0 - - async def op(): - nonlocal calls - calls += 1 - raise _FakeGenaiError("invalid api key", code=401) - - with pytest.raises(_FakeGenaiError): - await _invoke_with_retry(op, "test-op") - assert calls == 1 - - -@pytest.mark.asyncio -async def test_invoke_with_retry_bad_request_never_retried(): - from artemis.agents.video_analyzer.video_analyzer import _invoke_with_retry - - calls = 0 - - async def op(): - nonlocal calls - calls += 1 - raise _FakeGenaiError("invalid request payload", code=400) - - with pytest.raises(_FakeGenaiError): - await _invoke_with_retry(op, "test-op") - assert calls == 1 - - -@pytest.mark.asyncio -async def test_invoke_with_retry_restarts_from_scratch_on_transient_failure(): - from artemis.agents.video_analyzer import video_analyzer as va - - calls = 0 - - async def op(): - nonlocal calls - calls += 1 - accumulated = "partial-" # per-attempt accumulator, like a stream loop - if calls < 2: - raise _FakeGenaiError("503 service unavailable", code=503) - return accumulated + "complete" - - with patch.object(va.asyncio, "sleep", new=AsyncMock()) as sleep_mock: - result = await va._invoke_with_retry(op, "test-op") - - # The successful attempt produced the whole output; the failed attempt's - # partial accumulation never leaked into the result. - assert result == "partial-complete" - assert calls == 2 - assert sleep_mock.await_count == 1 - - -@pytest.mark.asyncio -async def test_invoke_with_retry_exhaustion_reraises_original_error(): - from artemis.agents.video_analyzer import video_analyzer as va - - calls = 0 - - async def op(): - nonlocal calls - calls += 1 - raise _FakeGenaiError("503 service unavailable", code=503) - - with patch.object(va.asyncio, "sleep", new=AsyncMock()): - with pytest.raises(_FakeGenaiError): - await va._invoke_with_retry(op, "test-op", max_attempts=2) - assert calls == 2 - - -@pytest.mark.asyncio -async def test_invoke_with_retry_caps_unknown_failures_by_policy(): - from artemis.llm.reliability import FailureCategory, retry_policy_for - - from artemis.agents.video_analyzer import video_analyzer as va - - calls = 0 - - async def op(): - nonlocal calls - calls += 1 - raise RuntimeError("something odd") - - with patch.object(va.asyncio, "sleep", new=AsyncMock()): - with pytest.raises(RuntimeError): - await va._invoke_with_retry(op, "test-op", max_attempts=5) - # UNKNOWN failures follow the shared policy, not the caller's outer cap. - assert calls == retry_policy_for(FailureCategory.UNKNOWN).max_attempts - - -# ------------------------------------------------ mid-run agentic degrade - - -class _AgenticRejected(Exception): - status_code = 400 - - -@pytest.mark.asyncio -async def test_pending_chunk_sized_for_agentic_ceiling_is_not_run_statically(tmp_path): - """A chunk planned at 600s whose run degraded before it started is handed back.""" - with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None): - analyzer = VideoAnalyzer(_context()) - analyzer.use_native_gemini = True - # A sibling chunk already flipped the run to static (60s ceiling). - analyzer.video_processing = "static" - analyzer.chunk_size_seconds = analyzer.static_chunk_size_seconds - controller = SimpleNamespace(extract_segment_metadata=AsyncMock()) - - with ( - patch( - "artemis.agents.video_analyzer.video_analyzer.get_controller", - return_value=controller, - ), - patch( - "artemis.agents.video_analyzer.chunk_native._run_native_chunk_conversation", - new=AsyncMock(), - ) as static_path, - patch.object(analyzer, "_exec_single_chunk_universal", new=AsyncMock()) as universal, - pytest.raises(AgenticVideoDegraded) as raised, - ): - await analyzer._exec_single_chunk(0.0, 150.0, "verify toggle") - - # Refused before any extraction/upload; no static or fallback analysis. - controller.extract_segment_metadata.assert_not_awaited() - static_path.assert_not_awaited() - universal.assert_not_awaited() - assert classify_video_failure(raised.value).should_split - # The lease is released so the re-planned pieces can be claimed. - assert analyzer.blackboard.missing_intervals(0.0, 150.0, "verify toggle") == [(0.0, 150.0)] - - -@pytest.mark.asyncio -async def test_sibling_chunk_degraded_mid_run_is_replanned_into_static_pieces(tmp_path): - """Two 600s chunks: the first is rejected while the second is still in media prep.""" - with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None): - analyzer = VideoAnalyzer(_context()) - analyzer.use_native_gemini = True - analyzer.video_processing = "agentic" - analyzer.chunk_size_seconds = analyzer.agentic_chunk_size_seconds - assert analyzer.chunk_size_seconds == 600.0 - - raw_video = tmp_path / "segment.mp4" - raw_video.write_bytes(b"video") - first_rejected = asyncio.Event() - - async def extract(start: float, end: float): - if start >= 600.0 and analyzer.video_processing == "agentic": - # The second 600s chunk is mid-prep when the first one degrades. - await first_rejected.wait() - return SimpleNamespace( - success=True, - video_path=raw_video, - actual_start_relative_time=start, - duration_seconds=end - start, - warning=None, - ) - - controller = SimpleNamespace(extract_segment_metadata=AsyncMock(side_effect=extract)) - agentic_calls: list[tuple[float, float]] = [] - - async def agentic(analyzer_, media, cs, ce, *rest): - agentic_calls.append((cs, ce)) - first_rejected.set() - raise _AgenticRejected("400 INVALID_ARGUMENT: processing 'agentic' is not supported") - - async def static_answer(analyzer_, media, cs, ce, *rest): - return f"[from {cs:.1f}s to {ce:.1f}s] Summary: static Analysis: ok" - - with ( - patch( - "artemis.agents.video_analyzer.video_analyzer.get_controller", - return_value=controller, - ), - patch( - "artemis.agents.video_analyzer.video_analyzer.compress_video_for_api", - new=AsyncMock(return_value=raw_video), - ), - patch( - "artemis.agents.video_analyzer.chunk_native.run_agentic_chunk_conversation", - new=AsyncMock(side_effect=agentic), - ), - patch( - "artemis.agents.video_analyzer.chunk_native._run_native_chunk_conversation", - new=AsyncMock(side_effect=static_answer), - ) as static_path, - patch.object(analyzer, "_exec_single_chunk_universal", new=AsyncMock()) as universal, - patch("artemis.agents.video_analyzer.video_analyzer._record_llm_event"), - ): - result = await analyzer.exec_spawn_sub_agent(0.0, 1200.0, "verify toggle") - - # Only the first chunk ever reached the agentic API; the second never ran - # its 600s clip statically and neither went to the universal fallback. - assert agentic_calls == [(0.0, 600.0)] - universal.assert_not_awaited() - segments = sorted((call.args[2], call.args[3]) for call in static_path.await_args_list) - assert len(segments) == 20 - assert segments[0] == (0.0, 60.0) - assert segments[-1] == (1140.0, 1200.0) - assert all(ce - cs <= 60.0 for cs, ce in segments) - assert "PARTIAL" not in result - assert result.count("Summary: static") == 20 - assert analyzer.video_processing == "static" - - -@pytest.mark.asyncio -async def test_short_degraded_chunk_is_retried_statically_once(): - """A 5s chunk (below min_chunk*2) is re-run as-is instead of failing.""" - with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None): - analyzer = VideoAnalyzer(_context()) - calls: list[tuple[float, float]] = [] - - async def analyze(start: float, end: float, query: str) -> str: - calls.append((start, end)) - if len(calls) == 1: - analyzer.video_processing = "static" - analyzer.chunk_size_seconds = analyzer.static_chunk_size_seconds - raise AgenticVideoDegraded("agentic rejected") - return f"[from {start:.1f}s to {end:.1f}s] Summary: static {query}" - - with patch.object(analyzer, "_exec_single_chunk", side_effect=analyze) as child: - result = await analyzer.exec_spawn_sub_agent(0.0, 5.0, "find toggle") - - assert child.await_count == 2 - assert calls == [(0.0, 5.0), (0.0, 5.0)] - assert "PARTIAL" not in result - assert "failed" not in result.lower() - assert result.count("Summary: static find toggle") == 1 - - -@pytest.mark.asyncio -async def test_static_retry_of_degraded_chunk_is_bounded(): - """A second degrade on the same interval is terminal: no loop, no bisect.""" - with patch("artemis.agents.video_analyzer.video_analyzer.settings.GOOGLE_API_KEY", None): - analyzer = VideoAnalyzer(_context()) - calls: list[tuple[float, float]] = [] - - async def analyze(start: float, end: float, query: str) -> str: - calls.append((start, end)) - raise AgenticVideoDegraded("agentic rejected again") - - with patch.object(analyzer, "_exec_single_chunk", side_effect=analyze) as child: - # 30s is above min_chunk*2, so a bisect would otherwise be legal. - result = await analyzer.exec_spawn_sub_agent(0.0, 30.0, "find toggle") - - assert child.await_count == 2 - assert calls == [(0.0, 30.0), (0.0, 30.0)] - assert result.startswith("All sub-agent chunks failed") - assert "0.0s-30.0s [bad_request]" in result diff --git a/tests/unit/config/test_video_processing_config.py b/tests/unit/config/test_video_processing_config.py deleted file mode 100644 index f24683b7b..000000000 --- a/tests/unit/config/test_video_processing_config.py +++ /dev/null @@ -1,39 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""The video-processing knob is typed once, in the Google provider module.""" - -from typing import get_args - -from pydantic import ValidationError -import pytest - -from artemis.config.agent import VideoAnalyzerConfig -from artemis.llm.google import VideoProcessing - - -def test_processing_field_reuses_the_provider_literal(): - assert VideoAnalyzerConfig.model_fields["processing"].annotation is VideoProcessing - assert set(get_args(VideoProcessing)) == {"auto", "agentic", "static"} - - -def test_processing_defaults_to_auto_and_accepts_every_mode(): - assert VideoAnalyzerConfig().processing == "auto" - for mode in get_args(VideoProcessing): - assert VideoAnalyzerConfig(processing=mode).processing == mode - - -def test_processing_rejects_unknown_modes(): - with pytest.raises(ValidationError, match="processing"): - VideoAnalyzerConfig(processing="bogus") diff --git a/tests/unit/core/test_config_engine.py b/tests/unit/core/test_config_engine.py deleted file mode 100644 index c318c5824..000000000 --- a/tests/unit/core/test_config_engine.py +++ /dev/null @@ -1,42 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for CascadingConfigEngine and variable interpolation.""" - -from unittest.mock import patch - -from artemis.config.core import CascadingConfigEngine, interpolate_config_value - - -def test_interpolate_env_variables(): - """Verify ${env:KEY} expressions are properly interpolated.""" - with patch.dict("os.environ", {"TEST_KEY": "secret_123"}): - result = interpolate_config_value("Bearer ${env:TEST_KEY}") - assert result == "Bearer secret_123" - - default_result = interpolate_config_value("${env:MISSING_KEY:-fallback_val}") - assert default_result == "fallback_val" - - -def test_interpolate_path_variables(): - """Verify ${path:KEY} expressions resolve system standard paths.""" - result = interpolate_config_value("${path:temp_dir}/screenshots") - assert "/screenshots" in result or "\\screenshots" in result - - -def test_cascading_config_merge(): - """Verify CLI overrides take precedence in CascadingConfigEngine.""" - engine = CascadingConfigEngine() - merged = engine.load_raw_config(cli_overrides={"default": {"model": "gemini-test-custom"}}) - assert merged.get("default", {}).get("model") == "gemini-test-custom" diff --git a/tests/unit/core/test_diagnostics.py b/tests/unit/core/test_diagnostics.py index 322c0f277..063d57e5a 100644 --- a/tests/unit/core/test_diagnostics.py +++ b/tests/unit/core/test_diagnostics.py @@ -20,14 +20,7 @@ import pytest from artemis.core.diagnostics.engine import ReadinessEngine from artemis.core.diagnostics.probes.adb_probe import AdbDeviceProbe -from artemis.core.diagnostics.probes.credentials_probe import ( - LLMCredentialsProbe, - VisionOCRProbe, -) -from artemis.core.diagnostics.probes.runtime_probe import ( - PythonRuntimeProbe, - SystemConfigProbe, -) +from artemis.core.diagnostics.probes.runtime_probe import PythonRuntimeProbe from artemis.core.diagnostics.probes.toolchain_probe import ToolchainProbe from artemis.core.diagnostics.schema import ( ProbeCategory, @@ -44,16 +37,15 @@ async def test_readiness_engine_run_all(): report: SystemReadinessReport = await engine.run_all() assert isinstance(report, SystemReadinessReport) - assert report.blocker_count >= 3 assert isinstance(report.probes, list) - assert len(report.probes) >= 5 + # Only the device probes remain after the contraction: the python runtime, + # the adb device gate (both blockers) and the optional toolchain probe. + assert len(report.probes) == 3 + assert report.blocker_count == 2 probe_ids = [p.id for p in report.probes] assert "python_runtime" in probe_ids - assert "system_config" in probe_ids assert "android_adb" in probe_ids - assert "gemini_api_key" in probe_ids - assert "vision_ocr_key" in probe_ids assert "toolchain" in probe_ids @@ -72,34 +64,6 @@ async def test_python_runtime_probe_structure(): assert "executable" in result.metadata -@pytest.mark.asyncio -async def test_system_config_probe_structure(): - """Verify SystemConfigProbe checks configuration file health.""" - probe = SystemConfigProbe() - assert probe.probe_id == "system_config" - assert probe.category == ProbeCategory.RUNTIME - assert probe.is_blocker is True - - result: ProbeResult = await probe.probe() - assert isinstance(result, ProbeResult) - assert result.status in (ProbeStatus.PASS, ProbeStatus.FAIL) - assert "valid" in result.metadata - - -@pytest.mark.asyncio -async def test_vision_ocr_probe_structure(): - """Verify VisionOCRProbe returns optional non-blocker status.""" - probe = VisionOCRProbe() - assert probe.probe_id == "vision_ocr_key" - assert probe.category == ProbeCategory.CREDENTIALS - assert probe.is_blocker is False - - result: ProbeResult = await probe.probe() - assert isinstance(result, ProbeResult) - assert result.status == ProbeStatus.PASS - assert "configured" in result.metadata - - @pytest.mark.asyncio async def test_adb_probe_structure(): """Verify AdbDeviceProbe returns correct category, blocker status, and schema.""" @@ -353,20 +317,6 @@ async def test_adb_probe_prefers_unlocked_device_when_one_is_locked(monkeypatch) assert result.metadata["active_device"]["is_locked"] is False -@pytest.mark.asyncio -async def test_llm_credentials_probe_structure(): - """Verify LLMCredentialsProbe returns correct category and schema.""" - probe = LLMCredentialsProbe() - assert probe.probe_id == "gemini_api_key" - assert probe.category == ProbeCategory.CREDENTIALS - assert probe.is_blocker is True - - result: ProbeResult = await probe.probe() - assert isinstance(result, ProbeResult) - assert result.status in (ProbeStatus.PASS, ProbeStatus.FAIL) - assert "configured_count" in result.metadata - - @pytest.mark.asyncio async def test_toolchain_probe_structure(): """Verify ToolchainProbe returns valid probe category, metadata, and schema.""" @@ -392,22 +342,6 @@ async def test_probe_target_serial_forwards_to_adb_probe(): assert engine._adb_probe._target_serial is None -@pytest.mark.asyncio -async def test_credentials_probe_and_dynamic_update(): - """Verify dynamic API key updates and metadata reflection.""" - from artemis.config import settings - - settings.set_api_key("google", "test_gemini_key_1234567890", persist_to_env=False) - - probe = LLMCredentialsProbe() - result = await probe.probe() - assert result.status == ProbeStatus.PASS - assert "current_key" in result.metadata - assert result.metadata["current_key"] == "test_gemini_key_1234567890" - assert "api_keys" in result.metadata - assert result.metadata["api_keys"]["google"] == "test_gemini_key_1234567890" - - @pytest.mark.asyncio async def test_emulator_manager_lifecycle(): """Verify EmulatorManager status querying, validation, and dismissal.""" diff --git a/tests/unit/core/test_host_probe.py b/tests/unit/core/test_host_probe.py deleted file mode 100644 index 147f8dbec..000000000 --- a/tests/unit/core/test_host_probe.py +++ /dev/null @@ -1,471 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for the MCP / IDE integration-host readiness probe.""" - -import os -from pathlib import Path -import sys - -import pytest - -from artemis.core.diagnostics import IntegrationHostProbe, ReadinessEngine -from artemis.core.diagnostics.probes import host_probe -from artemis.core.diagnostics.schema import ProbeCategory, ProbeStatus - - -#: Environment keys / prefixes any IDE might export; scrubbed so the developer's -#: own host (this test suite is often run from inside Claude Code or Cursor) does -#: not leak into the detection tests. -_SIGNAL_PREFIXES = ( - "ANTIGRAVITY_", - "CLAUDE_CODE_", - "CURSOR_", - "WINDSURF_", - "CODEX_", - "OPENCLAW_", - "CLINE_", - "ROO_", - "VSCODE_", -) -_SIGNAL_KEYS = ("CLAUDECODE", "TERM_PROGRAM") - - -@pytest.fixture -def clean_ide_env(monkeypatch): - for key in list(os.environ): - if key in _SIGNAL_KEYS or key.startswith(_SIGNAL_PREFIXES): - monkeypatch.delenv(key, raising=False) - return monkeypatch - - -@pytest.fixture -def host_env(tmp_path, monkeypatch, clean_ide_env): - """A consistent, healthy host layout the individual tests then break.""" - venv_python = host_probe.project_venv_python(tmp_path) - venv_python.parent.mkdir(parents=True) - venv_python.write_bytes(b"") - traces = tmp_path / "traces" - - monkeypatch.setattr("artemis.config.paths.ROOT_DIR", tmp_path) - monkeypatch.setattr("artemis.config.paths.get_env_file", lambda: tmp_path / ".env") - monkeypatch.setattr("artemis.config.paths.get_default_traces_path", lambda: traces) - monkeypatch.setattr("artemis.config.paths.is_source_checkout", lambda: True) - monkeypatch.setattr(sys, "executable", str(venv_python)) - - monkeypatch.setattr("artemis.runtime.daemon_client.is_standalone_forced", lambda: False) - monkeypatch.setattr("artemis.runtime.daemon_client.is_artemis_daemon", lambda: True) - monkeypatch.setattr( - "artemis.runtime.daemon_client.daemon_log_path", lambda: tmp_path / "daemon.log" - ) - monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", lambda *a, **k: True) - return tmp_path - - -def test_probe_is_not_part_of_the_default_console_report(): - assert "integration_host" not in ReadinessEngine()._probes - probe = IntegrationHostProbe() - assert probe.probe_id == "integration_host" - assert probe.category is ProbeCategory.RUNTIME - assert probe.is_blocker is True - - -@pytest.mark.asyncio -async def test_consistent_host_passes(host_env): - result = await IntegrationHostProbe().probe() - - assert result.status is ProbeStatus.PASS - assert result.is_blocker is True - assert result.summary == "Host Ready" - meta = result.metadata - assert meta["venv_exists"] is True - assert meta["interpreter_matches_venv"] is True - assert Path(meta["runner_python"]) == host_probe.project_venv_python(host_env) - assert meta["env_file_exists"] is False - assert meta["traces_dir_writable"] is True - assert (host_env / "traces").is_dir() - assert meta["daemon"] == { - "host": "127.0.0.1", - "port": 8000, - "standalone_forced": False, - "reachable": True, - "port_in_use": True, - "port_held_by_other_process": False, - "log_path": str(host_env / "daemon.log"), - } - labels = [a.label for a in result.actions] - assert labels[0] == "Host OK" - assert "Env File Location" in labels # informational even when passing - - -@pytest.mark.asyncio -async def test_daemon_port_held_by_other_process_warns(host_env, monkeypatch): - monkeypatch.setattr("artemis.runtime.daemon_client.is_artemis_daemon", lambda: False) - - result = await IntegrationHostProbe().probe() - - assert result.status is ProbeStatus.WARN - assert result.is_blocker is False # tasks still run standalone - assert result.metadata["daemon"]["port_held_by_other_process"] is True - assert "port 8000 is held" in result.description.lower() - port_fix = next(a for a in result.actions if a.label == "Free the Daemon Port") - assert "ARTEMIS_DAEMON_PORT" in port_fix.payload - - -@pytest.mark.asyncio -async def test_daemon_not_running_and_port_free_is_informational(host_env, monkeypatch): - monkeypatch.setattr("artemis.runtime.daemon_client.is_artemis_daemon", lambda: False) - monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", lambda *a, **k: False) - - result = await IntegrationHostProbe().probe() - - assert result.status is ProbeStatus.PASS - assert "auto-start" in result.description - auto = next(a for a in result.actions if a.label == "Daemon Auto-Start") - assert str(host_env / "daemon.log") in auto.payload - - -@pytest.mark.asyncio -async def test_standalone_mode_skips_daemon_probe(host_env, monkeypatch): - monkeypatch.setattr("artemis.runtime.daemon_client.is_standalone_forced", lambda: True) - monkeypatch.setattr("artemis.runtime.daemon_client.is_artemis_daemon", lambda: False) - - result = await IntegrationHostProbe().probe() - - assert result.status is ProbeStatus.PASS - assert result.metadata["daemon"]["standalone_forced"] is True - assert result.metadata["daemon"]["port_held_by_other_process"] is False - auto = next(a for a in result.actions if a.label == "Daemon Auto-Start") - assert "ARTEMIS_STANDALONE" in auto.payload - - -@pytest.mark.asyncio -async def test_interpreter_mismatch_warns_and_points_at_installer(host_env, monkeypatch): - other = host_env / "other-python.exe" - other.write_bytes(b"") - monkeypatch.setattr(sys, "executable", str(other)) - - result = await IntegrationHostProbe().probe() - - assert result.status is ProbeStatus.WARN - assert result.metadata["interpreter_matches_venv"] is False - assert Path(result.metadata["runner_python"]) == host_probe.project_venv_python(host_env) - assert result.metadata["mcp_client"] is None - assert not [a for a in result.actions if a.action_type == "command"] - regen = next(a for a in result.actions if a.label == "Regenerate MCP Config") - assert regen.action_type == "hint" - assert "ask the user which ide" in regen.payload.lower() - assert "uv run artemis mcp --install " in regen.payload - for name in host_probe.MCP_CLIENT_NAMES: - assert name in regen.payload - assert next(a for a in result.actions if a.label == "Interpreter Mismatch") - - -@pytest.mark.asyncio -async def test_interpreter_mismatch_emits_runnable_install_for_detected_client( - host_env, monkeypatch -): - monkeypatch.setenv("CLAUDECODE", "1") - other = host_env / "other-python.exe" - other.write_bytes(b"") - monkeypatch.setattr(sys, "executable", str(other)) - - result = await IntegrationHostProbe().probe() - - assert result.status is ProbeStatus.WARN - assert result.metadata["mcp_client"] == "claude" - regen = next(a for a in result.actions if a.action_type == "command") - assert regen.label == "Regenerate MCP Config" - assert regen.payload == "uv run artemis mcp --install claude" - assert "" not in regen.payload - - -@pytest.mark.asyncio -async def test_metadata_reports_client_even_when_host_is_healthy(host_env, monkeypatch): - monkeypatch.setenv("CURSOR_TRACE_ID", "abc") - - result = await IntegrationHostProbe().probe() - - assert result.status is ProbeStatus.PASS - assert result.metadata["mcp_client"] == "cursor" - - -def test_detect_mcp_client_unknown_in_clean_environment(clean_ide_env): - assert host_probe.detect_mcp_client() is None - - -@pytest.mark.parametrize( - ("variables", "expected"), - [ - ({"ANTIGRAVITY_LS_ADDRESS": "127.0.0.1:1"}, "antigravity"), - ({"ANTIGRAVITY_CSRF_TOKEN": "t"}, "antigravity"), - ({"CLAUDECODE": "1"}, "claude"), - ({"CLAUDE_CODE_ENTRYPOINT": "cli"}, "claude"), - ({"CURSOR_TRACE_ID": "abc"}, "cursor"), - ({"CURSOR_AGENT": "1"}, "cursor"), - ({"WINDSURF_SESSION": "x"}, "windsurf"), - ({"CODEX_SANDBOX": "seatbelt"}, "codex"), - ({"CODEX_HOME": "/tmp/codex", "CODEX_SANDBOX_NETWORK_DISABLED": "1"}, "codex"), - ({"OPENCLAW_SESSION_ID": "s"}, "openclaw"), - ({"CLINE_TASK_ID": "1"}, "cline"), - ({"ROO_CODE_TASK": "1"}, "roo"), - ({"VSCODE_PID": "42"}, "vscode"), - ({"TERM_PROGRAM": "vscode"}, "vscode"), - ], -) -def test_detect_mcp_client_signals(clean_ide_env, variables, expected): - for key, value in variables.items(): - clean_ide_env.setenv(key, value) - assert host_probe.detect_mcp_client() == expected - - -@pytest.mark.parametrize( - "variables", - [ - {"CODEX_HOME": "/home/me/.codex"}, # user relocated the Codex config dir - {"OPENCLAW_WEBHOOK_URL": "https://hooks.example/x"}, # notifier setting, any host - {"TERM_PROGRAM": "iTerm.app"}, - ], -) -def test_detect_mcp_client_ignores_weak_or_user_set_variables(clean_ide_env, variables): - for key, value in variables.items(): - clean_ide_env.setenv(key, value) - assert host_probe.detect_mcp_client() is None - - -@pytest.mark.parametrize( - ("variables", "expected"), - [ - # Claude Code or Codex running inside a VS Code / Cursor terminal: the - # agent that spawned the server wins over the editor around it. - ({"CLAUDECODE": "1", "VSCODE_PID": "1", "TERM_PROGRAM": "vscode"}, "claude"), - ({"CLAUDECODE": "1", "CURSOR_TRACE_ID": "x"}, "claude"), - ({"CODEX_SANDBOX": "seatbelt", "VSCODE_PID": "1"}, "codex"), - # Cursor / Windsurf / Cline are VS Code forks or extensions and inherit - # the generic VS Code markers. - ({"CURSOR_TRACE_ID": "x", "VSCODE_PID": "1", "TERM_PROGRAM": "vscode"}, "cursor"), - ({"WINDSURF_SESSION": "x", "TERM_PROGRAM": "vscode"}, "windsurf"), - ({"CLINE_TASK_ID": "1", "VSCODE_PID": "1"}, "cline"), - ], -) -def test_detect_mcp_client_specific_host_beats_generic_vscode(clean_ide_env, variables, expected): - for key, value in variables.items(): - clean_ide_env.setenv(key, value) - assert host_probe.detect_mcp_client() == expected - - -def test_mcp_install_actions_shapes(): - (cmd,) = host_probe.mcp_install_actions("antigravity") - assert cmd.action_type == "command" - assert cmd.payload == "uv run artemis mcp --install antigravity" - - (hint,) = host_probe.mcp_install_actions(None) - assert hint.action_type == "hint" - assert "antigravity, claude, cursor, windsurf, vscode, cline, roo, openclaw, codex" in ( - hint.payload - ) - - -@pytest.mark.asyncio -async def test_missing_venv_warns_with_uv_sync(host_env, monkeypatch): - host_probe.project_venv_python(host_env).unlink() - monkeypatch.setattr(sys, "executable", str(host_env / "system-python.exe")) - - result = await IntegrationHostProbe().probe() - - assert result.status is ProbeStatus.WARN - assert result.metadata["venv_exists"] is False - assert result.metadata["runner_python"] == str(host_env / "system-python.exe") - assert any(a.payload == "uv sync" for a in result.actions) - - -@pytest.mark.asyncio -async def test_unwritable_traces_dir_fails_even_with_other_warnings(host_env, monkeypatch): - monkeypatch.setattr(host_probe, "directory_is_writable", lambda _p: (False, "denied")) - monkeypatch.setattr("artemis.runtime.daemon_client.is_artemis_daemon", lambda: False) - - result = await IntegrationHostProbe().probe() - - assert result.status is ProbeStatus.FAIL - assert result.is_blocker is True - assert result.summary == "Host Misconfigured" - assert result.metadata["traces_dir_writable"] is False - assert result.metadata["traces_dir_error"] == "denied" - assert result.actions[0].label == "Fix Traces Directory" - assert "ARTEMIS_TRACES_DIR" in result.actions[0].payload - # The port problem is still reported alongside the blocking failure. - assert "port 8000 is held" in result.description.lower() - - -def test_directory_is_writable_reports_real_errors(tmp_path): - ok, error = host_probe.directory_is_writable(tmp_path / "new" / "nested") - assert ok is True and error is None - - blocker = tmp_path / "file" - blocker.write_text("x") - ok, error = host_probe.directory_is_writable(blocker / "child") - assert ok is False and error - - -@pytest.mark.asyncio -async def test_wheel_install_without_venv_is_not_a_venv_warning(host_env, monkeypatch): - """A wheel install has no .venv under site-packages; that is normal, not a warning.""" - host_probe.project_venv_python(host_env).unlink() - system_python = host_env / "system-python.exe" - monkeypatch.setattr(sys, "executable", str(system_python)) - monkeypatch.setattr("artemis.config.paths.is_source_checkout", lambda: False) - - result = await IntegrationHostProbe().probe() - - assert result.status is ProbeStatus.PASS - assert result.summary == "Host Ready" - meta = result.metadata - assert meta["is_source_checkout"] is False - assert meta["venv_python"] is None - assert meta["venv_exists"] is False - assert meta["interpreter_matches_venv"] is False - assert meta["runner_python"] == str(system_python) - assert "venv" not in result.description.lower() - assert not [a for a in result.actions if a.payload == "uv sync"] - assert not [a for a in result.actions if a.label == "Interpreter Mismatch"] - - -@pytest.mark.asyncio -async def test_wheel_install_ignores_stray_venv_next_to_site_packages(host_env, monkeypatch): - """Even if a .venv happens to exist under ROOT_DIR, a non-source install never compares against it.""" - other = host_env / "other-python.exe" - other.write_bytes(b"") - monkeypatch.setattr(sys, "executable", str(other)) - monkeypatch.setattr("artemis.config.paths.is_source_checkout", lambda: False) - - result = await IntegrationHostProbe().probe() - - assert result.status is ProbeStatus.PASS - assert result.metadata["venv_python"] is None - assert result.metadata["runner_python"] == str(other) - assert not [a for a in result.actions if a.label == "Interpreter Mismatch"] - - -@pytest.mark.asyncio -async def test_source_checkout_without_venv_still_warns_with_uv_sync(host_env, monkeypatch): - host_probe.project_venv_python(host_env).unlink() - monkeypatch.setattr(sys, "executable", str(host_env / "system-python.exe")) - monkeypatch.setattr("artemis.config.paths.is_source_checkout", lambda: True) - - result = await IntegrationHostProbe().probe() - - assert result.status is ProbeStatus.WARN - assert result.metadata["is_source_checkout"] is True - assert result.metadata["venv_python"] == str(host_probe.project_venv_python(host_env)) - assert result.metadata["venv_exists"] is False - assert "no project virtualenv" in result.description.lower() - assert any(a.payload == "uv sync" for a in result.actions) - - -@pytest.mark.asyncio -async def test_traces_path_creation_error_becomes_fail_not_crash(host_env, monkeypatch): - """get_default_traces_path mkdirs as a side effect; a read-only parent must not crash the probe.""" - - def denied(): - raise PermissionError(13, "Permission denied", str(host_env / "traces")) - - monkeypatch.setattr("artemis.config.paths.get_default_traces_path", denied) - monkeypatch.setenv("ARTEMIS_TRACES_DIR", str(host_env / "ro" / "traces")) - - result = await IntegrationHostProbe().probe() - - assert result.status is ProbeStatus.FAIL - assert result.is_blocker is True - assert result.summary == "Host Misconfigured" - meta = result.metadata - assert meta["traces_dir_writable"] is False - assert "Permission denied" in meta["traces_dir_error"] - # The intended path is still named, resolved without touching the disk. - assert Path(meta["traces_dir"]) == host_env / "ro" / "traces" - assert not (host_env / "ro").exists() - assert result.actions[0].label == "Fix Traces Directory" - assert "ARTEMIS_TRACES_DIR" in result.actions[0].payload - assert "not writable" in result.description - - -def test_intended_traces_path_mirrors_selection_without_mkdir(tmp_path, monkeypatch): - monkeypatch.setattr("artemis.config.paths.ROOT_DIR", tmp_path) - monkeypatch.delenv("ARTEMIS_TRACES_DIR", raising=False) - - monkeypatch.setattr("artemis.config.paths._use_user_app_dir", lambda: False) - assert host_probe.intended_traces_path() == tmp_path / "traces" - - monkeypatch.setattr("artemis.config.paths._use_user_app_dir", lambda: True) - monkeypatch.setattr("artemis.config.paths.get_app_dir", lambda: tmp_path / "app") - assert host_probe.intended_traces_path() == tmp_path / "app" / "traces" - - monkeypatch.setenv("ARTEMIS_TRACES_DIR", str(tmp_path / "env-traces")) - assert host_probe.intended_traces_path() == tmp_path / "env-traces" - - assert not (tmp_path / "traces").exists() - assert not (tmp_path / "app").exists() - assert not (tmp_path / "env-traces").exists() - - -# --------------------------------------------------------------------------- # -# Daemon identity (a dev server on port 8000 is not the daemon) -# --------------------------------------------------------------------------- # - - -class _FakeResponse: - def __init__(self, status: int, body: bytes): - self.status = status - self._body = body - - def read(self) -> bytes: - return self._body - - def __enter__(self): - return self - - def __exit__(self, *exc): - return False - - -@pytest.mark.parametrize( - "status, body, expected", - [ - (200, b'{"running": true, "port": 8000, "current_pid": 42}', True), - (200, b"Vite App", False), # dev server on / - (200, b'{"hello": "world"}', False), - (404, b"", False), - ], -) -def test_is_artemis_daemon_requires_the_server_status_shape(monkeypatch, status, body, expected): - from artemis.runtime import daemon_client - - seen: dict[str, str] = {} - - def fake_urlopen(req, timeout): - seen["url"] = req.full_url - return _FakeResponse(status, body) - - monkeypatch.setattr(daemon_client.urllib.request, "urlopen", fake_urlopen) - assert daemon_client.is_artemis_daemon("127.0.0.1", 8000) is expected - assert seen["url"] == "http://127.0.0.1:8000/api/system/server-status" - - -def test_is_artemis_daemon_is_false_when_nothing_listens(monkeypatch): - from artemis.runtime import daemon_client - - def refuse(req, timeout): - raise ConnectionRefusedError() - - monkeypatch.setattr(daemon_client.urllib.request, "urlopen", refuse) - assert daemon_client.is_artemis_daemon("127.0.0.1", 8000) is False diff --git a/tests/unit/core/test_readiness.py b/tests/unit/core/test_readiness.py index 0cfb0c775..4e43cc80c 100644 --- a/tests/unit/core/test_readiness.py +++ b/tests/unit/core/test_readiness.py @@ -12,14 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Unit tests for the readiness core shared by ``artemis doctor`` and ``mobile_diagnose``.""" +"""Unit tests for the device readiness core shared by ``artemis doctor``.""" import asyncio import time from unittest.mock import AsyncMock from artemis.core.diagnostics import readiness -from artemis.core.diagnostics.probes.host_probe import IntegrationHostProbe from artemis.core.diagnostics.readiness import ( CHECK_ORDER, adb_keys_corrupted, @@ -88,7 +87,7 @@ def test_base_verdict_all_pass_is_ready(): def test_base_verdict_warning_blocker_is_blocked_not_degraded(): - results = [_probe("python_runtime"), _probe("integration_host", ProbeStatus.WARN)] + results = [_probe("python_runtime"), _probe("custom_blocker", ProbeStatus.WARN)] assert base_verdict(results) == "blocked" @@ -125,18 +124,10 @@ def test_sort_by_fix_order_follows_check_order_and_appends_unknown_ids(): ] ordered = [r.id for r in sort_by_fix_order(results)] assert ordered == ["python_runtime", "android_adb", "toolchain", "custom_probe"] - assert list(CHECK_ORDER) == [ - "python_runtime", - "system_config", - "integration_host", - "gemini_api_key", - "android_adb", - "toolchain", - "vision_ocr_key", - ] + assert list(CHECK_ORDER) == ["python_runtime", "android_adb", "toolchain"] -def test_collect_readiness_gathers_engine_report_and_host_probe(monkeypatch): +def test_collect_readiness_gathers_the_engine_report(monkeypatch): report = SystemReadinessReport( overall_ready=True, blocker_count=1, @@ -145,13 +136,10 @@ def test_collect_readiness_gathers_engine_report_and_host_probe(monkeypatch): os_type="windows", timestamp=time.time(), ) - host = _probe("integration_host") run_all = AsyncMock(return_value=report) monkeypatch.setattr(readiness.readiness_engine, "run_all", run_all) - monkeypatch.setattr(IntegrationHostProbe, "probe", AsyncMock(return_value=host)) - got_report, got_host = asyncio.run(collect_readiness()) + got_report = asyncio.run(collect_readiness()) assert got_report is report - assert got_host is host run_all.assert_awaited_once_with(force_refresh=True) diff --git a/tests/unit/data_engine/test_end_to_end_thoughts.py b/tests/unit/data_engine/test_end_to_end_thoughts.py deleted file mode 100644 index d233dd429..000000000 --- a/tests/unit/data_engine/test_end_to_end_thoughts.py +++ /dev/null @@ -1,165 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from pathlib import Path -import shutil -import tempfile -from unittest.mock import MagicMock -from uuid import uuid4 - -from langchain_core.messages import AIMessage, HumanMessage -from artemis.context import ArtemisContext -from artemis.data_engine.engine import DataEngine -from artemis.data_engine.trace import CURRENT_TRACE_ID, DataEngineCallbackHandler -from artemis.utils.task_tree import build_plan_and_history -import pytest - - -@pytest.fixture -def temp_workspace(): - tmpdir = tempfile.mkdtemp() - yield Path(tmpdir) - shutil.rmtree(tmpdir) - - -@pytest.mark.asyncio -async def test_end_to_end_thoughts_non_duplication(temp_workspace): - # 1. Setup mock context and DataEngine - mock_ctx = MagicMock(spec=ArtemisContext) - mock_execution_setup = MagicMock() - mock_execution_setup.traces_path = str(temp_workspace / "traces") - mock_ctx.execution_setup = mock_execution_setup - mock_ctx.device = None - mock_ctx.llm_config = MagicMock() - - engine = DataEngine(mock_ctx) - mock_ctx.data_engine = engine - - # Start session - engine.start_session("Test initial goal") - - # 2. Allocate step ID - step_id = engine.allocate_step_id() - - # 3. Simulate Operator Agent run starts - operator_trace_id = uuid4() - engine.record_trace( - type="agent", - name="operator", - payload={"args": {}}, - trace_id=operator_trace_id, - step_id=step_id, - status="running", - ) - - # Set context variables for parenting - token = CURRENT_TRACE_ID.set(operator_trace_id) - try: - # 4. Simulate LLM Call callback sequence - handler = DataEngineCallbackHandler(mock_ctx) - llm_run_id = uuid4() - - # Start model - handler.on_chat_model_start( - serialized={"name": "ChatGoogleGenerativeAI"}, - messages=[[HumanMessage(content="What should I do?")]], - run_id=llm_run_id, - parent_run_id=None, - ) - - # End model (generating thought + action) - class MockGeneration: - def __init__(self, message): - self.message = message - - class MockLLMResult: - def __init__(self, generations): - self.generations = generations - - response_message = AIMessage( - content=[ - { - "type": "thinking", - "thinking": "I need to tap the search bar to find books.", - }, - {"type": "text", "text": "Tapping search bar."}, - ] - ) - mock_response = MockLLMResult(generations=[[MockGeneration(response_message)]]) - - handler.on_llm_end(mock_response, run_id=llm_run_id) - finally: - CURRENT_TRACE_ID.reset(token) - - # 5. Finish Operator Agent trace - engine.record_trace( - type="agent", - name="operator", - payload={"result": "Success"}, - trace_id=operator_trace_id, - step_id=step_id, - status="success", - ) - - # 6. Record step - engine.record_step( - pre_screenshot_bytes=b"pre_screenshot", - ui_tree=[], - ocr_result=[], - action_taken=[{"action": "tap", "coordinates": [100, 200]}], - operator_raw_thinking="I need to tap the search bar to find books.", - operator_native_thinking="I need to tap the search bar to find books.", - last_execution_result={"status": "success"}, - summary="Tapped search bar", - ) - - # Flush any pending tasks - await engine.shutdown() - - # 7. Retrieve agent-friendly steps - steps = engine.get_agent_friendly_steps() - - # Assert step was retrieved - assert len(steps) == 1 - step_data = steps[0] - - # Assert thoughts are present in interleaved events (since we associated step_id now!) - events = step_data.get("interleaved_events", []) - thought_events = [e for e in events if e.get("type") in ("thought", "native_thought")] - assert len(thought_events) > 0 - assert any( - e["content"] == "I need to tap the search bar to find books." for e in thought_events - ) - - # 8. Build history and check for duplications - plan = "- [ ] Search for books" - history = build_plan_and_history(plan, steps, "default", last_n_detailed=1) - - print("\n--- Generated History Output ---") - print(history) - print("--------------------------------") - - # Assert thought is printed - assert "I need to tap the search bar to find books." in history - - # Assert thought is NOT printed multiple times - # Count occurrences of the thought string - count = history.count("I need to tap the search bar to find books.") - # The thought might appear in native thinking block and raw thinking block if both were stored. - # In our test we set BOTH operator_raw_thinking and operator_native_thinking to the same string, - # AND the LLM trace generated both "thinking" block and "text" block. - # Let's count how many times it was actually rendered in the Operator Decision Loop. - # It should appear at most 2 times (once as Native Thought, once as Thought/Raw Thought). - # If there was duplicate appending due to fallback failure, it would appear 4+ times. - assert count <= 2 diff --git a/tests/unit/data_engine/test_engine.py b/tests/unit/data_engine/test_engine.py deleted file mode 100644 index f4bf78c8e..000000000 --- a/tests/unit/data_engine/test_engine.py +++ /dev/null @@ -1,631 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -import json -from unittest.mock import MagicMock, patch - -from artemis.context import ArtemisContext -from artemis.data_engine.engine import DataEngine - - -def test_ipc_send_reconnects_and_retries_current_event(tmp_path): - """A stale Windows TCP socket must not make the triggering SSE event disappear.""" - mock_ctx = MagicMock(spec=ArtemisContext) - mock_execution_setup = MagicMock() - mock_execution_setup.traces_path = str(tmp_path) - mock_ctx.execution_setup = mock_execution_setup - mock_ctx.device = None - - stale_socket = MagicMock() - stale_socket.sendall.side_effect = OSError("connection reset") - replacement_socket = MagicMock() - - with ( - patch("artemis.data_engine.engine.read_ipc_port", return_value=49152), - patch( - "artemis.data_engine.engine.socket.create_connection", - side_effect=[stale_socket, replacement_socket], - ) as create_connection, - ): - engine = DataEngine(mock_ctx) - engine._publish("llm_stream", {"session_id": "s1", "chunk": "hello"}) - - assert create_connection.call_count == 2 - stale_socket.close.assert_called_once() - replacement_socket.sendall.assert_called_once() - frame = replacement_socket.sendall.call_args.args[0] - assert frame.endswith(b"\n") - assert json.loads(frame.decode("utf-8"))["data"]["chunk"] == "hello" - - -def test_ipc_connect_falls_back_to_refreshed_port_file(tmp_path): - """A UI restart must supersede the worker's stale inherited Windows port.""" - mock_ctx = MagicMock(spec=ArtemisContext) - mock_execution_setup = MagicMock() - mock_execution_setup.traces_path = str(tmp_path / "traces") - mock_ctx.execution_setup = mock_execution_setup - mock_ctx.device = None - - port_file = tmp_path / ".artemis_ipc_port" - port_file.write_text("51629", encoding="utf-8") - live_socket = MagicMock() - - with ( - patch("artemis.data_engine.engine.read_ipc_port", return_value=49555), - patch("artemis.data_engine.engine.get_ipc_port_file", return_value=port_file), - patch( - "artemis.data_engine.engine.socket.create_connection", - side_effect=[OSError("stale port"), live_socket], - ) as create_connection, - ): - engine = DataEngine(mock_ctx) - - assert engine.ipc_socket is live_socket - assert create_connection.call_args_list[0].args[0] == ("127.0.0.1", 49555) - assert create_connection.call_args_list[1].args[0] == ("127.0.0.1", 51629) - - -def test_ipc_does_not_reconnect_after_engine_shutdown(tmp_path): - mock_ctx = MagicMock(spec=ArtemisContext) - mock_execution_setup = MagicMock() - mock_execution_setup.traces_path = str(tmp_path) - mock_ctx.execution_setup = mock_execution_setup - mock_ctx.device = None - connected_socket = MagicMock() - - with ( - patch("artemis.data_engine.engine.read_ipc_port", return_value=49152), - patch("artemis.data_engine.engine.get_ipc_port_file", return_value=tmp_path / "none"), - patch( - "artemis.data_engine.engine.socket.create_connection", - return_value=connected_socket, - ) as create_connection, - ): - engine = DataEngine(mock_ctx) - asyncio.run(engine.shutdown()) - engine._publish("llm_stream", {"chunk": "after shutdown"}) - - assert create_connection.call_count == 1 - connected_socket.close.assert_called_once() - - -def test_ipc_connection_failure_is_backed_off(tmp_path): - """A stale desktop port must not block every emitted trace event.""" - mock_ctx = MagicMock(spec=ArtemisContext) - mock_execution_setup = MagicMock() - mock_execution_setup.traces_path = str(tmp_path) - mock_ctx.execution_setup = mock_execution_setup - mock_ctx.device = None - - with ( - patch("artemis.data_engine.engine.read_ipc_port", return_value=49152), - patch("artemis.data_engine.engine.get_ipc_port_file", return_value=tmp_path / "none"), - patch( - "artemis.data_engine.engine.socket.create_connection", - side_effect=TimeoutError("stale port"), - ) as create_connection, - ): - engine = DataEngine(mock_ctx) - for index in range(20): - engine._publish("llm_stream", {"chunk": str(index)}) - - assert create_connection.call_count == 1 - - -def test_get_or_create_image_updates_missing_data(tmp_path): - # Setup mock context - mock_ctx = MagicMock(spec=ArtemisContext) - mock_execution_setup = MagicMock() - mock_execution_setup.traces_path = str(tmp_path) - mock_ctx.execution_setup = mock_execution_setup - mock_ctx.device = None - - # Initialize DataEngine - engine = DataEngine(mock_ctx) - - image_bytes = b"fake_image_bytes" - - # 1. Save image without OCR/UI tree - image_name1 = engine.get_or_create_image(image_bytes) - - # Verify it was saved with None for OCR/UI tree - record1 = engine.storage.get_image(image_name1) - assert record1 is not None - assert record1.ocr_result is None - assert record1.ui_tree is None - - # 2. Save same image with OCR/UI tree - fake_ocr = [{"text": "hello", "box": [0, 0, 10, 10]}] - fake_ui = [{"resource-id": "button1", "text": "click me"}] - - image_name2 = engine.get_or_create_image(image_bytes, ui_tree=fake_ui, ocr_result=fake_ocr) - - assert image_name1 == image_name2 - - # Verify it now has OCR/UI tree - record2 = engine.storage.get_image(image_name2) - assert record2 is not None - assert record2.ocr_result == fake_ocr - assert record2.ui_tree == fake_ui - - -def test_create_image_duplicate_handling(tmp_path): - from artemis.data_engine.storage import StorageManager - from artemis.data_engine.models import ImageRecord - - storage = StorageManager(tmp_path / "data_engine.db", tmp_path) - image_record = ImageRecord( - image_name="test_image_hash", - timestamp=123.456, - ocr_result=None, - ui_tree=None, - extra_metadata={}, - ) - - # First insert - storage.create_image(image_record) - - # Second insert with same image_name should not raise IntegrityError due to INSERT OR IGNORE - storage.create_image(image_record) - - record = storage.get_image("test_image_hash") - assert record is not None - assert record.image_name == "test_image_hash" - - -def test_video_recording_persistence(tmp_path): - from uuid import uuid4 - from artemis.data_engine.storage import StorageManager - from artemis.data_engine.models import VideoRecordingRecord - - storage = StorageManager(tmp_path / "data_engine.db", tmp_path) - video_id = uuid4() - session_id = uuid4() - - record = VideoRecordingRecord( - video_id=video_id, - session_id=session_id, - device_id="emulator-5554", - start_time=100.0, - end_time=None, - local_video_path="/tmp/recording.mp4", - ) - - # Test insert - storage.create_video_recording(record) - - persisted = storage.get_video_recording(video_id) - assert persisted is not None - assert persisted.video_id == video_id - assert persisted.session_id == session_id - assert persisted.device_id == "emulator-5554" - assert persisted.start_time == 100.0 - assert persisted.end_time is None - assert persisted.local_video_path == "/tmp/recording.mp4" - assert persisted.status == "recording" - - # Test update - record.end_time = 150.0 - record.local_video_path = "/tmp/recording_final.mp4" - record.status = "ready" - storage.update_video_recording(record) - - updated = storage.get_video_recording(video_id) - assert updated is not None - assert updated.end_time == 150.0 - assert updated.local_video_path == "/tmp/recording_final.mp4" - assert updated.status == "ready" - - -def test_record_step_suppresses_identical_post_screenshot(tmp_path): - mock_ctx = MagicMock(spec=ArtemisContext) - mock_execution_setup = MagicMock() - mock_execution_setup.traces_path = str(tmp_path) - mock_ctx.execution_setup = mock_execution_setup - mock_ctx.device = None - - engine = DataEngine(mock_ctx) - engine.start_session("test_goal") - same_image_bytes = b"identical_screen_bytes_123" - - step_id = engine.record_step( - pre_screenshot_bytes=same_image_bytes, - post_screenshot_bytes=same_image_bytes, - summary="Test step with identical screenshot", - ) - for t in list(engine._pending_threads): - t.join() - - step_record = engine.storage.get_steps(engine.current_session_id)[0] - assert step_record is not None - assert step_record.pre_image_name is not None - # post_image_name must be None when no visual transition occurred - assert step_record.post_image_name is None - - -def test_update_step_execution_result_suppresses_identical_post_screenshot(tmp_path): - mock_ctx = MagicMock(spec=ArtemisContext) - mock_execution_setup = MagicMock() - mock_execution_setup.traces_path = str(tmp_path) - mock_ctx.execution_setup = mock_execution_setup - mock_ctx.device = None - - engine = DataEngine(mock_ctx) - engine.start_session("test_goal") - image_bytes = b"pre_screen_bytes_456" - - step_id = engine.record_step( - pre_screenshot_bytes=image_bytes, - summary="Initial step", - ) - for t in list(engine._pending_threads): - t.join() - - step_record = engine.storage.get_steps(engine.current_session_id)[0] - assert step_record.post_image_name is None - - # Update with post_image_name matching pre_image_name - engine.storage.update_step_execution_result( - step_id, - {"status": "success"}, - post_image_name=step_record.pre_image_name, - ) - - updated_step = engine.storage.get_steps(engine.current_session_id)[0] - assert updated_step.post_image_name is None - - # Update with distinct post_image_name - engine.storage.update_step_execution_result( - step_id, - {"status": "success"}, - post_image_name="distinct_new_hash_789", - ) - - updated_step_distinct = engine.storage.get_steps(engine.current_session_id)[0] - assert updated_step_distinct.post_image_name == "distinct_new_hash_789" - - -def test_resumed_session_monotonic_step_numbering(tmp_path): - mock_ctx = MagicMock(spec=ArtemisContext) - mock_execution_setup = MagicMock() - mock_execution_setup.traces_path = str(tmp_path) - mock_ctx.execution_setup = mock_execution_setup - mock_ctx.device = None - - # First runner records Step 1 and Step 2 - engine1 = DataEngine(mock_ctx) - sid = engine1.start_session("Monotonic test") - engine1.record_step(summary="Step 1 action") - engine1.record_step(summary="Step 2 action") - for t in list(engine1._pending_threads): - t.join() - - # Second runner starts or resumes the same session - engine2 = DataEngine(mock_ctx) - engine2.start_session("Resumed goal", session_id=sid) - assert engine2.current_step_number == 2 - - # Recording a new step must be Step 3, strictly monotonic - engine2.record_step(summary="Step 3 action") - for t in list(engine2._pending_threads): - t.join() - - steps = engine2.storage.get_steps(sid) - assert len(steps) == 3 - assert [s.step_number for s in steps] == [1, 2, 3] - - -def test_end_session_normalizes_legacy_success_status(tmp_path): - mock_ctx = MagicMock(spec=ArtemisContext) - mock_execution_setup = MagicMock() - mock_execution_setup.traces_path = str(tmp_path) - mock_ctx.execution_setup = mock_execution_setup - mock_ctx.device = None - - engine = DataEngine(mock_ctx) - sid = engine.start_session("Terminal status normalization test") - engine.end_session("success") - - session = engine.storage.get_session(sid) - assert session is not None - assert session.status == "completed" - assert session.end_time is not None - - -def test_update_step_summary_includes_step_number_in_sse(tmp_path): - mock_ctx = MagicMock(spec=ArtemisContext) - mock_execution_setup = MagicMock() - mock_execution_setup.traces_path = str(tmp_path) - mock_ctx.execution_setup = mock_execution_setup - mock_ctx.device = None - - published_events = [] - - def mock_subscriber(event_type, data): - published_events.append((event_type, data)) - - engine = DataEngine(mock_ctx) - engine.subscribe(mock_subscriber) - sid = engine.start_session("SSE step_number verification") - - step_id = engine.record_step(summary="Initial step 1") - for t in list(engine._pending_threads): - t.join() - - assert engine.get_step_number(step_id) == 1 - - # Call update_step_summary - engine.update_step_summary(step_id, "Updated concise summary for Step 1") - for t in list(engine._pending_threads): - t.join() - - # Find the published step_updated event - step_updated_events = [e for e in published_events if e[0] == "step_updated"] - assert len(step_updated_events) >= 1 - last_update = step_updated_events[-1][1] - assert last_update["step_id"] == str(step_id) - assert last_update["summary"] == "Updated concise summary for Step 1" - assert last_update["step_number"] == 1 - - -def _foreground_engine(tmp_path): - mock_ctx = MagicMock(spec=ArtemisContext) - mock_execution_setup = MagicMock() - mock_execution_setup.traces_path = str(tmp_path) - mock_ctx.execution_setup = mock_execution_setup - mock_ctx.device = None - engine = DataEngine(mock_ctx) - engine.start_session("foreground_app persistence") - return engine - - -def test_record_step_persists_explicit_foreground_app(tmp_path): - """The foreground_app parameter is persisted.""" - engine = _foreground_engine(tmp_path) - engine.record_step( - foreground_app="com.android.settings", - ui_tree=[{"package": "com.other.app", "bounds": "[0,0][10,10]"}], - summary="Explicit parameter wins", - ) - for t in list(engine._pending_threads): - t.join() - - step = engine.storage.get_steps(engine.current_session_id)[0] - assert step.extra_metadata["foreground_app"] == "com.android.settings" - - -def test_record_step_derives_foreground_app_from_ui_tree(tmp_path): - """Without the parameter, the dominant non-overlay package is stamped.""" - engine = _foreground_engine(tmp_path) - engine.record_step( - ui_tree=[ - {"package": "com.android.systemui", "bounds": "[0,0][10,10]"}, - {"package": "com.google.android.deskclock"}, - { - "packageName": "com.google.android.deskclock", - "children": [{"package": "com.google.android.deskclock"}], - }, - ], - summary="Derived from UI tree", - ) - for t in list(engine._pending_threads): - t.join() - - step = engine.storage.get_steps(engine.current_session_id)[0] - assert step.extra_metadata["foreground_app"] == "com.google.android.deskclock" - - -def test_record_step_without_app_data_stamps_nothing(tmp_path): - engine = _foreground_engine(tmp_path) - engine.record_step( - ui_tree=[{"package": "com.android.systemui"}], - summary="Only overlay packages", - ) - for t in list(engine._pending_threads): - t.join() - - step = engine.storage.get_steps(engine.current_session_id)[0] - assert "foreground_app" not in (step.extra_metadata or {}) - - -# --- Agent-friendly single-step / range lookups (step replay backing) ---------------- - - -def _replay_engine(tmp_path): - mock_ctx = MagicMock(spec=ArtemisContext) - mock_execution_setup = MagicMock() - mock_execution_setup.traces_path = str(tmp_path) - mock_ctx.execution_setup = mock_execution_setup - mock_ctx.device = None - engine = DataEngine(mock_ctx) - engine.start_session("replay lookup session") - return engine - - -def _join_pending(engine): - for t in list(engine._pending_threads): - t.join() - - -def test_friendly_step_range_expands_tool_traces_and_matches_full_listing(tmp_path): - engine = _replay_engine(tmp_path) - engine.record_step( - summary="Asked the explorer about the toggle.", - action_taken={"action": "click", "target": [540, 1200], "target_text": "Save"}, - last_execution_result={"status": "success"}, - ) - engine.record_trace( - type="tool", - name="ask_explorer", - payload={"args": {"question": "toggle?"}, "result": "The toggle is ON"}, - step_id=engine.last_recorded_step_id, - ) - engine.record_step(summary="Second step.", action_taken={"action": "swipe"}) - engine.record_step(summary="Third step.", action_taken={"action": "press_key", "key": "BACK"}) - _join_pending(engine) - - rows = engine.get_agent_friendly_steps_in_range(1, 2) - assert [r["step_number"] for r in rows] == [1, 2] - calls = rows[0]["tool_calls"] - assert calls and calls[0]["name"] == "ask_explorer" - assert calls[0]["args"] == {"question": "toggle?"} - assert calls[0]["result"] == "The toggle is ON" - assert any(e["type"] == "tool_call" for e in rows[0]["interleaved_events"]) - - # The single-step path renders exactly what the whole-session listing does - # (same trace expansion, same coordinate normalization). - full = engine.get_agent_friendly_steps() - assert rows[0]["action_taken"] == full[0]["action_taken"] - assert rows[0]["interleaved_events"] == full[0]["interleaved_events"] - - # Reversed bounds are tolerated; a single step defaults end to start. - assert [r["step_number"] for r in engine.get_agent_friendly_steps_in_range(3, 2)] == [2, 3] - assert [r["step_number"] for r in engine.get_agent_friendly_steps_in_range(3)] == [3] - - one = engine.get_agent_friendly_step(3) - assert one is not None and one["summary"] == "Third step." - assert engine.get_agent_friendly_step(99) is None - - -def test_friendly_step_lookups_without_session_are_empty(tmp_path): - mock_ctx = MagicMock(spec=ArtemisContext) - mock_execution_setup = MagicMock() - mock_execution_setup.traces_path = str(tmp_path) - mock_ctx.execution_setup = mock_execution_setup - mock_ctx.device = None - engine = DataEngine(mock_ctx) - assert engine.get_agent_friendly_steps_in_range(1, 5) == [] - assert engine.get_agent_friendly_step(1) is None - - -def test_tool_result_images_are_described_as_step_screenshots(tmp_path): - """An image a tool result embedded (stored as an - placeholder) is rewritten into the description of the step screenshot it - is, never dropped silently; unknown images get the generic label.""" - import hashlib - import io - - from PIL import Image - - from artemis.data_engine.engine import GENERIC_IMAGE_LABEL - - buf = io.BytesIO() - Image.new("RGB", (32, 64), "red").save(buf, format="JPEG") - pre_bytes = buf.getvalue() - pre_hash = hashlib.sha256(pre_bytes).hexdigest() - - engine = _replay_engine(tmp_path) - engine.record_step( - pre_screenshot_bytes=pre_bytes, - summary="Opened the alarms list.", - action_taken={"action": "click"}, - ocr_result=[{"text": "Alarm 07:30 needleocr", "bounds": [0, 0, 10, 10]}], - ) - engine.record_step(summary="Recalled the earlier screen.", action_taken={"action": "click"}) - engine.record_trace( - type="tool", - name="get_step_screenshot", - payload={ - "args": {"step_number": 1, "which": "pre"}, - "result": [ - {"type": "text", "text": "Screenshot of step 1 (pre-action) is attached."}, - { - "type": "image_url", - "image_url": {"url": f""}, - }, - { - "type": "image_url", - "image_url": {"url": ""}, - }, - ], - }, - step_id=engine.last_recorded_step_id, - ) - _join_pending(engine) - - step2 = engine.get_agent_friendly_step(2) - result = step2["tool_calls"][0]["result"] - assert all(block["type"] == "text" for block in result) - assert result[1]["text"] == "[screenshot: pre-action of Step 1]" - assert result[1]["image_name"] == pre_hash - assert result[2]["text"] == GENERIC_IMAGE_LABEL - # Whole-session listing describes the same way. - assert engine.get_agent_friendly_steps()[1]["tool_calls"][0]["result"] == result - - -# --- Coordinate space of agent-friendly steps ------------------------- - - -def test_flash_normalized_record_survives_agent_friendly_steps_unchanged(tmp_path): - """Flash records the model's 0–1000 target with an explicit space marker; - the agent-friendly view must not normalize it a second time.""" - from artemis.utils.coordinates import COORDINATE_SPACE_KEY, COORDINATE_SPACE_NORMALIZED - - engine = _replay_engine(tmp_path) - engine.record_step( - action_taken={ - "action": "click", - "coordinates": [320, 399], - "normalized_coordinates": [320, 399], - COORDINATE_SPACE_KEY: COORDINATE_SPACE_NORMALIZED, - "args": {"target": [320, 399]}, - }, - last_execution_result={"status": "success", "result": "click executed"}, - ) - _join_pending(engine) - - row = engine.get_agent_friendly_steps()[0] - assert row["action_taken"]["coordinates"] == [320, 399] - assert row["action_taken"][COORDINATE_SPACE_KEY] == COORDINATE_SPACE_NORMALIZED - assert engine.get_agent_friendly_step(1)["action_taken"]["coordinates"] == [320, 399] - - -def test_pro_pixel_record_is_normalized_exactly_once(tmp_path): - """Pro records physical pixels (with the frame size in extra_metadata); - the friendly view converts them once, and the result is stamped so a - downstream pass (MCP inspector, replay) leaves it alone.""" - from artemis.utils.coordinates import ( - COORDINATE_SPACE_KEY, - COORDINATE_SPACE_NORMALIZED, - COORDINATE_SPACE_PIXEL, - normalize_any_structure, - ) - - engine = _replay_engine(tmp_path) - action = { - "action": "tap", - "coordinates": [540, 1200], - "normalized_coordinates": [500, 500], - COORDINATE_SPACE_KEY: COORDINATE_SPACE_PIXEL, - "target_text": "Battery", - } - engine.record_step( - action_taken=[action], - last_execution_result={ - "status": "success", - "execution": [dict(action, attempts=["Dispatched"])], - }, - extra_metadata={"width": 1080, "height": 2400}, - ) - _join_pending(engine) - - row = engine.get_agent_friendly_steps()[0] - friendly = row["action_taken"][0] - assert friendly["coordinates"] == [500, 500] - assert friendly[COORDINATE_SPACE_KEY] == COORDINATE_SPACE_NORMALIZED - assert row["last_execution_result"]["execution"][0]["coordinates"] == [500, 500] - - again = normalize_any_structure(row["action_taken"], 1080, 2400) - assert again == row["action_taken"] diff --git a/tests/unit/data_engine/test_history_chunk_storage.py b/tests/unit/data_engine/test_history_chunk_storage.py deleted file mode 100644 index 996395472..000000000 --- a/tests/unit/data_engine/test_history_chunk_storage.py +++ /dev/null @@ -1,105 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""History-chunk storage (M3 §6.1): append-only versioned rows keyed by step range.""" - -from uuid import uuid4 - -import pytest - -from artemis.data_engine.models import HistoryChunkRecord -from artemis.data_engine.storage import StorageManager - -SESSION = "11111111-1111-1111-1111-111111111111" - - -@pytest.fixture -def storage(tmp_path): - return StorageManager(tmp_path / "test.db", tmp_path / "traces") - - -def _chunk(start: int, end: int, version: int = 1, **overrides) -> HistoryChunkRecord: - fields = { - "chunk_id": uuid4(), - "session_id": SESSION, - "start_step_id": f"sid-{start}", - "end_step_id": f"sid-{end}", - "start_step_number": start, - "end_step_number": end, - "source_step_ids": [f"sid-{n}" for n in range(start, end + 1)], - "subgoal_hash": "hash-a", - "version": version, - "status": "pending", - "band1": {}, - "band2": None, - "band3": f"- Step {start} (T+00:10): Tapped 'x' at [1, 2] -> executed", - "rendered_text": "[Chunk 1 | ...]", - } - fields.update(overrides) - return HistoryChunkRecord(**fields) - - -def test_history_chunk_roundtrip(storage): - storage.create_history_chunk(_chunk(1, 4)) - chunks = storage.get_history_chunks(SESSION) - assert len(chunks) == 1 - rec = chunks[0] - assert (rec.start_step_number, rec.end_step_number) == (1, 4) - assert rec.source_step_ids == [f"sid-{n}" for n in range(1, 5)] - assert rec.subgoal_hash == "hash-a" - assert rec.status == "pending" - assert "T+00:10" in rec.band3 - - -def test_history_chunk_versions_are_append_only_latest_wins(storage): - storage.create_history_chunk(_chunk(1, 4, version=1, status="pending")) - storage.create_history_chunk( - _chunk( - 1, - 4, - version=2, - status="ready", - band1={"doing": "d", "verified_facts": ["f1"]}, - band2=" - Steps 1–4: did things", - ) - ) - - latest = storage.get_history_chunks(SESSION) - assert len(latest) == 1 - assert latest[0].version == 2 - assert latest[0].status == "ready" - assert latest[0].band1["verified_facts"] == ["f1"] - - trail = storage.get_history_chunks(SESSION, all_versions=True) - assert [r.version for r in trail] == [1, 2] - - -def test_history_chunks_ordered_by_step_range(storage): - storage.create_history_chunk(_chunk(9, 12)) - storage.create_history_chunk(_chunk(1, 4)) - storage.create_history_chunk(_chunk(5, 8)) - latest = storage.get_history_chunks(SESSION) - assert [(r.start_step_number, r.end_step_number) for r in latest] == [ - (1, 4), - (5, 8), - (9, 12), - ] - - -def test_history_chunks_are_session_scoped(storage): - storage.create_history_chunk(_chunk(1, 4)) - other = "22222222-2222-2222-2222-222222222222" - storage.create_history_chunk(_chunk(1, 4, session_id=other)) - assert len(storage.get_history_chunks(SESSION)) == 1 - assert len(storage.get_history_chunks(other)) == 1 diff --git a/tests/unit/data_engine/test_latency_optimizations_deep.py b/tests/unit/data_engine/test_latency_optimizations_deep.py deleted file mode 100644 index 661b60548..000000000 --- a/tests/unit/data_engine/test_latency_optimizations_deep.py +++ /dev/null @@ -1,452 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -import base64 -import hashlib -import json -import logging -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch - -from langchain_core.messages import HumanMessage -from artemis.context import ArtemisContext -from artemis.data_engine.engine import DataEngine -from artemis.graph.state import State -from third_party.mobile_use.utils.logger import DataEngineHandler -import pytest - - -@pytest.fixture -def mock_artemis_ctx(tmp_path): - mock_ctx = MagicMock(spec=ArtemisContext) - mock_execution_setup = MagicMock() - mock_execution_setup.traces_path = str(tmp_path) - mock_ctx.execution_setup = mock_execution_setup - mock_ctx.device = None - return mock_ctx - - -@pytest.mark.asyncio -async def test_data_engine_handler_queue_buffering_and_background_worker( - mock_artemis_ctx, -): - """Verify DataEngineHandler.emit buffers log records via in-memory queue without spawning OS threads per log line.""" - engine = DataEngine(mock_artemis_ctx) - session_id = engine.start_session("Test Log Queue") - - handler = DataEngineHandler() - - with patch("sys.modules") as mock_modules: - mock_engine_mod = MagicMock() - mock_engine_mod._CURRENT_DATA_ENGINE = engine - mock_modules.get.return_value = mock_engine_mod - - # Emit 50 rapid log records - for i in range(50): - record = logging.LogRecord( - name=f"test.logger.{i}", - level=logging.INFO, - pathname="test.py", - lineno=10 + i, - msg=f"Async queue test log message {i}", - args=(), - exc_info=None, - ) - handler.emit(record) - - # Wait for the background worker thread of DataEngineHandler and DataEngine tasks to drain - await asyncio.sleep(0.4) - if engine._pending_tasks: - await asyncio.gather(*engine._pending_tasks) - - # Verify that traces were recorded and persisted accurately via direct SQLite query - with engine.storage._get_connection() as conn: - cursor = conn.execute( - "SELECT * FROM traces WHERE session_id = ? AND type = 'log'", - (str(session_id),), - ) - log_rows = cursor.fetchall() - - assert len(log_rows) >= 50 - messages = {json.loads(row["payload"])["message"] for row in log_rows} - for i in range(50): - assert f"Async queue test log message {i}" in messages - - -@pytest.mark.asyncio -async def test_update_step_action_and_metadata_background_offloading(mock_artemis_ctx, tmp_path): - """Verify that update_step_* methods publish SSE events instantly in memory and offload SQLite & disk I/O to background.""" - engine = DataEngine(mock_artemis_ctx) - session_id = engine.start_session("Test Step Offloading") - step_id = engine.allocate_step_id() - - # Create the initial step - engine.record_step(summary="Initial step summary") - if engine._pending_tasks: - await asyncio.gather(*engine._pending_tasks) - - # Mock _publish to verify immediate real-time SSE delivery - published_events = [] - engine._publish = lambda event_type, payload: published_events.append((event_type, payload)) - - fake_action = {"action": "tap", "coordinates": [100, 200]} - fake_post_jpg = b"fake_post_screenshot_jpg_bytes_12345" - - # Call update_step_action with post screenshot bytes - engine.update_step_action(fake_action, post_screenshot_bytes=fake_post_jpg) - - # Verify SSE was published immediately before/during task execution - assert len(published_events) == 1 - assert published_events[0][0] == "step_updated" - assert published_events[0][1]["action_taken"] == fake_action - - # Call other update_step_* methods - engine.update_step_summary(step_id, "Updated concise summary") - engine.update_step_thinking(step_id, "Raw thinking process...") - engine.update_step_native_thinking(step_id, "Native thinking process...") - engine.update_step_execution_result( - step_id, {"status": "success"}, post_image_name="hash_post_123" - ) - - # Now await all background pending tasks - if engine._pending_tasks: - await asyncio.gather(*engine._pending_tasks) - - # Verify SQLite record has been correctly updated by background tasks - steps = engine.storage.get_steps(session_id) - step_record = next((s for s in steps if s.step_id == step_id), None) - assert step_record is not None - assert step_record.action_taken == fake_action - assert step_record.summary == "Updated concise summary" - assert step_record.operator_raw_thinking == "Raw thinking process..." - assert step_record.operator_native_thinking == "Native thinking process..." - assert step_record.last_execution_result == {"status": "success"} - assert step_record.post_image_name == "hash_post_123" - - # Verify post.jpg was written to disk inside step directory - post_jpg_path = engine.current_step_dir / "post.jpg" - assert post_jpg_path.exists() - assert post_jpg_path.read_bytes() == fake_post_jpg - - -@pytest.mark.asyncio -async def test_record_step_background_image_and_step_persistence( - mock_artemis_ctx, -): - """Verify record_step immediately calculates SHA-256 hashes and offloads get_or_create_image + create_step to background.""" - engine = DataEngine(mock_artemis_ctx) - session_id = engine.start_session("Test Record Step Image Offloading") - step_id = engine.allocate_step_id() - - pre_img_bytes = b"fake_pre_screenshot_data_001" - post_img_bytes = b"fake_post_screenshot_data_002" - expected_pre_hash = hashlib.sha256(pre_img_bytes).hexdigest() - expected_post_hash = hashlib.sha256(post_img_bytes).hexdigest() - - returned_step_id = engine.record_step( - pre_screenshot_bytes=pre_img_bytes, - post_screenshot_bytes=post_img_bytes, - summary="Testing background image persistence", - ) - assert returned_step_id == step_id - - # Await background storage tasks - if engine._pending_tasks: - await asyncio.gather(*engine._pending_tasks) - - # Verify image records were created in SQLite - pre_record = engine.storage.get_image(expected_pre_hash) - post_record = engine.storage.get_image(expected_post_hash) - assert pre_record is not None - assert pre_record.image_name == expected_pre_hash - assert post_record is not None - assert post_record.image_name == expected_post_hash - - # Verify image files exist on disk - pre_path = engine.get_image_path(expected_pre_hash) - post_path = engine.get_image_path(expected_post_hash) - assert pre_path.exists() and pre_path.read_bytes() == pre_img_bytes - assert post_path.exists() and post_path.read_bytes() == post_img_bytes - - # Verify StepRecord has correct hashes and summary - steps = engine.storage.get_steps(session_id) - step_record = next((s for s in steps if s.step_id == step_id), None) - assert step_record is not None - assert step_record.pre_image_name == expected_pre_hash - assert step_record.post_image_name == expected_post_hash - assert step_record.summary == "Testing background image persistence" - - -@pytest.mark.asyncio -async def test_perception_node_async_offloading(mock_artemis_ctx, tmp_path): - """Verify perception_node offloads injected_instruction reading/unlinking and get_or_create_image to background.""" - from artemis.graph.perception import perception_node - - engine = DataEngine(mock_artemis_ctx) - engine.start_session("Test Perception Node Offloading") - mock_artemis_ctx.data_engine = engine - - # Create an injected_instruction.json file - instruction_file = Path(engine.base_dir) / "injected_instruction.json" - instruction_file.write_text(json.dumps({"instruction": "Avoid clicking Ads"}), encoding="utf-8") - - # Prepare mock controller - fake_screenshot_bytes = b"fake_screenshot_for_perception_node_123" - fake_screenshot_b64 = base64.b64encode(fake_screenshot_bytes).decode("utf-8") - expected_hash = hashlib.sha256(fake_screenshot_bytes).hexdigest() - - mock_screen_data = MagicMock() - mock_screen_data.base64 = fake_screenshot_b64 - mock_screen_data.elements = [{"resource-id": "app_bar", "bounds": "[0,0][1080,200]"}] - mock_screen_data.width = 1080 - mock_screen_data.height = 2400 - - with ( - patch("artemis.graph.perception.UnifiedMobileController") as mock_controller_cls, - patch("artemis.graph.perception.perform_ocr", new_callable=AsyncMock) as mock_ocr, - patch("artemis.graph.perception._detect_status_bar_height", return_value=0), - patch("artemis.graph.perception._should_skip_settling", return_value=True), - ): - mock_controller = mock_controller_cls.return_value - mock_controller.get_screen_data = AsyncMock(return_value=mock_screen_data) - mock_ocr.return_value = [{"text": "Home", "box": [100, 100, 200, 200]}] - - state = State( - initial_goal="test goal", - latest_ui_hierarchy=[], - latest_screenshot=None, - structured_decisions=None, - ) - - update = await perception_node(state, mock_artemis_ctx) - - # Verify perception_node returned expected update immediately - assert update["injected_instruction"] == "Avoid clicking Ads" - assert update["latest_screenshot"] == str(engine.get_image_path(expected_hash)) - assert update["operator_raw_data"]["width"] == 1080 - - # Verify injected_instruction.json was unlinked - assert not instruction_file.exists() - - # Await background tasks triggered by perception_node - await asyncio.sleep(0.1) - if engine._pending_tasks: - await asyncio.gather(*engine._pending_tasks) - - # Verify image record and file were created in background by perception_node - img_record = engine.storage.get_image(expected_hash) - assert img_record is not None - assert img_record.image_name == expected_hash - assert engine.get_image_path(expected_hash).exists() - - -@pytest.mark.asyncio -async def test_ocr_api_persistent_http_client_singleton_and_tls_reuse(): - """Verify get_http_client returns a persistent singleton httpx.AsyncClient that reuses TLS connections across OCR calls.""" - import httpx - from artemis.utils import ocr_api - - # Reset singleton to test fresh creation & persistence - ocr_api._HTTP_CLIENT = None - client1 = ocr_api.get_http_client() - client2 = ocr_api.get_http_client() - - assert isinstance(client1, httpx.AsyncClient) - assert client1 is client2 - - # Verify perform_ocr uses this exact persistent client - fake_ocr_resp = MagicMock() - fake_ocr_resp.status_code = 200 - fake_ocr_resp.json.return_value = { - "responses": [ - { - "textAnnotations": [ - { - "description": "Combined text block", - "boundingPoly": {"vertices": []}, - }, - { - "description": "Hello World", - "boundingPoly": {"vertices": [{"x": 10, "y": 20}, {"x": 100, "y": 200}]}, - }, - ] - } - ] - } - - with ( - patch.object(client1, "post", new_callable=AsyncMock) as mock_post, - patch.dict("os.environ", {"OCR_API_KEY": "mock_api_key"}), - ): - mock_post.return_value = fake_ocr_resp - result = await ocr_api.perform_ocr(screenshot_b64="ZHVtbXlfYjY0") - - mock_post.assert_called_once() - assert len(result) == 1 - assert result[0]["text"] == "Hello World" - - -@pytest.mark.asyncio -async def test_ui_filter_and_ui_automator_client_pre_parsed_bounds_o1_lookup(): - """Verify XML parsing pre-populates parsed_bounds and _parse_bounds hits this cache in O(1) without regex matches.""" - import re - from third_party.mobile_use.clients.ui_automator_client import parse_hierarchy_xml_to_elements - from artemis.utils.ui_filter import _parse_bounds - - # 1. Test ingestion pre-parsing in parse_hierarchy_xml_to_elements - xml_data = '' - elements = parse_hierarchy_xml_to_elements(xml_data) - assert len(elements) == 1 - assert elements[0]["parsed_bounds"] == { - "left": 15, - "top": 25, - "right": 350, - "bottom": 120, - } - - # 2. Test _parse_bounds O(1) hit vs fallback - with patch.object(re, "match", wraps=re.match) as spy_match: - # Hit pre-parsed bounds -> re.match MUST NOT be called - hit_result = _parse_bounds(elements[0]) - assert hit_result == { - "left": 15, - "top": 25, - "right": 350, - "bottom": 120, - } - spy_match.assert_not_called() - - # Fallback raw string -> re.match is called exactly once - fallback_result = _parse_bounds("[50,60][150,160]") - assert fallback_result == { - "left": 50, - "top": 60, - "right": 150, - "bottom": 160, - } - assert spy_match.call_count == 1 - - -@pytest.mark.asyncio -async def test_validator_pre_execution_loop_reverted_to_exact_safety_contract( - mock_artemis_ctx, tmp_path -): - """Verify Validator pre-execution check runs VLM on XML failure, but retains XML error if VLM fails (reverted contract).""" - from artemis.agents.validator.validator import ValidatorNode, ValidationErrorCategory - - # Set mock context properties required by tracing decorators - mock_artemis_ctx.data_engine = MagicMock() - - node = ValidatorNode(mock_artemis_ctx) - # The Validator now talks to the in-process unified action session. - session = MagicMock() - session.started = True - session.screenshot_b64 = AsyncMock(return_value="ZHVtbXlfZGF0YQ==") - session.ui_hierarchy = AsyncMock(return_value=[]) - session.call = AsyncMock() - - decisions = json.dumps( - [ - { - "action": "tap", - "coordinates": [200, 250], - "target_text": "Login", - "target_bounds": [100, 200, 300, 300], - "target_resource_id": "btn_login", - } - ] - ) - state = State( - initial_goal="test", - latest_ui_hierarchy=[], - latest_screenshot="dummy.png", - structured_decisions=decisions, - ) - - with ( - patch( - "artemis.agents.validator.validator.get_action_session", - AsyncMock(return_value=session), - ), - patch.object( - ValidatorNode, "_get_initial_screenshot", new_callable=AsyncMock - ) as mock_get_screen, - patch.object( - ValidatorNode, "_validate_action_precondition", new_callable=AsyncMock - ) as mock_xml, - patch.object( - ValidatorNode, - "_validate_action_precondition_pixel", - new_callable=AsyncMock, - ) as mock_pixel, - ): - mock_get_screen.return_value = ( - base64.b64encode(b"dummy_bytes_here").decode("utf-8"), - "dummy_stem", - ) - # 1. XML check returns failure (TARGET_OCCUPIED by 'Sign Up' button) - mock_xml.return_value = ( - False, - ValidationErrorCategory.TARGET_OCCUPIED, - "Occupied by Sign Up button", - ) - # 2. VLM check returns failure (PIXEL_TARGET_DISAPPEARED) - mock_pixel.return_value = ( - False, - ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED, - "VLM target missing", - ) - - result = await node(state) - - # Verify both XML check and VLM check were called (reverted double-check contract) - mock_xml.assert_called_once() - mock_pixel.assert_called_once() - - # The incident carries the EXACT XML error (TARGET_OCCUPIED), NOT the VLM error - incident = result["last_execution_result"]["incident"] - assert incident["kind"] == "safety_net" - assert incident["category"] == ValidationErrorCategory.TARGET_OCCUPIED.value - assert "Occupied by Sign Up button" in incident["reason"] - assert result["open_incident"] == incident - - -@pytest.mark.asyncio -async def test_record_step_pre_allocated_id_reset(mock_artemis_ctx): - """Verify that after record_step is called, the pre-allocated current_step_id is reset to None so subsequent steps generate distinct IDs.""" - from artemis.data_engine.engine import DataEngine - - engine = DataEngine(mock_artemis_ctx) - engine.start_session("Pre-allocated ID Reset Test") - - # 1. Pre-allocate ID for step 1 - step_id_1 = engine.allocate_step_id() - assert engine.current_step_id == step_id_1 - - # 2. Record step 1 (should use step_id_1 and reset current_step_id to None) - returned_id_1 = engine.record_step(summary="Step 1") - assert returned_id_1 == step_id_1 - assert engine.current_step_id is None - - # 3. Pre-allocate ID for step 2 (should get a distinct new ID) - step_id_2 = engine.allocate_step_id() - assert step_id_2 != step_id_1 - assert engine.current_step_id == step_id_2 - - # 4. Record step 2 (should use step_id_2 and reset current_step_id to None) - returned_id_2 = engine.record_step(summary="Step 2") - assert returned_id_2 == step_id_2 - assert returned_id_2 != returned_id_1 - assert engine.current_step_id is None diff --git a/tests/unit/data_engine/test_offline_reader.py b/tests/unit/data_engine/test_offline_reader.py deleted file mode 100644 index aeb51373d..000000000 --- a/tests/unit/data_engine/test_offline_reader.py +++ /dev/null @@ -1,139 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""OfflineHistoryReader: the MCP inspector's read-only twin of the live engine. - -The offline reader must produce exactly the records the live ``DataEngine`` -produces (same ``friendly_step``), open the database read-only, and satisfy -the ``HistoryReader`` protocol the history tools are written against. -""" - -import io -import sqlite3 -from unittest.mock import MagicMock - -import pytest -from PIL import Image - -from artemis.context import ArtemisContext -from artemis.data_engine.engine import DataEngine -from artemis.data_engine.history_reader import HistoryReader, OfflineHistoryReader - - -def _jpeg(color: str) -> bytes: - buf = io.BytesIO() - Image.new("RGB", (64, 128), color).save(buf, format="JPEG") - return buf.getvalue() - - -def _engine(tmp_path): - ctx = MagicMock(spec=ArtemisContext) - ctx.execution_setup = MagicMock() - ctx.execution_setup.traces_path = str(tmp_path) - ctx.device = None - engine = DataEngine(ctx) - engine.start_session("offline reader test") - return engine - - -def _flush(engine): - for t in list(engine._pending_threads): - t.join() - - -@pytest.fixture -def recorded(tmp_path): - engine = _engine(tmp_path) - engine.record_step( - pre_screenshot_bytes=_jpeg("red"), - post_screenshot_bytes=_jpeg("blue"), - summary="Tapped Save after asking the explorer.", - action_taken={"action": "click", "target": [540, 1200], "target_text": "Save"}, - last_execution_result={"status": "success"}, - operator_raw_thinking="Confirm the toggle first.", - extra_metadata={"width": 1080, "height": 2400}, - ) - engine.record_trace( - type="tool", - name="ask_explorer", - payload={"args": {"question": "toggle?"}, "result": "The toggle is ON."}, - step_id=engine.last_recorded_step_id, - ) - engine.record_step( - summary="Went back.", - action_taken={"action": "press_key", "key": "back"}, - last_execution_result={"status": "success"}, - ) - engine.record_history_chunk( - start_step_number=1, - end_step_number=2, - version=1, - status="ready", - band2=" - Steps 1–2: saved the toggle", - band3="- Step 1 (T+00:00): click -> executed", - rendered_text="[Chunk 1 | Steps 1–2]", - ) - _flush(engine) - return engine - - -def _reader(engine) -> OfflineHistoryReader: - return OfflineHistoryReader(engine.db_path, engine.global_base_dir, engine.current_session_id) - - -def test_offline_reader_matches_live_friendly_steps(recorded): - reader = _reader(recorded) - assert isinstance(reader, HistoryReader) - assert reader.session_start_time == recorded.session_start_time - assert reader.base_dir == recorded.base_dir - - live = recorded.get_agent_friendly_steps() - offline = reader.get_agent_friendly_steps() - assert offline == live - assert offline[0]["tool_calls"][0]["name"] == "ask_explorer" - # Coordinates normalized exactly like the live engine. - assert offline[0]["action_taken"]["target"] == live[0]["action_taken"]["target"] - assert offline[0]["relative_time"] == live[0]["relative_time"] - - assert reader.get_agent_friendly_steps_in_range(2, 1) == live - assert reader.get_agent_friendly_step(2) == live[1] - assert reader.get_agent_friendly_step(9) is None - - -def test_offline_reader_step_records_images_and_chunks(recorded): - reader = _reader(recorded) - record = reader.get_step_record(1) - assert record is not None and record.step_number == 1 - assert reader.get_step_record(7) is None - - pre = reader.get_step_image_path(1, "pre") - post = reader.get_step_image_path(1, "post") - assert pre is not None and pre.exists() - assert post is not None and post.exists() - assert pre == recorded.get_image_path(record.pre_image_name) - assert reader.get_step_image_path(2, "pre") is None - - chunks = reader.get_history_chunks() - assert len(chunks) == 1 - assert chunks[0].start_step_number == 1 - - -def test_offline_reader_is_read_only(recorded): - reader = _reader(recorded) - with reader.storage._get_connection() as conn: - with pytest.raises(sqlite3.OperationalError): - conn.execute("DELETE FROM steps") - # No directories or tables are created for a missing database. - with pytest.raises(FileNotFoundError): - OfflineHistoryReader(recorded.global_base_dir / "nope.db", recorded.global_base_dir, "x") diff --git a/tests/unit/data_engine/test_step_summary_versioning.py b/tests/unit/data_engine/test_step_summary_versioning.py deleted file mode 100644 index a20c60bcf..000000000 --- a/tests/unit/data_engine/test_step_summary_versioning.py +++ /dev/null @@ -1,171 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Tests for the versioned, status-carrying step summary write (M0 §6.1).""" - -from unittest.mock import MagicMock - -from artemis.context import ArtemisContext -from artemis.data_engine.engine import DataEngine - - -def _make_engine(tmp_path): - mock_ctx = MagicMock(spec=ArtemisContext) - mock_execution_setup = MagicMock() - mock_execution_setup.traces_path = str(tmp_path) - mock_ctx.execution_setup = mock_execution_setup - mock_ctx.device = None - engine = DataEngine(mock_ctx) - engine.start_session("summary versioning test") - return engine - - -def _flush(engine): - for t in list(engine._pending_threads): - t.join() - - -def _get_step(engine, step_id): - steps = engine.storage.get_steps(engine.current_session_id) - for step in steps: - if str(step.step_id) == str(step_id): - return step - return None - - -def test_ready_write_persists_summary_and_metadata(tmp_path): - engine = _make_engine(tmp_path) - step_id = engine.record_step(summary="initial") - _flush(engine) - - applied = engine.storage.update_step_summary( - step_id, - "Tapped the login button.", - source="visual_transition", - model="gemini-2.5-flash-lite", - status="ready", - ) - assert applied is True - - step = _get_step(engine, step_id) - assert step.summary == "Tapped the login button." - assert step.extra_metadata["summary_status"] == "ready" - assert step.extra_metadata["summary_source"] == "visual_transition" - assert step.extra_metadata["summary_version"] == 1 - assert step.extra_metadata["summary_model"] == "gemini-2.5-flash-lite" - - -def test_auto_version_increments_per_write(tmp_path): - engine = _make_engine(tmp_path) - step_id = engine.record_step(summary="initial") - _flush(engine) - - engine.storage.update_step_summary(step_id, "v1", status="ready") - engine.storage.update_step_summary(step_id, "v2", status="ready") - - step = _get_step(engine, step_id) - assert step.summary == "v2" - assert step.extra_metadata["summary_version"] == 2 - - -def test_stale_explicit_version_is_dropped(tmp_path): - engine = _make_engine(tmp_path) - step_id = engine.record_step(summary="initial") - _flush(engine) - - assert engine.storage.update_step_summary(step_id, "newer", version=5, status="ready") - applied = engine.storage.update_step_summary(step_id, "older", version=3, status="ready") - assert applied is False - - step = _get_step(engine, step_id) - assert step.summary == "newer" - assert step.extra_metadata["summary_version"] == 5 - - -def test_pending_does_not_downgrade_ready(tmp_path): - engine = _make_engine(tmp_path) - step_id = engine.record_step(summary="initial") - _flush(engine) - - engine.storage.update_step_summary(step_id, "done", status="ready") - applied = engine.storage.update_step_summary(step_id, None, status="pending") - assert applied is False - - step = _get_step(engine, step_id) - assert step.summary == "done" - assert step.extra_metadata["summary_status"] == "ready" - - -def test_failed_marks_status_without_touching_summary(tmp_path): - engine = _make_engine(tmp_path) - step_id = engine.record_step(summary="original text") - _flush(engine) - - engine.storage.update_step_summary(step_id, None, status="pending", source="visual_transition") - applied = engine.storage.update_step_summary( - step_id, None, status="failed", source="visual_transition" - ) - assert applied is True - - step = _get_step(engine, step_id) - assert step.summary == "original text" - assert step.extra_metadata["summary_status"] == "failed" - - # A failed write must never clobber an already-ready summary. - engine.storage.update_step_summary(step_id, "recovered", status="ready") - applied = engine.storage.update_step_summary(step_id, None, status="failed") - assert applied is False - step = _get_step(engine, step_id) - assert step.extra_metadata["summary_status"] == "ready" - - -def test_missing_step_returns_false(tmp_path): - from uuid import uuid4 - - engine = _make_engine(tmp_path) - assert engine.storage.update_step_summary(uuid4(), "text", status="ready") is False - - -def test_engine_level_write_carries_metadata_and_publishes_ready_only(tmp_path): - engine = _make_engine(tmp_path) - published = [] - engine.subscribe(lambda event_type, data: published.append((event_type, data))) - - step_id = engine.record_step(summary="initial") - _flush(engine) - published.clear() - - # Non-ready statuses persist metadata but publish no SSE. - engine.update_step_summary(step_id, None, status="pending", source="visual_transition") - _flush(engine) - assert not [e for e in published if e[0] == "step_updated"] - step = _get_step(engine, step_id) - assert step.extra_metadata["summary_status"] == "pending" - - engine.update_step_summary( - step_id, - "Ready text.", - status="ready", - source="visual_transition", - model="test-model", - ) - _flush(engine) - ready_events = [e for e in published if e[0] == "step_updated"] - assert len(ready_events) == 1 - assert ready_events[0][1]["summary"] == "Ready text." - - step = _get_step(engine, step_id) - assert step.summary == "Ready text." - assert step.extra_metadata["summary_status"] == "ready" - assert step.extra_metadata["summary_model"] == "test-model" diff --git a/tests/unit/data_engine/test_trace_detached_lens_calls.py b/tests/unit/data_engine/test_trace_detached_lens_calls.py deleted file mode 100644 index 53ba8a45e..000000000 --- a/tests/unit/data_engine/test_trace_detached_lens_calls.py +++ /dev/null @@ -1,79 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""A detached lens call (segment capsule, visual summary) must never be -recorded as the current step's reasoning: the callback handler attaches -``thinking``/``raw_thinking`` traces by the DataEngine's global -``current_step_id``, which is independent of the trace context, so the -handler itself has to recognise the detached lens context.""" - -import uuid -from unittest.mock import Mock - -from langchain_core.messages import AIMessage -from langchain_core.outputs import ChatGeneration, LLMResult - -from artemis.data_engine.context_vars import CURRENT_NODE_NAME, CURRENT_TRACE_ID -from artemis.data_engine.trace import DataEngineCallbackHandler, detached_trace - -CAPSULE = '{"doing": "scrolling", "did": "swiped", "entry_state": "home"}' - - -def _handler(): - ctx = Mock() - ctx.data_engine = Mock() - ctx.data_engine.current_step_id = uuid.uuid4() - ctx.data_engine.record_trace = Mock() - return DataEngineCallbackHandler(ctx), ctx - - -def _result(text: str) -> LLMResult: - return LLMResult(generations=[[ChatGeneration(message=AIMessage(content=text))]]) - - -def _recorded(ctx) -> dict[str, dict]: - return {c.kwargs["type"]: c.kwargs for c in ctx.data_engine.record_trace.call_args_list} - - -def test_agent_call_reply_is_recorded_as_raw_thinking_under_the_step(): - handler, ctx = _handler() - span = uuid.uuid4() - token = CURRENT_TRACE_ID.set(span) - token_name = CURRENT_NODE_NAME.set("operator") - try: - handler.on_llm_end(_result("I see the settings list."), run_id=uuid.uuid4()) - finally: - CURRENT_TRACE_ID.reset(token) - CURRENT_NODE_NAME.reset(token_name) - recorded = _recorded(ctx) - assert recorded["llm_call"]["step_id"] == ctx.data_engine.current_step_id - assert recorded["raw_thinking"]["step_id"] == ctx.data_engine.current_step_id - assert recorded["raw_thinking"]["payload"]["thought"] == "I see the settings list." - - -def test_detached_lens_reply_is_neither_thinking_nor_attached_to_the_step(): - handler, ctx = _handler() - span = uuid.uuid4() - token = CURRENT_TRACE_ID.set(span) - token_name = CURRENT_NODE_NAME.set("operator") - try: - with detached_trace("lens:step_capsule"): - handler.on_llm_end(_result(CAPSULE), run_id=uuid.uuid4()) - finally: - CURRENT_TRACE_ID.reset(token) - CURRENT_NODE_NAME.reset(token_name) - recorded = _recorded(ctx) - assert "raw_thinking" not in recorded - assert "thinking" not in recorded - assert recorded["llm_call"]["step_id"] is None diff --git a/tests/unit/data_engine/test_trace_enhancements.py b/tests/unit/data_engine/test_trace_enhancements.py deleted file mode 100644 index 1d579ded4..000000000 --- a/tests/unit/data_engine/test_trace_enhancements.py +++ /dev/null @@ -1,189 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json -from unittest.mock import MagicMock - -from artemis.context import ArtemisContext -from artemis.data_engine.trace import ( - TraceSpan, - smart_serialize, -) -import pytest - - -def test_smart_serialize(): - # Test dict - d = {"a": 1, "b": "string"} - assert smart_serialize(d) == json.dumps(d, ensure_ascii=False) - - # Test list - items_list = [1, 2, "three"] - assert smart_serialize(items_list) == json.dumps(items_list, ensure_ascii=False) - - # Test bytes - b = b"hello world" - import hashlib - - expected_sha = hashlib.sha256(b).hexdigest()[:8] - assert smart_serialize(b) == f"" - - # Test other types - assert smart_serialize(123) == "123" - assert smart_serialize("just a string") == "just a string" - - -@pytest.mark.asyncio -async def test_trace_span(): - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = MagicMock() - - with TraceSpan(name="test_span", ctx=mock_ctx) as span: - span.result = "span_result" - # Simulate work - - # TraceSpan writes twice: start (running) and end (success) - assert mock_ctx.data_engine.record_trace.call_count == 2 - - # Verify the final success call (which is the last call) - args, kwargs = mock_ctx.data_engine.record_trace.call_args - assert kwargs["type"] == "span" - assert kwargs["name"] == "test_span" - assert kwargs["status"] == "success" - assert kwargs["payload"]["result"] == "span_result" - - -@pytest.mark.asyncio -async def test_trace_span_failure(): - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = MagicMock() - - try: - with TraceSpan(name="failed_span", ctx=mock_ctx): - raise ValueError("Span error") - except ValueError: - pass - - # TraceSpan writes twice: start (running) and end (failed) - assert mock_ctx.data_engine.record_trace.call_count == 2 - - # Verify the final failed call (which is the last call) - args, kwargs = mock_ctx.data_engine.record_trace.call_args - assert kwargs["type"] == "span" - assert kwargs["name"] == "failed_span" - assert kwargs["status"] == "failed" - assert "error" in kwargs["payload"] - assert "Span error" in kwargs["payload"]["error"] - - -def test_data_engine_callback_handler(): - from uuid import uuid4 - from langchain_core.messages import HumanMessage, AIMessage - from artemis.data_engine.trace import DataEngineCallbackHandler - - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = MagicMock() - - mock_step_id = uuid4() - mock_ctx.data_engine.current_step_id = mock_step_id - - handler = DataEngineCallbackHandler(mock_ctx) - run_id = uuid4() - - # 1. Simulate on_chat_model_start - serialized = {"name": "test_model"} - messages = [[HumanMessage(content="Hello LLM")]] - handler.on_chat_model_start(serialized, messages, run_id=run_id) - - assert mock_ctx.data_engine.record_trace.call_count == 1 - call1_args, call1_kwargs = mock_ctx.data_engine.record_trace.call_args_list[0] - assert call1_kwargs["type"] == "llm_call" - assert call1_kwargs["name"] == "test_model" - assert call1_kwargs["status"] == "running" - assert call1_kwargs["step_id"] == mock_step_id - assert call1_kwargs["payload"]["messages"][0]["content"] == "Hello LLM" - - # 2. Simulate on_llm_end (successful complete response) - class MockGeneration: - def __init__(self, message): - self.message = message - - class MockLLMResult: - def __init__(self, generations): - self.generations = generations - - response_message = AIMessage(content="Hello User") - mock_response = MockLLMResult(generations=[[MockGeneration(response_message)]]) - - handler.on_llm_end(mock_response, run_id=run_id) - - assert mock_ctx.data_engine.record_trace.call_count == 3 - call2_args, call2_kwargs = mock_ctx.data_engine.record_trace.call_args_list[1] - assert call2_kwargs["type"] == "llm_call" - assert call2_kwargs["status"] == "success" - assert call2_kwargs["step_id"] == mock_step_id - assert call2_kwargs["payload"]["messages"][0]["content"] == "Hello LLM" - assert call2_kwargs["payload"]["response"][0]["content"] == "Hello User" - assert isinstance(call2_kwargs.get("duration"), float) - assert call2_kwargs["duration"] >= 0.0 - - call3_args, call3_kwargs = mock_ctx.data_engine.record_trace.call_args_list[2] - assert call3_kwargs["type"] == "raw_thinking" - assert call3_kwargs["parent_trace_id"] == run_id - assert call3_kwargs["payload"]["thought"] == "Hello User" - - -def test_callback_handler_chain_parenting(): - from uuid import uuid4 - from langchain_core.messages import HumanMessage - from artemis.data_engine.trace import DataEngineCallbackHandler, CURRENT_TRACE_ID - - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = MagicMock() - - handler = DataEngineCallbackHandler(mock_ctx) - - agent_run_id = uuid4() - chain1_run_id = uuid4() - chain2_run_id = uuid4() - llm_run_id = uuid4() - - token = CURRENT_TRACE_ID.set(agent_run_id) - try: - # Simulate chain start (top level, parent is agent_run_id) - handler.on_chain_start({}, {}, run_id=chain1_run_id, parent_run_id=None) - # Simulate nested chain start (parent is chain1_run_id) - handler.on_chain_start({}, {}, run_id=chain2_run_id, parent_run_id=chain1_run_id) - - # Simulate LLM start inside nested chain (parent is chain2_run_id) - messages = [[HumanMessage(content="Hello Nested LLM")]] - handler.on_chat_model_start( - {"name": "test_nested_model"}, - messages, - run_id=llm_run_id, - parent_run_id=chain2_run_id, - ) - - # Verify parent trace ID resolves to agent_run_id (since chains are not recorded) - assert mock_ctx.data_engine.record_trace.call_count == 1 - args, kwargs = mock_ctx.data_engine.record_trace.call_args - assert kwargs["parent_trace_id"] == agent_run_id - - # Cleanup - handler.on_chain_end({}, run_id=chain2_run_id) - handler.on_chain_end({}, run_id=chain1_run_id) - - assert len(handler.chain_parents) == 0 - finally: - CURRENT_TRACE_ID.reset(token) diff --git a/tests/unit/graph/test_checker_events.py b/tests/unit/graph/test_checker_events.py deleted file mode 100644 index dd00b121d..000000000 --- a/tests/unit/graph/test_checker_events.py +++ /dev/null @@ -1,348 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Checker process visibility: every visible step of the Checker (checkpoint -spawn/harvest, final review, run outcome) is published as a ``checker_event`` -on the DataEngine bus, and the persisted ledger carries enough context -(subgoal text) for the admin console to rebuild the same view offline.""" - -import asyncio -import json -import time -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from artemis.agents.checker.checker import CheckReport, CheckVerdict -from artemis.context import ArtemisContext, ExecutionSetup -from artemis.graph.checkpoints import ( - CHECKER_EVENT, - CheckpointRun, - PendingCheckpoint, - harvest_run, - publish_checker_event, - read_ledger, - spawn_pending_checkpoints, -) -from artemis.graph.graph import exit_settlement_node -from artemis.graph.state import State -from artemis.utils.plan_grammar import CheckItem, subgoal_hash - -GOAL_TEXT = "Create the alarm" -GOAL_KEY = subgoal_hash(GOAL_TEXT) -PLAN = ( - f"- [x] {GOAL_TEXT}\n - verify: the alarm list shows 7:30 AM\n - assert: a toast appeared\n" -) - - -def _make_ctx(tmp_path, **setup_kwargs): - setup_kwargs.setdefault("disable_checker", False) - setup_kwargs.setdefault("disable_midway_checks", False) - ctx = MagicMock(spec=ArtemisContext) - ctx.execution_setup = ExecutionSetup(**setup_kwargs) - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = tmp_path - ctx.pending_checkpoints = [] - ctx.checkpoint_tasks = {} - ctx.checkpoint_attempt_seq = {} - ctx.checkpoint_repairs = {} - ctx.assert_halt = False - ctx.final_check_attempts = 0 - return ctx - - -def _make_state(**overrides): - state = MagicMock(spec=State) - state.initial_goal = "the user goal" - state.user_stop_requested = False - for k, v in overrides.items(): - setattr(state, k, v) - return state - - -def _write_plan(tmp_path, content=PLAN): - notes = tmp_path / "notes" - notes.mkdir(parents=True, exist_ok=True) - (notes / "task_plan.md").write_text(content, encoding="utf-8") - - -def _pending(): - return PendingCheckpoint( - checkpoint_id=GOAL_KEY, - subgoal_text=GOAL_TEXT, - check_items=( - CheckItem( - kind="verify", - text="the alarm list shows 7:30 AM", - when="on_complete", - parent_key=GOAL_KEY, - ), - CheckItem( - kind="assert", text="a toast appeared", when="on_complete", parent_key=GOAL_KEY - ), - ), - plan_text=PLAN, - trigger_ts=time.time(), - ) - - -def _events(ctx, event: str | None = None) -> list[dict]: - out = [] - for call in ctx.data_engine._publish.call_args_list: - if call.args[0] != CHECKER_EVENT: - continue - payload = call.args[1] - if event is None or payload.get("event") == event: - out.append(payload) - return out - - -def test_publish_is_best_effort_without_engine(): - ctx = MagicMock(spec=ArtemisContext) - ctx.data_engine = None - publish_checker_event(ctx, {"event": "attempt_started"}) # must not raise - - ctx.data_engine = MagicMock() - ctx.data_engine._publish.side_effect = RuntimeError("bus down") - publish_checker_event(ctx, {"event": "attempt_started"}) # swallowed - - -@pytest.mark.asyncio -async def test_spawn_passes_attempt_id_to_checker(tmp_path): - """The announcement lives inside the checker (it needs the trace id), so - spawn must hand the attempt id over.""" - _write_plan(tmp_path) - ctx = _make_ctx(tmp_path) - ctx.pending_checkpoints.append(_pending()) - never = asyncio.Event() - seen: dict = {} - - async def hang(*a, **k): - seen.update(k) - await never.wait() - - with patch("artemis.agents.checker.checker.run_checkpoint_check", side_effect=hang): - await spawn_pending_checkpoints(ctx, _make_state(), anchor_step_id="step-7") - await asyncio.sleep(0) - - assert seen.get("attempt_id") == f"{GOAL_KEY}#1" - assert _events(ctx, "attempt_started") == [] - - for run in ctx.checkpoint_tasks.values(): - run.task.cancel() - try: - await run.task - except (asyncio.CancelledError, Exception): - pass - - -@pytest.mark.asyncio -async def test_checker_entry_announces_attempt_with_trace_id(tmp_path): - """attempt_started is published from inside the traced checker scope and - carries the Checker's own trace id (UI routing key for its stream/tools), - the anchor and the declared items.""" - from artemis.agents.checker.checker import run_checkpoint_check, run_final_check - from artemis.graph.checkpoints import EvidenceAnchor - - _write_plan(tmp_path) - ctx = _make_ctx(tmp_path) - ctx.data_engine.get_step_number.return_value = 7 - empty_report = CheckReport(verdicts=[]) - anchor = EvidenceAnchor(anchor_step_id="step-7", trigger_ts=time.time(), plan_text=PLAN) - - with ( - patch( - "artemis.agents.checker.checker._run_check_loop", AsyncMock(return_value=empty_report) - ), - patch("artemis.agents.checker.checker.build_checker_tools", return_value=[]), - patch("artemis.agents.checker.checker._format_history", return_value=""), - patch( - "artemis.agents.checker.checker._capture_final_screen", - AsyncMock(return_value=(None, "")), - ), - ): - await run_checkpoint_check( - ctx, - check_items=_pending().check_items, - anchor=anchor, - goal="g", - subgoal_text=GOAL_TEXT, - attempt_id=f"{GOAL_KEY}#1", - ) - await run_final_check( - ctx, - goal="g", - plan_text=PLAN, - ledger=[], - check_items=_pending().check_items, - attempt_id="final#1", - ) - - started = _events(ctx, "attempt_started") - assert [e["phase"] for e in started] == ["checkpoint", "final"] - cp, fin = started - assert cp["attempt_id"] == f"{GOAL_KEY}#1" - assert cp["checkpoint_id"] == GOAL_KEY - assert cp["subgoal_text"] == GOAL_TEXT - assert cp["anchor_step_id"] == "step-7" - assert [i["kind"] for i in cp["items"]] == ["verify", "assert"] - assert cp["trace_id"] # set by the @trace decorator scope - assert "timestamp" in cp and "ts" in cp - assert fin["attempt_id"] == "final#1" - assert fin["checkpoint_id"] == "final" - assert fin["trace_id"] - - -@pytest.mark.asyncio -async def test_harvest_publishes_verdicts_findings_and_ledger_carries_subgoal(tmp_path): - _write_plan(tmp_path) - ctx = _make_ctx(tmp_path) - report = CheckReport( - verdicts=[ - CheckVerdict( - item_text="the alarm list shows 7:30 AM", - kind="verify", - status="failed", - evidence="list shows 7:00 AM", - suggestion="open the alarm and set 7:30", - ), - CheckVerdict( - item_text="a toast appeared", kind="assert", status="passed", evidence="toast seen" - ), - ] - ) - task = asyncio.get_event_loop().create_future() - task.set_result(report) - run = CheckpointRun(attempt_id=f"{GOAL_KEY}#1", task=task, checkpoint=_pending()) - - findings = harvest_run(ctx, _make_state(), run, allow_side_effects=True, anchor_step_id="s1") - - finished = _events(ctx, "attempt_finished") - assert len(finished) == 1 - ev = finished[0] - assert ev["status"] == "done" - assert ev["phase"] == "checkpoint" - assert ev["subgoal_text"] == GOAL_TEXT - assert [v["status"] for v in ev["verdicts"]] == ["failed", "passed"] - assert ev["verdicts"][0]["suggestion"] == "open the alarm and set 7:30" - assert ev["findings"] == findings and findings # the same strings the Operator sees - assert ev["reverted"] is True - assert ev["applicable"] is True - assert ev["repairs_used"] == 1 - - # The ledger (offline source for the UI backfill) names the subgoal too. - for rec in read_ledger(tmp_path): - assert rec["subgoal_text"] == GOAL_TEXT - - -@pytest.mark.asyncio -async def test_harvest_superseded_and_error_publish_terminal_status(tmp_path): - _write_plan(tmp_path) - ctx = _make_ctx(tmp_path) - - cancelled = asyncio.get_event_loop().create_future() - cancelled.cancel() - harvest_run( - ctx, - _make_state(), - CheckpointRun(attempt_id=f"{GOAL_KEY}#1", task=cancelled, checkpoint=_pending()), - allow_side_effects=False, - ) - errored = asyncio.get_event_loop().create_future() - errored.set_exception(TimeoutError()) - harvest_run( - ctx, - _make_state(), - CheckpointRun(attempt_id=f"{GOAL_KEY}#2", task=errored, checkpoint=_pending()), - allow_side_effects=True, - ) - - finished = _events(ctx, "attempt_finished") - assert [e["status"] for e in finished] == ["superseded", "error"] - assert all(v["status"] == "superseded" for v in finished[0]["verdicts"]) - assert all(v["status"] == "inconclusive" for v in finished[1]["verdicts"]) - assert "timed out" in finished[1]["error"] - - -@pytest.mark.asyncio -async def test_final_review_and_run_outcome_are_published(tmp_path): - _write_plan(tmp_path) - ctx = _make_ctx(tmp_path, disable_midway_checks=True) - report = CheckReport( - verdicts=[ - CheckVerdict( - item_text="the alarm list shows 7:30 AM", - kind="verify", - status="passed", - evidence="final screen shows 7:30 AM", - ), - CheckVerdict( - item_text="a toast appeared", - kind="assert", - status="inconclusive", - evidence="no history evidence", - ), - ] - ) - with patch("artemis.graph.graph.run_final_check", AsyncMock(return_value=report)): - update = await exit_settlement_node(_make_state(), ctx) - - assert update["exit_settlement_route"] == "end" - finished = _events(ctx, "attempt_finished") - assert finished and finished[0]["phase"] == "final" - assert finished[0]["attempt_id"] == "final#1" - assert finished[0]["status"] == "done" - assert finished[0]["route"] == "end" - assert [v["status"] for v in finished[0]["verdicts"]] == ["passed", "inconclusive"] - - outcome = _events(ctx, "run_outcome") - assert len(outcome) == 1 - assert outcome[0]["task_status"] == "completed" - assert outcome[0]["tests"]["passed"] == 1 - assert outcome[0]["tests"]["inconclusive"] == 1 - assert outcome[0]["tests"]["failed"] == 0 - assert outcome[0]["last_findings"] == [] - - # Persisted mirror of the same event for the UI backfill endpoint. - persisted = json.loads((tmp_path / "run_outcome.json").read_text(encoding="utf-8")) - assert persisted["task_status"] == "completed" - assert all(r["subgoal_text"] for r in read_ledger(tmp_path)) - - -@pytest.mark.asyncio -async def test_final_review_bounce_back_publishes_continue_route(tmp_path): - _write_plan(tmp_path) - ctx = _make_ctx(tmp_path, disable_midway_checks=True, final_check_max_attempts=3) - report = CheckReport( - verdicts=[ - CheckVerdict( - item_text="the alarm list shows 7:30 AM", - kind="verify", - status="failed", - evidence="list is empty", - suggestion="create it", - ) - ], - unmet_subgoals=[GOAL_TEXT], - ) - with patch("artemis.graph.graph.run_final_check", AsyncMock(return_value=report)): - update = await exit_settlement_node(_make_state(), ctx) - - assert update["exit_settlement_route"] == "continue" - finished = _events(ctx, "attempt_finished") - assert finished[0]["route"] == "continue" - assert finished[0]["unmet_subgoals"] == [GOAL_TEXT] - assert finished[0]["findings"] == update["operator_feedback"] - # No run outcome while the loop continues. - assert _events(ctx, "run_outcome") == [] diff --git a/tests/unit/graph/test_checkpoints.py b/tests/unit/graph/test_checkpoints.py deleted file mode 100644 index d8f3d4b3b..000000000 --- a/tests/unit/graph/test_checkpoints.py +++ /dev/null @@ -1,1149 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Checkpoint scheduling and harvest tests: queue/spawn separation, supersede -without verdict loss, applicability gating, repair quota, halt policy, -timeouts, unconditional record_step, and fail-open axis separation.""" - -import asyncio -import base64 -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from artemis.agents.checker.checker import CheckReport, CheckVerdict -from artemis.context import ArtemisContext, ExecutionSetup -from artemis.graph.checkpoints import ( - CheckpointRun, - PendingCheckpoint, - checker_note_key, - harvest_finished_checkpoints, - harvest_run, - is_checker_note_key, - queue_checkpoints, - read_ledger, - spawn_pending_checkpoints, -) -from artemis.graph.graph import execution_check_node, wrap_note_tool, wrap_update_note_tool -from artemis.graph.state import State -from artemis.utils.plan_grammar import CheckItem, parse_plan, subgoal_hash - -GOAL_TEXT = "Create the alarm" -GOAL_KEY = subgoal_hash(GOAL_TEXT) - -PLAN_WITH_CHECKS = ( - f"- [x] {GOAL_TEXT}\n" - " - verify: the alarm list shows 7:30 AM\n" - " - assert: a toast appeared\n" - "- [ ] Next milestone\n" -) - - -def _make_ctx(tmp_path, **setup_kwargs): - setup_kwargs.setdefault("disable_checker", False) - # These tests exercise the midway checkpoint machinery, which is off in - # the factory layering — pin it on unless a test overrides it. - setup_kwargs.setdefault("disable_midway_checks", False) - ctx = MagicMock(spec=ArtemisContext) - ctx.execution_setup = ExecutionSetup(**setup_kwargs) - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = tmp_path - ctx.pending_checkpoints = [] - ctx.checkpoint_tasks = {} - ctx.checkpoint_attempt_seq = {} - ctx.checkpoint_repairs = {} - ctx.assert_halt = False - ctx.final_check_attempts = 0 - ctx.planner_task = None - ctx.last_validated_plan = None - ctx.pending_validated_plan = None - return ctx - - -def _make_state(**overrides): - state = MagicMock(spec=State) - state.initial_goal = "the user goal" - state.user_stop_requested = False - state.operator_feedback = None - state.operator_raw_data = None - state.structured_decisions = None - defaults = { - "operator_raw_thinking": None, - "operator_native_thinking": None, - } - for k, v in {**defaults, **overrides}.items(): - setattr(state, k, v) - return state - - -def _write_plan(tmp_path, content=PLAN_WITH_CHECKS): - notes = tmp_path / "notes" - notes.mkdir(parents=True, exist_ok=True) - path = notes / "task_plan.md" - path.write_text(content, encoding="utf-8") - return path - - -def _pending(plan=PLAN_WITH_CHECKS, kinds=("verify", "assert")): - snapshot = parse_plan(plan) - item = next(i for i in snapshot.top_level if i.key == GOAL_KEY) - items = tuple( - ci for ci in snapshot.check_items_of(item) if ci.when == "on_complete" and ci.kind in kinds - ) - return PendingCheckpoint( - checkpoint_id=GOAL_KEY, - subgoal_text=GOAL_TEXT, - check_items=items, - plan_text=plan, - trigger_ts=1.0, - ) - - -def _done_run(ctx, report_or_exc, attempt_id=f"{GOAL_KEY}#1", pending=None): - loop = asyncio.get_event_loop() - fut = loop.create_future() - if isinstance(report_or_exc, Exception): - fut.set_exception(report_or_exc) - else: - fut.set_result(report_or_exc) - return CheckpointRun(attempt_id=attempt_id, task=fut, checkpoint=pending or _pending()) - - -def _verdict(kind="verify", status="failed", text=None, evidence="concrete evidence"): - default_text = "the alarm list shows 7:30 AM" if kind == "verify" else "a toast appeared" - return CheckVerdict( - item_text=text or default_text, - kind=kind, - status=status, - evidence=evidence, - suggestion="fix it" if (kind == "verify" and status == "failed") else "", - ) - - -# --- §8.1 / §8.2: queueing ------------------------------------------------------------ - - -def test_queue_checkpoints_enqueues_every_completion_with_items(tmp_path): - ctx = _make_ctx(tmp_path) - state = _make_state() - plan = ( - "- [x] A\n - verify: VA\n" - "- [x] B\n - verify: VB\n" - "- [x] C\n" # no check items -> never queued - ) - after = parse_plan(plan) - queue_checkpoints(ctx, state, after, ["A", "B", "C"], plan) - assert [p.checkpoint_id for p in ctx.pending_checkpoints] == [ - subgoal_hash("A"), - subgoal_hash("B"), - ] - # Only queued, never spawned here - assert ctx.checkpoint_tasks == {} - - -def test_queue_checkpoints_ignores_at_end_items(tmp_path): - ctx = _make_ctx(tmp_path) - plan = "- [x] A\n - assert@end: final only\n" - queue_checkpoints(ctx, _make_state(), parse_plan(plan), ["A"], plan) - assert ctx.pending_checkpoints == [] - - -def test_queue_checkpoints_respects_midway_gate_and_user_stop(tmp_path): - plan = "- [x] A\n - verify: VA\n" - after = parse_plan(plan) - - ctx = _make_ctx(tmp_path, disable_midway_checks=True) - queue_checkpoints(ctx, _make_state(), after, ["A"], plan) - assert ctx.pending_checkpoints == [] - - # Legacy master alias also gates - ctx2 = _make_ctx(tmp_path, disable_checker=True) - queue_checkpoints(ctx2, _make_state(), after, ["A"], plan) - assert ctx2.pending_checkpoints == [] - - ctx3 = _make_ctx(tmp_path) - queue_checkpoints(ctx3, _make_state(user_stop_requested=True), after, ["A"], plan) - assert ctx3.pending_checkpoints == [] - - -@pytest.mark.asyncio -async def test_process_plan_write_only_queues(tmp_path): - """The plan-write pipeline enqueues but never spawns: spawning waits for the - turn's step to be recorded.""" - plan_before = PLAN_WITH_CHECKS.replace(f"- [x] {GOAL_TEXT}", f"- [/] {GOAL_TEXT}") - task_plan_path = _write_plan(tmp_path, plan_before) - ctx = _make_ctx(tmp_path) - state = _make_state() - - async def fake_invoke(tool, args, tool_call_id, state, record_trace=None): - task_plan_path.write_text(args["content"], encoding="utf-8") - return "Success" - - mock_tool = MagicMock() - mock_tool.name = "save_note" - mock_tool.description = "save" - - with patch("artemis.graph.graph.invoke_tool_with_injection", side_effect=fake_invoke): - wrapped = wrap_note_tool(ctx, mock_tool) - await wrapped.ainvoke( - {"key": "task_plan", "content": PLAN_WITH_CHECKS, "tool_call_id": "t1"} - ) - - assert len(ctx.pending_checkpoints) == 1 - pc = ctx.pending_checkpoints[0] - assert pc.checkpoint_id == GOAL_KEY - assert [ci.kind for ci in pc.check_items] == ["verify", "assert"] - assert pc.plan_text == PLAN_WITH_CHECKS - assert ctx.checkpoint_tasks == {} - - -VERIFY_LINE = " - verify: the alarm list shows 7:30 AM\n" -ASSERT_LINE = " - assert: a toast appeared\n" - - -def _guidance_arrives(ctx, plan_text): - """What perception_node does when a non-empty user instruction lands: the - check lines that exist at that moment lose their machine restoration.""" - from artemis.utils.plan_grammar import parse_plan - - current = getattr(ctx, "guidance_unprotected_checks", None) - if not isinstance(current, set): - current = set() - ctx.guidance_unprotected_checks = current | { - (ci.kind, ci.text) for ci in parse_plan(plan_text).check_items - } - - -async def _plan_write(tmp_path, ctx, before, after): - """One accepted task_plan write through the shared post-write pipeline; - returns the plan text as it stands on disk afterwards.""" - from artemis.graph.graph import _process_plan_write - - task_plan_path = _write_plan(tmp_path, after) - await _process_plan_write(ctx, _make_state(), task_plan_path, before, after, "ok", True) - return task_plan_path.read_text(encoding="utf-8") - - -NEW_VERIFY_LINE = " - verify: the build number is recorded\n" - - -@pytest.mark.asyncio -async def test_pre_guidance_check_lines_may_be_dropped_at_any_later_write(tmp_path): - """Without guidance a deleted check line grows back. Once user guidance has - arrived, the lines that existed at that moment stay editable for the rest - of the run: the Operator re-plans over several writes, turns later, and - none of them is undone.""" - without_verify = PLAN_WITH_CHECKS.replace(VERIFY_LINE, "") - without_both = without_verify.replace(ASSERT_LINE, "") - ctx = _make_ctx(tmp_path, disable_planner_validation=True) - ctx.guidance_unprotected_checks = set() - - # Ordinary write (no guidance): the deleted line grows back. - assert VERIFY_LINE in await _plan_write(tmp_path, ctx, PLAN_WITH_CHECKS, without_verify) - - # Turn N: guidance arrives; the Operator only advances a milestone. - _guidance_arrives(ctx, PLAN_WITH_CHECKS) - started_next = PLAN_WITH_CHECKS.replace("- [ ] Next milestone", "- [/] Next milestone") - assert await _plan_write(tmp_path, ctx, PLAN_WITH_CHECKS, started_next) == started_next - - # Turn N+2: the first check line goes, and stands. - dropped_verify = started_next.replace(VERIFY_LINE, "") - assert await _plan_write(tmp_path, ctx, started_next, dropped_verify) == dropped_verify - # Turn N+3: the second one goes too, and also stands (no single-write window). - dropped_both = dropped_verify.replace(ASSERT_LINE, "") - assert await _plan_write(tmp_path, ctx, dropped_verify, dropped_both) == dropped_both - - -@pytest.mark.asyncio -async def test_check_lines_added_after_guidance_stay_protected(tmp_path): - """A check line the Operator adds after the instruction is a new standard: - deleting it later is restored, while the pre-guidance line still may go.""" - ctx = _make_ctx(tmp_path, disable_planner_validation=True) - ctx.guidance_unprotected_checks = set() - _guidance_arrives(ctx, PLAN_WITH_CHECKS) - - with_new = PLAN_WITH_CHECKS.replace(ASSERT_LINE, ASSERT_LINE + NEW_VERIFY_LINE) - assert await _plan_write(tmp_path, ctx, PLAN_WITH_CHECKS, with_new) == with_new - - dropped_new = with_new.replace(NEW_VERIFY_LINE, "") - on_disk = await _plan_write(tmp_path, ctx, with_new, dropped_new) - assert NEW_VERIFY_LINE in on_disk # restored: it postdates the guidance - - dropped_old = with_new.replace(VERIFY_LINE, "") - on_disk = await _plan_write(tmp_path, ctx, with_new, dropped_old) - assert VERIFY_LINE not in on_disk # stands: it predates the guidance - assert NEW_VERIFY_LINE in on_disk - - -@pytest.mark.asyncio -async def test_second_guidance_waives_the_lines_that_exist_by_then(tmp_path): - ctx = _make_ctx(tmp_path, disable_planner_validation=True) - ctx.guidance_unprotected_checks = set() - _guidance_arrives(ctx, PLAN_WITH_CHECKS) - with_new = PLAN_WITH_CHECKS.replace(ASSERT_LINE, ASSERT_LINE + NEW_VERIFY_LINE) - assert await _plan_write(tmp_path, ctx, PLAN_WITH_CHECKS, with_new) == with_new - dropped_new = with_new.replace(NEW_VERIFY_LINE, "") - assert NEW_VERIFY_LINE in await _plan_write(tmp_path, ctx, with_new, dropped_new) - - _guidance_arrives(ctx, with_new) # the new line exists now: it is waived too - assert NEW_VERIFY_LINE not in await _plan_write(tmp_path, ctx, with_new, dropped_new) - - -@pytest.mark.asyncio -async def test_mixed_write_restores_only_the_protected_lines(tmp_path): - """One write drops a pre-guidance line and a post-guidance line: only the - protected one is merged back.""" - ctx = _make_ctx(tmp_path, disable_planner_validation=True) - ctx.guidance_unprotected_checks = set() - _guidance_arrives(ctx, PLAN_WITH_CHECKS) - with_new = PLAN_WITH_CHECKS.replace(ASSERT_LINE, ASSERT_LINE + NEW_VERIFY_LINE) - assert await _plan_write(tmp_path, ctx, PLAN_WITH_CHECKS, with_new) == with_new - - dropped_both = with_new.replace(VERIFY_LINE, "").replace(NEW_VERIFY_LINE, "") - on_disk = await _plan_write(tmp_path, ctx, with_new, dropped_both) - assert VERIFY_LINE not in on_disk - assert NEW_VERIFY_LINE in on_disk - - -@pytest.mark.asyncio -async def test_guided_drop_records_the_retired_lines_and_the_outcome_excludes_them(tmp_path): - """A line dropped under guidance is remembered as retired; a verdict the - ledger recorded for it earlier is reported as retired, not as a failure, - while a line still declared keeps its verdict.""" - from artemis.graph.checkpoints import compute_test_summary - from artemis.utils.plan_grammar import parse_plan - - ctx = _make_ctx(tmp_path, disable_planner_validation=True) - ctx.guidance_unprotected_checks = set() - ctx.guidance_retired_checks = set() - _guidance_arrives(ctx, PLAN_WITH_CHECKS) - without_verify = PLAN_WITH_CHECKS.replace(VERIFY_LINE, "") - assert await _plan_write(tmp_path, ctx, PLAN_WITH_CHECKS, without_verify) == without_verify - assert ctx.guidance_retired_checks == {("verify", "the alarm list shows 7:30 AM")} - - records = [ - {"kind": "verify", "item_text": "the alarm list shows 7:30 AM", "status": "failed"}, - {"kind": "assert", "item_text": "a toast appeared", "status": "passed"}, - ] - summary = compute_test_summary( - list(parse_plan(without_verify).all_check_items), - records, - retired=ctx.guidance_retired_checks, - ) - assert (summary.passed, summary.failed, summary.retired) == (1, 0, 1) - assert summary.retired_items == [ - {"item_text": "the alarm list shows 7:30 AM", "kind": "verify", "last_status": "failed"} - ] - # A retired line that is declared again (the Operator re-added it) counts normally. - summary = compute_test_summary( - list(parse_plan(PLAN_WITH_CHECKS).all_check_items), - records, - retired=ctx.guidance_retired_checks, - ) - assert (summary.passed, summary.failed, summary.retired) == (1, 1, 0) - - -REWORDED_MILESTONE = PLAN_WITH_CHECKS.replace(f"- [x] {GOAL_TEXT}", "- [x] Create the 7:30 alarm") - - -@pytest.mark.asyncio -async def test_rewording_a_milestone_keeps_its_check_lines_in_place(tmp_path): - """The check lines under a reworded milestone change signature (parent - hash) but stay declared: they are neither duplicated as task-level @end - orphans nor, under guidance, retired.""" - ctx = _make_ctx(tmp_path, disable_planner_validation=True) - ctx.guidance_unprotected_checks = set() - ctx.guidance_retired_checks = set() - - # No guidance: nothing to restore, the plan stands as written. - on_disk = await _plan_write(tmp_path, ctx, PLAN_WITH_CHECKS, REWORDED_MILESTONE) - assert on_disk == REWORDED_MILESTONE - assert "@end" not in on_disk - - # Guidance armed: the same edit retires nothing. - _guidance_arrives(ctx, PLAN_WITH_CHECKS) - on_disk = await _plan_write(tmp_path, ctx, PLAN_WITH_CHECKS, REWORDED_MILESTONE) - assert on_disk == REWORDED_MILESTONE - assert ctx.guidance_retired_checks == set() - - -@pytest.mark.asyncio -async def test_redeclaring_a_retired_line_reinstates_it(tmp_path): - """Dropped under guidance, then added back: the harvest gate must see the - line as live again, matching what compute_test_summary reports.""" - from artemis.graph.checkpoints import reinstate_check_items - - ctx = _make_ctx(tmp_path, disable_planner_validation=True) - ctx.guidance_unprotected_checks = set() - ctx.guidance_retired_checks = set() - _guidance_arrives(ctx, PLAN_WITH_CHECKS) - without_verify = PLAN_WITH_CHECKS.replace(VERIFY_LINE, "") - await _plan_write(tmp_path, ctx, PLAN_WITH_CHECKS, without_verify) - assert ctx.guidance_retired_checks == {RETIRED_VERIFY} - - await _plan_write(tmp_path, ctx, without_verify, PLAN_WITH_CHECKS) - assert ctx.guidance_retired_checks == set() - - # Direct helper: unrelated items leave the set untouched. - ctx.guidance_retired_checks = {RETIRED_VERIFY} - reinstate_check_items(ctx, [RETIRED_ASSERT]) - assert ctx.guidance_retired_checks == {RETIRED_VERIFY} - - -@pytest.mark.asyncio -async def test_rejected_plan_write_leaves_the_waiver_untouched(tmp_path): - from langchain_core.messages import ToolMessage - - from artemis.graph.graph import _process_plan_write - - ctx = _make_ctx(tmp_path, disable_planner_validation=True) - ctx.guidance_unprotected_checks = set() - _guidance_arrives(ctx, PLAN_WITH_CHECKS) - waived = set(ctx.guidance_unprotected_checks) - reworded = PLAN_WITH_CHECKS.replace("- [ ] Next milestone", "- [ ] The next milestone") - task_plan_path = _write_plan(tmp_path, reworded) - result = await _process_plan_write( - ctx, _make_state(), task_plan_path, PLAN_WITH_CHECKS, reworded, "ok", False - ) - assert isinstance(result, ToolMessage) - assert task_plan_path.read_text(encoding="utf-8") == PLAN_WITH_CHECKS - assert ctx.guidance_unprotected_checks == waived - - -# --- §8.1 / §8.10: spawn after record_step; record_step unconditional ---------------- - - -def _raw_data(): - return { - "screenshot_b64": base64.b64encode(b"img").decode(), - "xml_hierarchy": [], - "ocr_results": [], - "width": 1080, - "height": 2400, - } - - -@pytest.mark.asyncio -async def test_execution_check_spawns_after_record_step_with_correct_anchor(tmp_path): - _write_plan(tmp_path) - ctx = _make_ctx(tmp_path) - ctx.data_engine.record_step.return_value = "step-uuid-42" - ctx.pending_checkpoints.append(_pending()) - state = _make_state(operator_raw_data=_raw_data()) - - captured = {} - - async def fake_check(ctx_arg, check_items, anchor, goal, subgoal_text, attempt_id=None): - captured["anchor"] = anchor - captured["goal"] = goal - return CheckReport(verdicts=[]) - - with ( - patch( - "artemis.agents.checker.checker.run_checkpoint_check", - side_effect=fake_check, - ), - patch("artemis.graph.graph._get_active_subgoal_hashes", return_value=("h", None)), - ): - update = await execution_check_node(state, ctx) - assert ctx.data_engine.record_step.called - assert update["current_step_id"] == "step-uuid-42" - assert GOAL_KEY in ctx.checkpoint_tasks - # Let the spawned task run - await ctx.checkpoint_tasks[GOAL_KEY].task - - assert captured["anchor"].anchor_step_id == "step-uuid-42" - assert captured["anchor"].plan_text == PLAN_WITH_CHECKS - assert captured["goal"] == "the user goal" - - -@pytest.mark.asyncio -async def test_execution_check_no_spawn_after_user_stop(tmp_path): - _write_plan(tmp_path) - ctx = _make_ctx(tmp_path) - ctx.data_engine.record_step.return_value = "sid" - ctx.pending_checkpoints.append(_pending()) - state = _make_state(operator_raw_data=_raw_data(), user_stop_requested=True) - - with patch("artemis.graph.graph._get_active_subgoal_hashes", return_value=("h", None)): - await execution_check_node(state, ctx) - - assert ctx.checkpoint_tasks == {} - # Step is still recorded regardless - assert ctx.data_engine.record_step.called - - -@pytest.mark.asyncio -async def test_planner_flag_is_advisory_hint_only(tmp_path): - """A flagged plan change is advisory: no rollback, the turn's actions - still run, the Operator gets the concern + reason as feedback, the turn - is recorded (tagged planner_flagged) and the ratchet baseline advances so - the same change is not re-flagged on every later write.""" - plan_path = _write_plan(tmp_path) - plan_on_disk = plan_path.read_text(encoding="utf-8") - ctx = _make_ctx(tmp_path) - ctx.data_engine.record_step.return_value = "flagged-step" - ctx.last_validated_plan = "- [ ] old baseline\n" - ctx.pending_validated_plan = plan_on_disk - flagged = asyncio.get_event_loop().create_future() - flagged.set_result({"status": "failed", "feedback": "milestone 2 drifts from the goal"}) - ctx.planner_task = flagged - state = _make_state(operator_raw_data=_raw_data()) - - with patch("artemis.graph.graph._get_active_subgoal_hashes", return_value=("h", None)): - update = await execution_check_node(state, ctx) - - # Actions are not suppressed and the plan file is untouched. - assert update["checker_success"] is True - assert "structured_decisions" not in update - assert plan_path.read_text(encoding="utf-8") == plan_on_disk - assert update["current_step_id"] == "flagged-step" - extra = ctx.data_engine.record_step.call_args.kwargs["extra_metadata"] - assert extra.get("planner_flagged") is True - # Hint + reason reach the Operator through operator_feedback. - feedback = update["operator_feedback"] - assert feedback and feedback[0].startswith("[planner]") - assert "milestone 2 drifts from the goal" in feedback[0] - assert "NOT rolled back" in feedback[0] - # Baseline advanced to the reviewed content. - assert ctx.last_validated_plan == plan_on_disk - assert ctx.pending_validated_plan is None - assert ctx.planner_task is None - - -@pytest.mark.asyncio -async def test_concurrency_cap_leaves_excess_pending(tmp_path): - _write_plan(tmp_path) - ctx = _make_ctx(tmp_path, max_concurrent_checkpoints=1) - plans = [] - for name in ("A", "B"): - plan = f"- [x] {name}\n - verify: V{name}\n" - plans.append(plan) - snapshot = parse_plan(plan) - item = snapshot.top_level[0] - ctx.pending_checkpoints.append( - PendingCheckpoint( - checkpoint_id=item.key, - subgoal_text=name, - check_items=snapshot.check_items_of(item), - plan_text=plan, - trigger_ts=0.0, - ) - ) - - async def fake_check(*a, **k): - return CheckReport(verdicts=[]) - - with patch("artemis.agents.checker.checker.run_checkpoint_check", side_effect=fake_check): - await spawn_pending_checkpoints(ctx, _make_state(), "sid") - assert len(ctx.checkpoint_tasks) == 1 - assert len(ctx.pending_checkpoints) == 1 - for run in ctx.checkpoint_tasks.values(): - await run.task - - -# --- §8.3: supersede never loses verdicts -------------------------------------------- - - -@pytest.mark.asyncio -async def test_supersede_books_finished_unharvested_attempt_first(tmp_path): - _write_plan(tmp_path) - ctx = _make_ctx(tmp_path) - failed_report = CheckReport(verdicts=[_verdict("verify", "failed")]) - ctx.checkpoint_tasks[GOAL_KEY] = _done_run(ctx, failed_report) - ctx.checkpoint_attempt_seq[GOAL_KEY] = 1 - ctx.pending_checkpoints.append(_pending()) - - async def fake_check(*a, **k): - return CheckReport(verdicts=[]) - - with patch("artemis.agents.checker.checker.run_checkpoint_check", side_effect=fake_check): - await spawn_pending_checkpoints(ctx, _make_state(), "sid") - new_run = ctx.checkpoint_tasks[GOAL_KEY] - await new_run.task - - # The failed verdict was booked before replacement — never dropped - records = read_ledger(tmp_path) - failed = [r for r in records if r["status"] == "failed"] - assert len(failed) == 1 - assert failed[0]["attempt_id"] == f"{GOAL_KEY}#1" - # And a fresh attempt id was allocated - assert new_run.attempt_id == f"{GOAL_KEY}#2" - # Superseded harvest is ledger-only: no plan revert happened - assert "- [x]" in (tmp_path / "notes" / "task_plan.md").read_text(encoding="utf-8") - - -@pytest.mark.asyncio -async def test_supersede_cancels_running_attempt_and_books_superseded(tmp_path): - _write_plan(tmp_path) - ctx = _make_ctx(tmp_path) - - async def hang(): - await asyncio.sleep(3600) - - running = asyncio.create_task(hang()) - await asyncio.sleep(0) # let it start - ctx.checkpoint_tasks[GOAL_KEY] = CheckpointRun( - attempt_id=f"{GOAL_KEY}#1", task=running, checkpoint=_pending() - ) - ctx.checkpoint_attempt_seq[GOAL_KEY] = 1 - ctx.pending_checkpoints.append(_pending()) - - async def fake_check(*a, **k): - return CheckReport(verdicts=[]) - - with patch("artemis.agents.checker.checker.run_checkpoint_check", side_effect=fake_check): - await spawn_pending_checkpoints(ctx, _make_state(), "sid") - await ctx.checkpoint_tasks[GOAL_KEY].task - - assert running.cancelled() - superseded = [r for r in read_ledger(tmp_path) if r["status"] == "superseded"] - assert superseded and superseded[0]["attempt_id"] == f"{GOAL_KEY}#1" - - -# --- §8.4: harvest only takes done(); applicability gates side effects --------------- - - -@pytest.mark.asyncio -async def test_harvest_only_takes_done_tasks(tmp_path): - _write_plan(tmp_path) - ctx = _make_ctx(tmp_path) - - async def hang(): - await asyncio.sleep(3600) - - running = asyncio.create_task(hang()) - await asyncio.sleep(0) - ctx.checkpoint_tasks["running"] = CheckpointRun( - attempt_id="running#1", task=running, checkpoint=_pending() - ) - ctx.checkpoint_tasks[GOAL_KEY] = _done_run( - ctx, CheckReport(verdicts=[_verdict("verify", "passed")]) - ) - - findings = harvest_finished_checkpoints(ctx, _make_state()) - assert findings == [] - # The running attempt is untouched (never awaited, never removed) - assert "running" in ctx.checkpoint_tasks - assert GOAL_KEY not in ctx.checkpoint_tasks - assert not running.done() - running.cancel() - with pytest.raises(asyncio.CancelledError): - await running - - -@pytest.mark.asyncio -async def test_stale_verdict_is_ledger_only_when_subgoal_text_changed(tmp_path): - # Plan no longer contains the anchored subgoal text - _write_plan(tmp_path, "- [x] Create the alarm (rephrased)\n") - ctx = _make_ctx(tmp_path) - run = _done_run(ctx, CheckReport(verdicts=[_verdict("verify", "failed")])) - - findings = harvest_run(ctx, _make_state(), run, allow_side_effects=True) - - assert findings == [] # no repair injection - assert ctx.checkpoint_repairs == {} - records = read_ledger(tmp_path) - assert records and records[0]["status"] == "failed" # but still booked - - -# --- §8.5: verify FAIL repairs, bounded by quota ------------------------------------- - - -@pytest.mark.asyncio -async def test_verify_fail_reverts_subgoal_and_injects_finding(tmp_path): - plan_path = _write_plan(tmp_path) - ctx = _make_ctx(tmp_path) - run = _done_run(ctx, CheckReport(verdicts=[_verdict("verify", "failed")])) - - findings = harvest_run(ctx, _make_state(), run, allow_side_effects=True) - - content = plan_path.read_text(encoding="utf-8") - assert f"- [/] {GOAL_TEXT}" in content # [x] -> [/], forward-looking - assert any("verify failed" in f for f in findings) - assert any("fix it" in f for f in findings) - assert ctx.checkpoint_repairs[GOAL_KEY] == 1 - - -@pytest.mark.asyncio -async def test_verify_fail_beyond_repair_quota_keeps_failed_without_revert(tmp_path): - plan_path = _write_plan(tmp_path) - ctx = _make_ctx(tmp_path, checkpoint_max_repairs=2) - ctx.checkpoint_repairs[GOAL_KEY] = 2 - run = _done_run(ctx, CheckReport(verdicts=[_verdict("verify", "failed")])) - - findings = harvest_run(ctx, _make_state(), run, allow_side_effects=True) - - assert findings == [] - assert f"- [x] {GOAL_TEXT}" in plan_path.read_text(encoding="utf-8") - # The verdict is NOT rewritten: it stays failed in the ledger - assert read_ledger(tmp_path)[0]["status"] == "failed" - - -@pytest.mark.asyncio -async def test_verify_fail_after_user_stop_never_reverts(tmp_path): - plan_path = _write_plan(tmp_path) - ctx = _make_ctx(tmp_path) - run = _done_run(ctx, CheckReport(verdicts=[_verdict("verify", "failed")])) - - harvest_run(ctx, _make_state(user_stop_requested=True), run, allow_side_effects=True) - assert f"- [x] {GOAL_TEXT}" in plan_path.read_text(encoding="utf-8") - - -# --- §8.6: assert FAIL is a result, never a repair ----------------------------------- - - -@pytest.mark.asyncio -async def test_assert_fail_is_ledger_only(tmp_path): - plan_path = _write_plan(tmp_path) - ctx = _make_ctx(tmp_path) - run = _done_run(ctx, CheckReport(verdicts=[_verdict("assert", "failed")])) - - findings = harvest_run(ctx, _make_state(), run, allow_side_effects=True) - - assert findings == [] - assert f"- [x] {GOAL_TEXT}" in plan_path.read_text(encoding="utf-8") - assert ctx.assert_halt is False - assert read_ledger(tmp_path)[0]["status"] == "failed" - - -@pytest.mark.asyncio -async def test_assert_fail_halt_policy_latches_halt(tmp_path): - _write_plan(tmp_path) - ctx = _make_ctx(tmp_path, assert_failure_policy="halt") - run = _done_run(ctx, CheckReport(verdicts=[_verdict("assert", "failed")])) - - harvest_run(ctx, _make_state(), run, allow_side_effects=True) - assert ctx.assert_halt is True - - -# --- Retired check items: late verdicts are ledger-only ------------------------------ - - -RETIRED_VERIFY = ("verify", "the alarm list shows 7:30 AM") -RETIRED_ASSERT = ("assert", "a toast appeared") - - -def _last_done_event(ctx): - calls = [ - c for c in ctx.data_engine._publish.call_args_list if c.args[1].get("status") == "done" - ] - return calls[-1].args[1] - - -@pytest.mark.asyncio -async def test_late_failing_verify_for_retired_line_is_ledger_only(tmp_path): - """The user retired the verify line but kept the milestone: a late failing - verdict for it neither reopens the subgoal nor produces findings/headline, - yet the ledger still books it and the done event reports it as retired.""" - plan_path = _write_plan(tmp_path, PLAN_WITH_CHECKS.replace(VERIFY_LINE, "")) - ctx = _make_ctx(tmp_path) - ctx.guidance_retired_checks = {RETIRED_VERIFY} - run = _done_run(ctx, CheckReport(verdicts=[_verdict("verify", "failed")])) - - findings = harvest_run(ctx, _make_state(), run, allow_side_effects=True) - - assert findings == [] - assert f"- [x] {GOAL_TEXT}" in plan_path.read_text(encoding="utf-8") - assert "- finding:" not in plan_path.read_text(encoding="utf-8") - assert ctx.checkpoint_repairs == {} - assert getattr(ctx, "checker_findings", {}) == {} - records = read_ledger(tmp_path) - assert [(r["kind"], r["status"]) for r in records] == [("verify", "failed")] - event = _last_done_event(ctx) - assert event["applicable"] is False - assert event["reverted"] is False - assert event["retired"] == [{"kind": "verify", "item_text": RETIRED_VERIFY[1]}] - assert [v["status"] for v in event["verdicts"]] == ["failed"] - - -@pytest.mark.asyncio -async def test_failed_assert_for_retired_line_never_latches_halt(tmp_path): - _write_plan(tmp_path, PLAN_WITH_CHECKS.replace(ASSERT_LINE, "")) - ctx = _make_ctx(tmp_path, assert_failure_policy="halt") - ctx.guidance_retired_checks = {RETIRED_ASSERT} - run = _done_run(ctx, CheckReport(verdicts=[_verdict("assert", "failed")])) - - harvest_run(ctx, _make_state(), run, allow_side_effects=True) - - assert ctx.assert_halt is False - assert read_ledger(tmp_path)[0]["status"] == "failed" # still booked - assert _last_done_event(ctx)["applicable"] is False - - -@pytest.mark.asyncio -async def test_non_retired_sibling_verdict_still_takes_effect(tmp_path): - """Same report: the retired verify is dropped, the live assert still halts - and the live verify sibling still repairs.""" - plan_path = _write_plan(tmp_path, PLAN_WITH_CHECKS.replace(VERIFY_LINE, "")) - ctx = _make_ctx(tmp_path, assert_failure_policy="halt") - ctx.guidance_retired_checks = {RETIRED_VERIFY} - run = _done_run( - ctx, - CheckReport(verdicts=[_verdict("verify", "failed"), _verdict("assert", "failed")]), - ) - - findings = harvest_run(ctx, _make_state(), run, allow_side_effects=True) - - assert findings == [] # the retired verify produces no repair finding - assert f"- [x] {GOAL_TEXT}" in plan_path.read_text(encoding="utf-8") - assert ctx.assert_halt is True # the live assert sibling still counts - event = _last_done_event(ctx) - assert event["applicable"] is True - assert event["retired"] == [{"kind": "verify", "item_text": RETIRED_VERIFY[1]}] - - # And a live verify sibling next to a retired one still drives the repair, - # with the headline naming only the live criterion. - live_line = " - verify: the alarm is enabled\n" - plan_path = _write_plan(tmp_path, PLAN_WITH_CHECKS.replace(VERIFY_LINE, live_line)) - ctx = _make_ctx(tmp_path) - ctx.guidance_retired_checks = {RETIRED_VERIFY} - run = _done_run( - ctx, - CheckReport( - verdicts=[ - _verdict("verify", "failed"), - _verdict("verify", "failed", text="the alarm is enabled", evidence="toggle off"), - ] - ), - ) - findings = harvest_run(ctx, _make_state(), run, allow_side_effects=True) - content = plan_path.read_text(encoding="utf-8") - assert f"- [/] {GOAL_TEXT}" in content - assert "- finding: verify failed — 'the alarm is enabled'" in content - assert RETIRED_VERIFY[1] not in " ".join(findings) - assert any("the alarm is enabled" in f for f in findings) - assert ctx.checker_finding_items[GOAL_KEY] == {("verify", "the alarm is enabled")} - - -@pytest.mark.asyncio -async def test_retiring_an_item_withdraws_its_registered_headline(tmp_path): - """A headline registered by an earlier failing attempt disappears from the - plan when the user guidance retires the item that produced it; a headline - that still names a live item stays.""" - from artemis.graph.checkpoints import retire_check_items - - plan_path = _write_plan(tmp_path) - ctx = _make_ctx(tmp_path) - run = _done_run(ctx, CheckReport(verdicts=[_verdict("verify", "failed")])) - harvest_run(ctx, _make_state(), run, allow_side_effects=True) - assert "- finding: verify failed" in plan_path.read_text(encoding="utf-8") - - retire_check_items(ctx, [RETIRED_ASSERT]) # unrelated item: headline stays - assert "- finding: verify failed" in plan_path.read_text(encoding="utf-8") - assert ctx.guidance_retired_checks == {RETIRED_ASSERT} - - retire_check_items(ctx, [RETIRED_VERIFY]) - assert "- finding:" not in plan_path.read_text(encoding="utf-8") - assert ctx.checker_findings == {} - assert ctx.checker_finding_items == {} - assert ctx.guidance_retired_checks == {RETIRED_ASSERT, RETIRED_VERIFY} - - # The plan-write path reaches the same helper: dropping a waived line - # after a headline was registered removes the headline on that write. - plan_path = _write_plan(tmp_path) - ctx = _make_ctx(tmp_path, disable_planner_validation=True) - ctx.guidance_unprotected_checks = set() - ctx.guidance_retired_checks = set() - run = _done_run(ctx, CheckReport(verdicts=[_verdict("verify", "failed")])) - harvest_run(ctx, _make_state(), run, allow_side_effects=True) - with_finding = plan_path.read_text(encoding="utf-8") - assert "- finding: verify failed" in with_finding - _guidance_arrives(ctx, with_finding) - without_verify = with_finding.replace(VERIFY_LINE, "") - on_disk = await _plan_write(tmp_path, ctx, with_finding, without_verify) - assert VERIFY_LINE not in on_disk - assert "- finding:" not in on_disk - assert ctx.guidance_retired_checks == {RETIRED_VERIFY} - - -@pytest.mark.asyncio -async def test_ledger_is_append_only_fail_then_pass_both_present(tmp_path): - _write_plan(tmp_path) - ctx = _make_ctx(tmp_path) - run1 = _done_run(ctx, CheckReport(verdicts=[_verdict("assert", "failed")])) - harvest_run(ctx, _make_state(), run1, allow_side_effects=True) - run2 = _done_run( - ctx, - CheckReport(verdicts=[_verdict("assert", "passed")]), - attempt_id=f"{GOAL_KEY}#2", - ) - harvest_run(ctx, _make_state(), run2, allow_side_effects=True) - - statuses = [r["status"] for r in read_ledger(tmp_path)] - assert statuses == ["failed", "passed"] # first failure permanently retained - - -# --- §8.7 / §8.13: timeouts and fail-open axis separation ---------------------------- - - -@pytest.mark.asyncio -async def test_timeout_records_inconclusive_not_passed(tmp_path): - _write_plan(tmp_path) - ctx = _make_ctx(tmp_path) - run = _done_run(ctx, TimeoutError()) - - findings = harvest_run(ctx, _make_state(), run, allow_side_effects=True) - - assert findings == [] # released (fail-open): no repair loop - records = read_ledger(tmp_path) - assert {r["status"] for r in records} == {"inconclusive"} - assert all(r["status"] != "passed" for r in records) - - -# --- Verify-finding four-layer persistence ------------------------------------------- - - -def _checker_note_path(tmp_path): - return tmp_path / "notes" / f"{checker_note_key(GOAL_KEY)}.md" - - -@pytest.mark.asyncio -async def test_verify_fail_writes_finding_line_and_checker_note(tmp_path): - plan_path = _write_plan(tmp_path) - ctx = _make_ctx(tmp_path) - run = _done_run(ctx, CheckReport(verdicts=[_verdict("verify", "failed")])) - - harvest_run(ctx, _make_state(), run, allow_side_effects=True) - - content = plan_path.read_text(encoding="utf-8") - lines = content.splitlines() - # Standing headline pinned directly under the (now reverted) subgoal, - # naming the failed criterion and pointing at the detail note. - idx = lines.index(f"- [/] {GOAL_TEXT}") - assert lines[idx + 1].startswith(" - finding: verify failed") - assert checker_note_key(GOAL_KEY) in lines[idx + 1] - - # Layer 3: the system note carries the full criterion/evidence/suggestion. - note = _checker_note_path(tmp_path).read_text(encoding="utf-8") - assert "the alarm list shows 7:30 AM" in note - assert "concrete evidence" in note - assert "fix it" in note - - -@pytest.mark.asyncio -async def test_finding_line_regrows_on_model_plan_write(tmp_path): - plan_path = _write_plan(tmp_path) - ctx = _make_ctx(tmp_path) - run = _done_run(ctx, CheckReport(verdicts=[_verdict("verify", "failed")])) - harvest_run(ctx, _make_state(), run, allow_side_effects=True) - assert "- finding:" in plan_path.read_text(encoding="utf-8") - - # The model rewrites the plan WITHOUT the finding line... - clean_rewrite = PLAN_WITH_CHECKS.replace(f"- [x] {GOAL_TEXT}", f"- [/] {GOAL_TEXT}") - - async def fake_invoke(tool, args, tool_call_id, state, record_trace=None): - plan_path.write_text(args["content"], encoding="utf-8") - return "Success" - - mock_tool = MagicMock() - mock_tool.name = "save_note" - mock_tool.description = "save" - - with patch("artemis.graph.graph.invoke_tool_with_injection", side_effect=fake_invoke): - wrapped = wrap_note_tool(ctx, mock_tool) - await wrapped.ainvoke({"key": "task_plan", "content": clean_rewrite, "tool_call_id": "t1"}) - - # ...and the deterministic projection grows it back on the write path. - assert "- finding: verify failed" in plan_path.read_text(encoding="utf-8") - - -@pytest.mark.asyncio -async def test_finding_line_removed_after_verify_passes_note_kept(tmp_path): - plan_path = _write_plan(tmp_path) - ctx = _make_ctx(tmp_path) - run1 = _done_run(ctx, CheckReport(verdicts=[_verdict("verify", "failed")])) - harvest_run(ctx, _make_state(), run1, allow_side_effects=True) - assert "- finding:" in plan_path.read_text(encoding="utf-8") - - run2 = _done_run( - ctx, - CheckReport(verdicts=[_verdict("verify", "passed")]), - attempt_id=f"{GOAL_KEY}#2", - ) - harvest_run(ctx, _make_state(), run2, allow_side_effects=True) - - # Layer 2 headline retired; layer 3 note stays on disk with the full - # per-attempt repair log (fail then pass). - assert "- finding:" not in plan_path.read_text(encoding="utf-8") - note = _checker_note_path(tmp_path).read_text(encoding="utf-8") - assert f"attempt {GOAL_KEY}#1 — verify failed" in note - assert f"attempt {GOAL_KEY}#2 — verify passed" in note - - -@pytest.mark.asyncio -async def test_finding_line_removed_on_repair_quota_exhaustion(tmp_path): - plan_path = _write_plan(tmp_path) - ctx = _make_ctx(tmp_path, checkpoint_max_repairs=1) - run1 = _done_run(ctx, CheckReport(verdicts=[_verdict("verify", "failed")])) - harvest_run(ctx, _make_state(), run1, allow_side_effects=True) - assert "- finding:" in plan_path.read_text(encoding="utf-8") - - # Re-complete, fail again: quota (1) is exhausted -> finding settled. - plan_path.write_text( - plan_path.read_text(encoding="utf-8").replace(f"- [/] {GOAL_TEXT}", f"- [x] {GOAL_TEXT}"), - encoding="utf-8", - ) - run2 = _done_run( - ctx, - CheckReport(verdicts=[_verdict("verify", "failed")]), - attempt_id=f"{GOAL_KEY}#2", - ) - harvest_run(ctx, _make_state(), run2, allow_side_effects=True) - - content = plan_path.read_text(encoding="utf-8") - assert "- finding:" not in content - assert f"- [x] {GOAL_TEXT}" in content # no further revert either - - -@pytest.mark.asyncio -async def test_checker_note_prefix_writes_rejected_by_wrappers(tmp_path): - ctx = _make_ctx(tmp_path) - save_tool = MagicMock() - save_tool.name = "save_note" - save_tool.description = "save" - update_tool = MagicMock() - update_tool.name = "update_note" - update_tool.description = "update" - - with patch("artemis.graph.graph.invoke_tool_with_injection", new_callable=AsyncMock) as inv: - wrapped_save = wrap_note_tool(ctx, save_tool) - result = await wrapped_save.ainvoke( - {"key": checker_note_key(GOAL_KEY), "content": "spoof", "tool_call_id": "t1"} - ) - assert "reserved" in str(result.content) - - wrapped_update = wrap_update_note_tool(ctx, update_tool) - result2 = await wrapped_update.ainvoke( - { - "key": "checker:abc", - "target": "a", - "replacement": "b", - "tool_call_id": "t2", - } - ) - assert "reserved" in str(result2.content) - - inv.assert_not_awaited() - assert is_checker_note_key("checker-123") and is_checker_note_key("checker:123") - assert not is_checker_note_key("checkerboard_notes") and not is_checker_note_key("notes") - - -@pytest.mark.asyncio -async def test_exception_fail_open_releases_but_books_inconclusive(tmp_path): - plan_path = _write_plan(tmp_path) - ctx = _make_ctx(tmp_path) - run = _done_run(ctx, RuntimeError("checker blew up")) - - findings = harvest_run(ctx, _make_state(), run, allow_side_effects=True) - - # Release: no revert, no finding - assert findings == [] - assert f"- [x] {GOAL_TEXT}" in plan_path.read_text(encoding="utf-8") - # Verdict axis: inconclusive, never rewritten to passed - for r in read_ledger(tmp_path): - assert r["status"] == "inconclusive" - assert "checker blew up" in r["evidence"] - - -# --- Attempt stream transcripts: bounded, append-only, replayable ------------------------ - - -def _seg(exec_id, text, role="answer", when=1.0): - return {"execution_id": exec_id, "role": role, "when": when, "text": text} - - -def test_clamp_keeps_short_transcripts_verbatim(): - from artemis.graph.checkpoints import clamp_stream_segments - - segs = [_seg("a", "hello", "thought"), _seg("b", "world")] - out, dropped = clamp_stream_segments(segs, limit=20) - assert out == segs and dropped == 0 - assert out[0] is not segs[0] # copies, the input is never mutated - - -def test_clamp_cuts_the_middle_once_and_keeps_head_and_tail(): - from artemis.graph.checkpoints import clamp_stream_segments - - segs = [ - _seg("a", "A" * 40, "thought", when=1.0), - _seg("b", "B" * 40, when=2.0), - _seg("c", "C" * 40, when=3.0), - _seg("d", "D" * 40, when=4.0), - ] - out, dropped = clamp_stream_segments(segs, limit=100) - - assert dropped == 60 - # head: 50 chars = all of a + 10 of b; tail: 50 chars = 10 of c + all of d - assert [s["execution_id"] for s in out] == ["a", "b", "c", "d"] - assert out[0]["text"] == "A" * 40 and out[0]["role"] == "thought" - assert out[1]["text"] == "B" * 10 + "\n…[60 chars truncated]…\n" - assert out[2]["text"] == "C" * 10 - assert out[3]["text"] == "D" * 40 - joined = "".join(s["text"] for s in out) - assert joined.count("chars truncated") == 1 - assert len(joined) == 100 + len("\n…[60 chars truncated]…\n") - # Timestamps and the other fields ride along untouched. - assert [s["when"] for s in out] == [1.0, 2.0, 3.0, 4.0] - - -def test_clamp_drops_segments_that_fall_entirely_into_the_cut(): - from artemis.graph.checkpoints import clamp_stream_segments - - segs = [_seg("a", "A" * 10), _seg("mid", "M" * 100), _seg("z", "Z" * 10)] - out, dropped = clamp_stream_segments(segs, limit=20) - assert dropped == 100 - assert [s["execution_id"] for s in out] == ["a", "z"] - assert out[0]["text"] == "A" * 10 - assert out[1]["text"] == "\n…[100 chars truncated]…\n" + "Z" * 10 - - -def test_clamp_single_huge_segment_keeps_both_ends(): - from artemis.graph.checkpoints import clamp_stream_segments - - out, dropped = clamp_stream_segments([_seg("a", "H" * 30 + "M" * 100 + "T" * 30)], limit=60) - assert dropped == 100 - assert len(out) == 1 - assert out[0]["text"] == "H" * 30 + "\n…[100 chars truncated]…\n" + "T" * 30 - - -def test_stream_records_round_trip_and_carry_truncation_flags(tmp_path): - from artemis.graph.checkpoints import ( - STREAM_TEXT_LIMIT, - append_attempt_stream_record, - read_attempt_streams, - streams_path, - ) - - assert STREAM_TEXT_LIMIT == 20_000 - append_attempt_stream_record( - tmp_path, - {"attempt_id": "abc#1", "trace_id": "t1", "segments": [_seg("a", "short")]}, - ) - append_attempt_stream_record( - tmp_path, - {"attempt_id": "abc#2", "trace_id": "t2", "segments": [_seg("b", "x" * 50)]}, - limit=10, - ) - with streams_path(tmp_path).open("a", encoding="utf-8") as f: - f.write("not json\n\n") - - records = read_attempt_streams(tmp_path) - assert [r["attempt_id"] for r in records] == ["abc#1", "abc#2"] - assert records[0]["truncated"] is False and records[0]["dropped_chars"] == 0 - assert records[0]["segments"] == [_seg("a", "short")] - assert isinstance(records[0]["ts"], float) - assert records[1]["truncated"] is True and records[1]["dropped_chars"] == 40 - assert records[1]["segments"][0]["text"] == "x" * 5 + "\n…[40 chars truncated]…\n" + "x" * 5 - assert read_attempt_streams(tmp_path / "nowhere") == [] diff --git a/tests/unit/graph/test_exit_settlement.py b/tests/unit/graph/test_exit_settlement.py deleted file mode 100644 index 80a897e15..000000000 --- a/tests/unit/graph/test_exit_settlement.py +++ /dev/null @@ -1,467 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Exit settlement tests: gate routing, settlement/final-review decoupling, -settlement timeout, when-semantics at the exit, run outcome, and the four -switch-combination matrix.""" - -import asyncio -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from artemis.agents.checker.checker import CheckReport, CheckVerdict -from artemis.context import ArtemisContext, ExecutionSetup -from artemis.graph.checkpoints import ( - CheckpointRun, - PendingCheckpoint, - append_ledger_record, - compute_run_outcome, - read_ledger, - resolve_item_status, - settle_all_checkpoints, -) -from artemis.graph.graph import ( - convergence_gate, - exit_settlement_gate, - exit_settlement_node, -) -from artemis.graph.state import State -from artemis.sdk.agent import attach_test_summary, resolve_trace_suffix -from artemis.utils.plan_grammar import CheckItem, parse_plan, subgoal_hash - -DONE_PLAN_WITH_CHECKS = ( - "- [x] Create alarm\n" - " - verify: alarm exists\n" - " - assert: toast appeared\n" - "- assert@end: no crash dialog\n" -) - -DONE_PLAN_NO_CHECKS = "- [x] Create alarm\n" - - -def _make_ctx(tmp_path, **setup_kwargs): - setup_kwargs.setdefault("disable_checker", False) - ctx = MagicMock(spec=ArtemisContext) - ctx.execution_setup = ExecutionSetup(**setup_kwargs) - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = tmp_path - ctx.pending_checkpoints = [] - ctx.checkpoint_tasks = {} - ctx.checkpoint_attempt_seq = {} - ctx.checkpoint_repairs = {} - ctx.assert_halt = False - ctx.final_check_attempts = 0 - return ctx - - -def _make_state(**overrides): - state = MagicMock(spec=State) - state.initial_goal = "the user goal" - state.user_stop_requested = False - state.checker_success = True - for k, v in overrides.items(): - setattr(state, k, v) - return state - - -def _write_plan(tmp_path, content): - notes = tmp_path / "notes" - notes.mkdir(parents=True, exist_ok=True) - (notes / "task_plan.md").write_text(content, encoding="utf-8") - - -def _pending_checkpoint(text="Create alarm"): - return PendingCheckpoint( - checkpoint_id=subgoal_hash(text), - subgoal_text=text, - check_items=( - CheckItem( - kind="verify", - when="on_complete", - text="alarm exists", - parent_key=subgoal_hash(text), - ), - ), - plan_text=f"- [x] {text}\n", - trigger_ts=0.0, - ) - - -def _hanging_run(cid="cid"): - async def hang(): - await asyncio.sleep(3600) - - task = asyncio.create_task(hang()) - return CheckpointRun(attempt_id=f"{cid}#1", task=task, checkpoint=_pending_checkpoint()) - - -# --- §8.8: gate routing — settlement decoupled from final review --------------------- - - -def test_gate_routes_settlement_when_tasks_pending_even_with_final_off(tmp_path): - _write_plan(tmp_path, DONE_PLAN_NO_CHECKS) - ctx = _make_ctx(tmp_path, disable_final_check=True) - ctx.checkpoint_tasks["cid"] = MagicMock() - assert convergence_gate(_make_state(), ctx) == "exit_settlement" - - -def test_gate_routes_empty_plan_to_final_review_when_enabled(tmp_path): - _write_plan(tmp_path, "just prose, no checkboxes\n") - ctx = _make_ctx(tmp_path) # final on (enabled master, default gates) - assert convergence_gate(_make_state(), ctx) == "exit_settlement" - - -def test_gate_ends_directly_when_nothing_check_related(tmp_path): - _write_plan(tmp_path, DONE_PLAN_NO_CHECKS) - ctx = _make_ctx(tmp_path, disable_checker=True) - assert convergence_gate(_make_state(), ctx) == "end" - - -def test_gate_check_items_force_settlement_even_all_switches_off(tmp_path): - """Combination row 1 (off/off): declared items still get settled (booked as - unchecked) instead of silently vanishing.""" - _write_plan(tmp_path, DONE_PLAN_WITH_CHECKS) - ctx = _make_ctx(tmp_path, disable_checker=True) - assert convergence_gate(_make_state(), ctx) == "exit_settlement" - - -def test_gate_incomplete_plan_continues(tmp_path): - _write_plan(tmp_path, "- [/] Create alarm\n - verify: alarm exists\n") - ctx = _make_ctx(tmp_path) - assert convergence_gate(_make_state(), ctx) == "continue" - - -def test_gate_unevaluated_plan_validation_is_not_a_failure(tmp_path): - """First pass after the Planner node: ``checker_success`` is still None - (never evaluated). That must not short-circuit the gate — a fully done - plan still routes to the exit, and no "validation failed" log fires.""" - _write_plan(tmp_path, DONE_PLAN_NO_CHECKS) - ctx = _make_ctx(tmp_path) - with patch("artemis.graph.graph.logger") as mock_logger: - assert convergence_gate(_make_state(checker_success=None), ctx) == "exit_settlement" - logged = " ".join(str(c.args[0]) for c in mock_logger.info.call_args_list if c.args) - assert "Plan validation failed" not in logged - - -def test_gate_explicit_validation_failure_still_continues(tmp_path): - _write_plan(tmp_path, DONE_PLAN_NO_CHECKS) - ctx = _make_ctx(tmp_path) - with patch("artemis.graph.graph.logger") as mock_logger: - assert convergence_gate(_make_state(checker_success=False), ctx) == "continue" - logged = " ".join(str(c.args[0]) for c in mock_logger.info.call_args_list if c.args) - assert "Plan validation failed" in logged - - -def test_gate_assert_halt_terminates_mid_plan(tmp_path): - _write_plan(tmp_path, "- [/] Create alarm\n - assert: toast appeared\n") - ctx = _make_ctx(tmp_path) - ctx.assert_halt = True - assert convergence_gate(_make_state(), ctx) == "exit_settlement" - - -@pytest.mark.asyncio -async def test_settlement_runs_phase_one_only_when_final_disabled(tmp_path): - _write_plan(tmp_path, DONE_PLAN_WITH_CHECKS) - ctx = _make_ctx(tmp_path, disable_final_check=True) - report = CheckReport( - verdicts=[ - CheckVerdict(item_text="alarm exists", kind="verify", status="passed", evidence="e") - ] - ) - fut = asyncio.get_event_loop().create_future() - fut.set_result(report) - cid = subgoal_hash("Create alarm") - ctx.checkpoint_tasks[cid] = CheckpointRun( - attempt_id=f"{cid}#1", task=fut, checkpoint=_pending_checkpoint() - ) - - with patch("artemis.graph.graph.run_final_check", new=AsyncMock()) as final_mock: - update = await exit_settlement_node(_make_state(), ctx) - - final_mock.assert_not_awaited() - assert update["exit_settlement_route"] == "end" - # Phase one still booked the attempt and produced the summary - assert any(r["status"] == "passed" for r in read_ledger(tmp_path)) - assert update["run_outcome"]["tests"]["passed"] == 1 - assert ctx.checkpoint_tasks == {} - - -# --- §8.7: settlement timeout -------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_settlement_timeout_cancels_and_books_unchecked(tmp_path): - _write_plan(tmp_path, DONE_PLAN_NO_CHECKS) - ctx = _make_ctx(tmp_path, settlement_timeout=0.05) - run = _hanging_run() - ctx.checkpoint_tasks["cid"] = run - - await settle_all_checkpoints(ctx, _make_state()) - - assert run.task.cancelled() - records = read_ledger(tmp_path) - assert records and all(r["status"] == "unchecked" for r in records) - assert "settlement timeout" in records[0]["evidence"] - - -# --- §8.9: when-semantics at the exit ------------------------------------------------ - - -@pytest.mark.asyncio -async def test_final_check_receives_ledger_and_at_end_items(tmp_path): - _write_plan(tmp_path, DONE_PLAN_WITH_CHECKS) - ctx = _make_ctx(tmp_path) - append_ledger_record( - tmp_path, - { - "attempt_id": "x#1", - "checkpoint_id": "x", - "item_text": "toast appeared", - "kind": "assert", - "when": "on_complete", - "status": "failed", - "evidence": "no toast in anchor history", - }, - ) - - captured = {} - - async def fake_final(ctx_arg, goal, plan_text, ledger, check_items, attempt_id=None): - captured["goal"] = goal - captured["ledger"] = ledger - captured["items"] = list(check_items) - return CheckReport(verdicts=[]) - - with patch("artemis.graph.graph.run_final_check", side_effect=fake_final): - await exit_settlement_node(_make_state(), ctx) - - # Final review audits the user's original goal, citing the ledger - assert captured["goal"] == "the user goal" - assert any(r["status"] == "failed" for r in captured["ledger"]) - whens = {(ci.text, ci.when) for ci in captured["items"]} - assert ("no crash dialog", "at_end") in whens - assert ("alarm exists", "on_complete") in whens - - -def test_on_complete_without_history_evidence_never_passes(): - """The 'current state satisfied but historical evidence missing' case: no - substantive record -> unchecked; a final inconclusive verdict stays - inconclusive. Neither ever resolves to passed.""" - assert resolve_item_status("assert", []) == "unchecked" - inconclusive_only = [{"status": "inconclusive", "evidence": "evidence missing for past moment"}] - assert resolve_item_status("assert", inconclusive_only) == "inconclusive" - # First assert failure is permanent even if a later record passes - assert resolve_item_status("assert", [{"status": "failed"}, {"status": "passed"}]) == "failed" - # Verify: latest substantive verdict wins (repair-then-pass is passed) - assert resolve_item_status("verify", [{"status": "failed"}, {"status": "passed"}]) == "passed" - - -# --- §8.14: run outcome + SDK wrap-up ------------------------------------------------ - - -@pytest.mark.asyncio -async def test_assert_failure_yields_failed_tests_but_completed_status(tmp_path): - _write_plan(tmp_path, DONE_PLAN_WITH_CHECKS) - ctx = _make_ctx(tmp_path) - report = CheckReport( - verdicts=[ - CheckVerdict(item_text="alarm exists", kind="verify", status="passed", evidence="e"), - CheckVerdict(item_text="toast appeared", kind="assert", status="failed", evidence="e"), - CheckVerdict(item_text="no crash dialog", kind="assert", status="passed", evidence="e"), - ] - ) - - with patch("artemis.graph.graph.run_final_check", new=AsyncMock(return_value=report)): - update = await exit_settlement_node(_make_state(), ctx) - - # Assert failures never bounce the task back - assert update["exit_settlement_route"] == "end" - outcome = update["run_outcome"] - assert outcome["task_status"] == "completed" - assert outcome["tests"]["failed"] == 1 - assert outcome["tests"]["passed"] == 2 - assert outcome["tests"]["failed_items"][0]["item_text"] == "toast appeared" - - # SDK wrap-up: trace suffix distinguishes assertion failures from _PASS - assert resolve_trace_suffix("completed", outcome) == "_TESTFAIL" - assert resolve_trace_suffix("completed", {"tests": {"failed": 0}}) == "_PASS" - assert resolve_trace_suffix("failed", outcome) == "_FAIL" - - # And the return structure surfaces the machine-readable summary - wrapped = attach_test_summary("report text", outcome) - assert wrapped["result"] == "report text" - assert wrapped["test_summary"]["failed"] == 1 - as_dict = attach_test_summary({"answer": 42}, outcome) - assert as_dict["answer"] == 42 and as_dict["test_summary"]["failed"] == 1 - - -@pytest.mark.asyncio -async def test_verify_unmet_bounces_back_then_blocks_on_budget_exhaustion(tmp_path): - _write_plan(tmp_path, DONE_PLAN_WITH_CHECKS) - ctx = _make_ctx(tmp_path, final_check_max_attempts=2) - failing = CheckReport( - verdicts=[ - CheckVerdict( - item_text="alarm exists", - kind="verify", - status="failed", - evidence="e", - suggestion="create it", - ) - ], - unmet_subgoals=["Create alarm"], - ) - - with patch("artemis.graph.graph.run_final_check", new=AsyncMock(return_value=failing)): - first = await exit_settlement_node(_make_state(), ctx) - assert first["exit_settlement_route"] == "continue" - assert exit_settlement_gate(_make_state(exit_settlement_route="continue")) == "continue" - # The unmet subgoal was reverted forward to in-progress - plan = (tmp_path / "notes" / "task_plan.md").read_text(encoding="utf-8") - assert "- [/] Create alarm" in plan - assert any("verify failed" in f for f in first["operator_feedback"]) - - # Re-complete and exhaust the budget - _write_plan(tmp_path, DONE_PLAN_WITH_CHECKS) - second = await exit_settlement_node(_make_state(), ctx) - - assert ctx.final_check_attempts == 2 - assert second["exit_settlement_route"] == "end" - outcome = second["run_outcome"] - assert outcome["task_status"] == "blocked" - - -def test_no_run_outcome_without_check_material(tmp_path): - """§1.3: the test summary only exists when check items exist.""" - outcome = compute_run_outcome(parse_plan(DONE_PLAN_NO_CHECKS), [], verify_blocked=False) - assert outcome.tests.passed + outcome.tests.failed == 0 - assert attach_test_summary("plain", outcome.model_dump()) == "plain" - - -# --- §8.15: continuous + user_stop + FAIL never loops back --------------------------- - - -@pytest.mark.asyncio -async def test_user_stop_final_fail_does_not_reenter_loop(tmp_path): - plan = "- [x] [Loop:continuous] Monitor inbox\n - verify: monitoring log is complete\n" - _write_plan(tmp_path, plan) - ctx = _make_ctx(tmp_path) - failing = CheckReport( - verdicts=[ - CheckVerdict( - item_text="monitoring log is complete", - kind="verify", - status="failed", - evidence="gaps in the log", - ) - ] - ) - - with patch("artemis.graph.graph.run_final_check", new=AsyncMock(return_value=failing)): - update = await exit_settlement_node(_make_state(user_stop_requested=True), ctx) - - assert update["exit_settlement_route"] == "end" - assert update["run_outcome"]["task_status"] == "blocked" - - -# --- §8.16: four switch-combination matrix ------------------------------------------- - - -def _fresh_final_report(): - return CheckReport( - verdicts=[ - CheckVerdict(item_text="no crash dialog", kind="assert", status="passed", evidence="e") - ] - ) - - -@pytest.mark.asyncio -async def test_matrix_off_off_reports_all_unchecked(tmp_path): - _write_plan(tmp_path, DONE_PLAN_WITH_CHECKS) - ctx = _make_ctx(tmp_path, disable_midway_checks=True, disable_final_check=True) - with patch("artemis.graph.graph.run_final_check", new=AsyncMock()) as final_mock: - update = await exit_settlement_node(_make_state(), ctx) - final_mock.assert_not_awaited() - outcome = update["run_outcome"] - assert outcome["tests"]["unchecked"] == 3 - assert outcome["tests"]["passed"] == 0 - - -@pytest.mark.asyncio -async def test_matrix_off_on_final_judges_from_state_and_history(tmp_path): - _write_plan(tmp_path, DONE_PLAN_WITH_CHECKS) - ctx = _make_ctx(tmp_path, disable_midway_checks=True) - report = CheckReport( - verdicts=[ - CheckVerdict(item_text="no crash dialog", kind="assert", status="passed", evidence="e"), - CheckVerdict(item_text="alarm exists", kind="verify", status="passed", evidence="e"), - CheckVerdict( - item_text="toast appeared", - kind="assert", - status="inconclusive", - evidence="transient evidence was never captured", - ), - ] - ) - with patch("artemis.graph.graph.run_final_check", new=AsyncMock(return_value=report)): - update = await exit_settlement_node(_make_state(), ctx) - outcome = update["run_outcome"] - assert outcome["tests"]["passed"] == 2 - assert outcome["tests"]["inconclusive"] == 1 - assert outcome["tests"]["failed"] == 0 - - -@pytest.mark.asyncio -async def test_matrix_on_off_settles_without_final(tmp_path): - # covered structurally by test_settlement_runs_phase_one_only_when_final_disabled; - # here assert the summary is still produced with unchecked leftovers. - _write_plan(tmp_path, DONE_PLAN_WITH_CHECKS) - ctx = _make_ctx(tmp_path, disable_final_check=True) - with patch("artemis.graph.graph.run_final_check", new=AsyncMock()) as final_mock: - update = await exit_settlement_node(_make_state(), ctx) - final_mock.assert_not_awaited() - assert update["run_outcome"]["tests"]["unchecked"] == 3 - - -@pytest.mark.asyncio -async def test_matrix_on_on_full_pipeline(tmp_path): - _write_plan(tmp_path, DONE_PLAN_WITH_CHECKS) - ctx = _make_ctx(tmp_path) - # A checkpoint verdict already in the ledger... - append_ledger_record( - tmp_path, - { - "attempt_id": "c#1", - "checkpoint_id": "c", - "item_text": "alarm exists", - "kind": "verify", - "when": "on_complete", - "status": "passed", - "evidence": "probe output", - }, - ) - # ...and the final review supplements the rest. - report = CheckReport( - verdicts=[ - CheckVerdict(item_text="no crash dialog", kind="assert", status="passed", evidence="e"), - CheckVerdict(item_text="toast appeared", kind="assert", status="failed", evidence="e"), - ] - ) - with patch("artemis.graph.graph.run_final_check", new=AsyncMock(return_value=report)): - update = await exit_settlement_node(_make_state(), ctx) - outcome = update["run_outcome"] - assert outcome["tests"]["passed"] == 2 - assert outcome["tests"]["failed"] == 1 - assert outcome["tests"]["unchecked"] == 0 diff --git a/tests/unit/graph/test_graph.py b/tests/unit/graph/test_graph.py deleted file mode 100644 index e8ace068c..000000000 --- a/tests/unit/graph/test_graph.py +++ /dev/null @@ -1,321 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import AsyncMock, MagicMock, patch - -from langchain_core.messages import ToolMessage - -from artemis.context import ArtemisContext -from artemis.graph.graph import wrap_note_tool, wrap_update_note_tool -import pytest - -ORIGINAL_PLAN = """# Test Plan -- [ ] Milestone 1 - - [ ] Substep 1.1 -- [ ] Milestone 2 -""" - - -def _make_ctx(base_dir, *, disable_checker=True, disable_planner_validation=False): - ctx = MagicMock(spec=ArtemisContext) - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = str(base_dir) - ctx.execution_setup = MagicMock() - ctx.execution_setup.disable_checker = disable_checker - ctx.execution_setup.disable_planner_validation = disable_planner_validation - ctx.planner_task = None - ctx.last_validated_plan = None - ctx.pending_checkpoints = [] - ctx.checkpoint_tasks = {} - ctx.pending_validated_plan = None - return ctx - - -def _setup_plan(tmp_path, content=ORIGINAL_PLAN): - notes_dir = tmp_path / "notes" - notes_dir.mkdir(parents=True, exist_ok=True) - task_plan_path = notes_dir / "task_plan.md" - task_plan_path.write_text(content, encoding="utf-8") - return task_plan_path - - -def _mock_tool(name): - tool = MagicMock() - tool.name = name - tool.description = f"{name} tool" - tool.ainvoke = AsyncMock(return_value="Success") - return tool - - -def _fake_update_invoke(task_plan_path): - async def fake_invoke_tool(tool, args, tool_call_id, state, record_trace=None): - content = task_plan_path.read_text(encoding="utf-8") - updated = content.replace(args["target"], args["replacement"]) - task_plan_path.write_text(updated, encoding="utf-8") - return "Success" - - return fake_invoke_tool - - -def _fake_save_invoke(task_plan_path): - async def fake_invoke_tool(tool, args, tool_call_id, state, record_trace=None): - task_plan_path.write_text(args["content"], encoding="utf-8") - return "Success" - - return fake_invoke_tool - - -@pytest.mark.asyncio -async def test_wrap_update_note_tool_valid_status_change(tmp_path): - task_plan_path = _setup_plan(tmp_path) - ctx = _make_ctx(tmp_path) - - with patch( - "artemis.graph.graph.invoke_tool_with_injection", - side_effect=_fake_update_invoke(task_plan_path), - ): - wrapped_tool = wrap_update_note_tool(ctx, _mock_tool("update_note")) - result = await wrapped_tool.ainvoke( - { - "key": "task_plan", - "target": "- [ ] Milestone 1", - "replacement": "- [x] Milestone 1", - "tool_call_id": "test_call_id", - } - ) - - assert result == "Success" - updated_content = task_plan_path.read_text(encoding="utf-8") - assert "- [x] Milestone 1" in updated_content - assert "- [ ] Milestone 2" in updated_content - # Status flips never disturb the milestone texts: no validation spawned - assert ctx.planner_task is None - - -@pytest.mark.asyncio -async def test_wrap_update_note_tool_intentional_rename_triggers_validation(tmp_path): - task_plan_path = _setup_plan(tmp_path) - ctx = _make_ctx(tmp_path) - - mock_validation = AsyncMock(return_value={"status": "success", "feedback": ""}) - with ( - patch( - "artemis.graph.graph.invoke_tool_with_injection", - side_effect=_fake_update_invoke(task_plan_path), - ), - patch("artemis.graph.graph.run_async_planner_validation", mock_validation), - ): - wrapped_tool = wrap_update_note_tool(ctx, _mock_tool("update_note")) - result = await wrapped_tool.ainvoke( - { - "key": "task_plan", - "target": "- [ ] Milestone 1", - "replacement": "- [ ] Milestone Renamed", - "tool_call_id": "test_call_id", - } - ) - assert result == "Success" - # Declared update_note edit is applied, but ratchet validation fires - assert ctx.planner_task is not None - await ctx.planner_task - - assert "Milestone Renamed" in task_plan_path.read_text(encoding="utf-8") - mock_validation.assert_called_once() - # Validation compares against the ratchet baseline (the original plan) - assert mock_validation.call_args.args[2] == ORIGINAL_PLAN - - -@pytest.mark.asyncio -async def test_ratchet_baseline_survives_consecutive_small_edits(tmp_path): - """Salami-slicing: every small rename is judged against the ORIGINAL - validated baseline, not the immediately preceding write.""" - import asyncio - - task_plan_path = _setup_plan(tmp_path) - ctx = _make_ctx(tmp_path) - - gate = asyncio.Event() - - async def blocking_validation(*args, **kwargs): - await gate.wait() - return {"status": "success", "feedback": ""} - - mock_validation = AsyncMock(side_effect=blocking_validation) - with ( - patch( - "artemis.graph.graph.invoke_tool_with_injection", - side_effect=_fake_update_invoke(task_plan_path), - ), - patch("artemis.graph.graph.run_async_planner_validation", mock_validation), - ): - wrapped_tool = wrap_update_note_tool(ctx, _mock_tool("update_note")) - await wrapped_tool.ainvoke( - { - "key": "task_plan", - "target": "- [ ] Milestone 1", - "replacement": "- [ ] Milestone 1a", - "tool_call_id": "call_1", - } - ) - first_task = ctx.planner_task - assert first_task is not None - - await wrapped_tool.ainvoke( - { - "key": "task_plan", - "target": "- [ ] Milestone 1a", - "replacement": "- [ ] Milestone 1b", - "tool_call_id": "call_2", - } - ) - second_task = ctx.planner_task - assert second_task is not first_task - - # The superseded in-flight validation is cancelled - gate.set() - with pytest.raises(asyncio.CancelledError): - await first_task - await second_task - - # Both validation runs were anchored to the original baseline - for call in mock_validation.call_args_list: - assert call.args[2] == ORIGINAL_PLAN - - -@pytest.mark.asyncio -async def test_wrap_update_note_tool_allow_subgoal_changes(tmp_path): - task_plan_path = _setup_plan(tmp_path) - ctx = _make_ctx(tmp_path) - - with patch( - "artemis.graph.graph.invoke_tool_with_injection", - side_effect=_fake_update_invoke(task_plan_path), - ): - wrapped_tool = wrap_update_note_tool(ctx, _mock_tool("update_note")) - result = await wrapped_tool.ainvoke( - { - "key": "task_plan", - "target": " - [ ] Substep 1.1", - "replacement": " - [x] Substep 1.1 Completed", - "tool_call_id": "test_call_id", - } - ) - - assert result == "Success" - updated_content = task_plan_path.read_text(encoding="utf-8") - assert "Substep 1.1 Completed" in updated_content - assert "- [ ] Milestone 1" in updated_content - # Sub-task edits never touch the top-level milestone texts - assert ctx.planner_task is None - - -@pytest.mark.asyncio -async def test_wrap_note_tool_full_rewrite_hand_slip_rejected(tmp_path): - """A save_note full rewrite that rewords status-unchanged milestones is the - hand-slip signature: rejected and rolled back, no validation spawned.""" - task_plan_path = _setup_plan(tmp_path) - ctx = _make_ctx(tmp_path) - - corrupted_plan = """# Test Plan -- [ ] Milestone 1 (Altered) -- [ ] New Milestone Added -""" - - with patch( - "artemis.graph.graph.invoke_tool_with_injection", - side_effect=_fake_save_invoke(task_plan_path), - ): - wrapped_tool = wrap_note_tool(ctx, _mock_tool("save_note")) - result = await wrapped_tool.ainvoke( - { - "key": "task_plan", - "content": corrupted_plan, - "tool_call_id": "test_call_id", - } - ) - - assert isinstance(result, ToolMessage) - assert task_plan_path.read_text(encoding="utf-8") == ORIGINAL_PLAN - assert ctx.planner_task is None - - -@pytest.mark.asyncio -async def test_wrap_note_tool_lexical_drift_rejected(tmp_path): - """Even a tiny same-status rewording in a full rewrite bounces back with - guidance — this is exactly the drift that similarity thresholds missed.""" - original_plan = """# Test Plan -- [ ] Open the Chrome browser and navigate to youtube.com -""" - task_plan_path = _setup_plan(tmp_path, original_plan) - ctx = _make_ctx(tmp_path) - - minor_drift_plan = """# Test Plan -- [ ] Open Chrome browser and navigate to youtube.com -""" - - with patch( - "artemis.graph.graph.invoke_tool_with_injection", - side_effect=_fake_save_invoke(task_plan_path), - ): - wrapped_tool = wrap_note_tool(ctx, _mock_tool("save_note")) - result = await wrapped_tool.ainvoke( - { - "key": "task_plan", - "content": minor_drift_plan, - "tool_call_id": "test_call_id", - } - ) - - assert isinstance(result, ToolMessage) - assert "update_note" in str(result.content) - assert task_plan_path.read_text(encoding="utf-8") == original_plan - assert ctx.planner_task is None - - -@pytest.mark.asyncio -async def test_wrap_note_tool_structural_replan_triggers_validation(tmp_path): - """Adding a milestone via full rewrite is a declared replan: applied, then - judged asynchronously by the planner against the ratchet baseline.""" - task_plan_path = _setup_plan(tmp_path) - ctx = _make_ctx(tmp_path) - - extended_plan = """# Test Plan -- [ ] Milestone 1 - - [ ] Substep 1.1 -- [ ] Milestone 2 -- [ ] Milestone 3 (new phase) -""" - - mock_validation = AsyncMock(return_value={"status": "success", "feedback": ""}) - with ( - patch( - "artemis.graph.graph.invoke_tool_with_injection", - side_effect=_fake_save_invoke(task_plan_path), - ), - patch("artemis.graph.graph.run_async_planner_validation", mock_validation), - ): - wrapped_tool = wrap_note_tool(ctx, _mock_tool("save_note")) - result = await wrapped_tool.ainvoke( - { - "key": "task_plan", - "content": extended_plan, - "tool_call_id": "test_call_id", - } - ) - assert result == "Success" - assert ctx.planner_task is not None - await ctx.planner_task - - assert "Milestone 3 (new phase)" in task_plan_path.read_text(encoding="utf-8") - mock_validation.assert_called_once() diff --git a/tests/unit/graph/test_guidance_plan_edit.py b/tests/unit/graph/test_guidance_plan_edit.py deleted file mode 100644 index 051038333..000000000 --- a/tests/unit/graph/test_guidance_plan_edit.py +++ /dev/null @@ -1,95 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Perception side of the guidance waiver: a non-empty user instruction records -the plan's current check lines in ``ctx.guidance_unprotected_checks`` on the run -context (which outlives the per-turn State rebuild); stop-only or empty -payloads do not, and an ordinary turn never touches the set.""" - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from artemis.graph.perception import perception_node -from artemis.graph.state import State - - -async def _run_perception(ctx, payload): - with ( - patch("artemis.graph.perception.is_ocr_configured", return_value=False), - patch("artemis.graph.perception.UnifiedMobileController") as controller_cls, - patch("artemis.graph.perception._check_injected_instruction_file", return_value=payload), - ): - device_data = MagicMock() - device_data.width = 1080 - device_data.height = 2400 - device_data.base64 = "ZHVtbXk=" - device_data.elements = [] - controller = MagicMock() - controller.get_screen_data = AsyncMock(return_value=device_data) - controller_cls.return_value = controller - - state = MagicMock(spec=State) - state.structured_decisions = [] - return await perception_node(state, ctx) - - -PLAN = "- [/] Set the alarm\n - verify: the alarm list shows 7:30 AM\n- [ ] Next milestone\n" - - -def _ctx(tmp_path): - from artemis.utils.notes import get_note_file_path - - ctx = MagicMock() - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = tmp_path - ctx.data_engine.current_step_id = "step_1" - ctx.device = MagicMock() - ctx.guidance_unprotected_checks = set() - plan_path = get_note_file_path(tmp_path, "task_plan") - plan_path.parent.mkdir(parents=True, exist_ok=True) - plan_path.write_text(PLAN, encoding="utf-8") - return ctx - - -@pytest.mark.asyncio -async def test_instruction_waives_the_current_check_lines_and_still_reaches_state(tmp_path): - ctx = _ctx(tmp_path) - update = await _run_perception(ctx, {"instruction": "Skip the toast check"}) - assert ctx.guidance_unprotected_checks == {("verify", "the alarm list shows 7:30 AM")} - assert update["injected_instruction"] == "Skip the toast check" - assert update["user_stop_requested"] is False - - -@pytest.mark.asyncio -async def test_stop_only_or_blank_payload_does_not_arm(tmp_path): - ctx = _ctx(tmp_path) - update = await _run_perception(ctx, {"release_loop": True}) - assert update["user_stop_requested"] is True - assert ctx.guidance_unprotected_checks == set() - - update = await _run_perception(ctx, {"instruction": " ", "release_loop": True}) - assert update["user_stop_requested"] is True - assert ctx.guidance_unprotected_checks == set() - - -@pytest.mark.asyncio -async def test_ordinary_turn_leaves_the_waiver_as_it_was(tmp_path): - """The per-turn reset applies to State (``injected_instruction`` goes back - to None); the waiver on the context lives for the rest of the run.""" - ctx = _ctx(tmp_path) - ctx.guidance_unprotected_checks = {("verify", "earlier line")} - update = await _run_perception(ctx, None) - assert update["injected_instruction"] is None - assert ctx.guidance_unprotected_checks == {("verify", "earlier line")} diff --git a/tests/unit/graph/test_state_contract.py b/tests/unit/graph/test_state_contract.py deleted file mode 100644 index 8dc8a58bb..000000000 --- a/tests/unit/graph/test_state_contract.py +++ /dev/null @@ -1,110 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Phase 1 contract tests: explicit State + per-node visibility manifests.""" - -import pytest -from pydantic import ValidationError - -from artemis.graph.state import State -from artemis.graph.visibility import ( - NODE_VISIBILITY, - StateView, - VisibilityError, - check_update, - strict_state, - validate_manifest, -) - - -def test_initial_state_round_trips_through_channel_values(): - """sdk/agent.py rebuilds State(**values) from the astream values stream; - with extra="forbid" every dumped key must construct back cleanly.""" - state = State.initial("test goal") - rebuilt = State(**state.model_dump()) - assert rebuilt.initial_goal == "test goal" - assert rebuilt.subagent_calls == [] - - -def test_unknown_constructor_key_is_rejected(): - with pytest.raises(ValidationError): - State(initial_goal="g", ghost_key=1) - - -def test_unknown_attribute_write_is_rejected(): - state = State.initial("g") - with pytest.raises(ValueError): - state.ui_tree = [] # the historical ghost key must never come back - - -def test_deleted_dead_fields_are_gone(): - for dead in ( - "messages", - "validator_messages", - "remaining_steps", - "focused_app_info", - "device_date", - "subgoal_plan", - "operator_tactical_plan", - "complete_subgoals_by_ids", - "current_agent", - "operator_replan_reason", - "check_feedback", - ): - assert dead not in State.model_fields, dead - - -def test_manifest_only_references_declared_fields(): - assert validate_manifest() == [] - - -def test_state_view_allows_declared_and_blocks_undeclared_reads(): - state = State.initial("g") - view = StateView(state, "operator") - assert view.initial_goal == "g" # declared read - with pytest.raises(VisibilityError): - _ = view.exit_settlement_route # not in operator's manifest - - -def test_state_view_blocks_undeclared_mutation(): - state = State.initial("g") - view = StateView(state, "summarizer") # summarizer declares no writes - with pytest.raises(VisibilityError): - view.structured_decisions = "[]" - - -def test_state_view_passes_methods_through(): - view = StateView(State.initial("g"), "operator") - dumped = view.model_dump() - assert dumped["initial_goal"] == "g" - - -def test_check_update_flags_undeclared_write(): - with pytest.raises(VisibilityError): - check_update("summarizer", {"structured_decisions": "[]"}) - check_update("operator", {"structured_decisions": "[]"}) # declared: ok - check_update("operator", None) # empty update: ok - - -def test_strict_state_is_identity_when_disabled(monkeypatch): - monkeypatch.delenv("ARTEMIS_STRICT_STATE", raising=False) - state = State.initial("g") - assert strict_state(state, "operator") is state - - -def test_strict_state_wraps_when_enabled(monkeypatch): - monkeypatch.setenv("ARTEMIS_STRICT_STATE", "1") - state = State.initial("g") - view = strict_state(state, "operator") - assert isinstance(view, StateView) diff --git a/tests/unit/mcp/test_action_executor_helper_tools.py b/tests/unit/mcp/test_action_executor_helper_tools.py deleted file mode 100644 index adb9a83bf..000000000 --- a/tests/unit/mcp/test_action_executor_helper_tools.py +++ /dev/null @@ -1,301 +0,0 @@ -"""Helper-tool routing in McpActionExecutor (Flash profile). - -The Flash runner binds ``ask_explorer``, ``video_analyzer`` and the history -tools (``search_history`` / ``replay_steps`` / ``get_step_screenshot``) next to its device actions; the executor must -route them to the same implementations the Pro operator's LangChain tools -delegate to, as agent-side text tools (no screenshot capture, no device -action), and report their status explicitly instead of sniffing the text for -the word "Error". -""" - -from unittest.mock import AsyncMock, Mock, patch - -import pytest - -from artemis.mcp.action_executor import AGENT_TOOL_NAMES, McpActionExecutor -from artemis.tools.explorer_tool import ExplorerCandidate, ExplorerOutcome - - -def _make_executor(**kwargs): - ctx = Mock() - ctx.data_engine = None - ctx.device.device_width = 1080 - ctx.device.device_height = 2400 - actuator = Mock() - actuator.controller = Mock() - actuator.extensions.return_value = [] - return McpActionExecutor(ctx, actuator=actuator, **kwargs) - - -def _state(): - state = Mock() - state.latest_screenshot = "/tmp/shot.jpg" - state.indexed_points = [] - state.indexed_elements = [] - state.operator_raw_data = None - return state - - -def test_helper_tools_are_not_device_actions(): - executor = _make_executor() - assert { - "ask_explorer", - "video_analyzer", - "search_history", - "replay_steps", - "get_step_screenshot", - } <= AGENT_TOOL_NAMES - assert not (AGENT_TOOL_NAMES & executor.action_tool_names) - - -# --------------------------------------------------------------------------- -# ask_explorer -# --------------------------------------------------------------------------- - - -def test_executor_agent_name_defaults_to_validator_and_is_configurable(): - assert _make_executor().agent_name == "validator" - assert _make_executor(agent_name="flash").agent_name == "flash" - - -@pytest.mark.asyncio -async def test_ask_explorer_runs_the_pipeline_for_the_executor_agent(): - """The tier is resolved from the executor's agent name, never from the call.""" - executor = _make_executor(agent_name="flash") - state = _state() - outcome = ExplorerOutcome( - candidates=[ExplorerCandidate(label="S1", coords=(500, 500), description="Send")] - ) - with patch("artemis.tools.explorer_tool.locate", new=AsyncMock(return_value=outcome)) as locate: - result = await executor.execute( - "ask_explorer", {"query": "blue Send button", "context_feedback": "n/a"}, "tc", state - ) - - locate.assert_awaited_once_with( - executor.ctx, state, "blue Send button", "n/a", agent_name="flash" - ) - assert result.status == "success" - assert "Explorer located 1 candidate(s) for 'blue Send button'" in result.text_summary - assert "[1] 'Send' at normalized [500, 500]" in result.text_summary - # This executor's click resolves an index against the element list the - # candidates just joined, so the guidance teaches the index syntax (Pro - # parity) next to the coordinates. - assert "target=3, a bare integer" in result.text_summary - assert "normalized coordinate" in result.text_summary - # Candidates were registered on the state for index-based actions. - assert state.indexed_points == [[540, 1200]] - assert state.indexed_elements[0]["index"] == 1 - assert result.screenshot_bytes is None - assert result.ui_elements_text is None - - -@pytest.mark.asyncio -async def test_ask_explorer_clean_not_found_is_a_successful_answer(): - executor = _make_executor() - outcome = ExplorerOutcome(message="Nothing like that is visible; the keyboard covers it.") - with patch("artemis.tools.explorer_tool.locate", new=AsyncMock(return_value=outcome)): - result = await executor.execute("ask_explorer", {"query": "gear icon"}, "tc", _state()) - assert result.status == "success" - assert "Explorer could not locate 'gear icon'" in result.text_summary - assert "keyboard covers it" in result.text_summary - - -@pytest.mark.asyncio -async def test_ask_explorer_run_failure_is_an_error_result(): - executor = _make_executor() - outcome = ExplorerOutcome.failure("Explorer failed: model quota exhausted") - with patch("artemis.tools.explorer_tool.locate", new=AsyncMock(return_value=outcome)): - result = await executor.execute("ask_explorer", {"query": "gear icon"}, "tc", _state()) - assert result.status == "error" - assert "Explorer could not run for 'gear icon'" in result.text_summary - assert "model quota exhausted" in result.text_summary - - -@pytest.mark.asyncio -async def test_ask_explorer_accepts_the_legacy_task_description_alias(): - executor = _make_executor() - outcome = ExplorerOutcome(message="not here") - with patch("artemis.tools.explorer_tool.locate", new=AsyncMock(return_value=outcome)) as locate: - result = await executor.execute( - "ask_explorer", {"task_description": "gear icon top-right"}, "tc", _state() - ) - assert locate.await_args.args[2] == "gear icon top-right" - assert locate.await_args.args[3] == "" - assert result.status == "success" - - -@pytest.mark.asyncio -async def test_ask_explorer_exception_is_contained(): - executor = _make_executor() - with patch( - "artemis.tools.explorer_tool.locate", new=AsyncMock(side_effect=RuntimeError("boom")) - ): - result = await executor.execute("ask_explorer", {"query": "x"}, "tc", _state()) - assert result.status == "error" - assert result.text_summary == "Error executing ask_explorer: boom" - - -@pytest.mark.asyncio -async def test_default_agent_name_reaches_the_tier_resolver_and_status_follows_the_outcome(): - """Without an explicit agent name the executor asks the Explorer as ``validator``; - a clean not-found is an answer, an Explorer run failure is an error.""" - executor = _make_executor() - assert executor.agent_name == "validator" - - not_found = ExplorerOutcome(message="Not visible.") - with patch( - "artemis.tools.explorer_tool.locate", new=AsyncMock(return_value=not_found) - ) as locate: - result = await executor.execute( - "ask_explorer", {"task_description": "gear icon"}, "tc", _state() - ) - assert locate.await_args.args[2] == "gear icon" - assert locate.await_args.kwargs["agent_name"] == "validator" - assert result.status == "success" - - failed = ExplorerOutcome.failure("Explorer failed: boom") - with patch("artemis.tools.explorer_tool.locate", new=AsyncMock(return_value=failed)): - result = await executor.execute("ask_explorer", {"query": "gear icon"}, "tc", _state()) - assert result.status == "error" - - -@pytest.mark.asyncio -async def test_flash_agent_name_reaches_the_tier_resolver(): - """The Flash profile resolves the Explorer tier from ``agent_name="flash"``.""" - executor = _make_executor(agent_name="flash") - with patch( - "artemis.tools.explorer_tool.locate", - new=AsyncMock(return_value=ExplorerOutcome(message="no")), - ) as locate: - await executor.execute("ask_explorer", {"query": "x"}, "tc", _state()) - assert locate.await_args.kwargs["agent_name"] == "flash" - assert locate.await_args.args[0] is executor.ctx - - -# --------------------------------------------------------------------------- -# video_analyzer -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_video_analyzer_routes_to_the_subagent_as_a_text_tool(): - executor = _make_executor() - with patch("artemis.agents.video_analyzer.video_analyzer.VideoAnalyzer") as analyzer_cls: - analyzer_cls.return_value.run = AsyncMock( - return_value=("The ad finished at 12s; an Error dialog never appeared.", "success") - ) - result = await executor.execute( - "video_analyzer", - {"time_description": "from 5s to 12s", "purpose": "what played"}, - "tc-video", - None, - ) - - analyzer_cls.return_value.run.assert_awaited_once_with("from 5s to 12s", "what played") - assert result.status == "success" # explicit status, not text sniffing - assert result.text_summary.startswith("The ad finished at 12s") - assert result.screenshot_bytes is None - assert result.ui_elements_text is None - assert result.raw_result is None - - -@pytest.mark.asyncio -async def test_video_analyzer_failure_is_an_error_result(): - executor = _make_executor() - with patch("artemis.agents.video_analyzer.video_analyzer.VideoAnalyzer") as analyzer_cls: - analyzer_cls.return_value.run = AsyncMock(return_value=("no recording found", "failed")) - result = await executor.execute( - "video_analyzer", {"time_description": "from 0s to 3s", "purpose": "x"}, "tc", None - ) - assert result.status == "error" - assert result.text_summary == "Video analysis failed: no recording found" - - -@pytest.mark.asyncio -async def test_video_analyzer_exception_is_contained(): - executor = _make_executor() - with patch("artemis.agents.video_analyzer.video_analyzer.VideoAnalyzer") as analyzer_cls: - analyzer_cls.return_value.run = AsyncMock(side_effect=RuntimeError("boom")) - result = await executor.execute( - "video_analyzer", {"time_description": "from 0s to 3s", "purpose": "x"}, "tc", None - ) - assert result.status == "error" - assert "boom" in result.text_summary - - -# --------------------------------------------------------------------------- -# history tools (search_history / replay_steps / get_step_screenshot) -# --------------------------------------------------------------------------- - - -def _history_tool_double(return_value=None, side_effect=None): - from artemis.tools.history import SearchHistoryArgs - - tool = Mock() - tool.args_schema = SearchHistoryArgs - tool.execute = AsyncMock(return_value=return_value, side_effect=side_effect) - return tool - - -@pytest.mark.asyncio -async def test_history_tool_routes_to_the_shared_tool_with_text_result(): - executor = _make_executor() - tool = _history_tool_double(return_value="- Step 3 (T+00:30): tap btn3 -> executed") - with patch("artemis.mcp.action_executor.history_tool_by_name", return_value=tool) as by_name: - result = await executor.execute( - "search_history", - {"query": "login Error timeout", "step_range": [1, 5], "unknown_arg": 1}, - "tc-search", - None, - ) - - by_name.assert_called_once_with("search_history") - tool.execute.assert_awaited_once_with( - ctx=executor.ctx, query="login Error timeout", step_range=[1, 5] - ) - # A lookup answer mentioning "Error" is still a successful lookup. - assert result.status == "success" - assert result.text_summary == "- Step 3 (T+00:30): tap btn3 -> executed" - assert result.raw_result is None - - -@pytest.mark.asyncio -async def test_history_tool_forwards_multimodal_blocks(): - executor = _make_executor() - blocks = [ - {"type": "text", "text": "Screenshot of step 4 (pre-action) is attached."}, - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,AAA"}}, - ] - tool = _history_tool_double(return_value=blocks) - with patch("artemis.mcp.action_executor.history_tool_by_name", return_value=tool): - result = await executor.execute("get_step_screenshot", {"query": "unused"}, "tc", None) - assert result.status == "success" - assert result.text_summary == "Screenshot of step 4 (pre-action) is attached." - assert result.raw_result == blocks - - -@pytest.mark.asyncio -async def test_history_tool_exception_is_contained(): - executor = _make_executor() - tool = _history_tool_double(side_effect=RuntimeError("db gone")) - with patch("artemis.mcp.action_executor.history_tool_by_name", return_value=tool): - result = await executor.execute("replay_steps", {"query": "x"}, "tc", None) - # Contained (no exception escapes) and reported structurally: the answer - # explains the failure and the status says it is one. - assert result.status == "error" - assert result.text_summary == "replay_steps failed: db gone" - - -@pytest.mark.asyncio -async def test_history_tools_dispatch_to_the_real_shared_instances(): - """No per-tool branch: every history tool name resolves through the package.""" - executor = _make_executor() # ctx.data_engine is None -> tools report a failure - for name in ("search_history", "replay_steps", "get_step_screenshot"): - result = await executor.execute( - name, {"query": "x", "start_step": 1, "step_number": 1}, "tc", None - ) - assert result.status == "error" - assert ( - "no active execution history" in result.text_summary - or "no execution history" in result.text_summary - ) diff --git a/tests/unit/mcp/test_action_executor_semantics.py b/tests/unit/mcp/test_action_executor_semantics.py deleted file mode 100644 index 1efa09adf..000000000 --- a/tests/unit/mcp/test_action_executor_semantics.py +++ /dev/null @@ -1,349 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Targets are either observed (an element index) or described (coordinates). - -Flash binds the Pro Operator's agent dialect: a click, long press or focused -input names an element index from the indexed UI list -- resolved here to the -element's center with its observed text/bounds/id recorded -- or a coordinate -pair, which the model must describe itself. A coordinate swipe or click -sequence must be described too. Nothing is inferred, the description never -reaches the wire, and the wire only ever sees coordinates. -""" - -from unittest.mock import Mock - -import pytest - -from artemis.mcp.action_executor import McpActionExecutor, _ArgError - - -def _make_executor(width=1080, height=2400): - ctx = Mock() - ctx.device.device_width = width - ctx.device.device_height = height - actuator = Mock() - actuator.controller = Mock() - return McpActionExecutor(ctx, actuator=actuator) - - -def _state(): - state = Mock() - state.indexed_elements = [] - state.indexed_points = [[540, 1440], [745, 1440]] - state.latest_ui_hierarchy = None - return state - - -def _indexed_state(): - """Two observed elements (pixel centers, the shape ``observe()`` writes).""" - state = _state() - state.indexed_elements = [ - { - "index": 1, - "center": [540, 1248], - "text": "Wi-Fi", - "bounds": [40, 1152, 1040, 1344], - "class": "android.widget.TextView", - "resource_id": "android:id/title", - "is_ocr": False, - }, - { - "index": 2, - "center": [540, 720], - "text": "Search settings", - "bounds": [80, 660, 1000, 780], - "class": "android.widget.EditText", - "resource_id": "com.android.settings:id/search", - "is_ocr": False, - }, - ] - state.indexed_points = [el["center"] for el in state.indexed_elements] - return state - - -# --- Index targets: resolved against the element list, observed semantics recorded --- - - -@pytest.mark.parametrize("index", [1, "1", 1.0, [1]]) -def test_click_index_resolves_to_the_elements_center_and_records_observed_fields(index): - executor = _make_executor() - wire_name, wire_args, _, recorded = executor._translate( - "click", {"target": index}, _indexed_state() - ) - assert wire_name == "click" - # 540/1080 -> 500, 1248/2400 -> 520 (normalized 0-1000) - assert wire_args == {"target": [500, 520], "times": 1, "delay_ms": 100} - assert recorded == { - "target_text": "Wi-Fi", - "target_bounds": [37, 480, 963, 560], - "target_resource_id": "android:id/title", - "target_class": "android.widget.TextView", - } - assert "target_description" not in recorded - - -def test_index_target_needs_no_description_and_ignores_a_stray_one(): - """An index already names its element; the model's belief never mixes with - the observation on the record.""" - executor = _make_executor() - _, wire_args, _, recorded = executor._translate( - "click", {"target": 2, "target_description": "the search box"}, _indexed_state() - ) - assert wire_args["target"] == [500, 300] - assert recorded["target_text"] == "Search settings" - assert "target_description" not in recorded - - -def test_long_press_and_input_text_resolve_indices_too(): - executor = _make_executor() - _, press_args, _, recorded = executor._translate( - "long_press", {"target": 1, "duration": 1500}, _indexed_state() - ) - assert press_args == {"target": [500, 520], "duration_ms": 1500} - assert recorded["target_text"] == "Wi-Fi" - - _, type_args, _, recorded = executor._translate( - "input_text", {"text": "wifi", "target": 2}, _indexed_state() - ) - assert type_args == {"text": "wifi", "target": [500, 300], "clear_exist": True} - assert recorded["target_resource_id"] == "com.android.settings:id/search" - - -@pytest.mark.parametrize("name, label", [("click", "click"), ("long_press", "long press")]) -def test_out_of_range_index_is_refused_with_the_active_range(name, label): - executor = _make_executor() - with pytest.raises(_ArgError) as excinfo: - executor._translate(name, {"target": 7}, _indexed_state()) - message = str(excinfo.value) - assert message.startswith(f"Error during {label}: Invalid target index 7.") - assert "1 to 2" in message - assert "ask_explorer" in message - - -def test_index_against_an_empty_list_is_refused(): - executor = _make_executor() - with pytest.raises(_ArgError) as excinfo: - executor._translate("click", {"target": 1}, _state()) - message = str(excinfo.value) - assert "Invalid target index 1" in message - assert "empty" in message - - -def test_unusable_target_is_refused_with_both_syntaxes_named(): - executor = _make_executor() - with pytest.raises(_ArgError) as excinfo: - executor._translate("click", {"target": "the button"}, _indexed_state()) - message = str(excinfo.value) - assert message.startswith("Error during click: Invalid target format") - assert "element index" in message and "[x, y]" in message - - -def test_index_resolution_uses_the_live_device_resolution(): - """The element list is built against the device size ``observe()`` wrote - on the context; a different resolution maps the same pixels differently.""" - executor = _make_executor(width=720, height=1600) - state = _indexed_state() - state.indexed_elements[0]["center"] = [360, 800] - _, wire_args, _, _ = executor._translate("click", {"target": 1}, state) - assert wire_args["target"] == [500, 500] - - -# --- Coordinate targets: the model describes what it aims at ------------------------- - - -@pytest.mark.parametrize( - "name, args, label", - [ - ("click", {"target": [500, 600]}, "click"), - ("long_press", {"target": [500, 600]}, "long press"), - ("input_text", {"text": "hi", "target": [500, 600]}, "input text"), - ("swipe", {"start": [200, 600], "end": [800, 600]}, "swipe"), - ], -) -def test_coordinate_targets_without_description_are_refused(name, args, label): - executor = _make_executor() - with pytest.raises(_ArgError) as excinfo: - executor._translate(name, args, _state()) - message = str(excinfo.value) - assert message.startswith(f"Error during {label}") - assert "target_description" in message - - -@pytest.mark.parametrize("blank", ["", " ", None, 7]) -def test_blank_or_non_string_description_is_refused(blank): - executor = _make_executor() - with pytest.raises(_ArgError): - executor._translate("click", {"target": [500, 600], "target_description": blank}, _state()) - - -def test_description_is_accepted_and_kept_off_the_wire(): - executor = _make_executor() - wire_name, wire_args, _, _ = executor._translate( - "click", {"target": [500, 600], "target_description": "play button"}, _state() - ) - assert wire_name == "click" - assert wire_args == {"target": [500, 600], "times": 1, "delay_ms": 100} - assert "target_description" not in wire_args - - _, swipe_args, _, _ = executor._translate( - "swipe", - {"start": [200, 600], "end": [800, 600], "target_description": "brightness knob"}, - _state(), - ) - assert swipe_args == {"start": [200, 600], "end": [800, 600], "duration_ms": 400} - - -def test_recorded_target_is_the_cleaned_description_in_pro_shape(): - """The recorded semantics come from the same check that requires them.""" - executor = _make_executor() - _, _, _, recorded = executor._translate( - "click", {"target": [1, 2], "target_description": " ok "}, _state() - ) - assert recorded == {"target_description": "ok"} - - _, _, _, recorded = executor._translate( - "long_press", {"target": [1, 2], "target_description": "thumbnail"}, _state() - ) - assert recorded == {"target_description": "thumbnail"} - - _, _, _, recorded = executor._translate( - "click_sequence", {"sequence": [[1, 2]], "target_descriptions": ["a "]}, _state() - ) - assert recorded == {"target_descriptions": ["a"]} - - # No coordinate target: nothing is recorded, nothing inferred. - _, _, _, recorded = executor._translate("press_key", {"key": "BACK"}, _state()) - assert recorded == {} - - -def test_coordinate_swipe_records_its_description(): - executor = _make_executor() - _, wire_args, _, recorded = executor._translate( - "swipe", - {"start": [200, 600], "end": [800, 600], "target_description": " brightness knob "}, - _state(), - ) - assert recorded == {"target_description": "brightness knob"} - assert "target_description" not in wire_args - - -def test_targeted_input_records_its_description(): - executor = _make_executor() - _, wire_args, _, recorded = executor._translate( - "input_text", - {"text": "hi", "target": [500, 600], "target_description": "search input"}, - _state(), - ) - assert wire_args["target"] == [500, 600] - assert recorded == {"target_description": "search input"} - assert "target_description" not in wire_args - - -def test_focused_typing_without_a_target_needs_no_description(): - executor = _make_executor() - _, wire_args, _, recorded = executor._translate( - "input_text", {"text": "hi", "target": None}, _state() - ) - assert wire_args["target"] is None - assert recorded == {} - - -def test_focused_typing_ignores_a_stray_description(): - """Typing into the focused field has no coordinate target: a description the - model tacked on anyway is not recorded, so no self-described target enters - the history for an action that aimed at nothing.""" - executor = _make_executor() - _, wire_args, _, recorded = executor._translate( - "input_text", - {"text": "hi", "target": None, "target_description": "search input"}, - _state(), - ) - assert wire_args["target"] is None - assert recorded == {} - - -def test_directional_swipe_needs_no_description(): - executor = _make_executor() - wire_name, wire_args, _, recorded = executor._translate("swipe", {"direction": "up"}, _state()) - assert wire_name == "swipe" - assert "start" in wire_args and "end" in wire_args - assert recorded == {} - - -def test_directional_swipe_ignores_a_stray_description(): - executor = _make_executor() - _, wire_args, _, recorded = executor._translate( - "swipe", {"direction": "up", "target_description": "the feed"}, _state() - ) - assert "target_description" not in wire_args - assert recorded == {} - - -def test_click_sequence_requires_one_description_per_entry(): - executor = _make_executor() - sequence = [[500, 600], [690, 600], [10, 10]] - - with pytest.raises(_ArgError) as excinfo: - executor._translate("click_sequence", {"sequence": sequence}, _state()) - assert "target_descriptions" in str(excinfo.value) - assert "3 expected" in str(excinfo.value) - - # Wrong length is refused too. - with pytest.raises(_ArgError): - executor._translate( - "click_sequence", - {"sequence": sequence, "target_descriptions": ["video body", "skip"]}, - _state(), - ) - - _, wire_args, _, recorded = executor._translate( - "click_sequence", - {"sequence": sequence, "target_descriptions": ["video body", "digit 2", "corner"]}, - _state(), - ) - assert wire_args["sequence"] == [[500, 600], [690, 600], [10, 10]] - assert "target_descriptions" not in wire_args - assert recorded == {"target_descriptions": ["video body", "digit 2", "corner"]} - - -@pytest.mark.parametrize("index_entry", [2, "2", 2.0, [2]]) -def test_click_sequence_refuses_element_indices(index_entry): - """click_sequence takes coordinate pairs only. An index would be resolved - against the element list and then recorded under the model's own - description, reading back as a self-described coordinate target.""" - executor = _make_executor() - sequence = [[500, 600], index_entry, [10, 10]] - - with pytest.raises(_ArgError) as excinfo: - executor._translate( - "click_sequence", - {"sequence": sequence, "target_descriptions": ["video body", "digit 2", "corner"]}, - _state(), - ) - message = str(excinfo.value) - assert message.startswith("Error during click sequence") - assert "entry 2" in message - assert "coordinate" in message - assert "ask_explorer" in message - - -def test_click_sequence_accepts_a_serialized_pair_list(): - executor = _make_executor() - _, wire_args, _, _ = executor._translate( - "click_sequence", - {"sequence": "[[500, 600], [10, 10]]", "target_descriptions": ["video body", "corner"]}, - _state(), - ) - assert wire_args["sequence"] == [[500, 600], [10, 10]] diff --git a/tests/unit/mcp/test_action_manifest.py b/tests/unit/mcp/test_action_manifest.py index f8eb53a36..684183e5e 100644 --- a/tests/unit/mcp/test_action_manifest.py +++ b/tests/unit/mcp/test_action_manifest.py @@ -12,24 +12,21 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Prompt <-> manifest consistency and actuator contract tests. +"""Device action manifest classification and actuator contract tests. -The scan below re-derives the manifest classification from the prompt sources, so -adding a tool name to a prompt without classifying it in -``artemis/mcp/action_manifest.py`` fails here rather than at runtime. +The smart-agent prompts used to live in this repository and were cross-checked +against the manifest here; they moved to the DSH host with the agent stack, so +the prompt-scan invariants were removed with them. What remains is the device +action manifest itself: disjoint action classification, the actuator contract, +and the declaration surface derivation. """ -import json -from pathlib import Path -import re - import pytest from artemis.core.tool_declaration import ToolDeclaration from artemis.mcp.action_manifest import ( ActuatorContractError, AGENT_ROLES, - ALL_KNOWN_TOOLS, BACKEND_INDEPENDENT_TOOLS, DEVICE_ACTIONS, ExtensionTool, @@ -41,89 +38,8 @@ validate_actuator, ) -REPO_ROOT = Path(__file__).resolve().parents[3] - -# Prompt sources belonging to the agents this manifest governs (plus the -# Validator's safety net). Other agents (log_analyzer, image_processor, ...) have their -# own tool universes and are deliberately out of scope. -SCOPED_PROMPT_FILES = [ - "artemis/agents/flash/flash_runner.md", - "artemis/agents/validator/pixel_safety_net.md", -] - -# Backtick-quoted identifiers in scoped prompts that are not tools: parameter names, -# note keys, and the availability-slot template helper rendered by -# apply_operator_prompt_contract. Anything new landing here should be a conscious -# decision. -#: Backticked identifiers in prompts that are parameters or note keys, not tools. -NON_TOOL_IDENTIFIERS = frozenset({"analysis", "task_plan", "tool_enum", "target_description"}) - -_TOOL_REF = re.compile(r"(?:`([a-z][a-z0-9_]{2,})`|\b([a-z][a-z0-9_]{2,})\()") - - -def _scoped_prompt_sources() -> dict[str, str]: - """Collects all in-scope prompt texts, keyed by a short source label.""" - sources: dict[str, str] = {} - for rel in SCOPED_PROMPT_FILES: - path = REPO_ROOT / rel - sources[path.name] = path.read_text(encoding="utf-8") - - # The operator templates carry all operator prompt prose (teaching segments are - # gated in-template by availability slots; prompts.py holds no prompt text). - operator_json = REPO_ROOT / "artemis/agents/operator/operator.json" - for key, text in json.loads(operator_json.read_text(encoding="utf-8")).items(): - sources[f"operator.json:{key}"] = text - - return sources - - -def _referenced_tools() -> dict[str, set[str]]: - """Returns {identifier: source labels} for every tool-shaped reference in scope.""" - found: dict[str, set[str]] = {} - for label, text in _scoped_prompt_sources().items(): - for match in _TOOL_REF.finditer(text): - name = match.group(1) or match.group(2) - found.setdefault(name, set()).add(label) - return found - - -# --- Prompt <-> manifest invariants -------------------------------------------------- - - -def test_scan_finds_prompt_sources(): - """The scan must cover all expected sources; silent scope loss would void the suite.""" - sources = _scoped_prompt_sources() - assert "flash_runner.md" in sources - assert "operator.json:main_template" in sources - - -def test_every_prompt_tool_reference_is_classified(): - """Every tool referenced by an in-scope prompt must be known to the manifest.""" - unclassified = { - name: sorted(labels) - for name, labels in _referenced_tools().items() - if name not in ALL_KNOWN_TOOLS and name not in NON_TOOL_IDENTIFIERS - } - assert not unclassified, ( - "Prompts reference tools the manifest does not classify. Add each to the " - f"appropriate set in artemis/mcp/action_manifest.py: {unclassified}" - ) - - -def test_required_actions_are_structurally_referenced(): - """A REQUIRED action must actually appear in a prompt; otherwise demote it.""" - referenced = set(_referenced_tools()) - unreferenced = REQUIRED_ACTIONS - referenced - assert not unreferenced, ( - f"REQUIRED_ACTIONS not referenced by any prompt: {sorted(unreferenced)}. " - "Required status exists only to protect structural prompt dependencies." - ) - -def test_internal_actions_never_appear_in_prompts(): - """INTERNAL actions are adapter plumbing and must stay invisible to every model.""" - leaked = INTERNAL_ACTIONS & set(_referenced_tools()) - assert not leaked, f"Internal actions leaked into prompts: {sorted(leaked)}" +# --- Manifest classification --------------------------------------------------------- def test_manifest_sets_are_disjoint(): diff --git a/tests/unit/mcp/test_action_server.py b/tests/unit/mcp/test_action_server.py deleted file mode 100644 index 8cb83c4c6..000000000 --- a/tests/unit/mcp/test_action_server.py +++ /dev/null @@ -1,156 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""End-to-end tests of the unified action server over the in-memory transport.""" - -import pytest - -from artemis.mcp.action_manifest import ( - ActuatorContractError, - DEVICE_ACTIONS, - INTERNAL_ACTIONS, - REQUIRED_ACTIONS, - ExtensionTool, -) -from artemis.mcp.action_server import build_action_server -from artemis.mcp.action_session import ActionSession -from artemis.mcp.actuators import MockActuator - -pytestmark = pytest.mark.asyncio - - -async def _session_for(actuator) -> ActionSession: - return await ActionSession(build_action_server(actuator)).start() - - -async def test_click_end_to_end(): - actuator = MockActuator(width=1000, height=2000) - session = await _session_for(actuator) - try: - res = await session.call("click", {"target": [500, 500]}) - assert res.ok - assert res.message == "Tapped at [500, 500] (normalized)." - assert actuator.action_history[-1]["action"] == "tap" - assert actuator.action_history[-1]["x"] == 500 - assert actuator.action_history[-1]["y"] == 1000 - finally: - await session.aclose() - - -async def test_device_refusal_is_not_a_protocol_error(): - """A failed action reaches the caller as ok=False, never as an exception.""" - actuator = MockActuator() - session = await _session_for(actuator) - try: - res = await session.call("press_key", {"key": ""}) - assert not res.ok - assert res.code.value == "INVALID_ARGS" - assert res.message == "Error executing key press ''." - finally: - await session.aclose() - - -async def test_partial_actuator_hides_optional_tools(): - """An unimplemented optional tool does not exist anywhere on the surface.""" - minimal = MockActuator(capabilities=REQUIRED_ACTIONS | INTERNAL_ACTIONS) - session = await _session_for(minimal) - try: - listed = {t.name for t in (await session._session.list_tools()).tools} - assert "click_sequence" in listed - assert "observe_screen" in listed - for absent in ("click", "swipe", "manage_app", "wait_for_text", "open_link"): - assert absent not in listed - finally: - await session.aclose() - - -async def test_missing_required_action_fails_at_build_time(): - broken = MockActuator(capabilities=DEVICE_ACTIONS - {"click_sequence"}) - with pytest.raises(ActuatorContractError, match="click_sequence"): - build_action_server(broken) - - -async def test_extension_tool_is_served_and_callable(): - async def calibrate(sweep_speed: int = 1) -> str: - return f"calibrated at speed {sweep_speed}" - - ext = ExtensionTool( - name="calibrate_arm", - description=( - "Runs the robot arm's touch calibration sweep. Use before the first " - "physical action of a session, or after any missed tap." - ), - input_schema={"type": "object", "properties": {}}, - handler=calibrate, - targets=frozenset({"flash"}), - ) - actuator = MockActuator(extensions=[ext]) - session = await _session_for(actuator) - try: - listed = {t.name for t in (await session._session.list_tools()).tools} - assert "calibrate_arm" in listed - result = await session.call_raw("calibrate_arm", {"sweep_speed": 3}) - text = next(b.text for b in result.content if b.type == "text") - assert text == "calibrated at speed 3" - finally: - await session.aclose() - - -async def test_extension_name_collision_fails_at_build_time(): - async def bogus() -> str: - return "x" - - ext = ExtensionTool( - name="click", - description="An extension improperly reusing a canonical tool name, which " - "must be rejected at build time.", - input_schema={"type": "object"}, - handler=bogus, - ) - with pytest.raises(ActuatorContractError, match="collides"): - build_action_server(MockActuator(extensions=[ext])) - - -async def test_unknown_tool_raises_protocol_error(): - session = await _session_for(MockActuator()) - try: - with pytest.raises(RuntimeError, match="not_a_tool"): - await session.call_raw("not_a_tool", {}) - finally: - await session.aclose() - - -async def test_screenshot_and_hierarchy_helpers(): - session = await _session_for(MockActuator(width=1000, height=2000)) - try: - b64 = await session.screenshot_b64() - assert isinstance(b64, str) and len(b64) > 0 - elements = await session.ui_hierarchy() - assert elements is not None - finally: - await session.aclose() - - -async def test_structured_content_carries_real_status(): - """The wire result exposes ok/code so no consumer ever sniffs substrings again.""" - session = await _session_for(MockActuator()) - try: - raw = await session.call_raw("click", {"target": [10, 10]}) - sc = raw.structuredContent - assert sc["ok"] is True - assert sc["code"] == "OK" - assert sc["action"] == "click" - assert raw.isError is False - finally: - await session.aclose() diff --git a/tests/unit/mcp/test_action_session.py b/tests/unit/mcp/test_action_session.py deleted file mode 100644 index 2c1525488..000000000 --- a/tests/unit/mcp/test_action_session.py +++ /dev/null @@ -1,188 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""ActionSession lifecycle tests, including the anyio same-task exit hazard.""" - -import asyncio - -import pytest - -from artemis.mcp.action_server import build_action_server -from artemis.mcp.action_session import ActionSession, get_action_session -from artemis.mcp.actuators import MockActuator - -pytestmark = pytest.mark.asyncio - - -async def test_start_and_close(): - session = ActionSession(build_action_server(MockActuator())) - await session.start() - assert session.started - await session.aclose() - assert not session.started - - -async def test_close_from_different_task(): - """The regression guard for the anyio cancel-scope same-task rule. - - The in-memory transport's task group is entered inside the owner task; closing - from any *other* task must not raise ``RuntimeError: Attempted to exit cancel - scope in a different task``. - """ - session = ActionSession(build_action_server(MockActuator())) - await session.start() - - async def _closer(): - await session.aclose() - - await asyncio.create_task(_closer()) - assert not session.started - - -async def test_caller_timeout_does_not_brick_the_session(): - """Regression: cancelling a caller must never corrupt the shared transport. - - Observed live on-device: ``asyncio.wait_for(session.ui_hierarchy(), 1.0)`` around - a slow hierarchy fetch cancelled ``call_tool`` mid-flight, corrupting the - in-memory streams -- every later call failed with an empty-message - ``BrokenResourceError`` and the server never received another request. The owner - request queue makes an abandoned caller harmless; this test pins that down. - """ - actuator = MockActuator(width=1000, height=2000) - - async def _slow_get_ui_elements(): - await asyncio.sleep(2.0) - return [] - - actuator.get_ui_elements = _slow_get_ui_elements - session = await ActionSession(build_action_server(actuator)).start() - try: - with pytest.raises(asyncio.TimeoutError): - await asyncio.wait_for(session.ui_hierarchy(), timeout=0.1) - - # The session must still be fully functional afterwards. - result = await session.call("click", {"target": [500, 500]}) - assert result.ok - taps = [h for h in actuator.action_history if h["action"] == "tap"] - assert len(taps) == 1 - finally: - await session.aclose() - - -async def test_per_call_timeout_fires_server_side_and_session_survives(): - """A per-call ``timeout`` runs inside the server tool, never on the transport. - - mcp 1.29's ``read_timeout_seconds`` is NOT safe on the in-memory transport: a - late response to a timed-out request crashes the client receive loop - (``_handle_response`` sends into the request's already-closed response stream) -- - observed live on-device as a session bricked after a single clean - ``McpError`` timeout. Server-side timeouts produce an ordinary error payload - and no late response, so the session stays healthy by construction. - """ - actuator = MockActuator(width=1000, height=2000) - - async def _slow_get_ui_elements(): - await asyncio.sleep(2.0) - return [] - - actuator.get_ui_elements = _slow_get_ui_elements - session = await ActionSession(build_action_server(actuator)).start() - try: - with pytest.raises(RuntimeError, match="timed out"): - await session.ui_hierarchy(timeout=0.1) - - result = await session.call("click", {"target": [500, 500]}) - assert result.ok - finally: - await session.aclose() - - -async def test_transport_death_retires_session_for_rebuild(): - """A dead transport must flip ``started`` so get_action_session rebuilds. - - Without this, one transport failure leaves the rest of a run failing on a - corpse (observed live: 24 minutes of empty-message ``ClosedResourceError``). - """ - import anyio - - session = await ActionSession(build_action_server(MockActuator())).start() - real_call_tool = session._session.call_tool - - async def _dying_call_tool(name, args, **kw): - raise anyio.ClosedResourceError() - - session._session.call_tool = _dying_call_tool - with pytest.raises(anyio.ClosedResourceError): - await session.call("click", {"target": [1, 1]}) - - # Owner loop retires; the session reports itself unusable. The owner task may - # finish with an exception group from the transport teardown -- aclose() is the - # API that swallows that, so here we just wait for `started` to flip. - for _ in range(50): - if not session.started: - break - await asyncio.sleep(0.1) - assert not session.started - - class Ctx: - action_session = session - - fresh = await get_action_session(Ctx(), actuator=MockActuator()) - assert fresh is not session - assert fresh.started - await fresh.aclose() - del real_call_tool - - -async def test_calls_are_serialized_by_the_lock(): - """Two concurrent calls must not interleave on the single device.""" - actuator = MockActuator(width=1000, height=2000) - session = await ActionSession(build_action_server(actuator)).start() - try: - await asyncio.gather( - session.call("click", {"target": [100, 100]}), - session.call("click", {"target": [900, 900]}), - ) - taps = [h for h in actuator.action_history if h["action"] == "tap"] - assert len(taps) == 2 - finally: - await session.aclose() - - -async def test_call_before_start_raises(): - session = ActionSession(build_action_server(MockActuator())) - with pytest.raises(RuntimeError, match="not started"): - await session.call("click", {"target": [1, 1]}) - - -async def test_double_start_is_idempotent(): - session = ActionSession(build_action_server(MockActuator())) - await session.start() - owner = session._owner - await session.start() - assert session._owner is owner - await session.aclose() - - -async def test_get_action_session_caches_on_ctx(): - class Ctx: - action_session = None - - ctx = Ctx() - actuator = MockActuator() - session = await get_action_session(ctx, actuator=actuator) - assert ctx.action_session is session - again = await get_action_session(ctx) - assert again is session - await session.aclose() diff --git a/tests/unit/mcp/test_action_specs.py b/tests/unit/mcp/test_action_specs.py deleted file mode 100644 index d89c2777f..000000000 --- a/tests/unit/mcp/test_action_specs.py +++ /dev/null @@ -1,244 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""The canonical action manifest's conformance suite. - -Two kinds of protection: - -* **Fixture pins** freeze the exact schema each model surface receives (operator - shells, Flash declarations, the action server manifest). A schema change must be a - conscious act: regenerate the fixture in the same commit and say why. -* **Projection identity** checks that the Flash declaration of every shared action - is the operator shell's own description and parameters, so the two profiles can - never see different tools under one name -- the historical definition of drift. -""" - -import json -from pathlib import Path - -from langchain_core.utils.function_calling import convert_to_openai_tool -import pytest - -from artemis.agents.operator.prompts import ( - _PHYSICAL_ACTIONS_ORDER, - _TURN_ENDING_ORDER, - OPERATOR_PROMPT_TOOLSET, -) -from artemis.agents.validator.tool_declarations import VALIDATOR_TOOLS_DECLARATION -from artemis.mcp.action_manifest import ( - INTERNAL_ACTIONS, - OPTIONAL_ACTIONS, - REQUIRED_ACTIONS, -) -from artemis.mcp.action_names import OPERATOR_ACTION_TO_CANONICAL -from artemis.mcp.action_specs import ( - ACTION_SPECS, - OPERATOR_SHELL_ORDER, - operator_shell_tool, - tool_declaration, - wire_dialects, -) -from artemis.mcp.actuators.base import Actuator - -FIXTURES = Path(__file__).resolve().parents[2] / "fixtures" / "action_surfaces" - -FIXTURE_HINT = ( - "The generated schema differs from the pinned fixture. If this change is" - " intentional, regenerate the fixture in the same commit and explain the schema" - " change in the commit message; models see this schema directly." -) - - -def _fixture(name: str) -> dict: - return json.loads((FIXTURES / name).read_text(encoding="utf-8")) - - -# --- Fixture pins -------------------------------------------------------------------- - - -def test_operator_shells_match_fixture(): - expected = _fixture("operator_shells.json") - assert set(expected) == set(OPERATOR_SHELL_ORDER) - for name in OPERATOR_SHELL_ORDER: - generated = convert_to_openai_tool(operator_shell_tool(name)) - assert generated == expected[name], f"operator shell '{name}': {FIXTURE_HINT}" - - -def test_tool_declarations_match_fixture(): - expected = _fixture("tool_declarations.json") - by_constant = { - "CLICK_TOOL": "click", - "CLICK_SEQUENCE_TOOL": "click_sequence", - "LONG_PRESS_TOOL": "long_press", - "INPUT_TEXT_TOOL": "input_text", - "SWIPE_TOOL": "swipe", - "PRESS_KEY_TOOL": "press_key", - "MANAGE_APP_TOOL": "manage_app", - "WAIT_FOR_DELAY_TOOL": "wait_for_delay", - } - assert set(expected) == set(by_constant) - for constant, name in by_constant.items(): - assert dict(tool_declaration(name)) == expected[constant], ( - f"declaration '{name}': {FIXTURE_HINT}" - ) - - -@pytest.mark.asyncio -async def test_action_server_manifest_matches_fixture(): - from artemis.mcp.action_server import build_action_server - from artemis.mcp.actuators import MockActuator - - expected = _fixture("action_server_manifest.json") - server = build_action_server(MockActuator()) - tools = await server.list_tools() - generated = { - t.name: { - "description": t.description, - "inputSchema": t.inputSchema, - "outputSchema": t.outputSchema, - } - for t in tools - } - assert set(generated) == set(expected) - for name in expected: - assert generated[name] == expected[name], f"server tool '{name}': {FIXTURE_HINT}" - - -def test_validator_declaration_order_is_stable(): - assert [d.name for d in VALIDATOR_TOOLS_DECLARATION] == [ - "click", - "long_press", - "input_text", - "swipe", - "press_key", - "read_note", - "list_notes", - "manage_app", - "wait_for_delay", - ] - - -# --- Projection identity: Flash binds the operator shell, projected ------------------ - - -def _without_optional_noise(schema: dict) -> dict: - """The pydantic-generated property schema minus what only marks optionality. - - ``default`` and the ``null`` alternative say "optional"; the declaration says - that through ``required`` alone. - """ - out = {k: v for k, v in schema.items() if k not in ("anyOf", "default")} - if "anyOf" in schema: - members = [m for m in schema["anyOf"] if m.get("type") != "null"] - out.update(members[0] if len(members) == 1 else {"anyOf": members}) - return out - - -def test_flash_declarations_are_the_operator_shells_projected(): - """One definition, two bindings: for every action with an operator shell, the - Flash ``ToolDeclaration`` carries the shell's description, parameter names, - descriptions, types (index-or-coordinates unions included) and required set.""" - for spec in ACTION_SPECS.values(): - if spec.operator is None: - continue - shell = convert_to_openai_tool(operator_shell_tool(spec.name))["function"] - declaration = tool_declaration(spec.name) - assert declaration.description == shell["description"], spec.name - shell_props = shell["parameters"]["properties"] - decl_props = declaration.parameters["properties"] - assert list(decl_props) == list(shell_props), spec.name - for param, schema in shell_props.items(): - assert decl_props[param] == _without_optional_noise(schema), ( - f"'{spec.name}.{param}' diverges between the operator shell and the" - " Flash declaration." - ) - assert declaration.parameters["required"] == shell["parameters"].get("required", []), ( - spec.name - ) - - -def test_point_targets_accept_an_index_or_coordinates_in_both_profiles(): - """click / long_press / input_text address an element by index or by - normalized coordinates; the description is required only for coordinates.""" - for name in ("click", "long_press", "input_text"): - target = tool_declaration(name).parameters["properties"]["target"] - assert target["anyOf"] == [ - {"type": "integer"}, - {"type": "array", "items": {"type": "integer"}}, - ], name - assert "target_description" not in tool_declaration(name).parameters["required"], name - assert tool_declaration("click_sequence").parameters["required"] == [ - "sequence", - "target_descriptions", - ] - - -def test_shared_descriptions_are_identical_between_profiles(): - for name in ("swipe", "press_key", "manage_app", "wait_for_delay"): - assert tool_declaration(name).description == operator_shell_tool(name).description - - -def test_documented_differences_concern_the_wire_only(): - """The agent dialect is single-sourced; a spec may only document how the - agent dialect differs from the wire (spelling, vocabulary, resolution).""" - for spec in ACTION_SPECS.values(): - assert "declaration dialect" not in spec.differences, spec.name - - -# --- Manifest coverage --------------------------------------------------------------- - - -def test_specs_cover_exactly_the_llm_reachable_device_actions(): - assert set(ACTION_SPECS) == (REQUIRED_ACTIONS | OPTIONAL_ACTIONS) - assert not set(ACTION_SPECS) & INTERNAL_ACTIONS - - -def test_operator_shell_order_covers_operator_dialects(): - with_operator = {s.name for s in ACTION_SPECS.values() if s.operator is not None} - assert set(OPERATOR_SHELL_ORDER) == with_operator - assert len(OPERATOR_SHELL_ORDER) == len(set(OPERATOR_SHELL_ORDER)) - - -def test_every_wire_dialect_matches_the_actuator_protocol(): - for spec in wire_dialects(): - method = getattr(Actuator, spec.name, None) - assert callable(method), f"wire dialect '{spec.name}' has no corresponding Actuator method." - - -def test_prompt_enum_orders_come_from_the_manifest(): - assert _PHYSICAL_ACTIONS_ORDER == OPERATOR_SHELL_ORDER - assert set(_TURN_ENDING_ORDER) == set(OPERATOR_SHELL_ORDER) - assert set(OPERATOR_SHELL_ORDER) <= OPERATOR_PROMPT_TOOLSET - - -def test_operator_verbs_lower_onto_manifest_actions(): - """The Operator's internal decision verbs resolve to canonical manifest names.""" - unresolved = set(OPERATOR_ACTION_TO_CANONICAL.values()) - set(ACTION_SPECS) - assert not unresolved, ( - f"action_names maps operator verbs onto names the manifest does not know:" - f" {sorted(unresolved)}" - ) - - -def test_manifest_wait_for_delay_uses_milliseconds(): - """The manifest's ``wait_for_delay`` takes ``time_in_ms``, never *seconds*. - - ``artemis/tools/actions/device_actions.py`` used to shadow-register a second - ``wait_for_delay`` taking *seconds* while every prompt teaches milliseconds. - The shadow ToolRegistry channel is deleted; the manifest is the only surface - left, and its unit must stay milliseconds. - """ - fields = set(tool_declaration("wait_for_delay").parameters["properties"]) - assert "seconds" not in fields - assert "time_in_ms" in fields diff --git a/tests/unit/mcp/test_actuators.py b/tests/unit/mcp/test_actuators.py index 4a9d63a67..28e2d57a4 100644 --- a/tests/unit/mcp/test_actuators.py +++ b/tests/unit/mcp/test_actuators.py @@ -18,7 +18,7 @@ from artemis.mcp.action_manifest import DEVICE_ACTIONS from artemis.mcp.action_types import ActionCode -from artemis.mcp.actuators import Actuator, AdbActuator, MockActuator +from artemis.mcp.actuators import AdbActuator, MockActuator @pytest.fixture @@ -26,8 +26,7 @@ def actuator() -> MockActuator: return MockActuator(width=1000, height=2000) -def test_mock_actuator_satisfies_protocol(actuator): - assert isinstance(actuator, Actuator) +def test_mock_actuator_satisfies_contract(actuator): assert isinstance(actuator, AdbActuator) assert actuator.capabilities() == DEVICE_ACTIONS assert actuator.extensions() == [] diff --git a/tests/unit/mcp/test_adb_server_contract.py b/tests/unit/mcp/test_adb_server_contract.py index ddf9cc0cf..8dc4d1fd8 100644 --- a/tests/unit/mcp/test_adb_server_contract.py +++ b/tests/unit/mcp/test_adb_server_contract.py @@ -12,26 +12,22 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Consistency contract between the stdio ADB MCP server and the action manifest. +"""Contract for the stdio ADB MCP server's external (pixel-space) surface. ``artemis/mcp/adb_server.py`` deliberately keeps its historical *pixel-space* contract for external MCP clients (absolute pixel coordinates, combined -``coordinates`` list parameters, ``KEYCODE_*`` vocabulary), so its FastMCP tools -are hand-written rather than generated from ``artemis.mcp.action_specs``. This -suite is the compensating contract: +``coordinates`` list parameters, ``KEYCODE_*`` vocabulary). These guards pin +that surface: * **Fixture pin** -- the full generated schema of every adb_server tool is - frozen; any signature or docstring change must regenerate the fixture in the - same commit (external clients see this schema directly). -* **Projection check** -- every device-action tool is mapped onto its canonical - ``ActionSpec`` wire dialect. Parameters that mirror a wire parameter must - agree on JSON type, requiredness, and default. Deliberate legacy divergences - are declared in ``DECLARED_DIVERGENCES`` and are *ratcheted*: if the two - surfaces converge, the stale declaration fails too, so the table always - matches reality. - -Together the two guards make silent drift between the second declaration -surface and the canonical manifest impossible in either direction. + frozen in ``tests/fixtures/action_surfaces/adb_server_manifest.json``; any + signature or docstring change must regenerate the fixture in the same commit + (external clients see this schema directly). +* **Coordinate packing** -- the legacy combined ``coordinates`` lists keep + their documented integer arity and stay required. +* **Vocabulary** -- ``press_key`` keeps teaching the ``KEYCODE_*`` vocabulary. +* **compare_ui_frames** -- the image-diff route's changed/unchanged and + undecodable-fallback behaviour is exercised. """ import json @@ -39,8 +35,6 @@ import pytest -from artemis.mcp.action_specs import ACTION_SPECS, ParamSpec - FIXTURES = Path(__file__).resolve().parents[2] / "fixtures" / "action_surfaces" FIXTURE_HINT = ( @@ -50,25 +44,11 @@ ) -# --- The declared projection ---------------------------------------------------------- - -#: adb_server tool name -> (canonical action name, {adb param -> wire param}). -#: Params absent from the mapping are legacy packaging with no one-to-one wire -#: counterpart (the combined ``coordinates`` lists); their shape is asserted -#: separately below and pinned by the fixture. -ADB_TOOL_PROJECTION: dict[str, tuple[str, dict[str, str]]] = { - "tap": ("click", {"times": "times", "delay_ms": "delay_ms"}), - "long_press_on": ("long_press", {"duration": "duration_ms"}), - "swipe": ("swipe", {"duration": "duration_ms"}), - "focus_and_input_text": ("input_text", {"text": "text", "clear_before_input": "clear_exist"}), - "press_key": ("press_key", {"keycode": "key"}), - "open_link": ("open_link", {"url": "url"}), - "erase_one_char": ("erase_one_char", {}), - "focus_and_clear_text": ("focus_and_clear_text", {}), -} +# --- Legacy combined-coordinates packaging -------------------------------------------- -#: Combined-coordinates legacy packaging: adb tool -> (marker in docstring, arity). -#: ``coordinates`` packs the wire dialect's coordinate pair(s) into one list. +#: adb tool -> (marker in docstring, arity). ``coordinates`` packs the absolute +#: pixel coordinate pair(s) into one list; its shape is asserted here and pinned +#: by the fixture. COORDINATE_PACKING: dict[str, tuple[str, int]] = { "tap": ("[x, y]", 2), "long_press_on": ("[x, y]", 2), @@ -77,31 +57,6 @@ "focus_and_clear_text": ("[x, y]", 2), } -#: Deliberate legacy divergences, (adb tool, adb param) -> reason. Each entry is -#: ratcheted: the test fails if the surfaces actually agree, so this table can -#: never go stale. -DECLARED_DIVERGENCES: dict[tuple[str, str], str] = { - ("swipe", "duration"): ( - "legacy stdio default is 400ms; the canonical wire duration_ms defaults" - " to 800ms (adaptive-duration smart swipes only exist in agent dialects)" - ), - ("focus_and_input_text", "clear_before_input"): ( - "legacy stdio appends by default (False); the canonical wire clear_exist" - " defaults to True (replace)" - ), -} - -_JSON_TYPE: dict[type, str] = {int: "integer", str: "string", bool: "boolean"} - - -def _wire_param(action: str, name: str) -> ParamSpec: - spec = ACTION_SPECS[action] - assert spec.wire is not None, f"'{action}' has no wire dialect." - for p in spec.wire.params: - if p.name == name: - return p - raise AssertionError(f"wire dialect of '{action}' has no param '{name}'") - async def _adb_server_tools() -> dict: from artemis.mcp import adb_server @@ -132,63 +87,7 @@ async def test_adb_server_manifest_matches_fixture(): assert generated[name] == expected[name], f"adb_server tool '{name}': {FIXTURE_HINT}" -# --- Projection consistency ------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_projection_covers_all_wire_backed_adb_tools(): - """Every adb_server tool that mirrors a canonical action is in the projection.""" - tools = await _adb_server_tools() - missing = set(ADB_TOOL_PROJECTION) - set(tools) - assert not missing, f"projection references adb_server tools that no longer exist: {missing}" - for adb_name, (canonical, _) in ADB_TOOL_PROJECTION.items(): - assert canonical in ACTION_SPECS, ( - f"adb_server tool '{adb_name}' maps to '{canonical}', which the canonical" - " manifest does not know." - ) - assert ACTION_SPECS[canonical].wire is not None, ( - f"adb_server tool '{adb_name}' maps to '{canonical}', which has no wire dialect." - ) - - -@pytest.mark.asyncio -async def test_mirrored_params_agree_with_wire_dialect(): - """Type/requiredness/default of every mirrored parameter matches the wire spec, - except where a divergence is declared -- and declared divergences must be real.""" - tools = await _adb_server_tools() - for adb_name, (canonical, param_map) in ADB_TOOL_PROJECTION.items(): - tool = tools[adb_name] - required = set(tool.inputSchema.get("required", [])) - for adb_param, wire_name in param_map.items(): - wire = _wire_param(canonical, wire_name) - prop = _prop(tool, adb_param) - expected_type = _JSON_TYPE.get(wire.annotation) - if expected_type is not None: - assert prop.get("type") == expected_type, ( - f"'{adb_name}.{adb_param}': schema type {prop.get('type')!r} !=" - f" canonical wire '{canonical}.{wire_name}' type {expected_type!r}" - ) - adb_required = adb_param in required - adb_default = prop.get("default") - diverged_key = (adb_name, adb_param) - agrees = adb_required == wire.required and ( - wire.required or adb_default == wire.default - ) - if diverged_key in DECLARED_DIVERGENCES: - assert not agrees, ( - f"'{adb_name}.{adb_param}' is declared divergent from" - f" '{canonical}.{wire_name}' but the surfaces now agree -- remove" - f" the stale entry from DECLARED_DIVERGENCES:" - f" {DECLARED_DIVERGENCES[diverged_key]}" - ) - else: - assert agrees, ( - f"'{adb_name}.{adb_param}' drifted from canonical wire" - f" '{canonical}.{wire_name}': required {adb_required} vs" - f" {wire.required}, default {adb_default!r} vs {wire.default!r}." - " Either re-align, or declare the divergence in" - " DECLARED_DIVERGENCES with a reason." - ) +# --- Coordinate packing ----------------------------------------------------------------- @pytest.mark.asyncio @@ -212,14 +111,60 @@ async def test_coordinate_packing_shape_is_documented(): @pytest.mark.asyncio -async def test_press_key_vocabulary_divergence_is_documented(): - """adb_server's press_key takes raw KEYCODE_* strings; the canonical wire takes - bare lowercase words. The canonical spec documents that divergence in prose.""" +async def test_press_key_teaches_keycode_vocabulary(): + """adb_server's press_key takes raw KEYCODE_* strings; its docstring must keep + teaching that vocabulary to external clients.""" tools = await _adb_server_tools() desc = tools["press_key"].description or "" assert "KEYCODE_" in desc, ( "adb_server press_key docstring must keep teaching KEYCODE_* vocabulary" ) - assert "key" in ACTION_SPECS["press_key"].differences, ( - "the canonical manifest no longer documents the press_key vocabulary split" + + +# --- compare_ui_frames behaviour -------------------------------------------------------- + +from artemis.mcp import adb_server # noqa: E402 (the manifest tests import it lazily) + + +def _png_b64(array) -> str: + import base64 + + import cv2 + import numpy as np + + ok, buf = cv2.imencode(".png", np.asarray(array, dtype=np.uint8)) + assert ok + return base64.b64encode(buf.tobytes()).decode("ascii") + + +@pytest.mark.asyncio +async def test_compare_ui_frames_reports_identical_frames_as_unchanged(): + import numpy as np + + frame = _png_b64(np.full((400, 800), 120)) + result = json.loads(await adb_server.compare_ui_frames(frame, frame, action="back")) + assert result == {"changed": False} + + +@pytest.mark.asyncio +async def test_compare_ui_frames_reports_a_navigation_as_changed(): + import numpy as np + + after_img = np.full((400, 800), 40, dtype=np.uint8) + after_img[:, 400:] = 240 + result = json.loads( + await adb_server.compare_ui_frames( + _png_b64(np.full((400, 800), 40)), _png_b64(after_img), "back" + ) ) + assert result == {"changed": True} + + +@pytest.mark.asyncio +async def test_compare_ui_frames_keeps_the_assume_changed_fallback(): + """Undecodable bytes must not read as "nothing happened" -- that would stall the router.""" + import base64 + + junk = base64.b64encode(b"definitely not a jpeg").decode("ascii") + result = json.loads(await adb_server.compare_ui_frames(junk, junk, action="tap")) + assert result == {"changed": True} diff --git a/tests/unit/mcp/test_background_task_runner.py b/tests/unit/mcp/test_background_task_runner.py deleted file mode 100644 index c0fbf7c85..000000000 --- a/tests/unit/mcp/test_background_task_runner.py +++ /dev/null @@ -1,104 +0,0 @@ -import asyncio -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from mcp_server.background.task_runner import _initialize_agent - - -@pytest.mark.asyncio -async def test_background_agent_initialization_has_hard_timeout(): - async def slow_init(**_): - await asyncio.sleep(60) - - agent = MagicMock() - agent.init = AsyncMock(side_effect=slow_init) - - with pytest.raises(TimeoutError, match="initialization exceeded 0.0s"): - await _initialize_agent( - agent, - retry_count=1, - retry_wait_seconds=1, - timeout_seconds=0.01, - ) - - -@pytest.mark.asyncio -async def test_background_agent_initialization_forwards_health_settings(): - agent = AsyncMock() - - await _initialize_agent( - agent, - retry_count=3, - retry_wait_seconds=4, - timeout_seconds=1.0, - ) - - agent.init.assert_awaited_once_with(retry_count=3, retry_wait_seconds=4) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("knobs", "expect_level", "expect_mode"), - [ - ({"verification_level": "strict", "explorer_pro_mode": "ultra"}, "strict", "ultra"), - ({}, None, None), - ], -) -async def test_run_task_applies_pro_tuning_to_agent_config( - tmp_path, monkeypatch, knobs, expect_level, expect_mode -): - """The detached runner applies --verification-level / --explorer-pro-mode on the builder.""" - from types import SimpleNamespace - from unittest.mock import patch - - from artemis.runtime import trace_store - from mcp_server.background import task_runner as bg - - monkeypatch.setattr(trace_store, "TRACES_DIR", str(tmp_path)) - trace_id = "trace-tuning" - trace_store.init_trace( - trace_id=trace_id, - task_desc="Audit checkout", - model="Pro", - conversation_id="", - device_serial="emulator-5554", - ) - - fake_builder = MagicMock() - fake_builders = MagicMock() - fake_builders.AgentConfig.with_default_profile.return_value = fake_builder - fake_agent = MagicMock() - fake_agent._device_context = SimpleNamespace(device_id="emulator-5554") - fake_agent.run_task = AsyncMock(return_value="done") - fake_agent.clean = AsyncMock() - - monkeypatch.setattr(bg.device_utils, "resolve_adb_path", lambda: "adb") - monkeypatch.setattr(bg.device_utils, "get_connected_devices", lambda _adb: ["emulator-5554"]) - monkeypatch.setattr(bg, "resolve_profile_file", lambda: None) - monkeypatch.setattr(bg, "_initialize_agent", AsyncMock()) - monkeypatch.setattr(bg, "notify", MagicMock()) - - with ( - patch("artemis.sdk.builders.Builders", fake_builders), - patch("artemis.sdk.Agent", return_value=fake_agent), - patch("artemis.sdk.types.AgentProfile", MagicMock()), - patch("artemis.config.initialize_llm_config", return_value=MagicMock()), - ): - await bg.run_task( - trace_id=trace_id, - task_desc="Audit checkout", - model="Pro", - conversation_id="", - device_serial="emulator-5554", - **knobs, - ) - - if expect_level is None: - fake_builder.with_verification_level.assert_not_called() - fake_builder.with_explorer.assert_not_called() - else: - fake_builder.with_verification_level.assert_called_once_with(expect_level) - fake_builder.with_explorer.assert_called_once_with(pro_mode=expect_mode) - fake_agent.run_task.assert_awaited_once() - assert trace_store.read_status(trace_id)["status"] == "completed" diff --git a/tests/unit/mcp/test_device_listing.py b/tests/unit/mcp/test_device_listing.py new file mode 100644 index 000000000..f8759d582 --- /dev/null +++ b/tests/unit/mcp/test_device_listing.py @@ -0,0 +1,149 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""G64: the read-only `list_devices` tool on the adb surface. + +The one thing that matters here is that **"could not ask adb" never collapses into "no devices +attached"** -- `device_pool.py:354-356` exists precisely for that distinction, and a scheduler that +loses it would treat a transient adb blip as an empty fleet and stall every auto submission. +""" + +import json + +import pytest + +from artemis.mcp import adb_server +from artemis.runtime.device_pool import DeviceStatus, device_pool + + +def patch_enumeration(monkeypatch, impl) -> None: + # Patched on the singleton instance the tool imports, not by dotted module path. + monkeypatch.setattr(device_pool, "try_list_devices_async", impl) + + +@pytest.mark.asyncio +async def test_enumerated_false_is_not_an_empty_fleet(monkeypatch): + async def could_not_ask(): + return None # try_list_devices_async's documented "no usable snapshot" answer + + patch_enumeration(monkeypatch, could_not_ask) + payload = json.loads(await adb_server.list_devices()) + assert payload == {"enumerated": False, "devices": []} + + +@pytest.mark.asyncio +async def test_empty_answer_is_a_real_answer(monkeypatch): + async def genuinely_none_attached(): + return [] + + patch_enumeration(monkeypatch, genuinely_none_attached) + payload = json.loads(await adb_server.list_devices()) + assert payload["enumerated"] is True + assert payload["devices"] == [] + assert payload != {"enumerated": False, "devices": []} # distinguishable from "could not ask" + + +@pytest.mark.asyncio +async def test_device_fields_come_through_unchanged(monkeypatch): + async def one_device(): + return [DeviceStatus(serial="emulator-5554", state="device", is_busy=False)] + + patch_enumeration(monkeypatch, one_device) + payload = json.loads(await adb_server.list_devices()) + assert payload["enumerated"] is True + device = payload["devices"][0] + assert device["serial"] == "emulator-5554" + # The plugin's pool reads these fields to decide eligibility, so the shape is the contract. + assert set(device) == { + "serial", + "state", + "model", + "product", + "is_emulator", + "is_busy", + "active_pid", + "active_task_desc", + "active_session_id", + "acquired_at", + } + + +@pytest.mark.asyncio +async def test_enumeration_crash_never_raises_out_of_the_tool(monkeypatch): + async def boom(): + raise RuntimeError("adb server died mid-call") + + patch_enumeration(monkeypatch, boom) + payload = json.loads(await adb_server.list_devices()) + assert payload["enumerated"] is False # a listing tool must not become a turn-killing error + + +@pytest.mark.asyncio +async def test_tool_stays_read_only_and_addresses_no_device(): + tools = {tool.name: tool for tool in await adb_server.mcp.list_tools()} + assert "list_devices" in tools + schema = tools["list_devices"].inputSchema + # The only knob is "do you want the expensive part". There is no serial parameter: the tool + # lists everything or nothing, and cannot address -- let alone wake -- a device. + assert set(schema.get("properties", {})) == {"include_lock_state"} + assert schema["properties"]["include_lock_state"]["type"] == "boolean" + assert schema["properties"]["include_lock_state"].get("default") is False + + +@pytest.mark.asyncio +async def test_lock_state_is_not_queried_unless_asked(monkeypatch): + """Default shape is the cheap one: no `is_locked` key at all, and no dumpsys either. + + "not asked" and "asked but unknown" mean opposite things to the caller (one must fail open, the + other fail closed), so absence of the key is part of the contract, not an accident. + """ + from artemis.core.diagnostics.probes import adb_probe as probe_module + from artemis.runtime.device_pool import DeviceStatus + + async def one(): + return [DeviceStatus(serial="A", state="device")] + + def boom(*a, **kw): # pragma: no cover - must never run + raise AssertionError("lock state must not be probed when it was not asked for") + + monkeypatch.setattr(probe_module.AdbDeviceProbe, "device_lock_state", boom) + patch_enumeration(monkeypatch, one) + entry = json.loads(await adb_server.list_devices())["devices"][0] + assert "is_locked" not in entry + + +@pytest.mark.asyncio +async def test_lock_state_is_tri_state_when_asked(monkeypatch): + from artemis.core.diagnostics.probes import adb_probe as probe_module + from artemis.runtime.device_pool import DeviceStatus + + async def two(): + return [ + DeviceStatus(serial="locked-one", state="device"), + DeviceStatus(serial="open-one", state="device"), + ] + + async def fake_lock(self, serial, timeout_seconds=1.0): # noqa: ANN001, ARG001 + return {"locked-one": True, "open-one": None}[serial] # unknown stays None + + monkeypatch.setattr(probe_module.AdbDeviceProbe, "device_lock_state", fake_lock) + patch_enumeration(monkeypatch, two) + devices = { + d["serial"]: d + for d in json.loads(await adb_server.list_devices(include_lock_state=True))["devices"] + } + assert devices["locked-one"]["is_locked"] is True + assert ( + devices["open-one"]["is_locked"] is None + ) # asked, could not tell -- NOT the same as false diff --git a/tests/unit/mcp/test_device_utils.py b/tests/unit/mcp/test_device_utils.py deleted file mode 100644 index a10bafb6b..000000000 --- a/tests/unit/mcp/test_device_utils.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Unit tests for platform-specific emulator process startup.""" - -from types import SimpleNamespace -from unittest.mock import MagicMock - -from mcp_server.utils import device_utils - - -def test_ensure_emulator_uses_windows_creation_flags(monkeypatch) -> None: - popen = MagicMock() - monkeypatch.setattr(device_utils.sys, "platform", "win32") - # CREATE_NEW_PROCESS_GROUP / DETACHED_PROCESS only exist in the Windows build of - # ``subprocess``; supply them (keeping the real values on Windows) so faking - # win32 works on any host instead of raising AttributeError inside the code. - for name, value in (("CREATE_NEW_PROCESS_GROUP", 0x200), ("DETACHED_PROCESS", 0x8)): - monkeypatch.setattr( - device_utils.subprocess, - name, - getattr(device_utils.subprocess, name, value), - raising=False, - ) - monkeypatch.setattr(device_utils, "is_emulator_running", lambda _adb: False) - monkeypatch.setattr(device_utils.os.path, "exists", lambda _path: True) - monkeypatch.setattr(device_utils.subprocess, "Popen", popen) - monkeypatch.setattr( - device_utils.subprocess, - "run", - lambda *_args, **_kwargs: SimpleNamespace(stdout="1"), - ) - monkeypatch.setattr(device_utils.time, "sleep", lambda _seconds: None) - - assert device_utils.ensure_emulator( - adb_path="adb.exe", emulator_path="emulator.exe", timeout_seconds=1 - ) - - kwargs = popen.call_args.kwargs - assert kwargs["creationflags"] == ( - device_utils.subprocess.CREATE_NEW_PROCESS_GROUP | device_utils.subprocess.DETACHED_PROCESS - ) - assert "start_new_session" not in kwargs - - -def test_ensure_emulator_starts_new_session_on_posix(monkeypatch) -> None: - popen = MagicMock() - monkeypatch.setattr(device_utils.sys, "platform", "linux") - monkeypatch.setattr(device_utils, "is_emulator_running", lambda _adb: False) - monkeypatch.setattr(device_utils.os.path, "exists", lambda _path: True) - monkeypatch.setattr(device_utils.subprocess, "Popen", popen) - monkeypatch.setattr( - device_utils.subprocess, - "run", - lambda *_args, **_kwargs: SimpleNamespace(stdout="1"), - ) - monkeypatch.setattr(device_utils.time, "sleep", lambda _seconds: None) - - assert device_utils.ensure_emulator(adb_path="adb", emulator_path="emulator", timeout_seconds=1) - - kwargs = popen.call_args.kwargs - assert kwargs["start_new_session"] is True - assert "creationflags" not in kwargs diff --git a/tests/unit/mcp/test_diagnose_tool.py b/tests/unit/mcp/test_diagnose_tool.py deleted file mode 100644 index cd4627f52..000000000 --- a/tests/unit/mcp/test_diagnose_tool.py +++ /dev/null @@ -1,1287 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for the mobile_diagnose MCP tool.""" - -import asyncio -from contextlib import ExitStack -import inspect -import shutil -import tempfile -import time -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from artemis.core.diagnostics.emulator_manager import EmulatorLaunchStage -from artemis.core.diagnostics.probes.host_probe import IntegrationHostProbe -from artemis.core.diagnostics.schema import ( - DeviceInfo, - ProbeAction, - ProbeCategory, - ProbeResult, - ProbeStatus, - SystemReadinessReport, -) -from artemis.runtime import trace_store -from artemis.runtime.device_lock import DeviceLockOwner -from mcp_server.tools import diagnose -from mcp_server.tools.diagnose import mobile_diagnose - -_IDLE_EMULATOR = { - "avd_name": None, - "status": EmulatorLaunchStage.IDLE, - "pid": None, - "serial": None, - "stage_message": "Ready to launch", - "progress_percent": 0, - "started_at": None, - "elapsed_seconds": 0, - "error": None, - "logs": [], - "can_retry": True, -} - - -def _emulator_state(status: EmulatorLaunchStage, avd: str = "Pixel_8", **extra) -> dict: - state = dict(_IDLE_EMULATOR) - state.update( - { - "avd_name": avd, - "status": status, - "pid": 4242, - "stage_message": f"stage {status.value}", - "progress_percent": 30, - "started_at": time.time() - 12, - "elapsed_seconds": 12, - "logs": ["emulator: INFO: boot", "second log line"], - } - ) - state.update(extra) - return state - - -@pytest.fixture -def temp_trace_env(monkeypatch): - temp_dir = tempfile.mkdtemp() - monkeypatch.setattr(trace_store, "TRACES_DIR", temp_dir) - yield temp_dir - shutil.rmtree(temp_dir, ignore_errors=True) - - -def _probe( - probe_id: str, - status: ProbeStatus, - *, - blocker: bool = True, - category: ProbeCategory = ProbeCategory.RUNTIME, - summary: str = "", - description: str = "", - metadata: dict | None = None, - actions: list[ProbeAction] | None = None, -) -> ProbeResult: - return ProbeResult( - id=probe_id, - category=category, - title=probe_id.replace("_", " ").title(), - status=status, - is_blocker=blocker, - summary=summary or status.value, - description=description or f"{probe_id} is {status.value}", - metadata=metadata or {}, - actions=actions or [], - ) - - -def _credentials_metadata() -> dict: - return { - "providers": [ - { - "provider": "google", - "label": "Gemini", - "masked": "AIza...abcd", - "raw_key": "SECRET-GOOGLE", - "key": "SECRET-GOOGLE", - }, - { - "provider": "openai", - "label": "ChatGPT", - "masked": "sk-...wxyz", - "raw_key": "SECRET-OPENAI", - "key": "SECRET-OPENAI", - }, - ], - "api_keys": { - "google": "SECRET-GOOGLE", - "gemini": "SECRET-GOOGLE", - "openai": "SECRET-OPENAI", - "ocr": "SECRET-OCR", - }, - "current_key": "SECRET-GOOGLE", - "current_gemini_key": "SECRET-GOOGLE", - "has_ocr_key": True, - } - - -def _healthy_probes() -> list[ProbeResult]: - return [ - _probe("python_runtime", ProbeStatus.PASS), - _probe("system_config", ProbeStatus.PASS), - _probe("toolchain", ProbeStatus.PASS, blocker=False, category=ProbeCategory.TOOLCHAIN), - _probe( - "gemini_api_key", - ProbeStatus.PASS, - category=ProbeCategory.CREDENTIALS, - metadata=_credentials_metadata(), - ), - _probe( - "vision_ocr_key", ProbeStatus.PASS, blocker=False, category=ProbeCategory.CREDENTIALS - ), - _probe( - "android_adb", - ProbeStatus.PASS, - category=ProbeCategory.DEVICE, - metadata={ - "installed": True, - "adb_keys": {"is_corrupted": False}, - "installed_avds": [], - "devices": [ - { - "serial": "pixel-1", - "state": "device", - "installed_packages": ["a", "b", "c"], - } - ], - }, - ), - ] - - -def _no_device_probes(installed_avds: list[str] | None = None) -> list[ProbeResult]: - probes = _healthy_probes() - avds = installed_avds if installed_avds is not None else ["Pixel_8", "Tablet_API_35"] - actions = [ - ProbeAction( - action_type="command", - label=f"Launch {avd}", - payload=f"C:/Android/Sdk/emulator/emulator.exe -avd {avd}", - ) - for avd in avds - ] or [ - ProbeAction( - action_type="command", - label="Start Default Emulator", - payload="emulator -avd Pixel_8_API_34", - ) - ] - actions.append( - ProbeAction( - action_type="hint", - label="Connect via USB", - payload="Connect your Android phone via USB cable and enable USB Debugging.", - ) - ) - probes[5] = _probe( - "android_adb", - ProbeStatus.WARN, - category=ProbeCategory.DEVICE, - summary="No Device", - description="ADB is ready, but no active Android device or emulator was detected.", - metadata={ - "installed": True, - "adb_keys": {"is_corrupted": False}, - "installed_avds": avds, - "devices": [], - }, - actions=actions, - ) - return probes - - -def _host(status: ProbeStatus = ProbeStatus.PASS, **metadata) -> ProbeResult: - base = { - "os": "win32", - "project_root": "/proj", - "server_python": "/proj/.venv/bin/python", - "runner_python": "/proj/.venv/bin/python", - "venv_python": "/proj/.venv/bin/python", - "venv_exists": True, - "interpreter_matches_venv": True, - "env_file": "/proj/.env", - "env_file_exists": True, - "traces_dir": "/proj/traces", - "traces_dir_writable": True, - "traces_dir_error": None, - "daemon": { - "host": "127.0.0.1", - "port": 8000, - "standalone_forced": False, - "reachable": False, - "port_in_use": False, - "port_held_by_other_process": False, - "log_path": "/logs/daemon.log", - }, - } - base.update(metadata) - # Mirrors the real probe: a WARN there degrades, it does not block. - return _probe("integration_host", status, blocker=status is not ProbeStatus.WARN, metadata=base) - - -def _report(probes: list[ProbeResult], active_device: DeviceInfo | None = None): - blockers = [p for p in probes if p.is_blocker] - passed = [p for p in blockers if p.status is ProbeStatus.PASS] - return SystemReadinessReport( - overall_ready=len(blockers) == len(passed), - blocker_count=len(blockers), - passed_blocker_count=len(passed), - probes=probes, - active_device=active_device, - os_type="linux", - timestamp=time.time(), - ) - - -def _owner(device_id: str = "pixel-1", session_id: str = "trace-123") -> DeviceLockOwner: - return DeviceLockOwner( - pid=777, - process_created_at=1.0, - token="tok-1", - device_id=device_id, - description="Open settings and toggle wifi", - acquired_at="2026-09-09T10:00:00+00:00", - session_id=session_id, - ingress="mcp", - lock_scope=None, - ) - - -def _run( - probes, - host=None, - *, - report=None, - run_all=None, - emulator_status=None, - launch=None, - active_owners=None, - queued=None, - cleanup=0, - validate=None, - smoke=None, - helper_status=None, - helper_provision=None, - backend="auto", - **kwargs, -): - """Run the tool with every side-effecting collaborator stubbed.""" - with ExitStack() as stack: - fake_helper = MagicMock() - fake_helper.status = MagicMock( - side_effect=lambda serial: dict(helper_status or _helper_healthy(serial)) - ) - fake_helper.provision = helper_provision or MagicMock(return_value=_provision_ok()) - stack.enter_context(patch.object(diagnose, "helper_manager", fake_helper)) - stack.enter_context(patch.object(diagnose, "_hierarchy_backend", lambda: backend)) - kwargs["_fake_helper"] = fake_helper - stack.enter_context( - patch.object( - diagnose.readiness_engine, - "run_all", - run_all or AsyncMock(return_value=report or _report(probes)), - ) - ) - stack.enter_context( - patch.object(IntegrationHostProbe, "probe", AsyncMock(return_value=host or _host())) - ) - stack.enter_context( - patch.object( - diagnose.readiness_engine, - "get_emulator_status", - MagicMock(return_value=emulator_status or dict(_IDLE_EMULATOR)), - ) - ) - stack.enter_context( - patch.object( - diagnose.readiness_engine, - "launch_emulator", - launch or AsyncMock(return_value=_emulator_state(EmulatorLaunchStage.STARTING)), - ) - ) - stack.enter_context( - patch.object( - diagnose.DeviceExecutionLock, - "get_active_owners", - MagicMock(return_value=active_owners or {}), - ) - ) - stack.enter_context( - patch.object( - diagnose.DeviceExecutionLock, - "get_queued_tasks", - MagicMock(return_value=queued or []), - ) - ) - stack.enter_context( - patch.object( - diagnose.DeviceExecutionLock, - "cleanup_stale_locks", - cleanup if isinstance(cleanup, MagicMock) else MagicMock(return_value=cleanup), - ) - ) - stack.enter_context( - patch.object( - diagnose, - "validate_api_key", - validate or AsyncMock(return_value=(True, "verified")), - ) - ) - stack.enter_context( - patch.object( - diagnose, - "_device_smoke_test", - smoke or AsyncMock(return_value=_smoke_ok()), - ) - ) - fake_helper = kwargs.pop("_fake_helper") - result = asyncio.run(mobile_diagnose(**kwargs)) - result["_fake_helper"] = fake_helper - return result - - -def _helper_healthy(serial: str = "pixel-1") -> dict: - return { - "package": "com.artemis.helper", - "installed": True, - "installed_version": 2, - "bundled_version": 2, - "bundled_apk_present": True, - "outdated": False, - "enabled": True, - "forward_port": 41234, - "reachable": True, - "reported_version": 2, - "transport_id": "7", - "session": None, - } - - -def _provision_ok(): - from artemis.runtime.helper_manager import ProvisionResult - - return ProvisionResult( - ok=True, action="installed", installed_version=2, bundled_version=2, enabled=True - ) - - -def _smoke_ok(serial: str = "pixel-1") -> dict: - return { - "ok": True, - "serial": serial, - "elapsed_seconds": 3.2, - "screenshot_bytes": 120_000, - "element_count": 42, - "error": None, - "fix": [], - } - - -# --------------------------------------------------------------------------- # -# Schema / shape -# --------------------------------------------------------------------------- # - - -def test_tool_signature_and_registration(): - sig = inspect.signature(mobile_diagnose) - assert sig.parameters["attempt_fix"].default is False - assert sig.parameters["device_serial"].default is None - assert sig.parameters["launch_avd"].default is None - assert sig.parameters["verify_credentials"].default is False - assert sig.parameters["probe_device"].default is False - doc = mobile_diagnose.__doc__ or "" - for field in ( - "next_steps", - "checks", - "host", - "device", - "emulator", - "tasks", - "credentials", - "device_probe", - "fixes_applied", - "logs", - "launch_avd", - "verify_credentials", - "probe_device", - ): - assert field in doc - - from mcp_server.base import mcp - - tool_names = {t.name for t in asyncio.run(mcp.list_tools())} - assert "mobile_diagnose" in tool_names - - -def test_ready_environment_reports_ready_with_slim_shape(temp_trace_env): - device = DeviceInfo( - serial="pixel-1", - model="Pixel 8", - android_version="15", - is_locked=False, - installed_packages=["a", "b"], - screen_resolution="1080x2400", - ) - result = _run(_healthy_probes(), report=_report(_healthy_probes(), active_device=device)) - - assert result["verdict"] == "ready" - assert "overall_ready" not in result - assert "active_device" not in result - assert result["next_steps"] == [ - "Environment is ready. Use mobile_run_task to delegate a task; pass device_serial " - "when more than one device is attached." - ] - assert "SECRET" not in repr(result) - - # Passing checks are one line each: no detail / fix / facts. - for check in result["checks"]: - assert check["status"] == "pass" - assert set(check) == {"id", "title", "status", "required", "summary"} - - # Fix order: runtime first, then host, credentials, device, toolchain. - assert [c["id"] for c in result["checks"]][:5] == [ - "python_runtime", - "system_config", - "integration_host", - "gemini_api_key", - "android_adb", - ] - assert result["device"] == { - "serial": "pixel-1", - "state": "device", - "model": "Pixel 8", - "android_version": "15", - "is_locked": False, - "is_emulator": False, - "accessibility_helper": {**_helper_healthy(), "serial": "pixel-1", "backend": "auto"}, - } - assert result["host"] == { - "server_python": "/proj/.venv/bin/python", - "runner_python": "/proj/.venv/bin/python", - "interpreter_matches_venv": True, - "env_file": "/proj/.env", - "env_file_exists": True, - "traces_dir": "/proj/traces", - "daemon": { - "port": 8000, - "reachable": False, - "port_held_by_other_process": False, - "log_path": "/logs/daemon.log", - }, - } - assert result["emulator"] is None - assert result["credentials"] is None - assert result["device_probe"] is None - assert result["tasks"] == {"active": [], "queued": []} - assert result["logs"]["mcp_stderr_log"].endswith("mcp_stderr.log") - assert result["logs"]["daemon_log"] == "/logs/daemon.log" - - -def test_host_mcp_client_is_forwarded_when_probe_reports_it(temp_trace_env): - result = _run(_healthy_probes(), host=_host(mcp_client={"name": "cursor", "key": "SECRET"})) - assert result["host"]["mcp_client"] == {"name": "cursor"} - assert result["device"] is None - - -def test_failing_checks_keep_scrubbed_facts_and_fix_list(temp_trace_env): - probes = _healthy_probes() - probes[3].status = ProbeStatus.WARN - probes[5].status = ProbeStatus.WARN - result = _run(probes) - - creds = next(c for c in result["checks"] if c["id"] == "gemini_api_key") - assert creds["category"] == "auth" - assert creds["facts"]["providers"] == [ - {"provider": "google", "label": "Gemini", "masked": "AIza...abcd"}, - {"provider": "openai", "label": "ChatGPT", "masked": "sk-...wxyz"}, - ] - assert "api_keys" not in creds["facts"] - assert creds["fix"] == [] - assert "SECRET" not in repr(result) - - adb = next(c for c in result["checks"] if c["id"] == "android_adb") - device = adb["facts"]["devices"][0] - assert device["installed_package_count"] == 3 - assert "installed_packages" not in device - - -def test_optional_toolchain_failure_is_degraded(temp_trace_env): - probes = _healthy_probes() - probes[2] = _probe( - "toolchain", - ProbeStatus.FAIL, - blocker=False, - category=ProbeCategory.TOOLCHAIN, - summary="Missing scrcpy", - description="scrcpy is not installed; video capture is disabled.", - actions=[ - ProbeAction(action_type="command", label="Install", payload="brew install scrcpy") - ], - ) - result = _run(probes) - - assert result["verdict"] == "degraded" - assert result["next_steps"][0] == ( - "[OPTIONAL] Toolchain: scrcpy is not installed; video capture is disabled." - ) - assert " Run: brew install scrcpy" in result["next_steps"] - - -# --------------------------------------------------------------------------- # -# Command hygiene -# --------------------------------------------------------------------------- # - - -def test_unauthorized_device_is_blocked_with_actionable_steps(temp_trace_env): - probes = _healthy_probes() - probes[5] = _probe( - "android_adb", - ProbeStatus.WARN, - category=ProbeCategory.DEVICE, - summary="Device Unauthorized", - description="Device detected (abc), but USB debugging is not yet authorized.", - metadata={ - "installed": True, - "adb_keys": {"is_corrupted": False}, - "installed_avds": [], - "devices": [{"serial": "abc", "state": "unauthorized"}], - }, - actions=[ - ProbeAction(action_type="hint", label="Authorize", payload="Tap Allow on the phone."), - ProbeAction( - action_type="command", - label="Restart ADB", - payload=r"C:\sdk\platform-tools\adb.exe kill-server && C:\sdk\platform-tools\adb.exe start-server", - ), - ProbeAction(action_type="link", label="Docs", payload="https://example.test/adb"), - ], - ) - result = _run(probes) - - assert result["verdict"] == "blocked" - steps = result["next_steps"] - assert steps[0] == ( - "[REQUIRED] Android Adb: Device detected (abc), but USB debugging is not yet authorized." - ) - assert " Guidance: Tap Allow on the phone." in steps - # `&&` chains are split into consecutive Run lines (PowerShell 5.1 cannot parse &&). - first = steps.index(r" Run: C:\sdk\platform-tools\adb.exe kill-server") - assert steps[first + 1] == r" Run: C:\sdk\platform-tools\adb.exe start-server" - assert not any("&&" in s for s in steps) - assert " Docs: https://example.test/adb" in steps - assert any("mobile_diagnose(attempt_fix=true)" in s for s in steps) - assert steps[-1] == "After each fix, call mobile_diagnose again until verdict is 'ready'." - assert "Attention: Android Adb: Device Unauthorized" in result["summary"] - - -def test_emulator_launch_commands_become_launch_avd_guidance(temp_trace_env): - result = _run(_no_device_probes(["Pixel_8", "Tablet_API_35"])) - steps = result["next_steps"] - - assert result["verdict"] == "blocked" - assert not any(s.startswith(" Run:") and "-avd" in s for s in steps) - assert ( - ' Guidance: Call mobile_diagnose(launch_avd="Pixel_8") to start it in the background.' - in steps - ) - assert ( - ' Guidance: Call mobile_diagnose(launch_avd="Tablet_API_35") to start it in the ' - "background." in steps - ) - # Explicit proposal for the first installed AVD when nothing is ready and nothing boots. - assert any( - s.startswith('No device is ready. Call mobile_diagnose(launch_avd="Pixel_8")') - and "Tablet_API_35" in s - for s in steps - ) - assert " Guidance: Connect your Android phone via USB cable and enable USB Debugging." in steps - - -def test_default_emulator_command_without_installed_avd_is_not_run(temp_trace_env): - result = _run(_no_device_probes([])) - steps = result["next_steps"] - - assert not any(s.startswith(" Run:") for s in steps) - assert any("No AVD named 'Pixel_8_API_34' is installed" in s for s in steps) - assert not any( - s.startswith("No device is ready. Call mobile_diagnose(launch_avd=") for s in steps - ) - - -# --------------------------------------------------------------------------- # -# Credentials guidance -# --------------------------------------------------------------------------- # - - -def test_missing_credentials_point_to_env_file_not_chat(temp_trace_env): - probes = _healthy_probes() - probes[3] = _probe( - "gemini_api_key", - ProbeStatus.FAIL, - category=ProbeCategory.CREDENTIALS, - summary="Key Missing", - actions=[ - ProbeAction(action_type="command", label="Init", payload="artemis init"), - ProbeAction(action_type="link", label="Key", payload="https://aistudio.google.com"), - ], - ) - result = _run(probes, host=_host(env_file="/home/u/artemis/.env")) - - steps = result["next_steps"] - assert result["verdict"] == "blocked" - assert not any(s.strip() == "Run: artemis init" for s in steps) - assert any("/home/u/artemis/.env" in s and "Never ask them to paste" in s for s in steps) - assert any("interactive" in s for s in steps) - assert steps[-1].startswith("After changing the env file") - - -def test_host_problems_come_before_credentials_and_request_restart(temp_trace_env): - """A host WARN (interpreter mismatch, missing venv, squatted port) is a - degradation the runner works around: the verdict is degraded, never - blocked, so a pip/conda checkout is not stuck in a diagnose loop.""" - host = _host(ProbeStatus.WARN, summary="Host Warning") - host.actions = [ - ProbeAction( - action_type="command", label="Regen", payload="uv run artemis mcp --install claude" - ) - ] - result = _run(_healthy_probes(), host=host) - - steps = result["next_steps"] - assert result["verdict"] == "degraded" - assert steps[0].startswith("[OPTIONAL] Integration Host") - assert " Run: uv run artemis mcp --install claude" in steps - assert "restart the MCP server" in steps[-1] - - -def test_unwritable_traces_dir_blocks(temp_trace_env): - host = _host(ProbeStatus.FAIL, summary="Host Misconfigured", traces_dir_writable=False) - result = _run(_healthy_probes(), host=host) - assert result["verdict"] == "blocked" - assert result["next_steps"][0].startswith("[REQUIRED] Integration Host") - - -# --------------------------------------------------------------------------- # -# Requested device -# --------------------------------------------------------------------------- # - - -def test_requested_device_missing_blocks_even_when_environment_is_ready(temp_trace_env): - result = _run(_healthy_probes(), device_serial=" emulator-5554 ") - - assert result["verdict"] == "blocked" - assert result["next_steps"][0].startswith( - "[REQUIRED] Requested device 'emulator-5554' is not attached. Attached: pixel-1 (device)." - ) - - -def test_requested_device_attached_and_ready_stays_ready(temp_trace_env): - result = _run(_healthy_probes(), device_serial="pixel-1") - assert result["verdict"] == "ready" - - -def test_multiple_ready_devices_ask_user_to_choose(temp_trace_env): - probes = _healthy_probes() - probes[5].metadata["devices"].append({"serial": "pixel-2", "state": "device"}) - result = _run(probes) - - assert result["verdict"] == "ready" - assert any( - s.startswith("Several devices are ready") and "pixel-2" in s for s in result["next_steps"] - ) - - -# --------------------------------------------------------------------------- # -# launch_avd -# --------------------------------------------------------------------------- # - - -def test_launch_avd_starts_installed_emulator_and_reports_compact_state(temp_trace_env): - launch = AsyncMock(return_value=_emulator_state(EmulatorLaunchStage.STARTING)) - result = _run(_no_device_probes(["Pixel_8"]), launch=launch, launch_avd=" Pixel_8 ") - - launch.assert_awaited_once_with("Pixel_8") - assert result["emulator"] == { - "avd_name": "Pixel_8", - "status": "starting", - "stage_message": "stage starting", - "error": None, - "serial": None, - "elapsed_seconds": 12, - "progress_percent": 30, - } - assert "logs" not in result["emulator"] - steps = result["next_steps"] - launch_line = next(s for s in steps if s.startswith("Emulator 'Pixel_8' is starting")) - assert "1-3 minutes" in launch_line - assert "60 seconds" in launch_line - assert "do not pass launch_avd again" in launch_line - # A launch that was just started supersedes the launch proposal and the attempt_fix nudge. - assert not any( - s.startswith("No device is ready. Call mobile_diagnose(launch_avd=") for s in steps - ) - assert not any("mobile_diagnose(attempt_fix=true)" in s for s in steps) - - -def test_launch_avd_rejects_uninstalled_avd_without_launching(temp_trace_env): - launch = AsyncMock() - result = _run(_no_device_probes(["Pixel_8", "Tablet_API_35"]), launch=launch, launch_avd="Nope") - - launch.assert_not_awaited() - assert result["emulator"] is None - assert any( - s.startswith("[REQUIRED] AVD 'Nope' is not installed") and "Pixel_8, Tablet_API_35" in s - for s in result["next_steps"] - ) - - -def test_launch_avd_does_not_relaunch_while_booting(temp_trace_env): - launch = AsyncMock() - booting = _emulator_state(EmulatorLaunchStage.BOOTING) - result = _run( - _no_device_probes(["Pixel_8"]), - launch=launch, - emulator_status=booting, - launch_avd="Pixel_8", - ) - - launch.assert_not_awaited() - assert result["emulator"]["status"] == "booting" - assert any( - s.startswith("Emulator 'Pixel_8' is already booting") and "60 seconds" in s - for s in result["next_steps"] - ) - - -def test_launch_in_progress_replaces_connect_device_advice(temp_trace_env): - result = _run( - _no_device_probes(["Pixel_8"]), - emulator_status=_emulator_state(EmulatorLaunchStage.WAITING_FOR_ADB), - ) - steps = result["next_steps"] - - assert result["verdict"] == "blocked" - assert result["emulator"]["status"] == "waiting_for_adb" - assert any( - s.startswith(" Guidance: Emulator 'Pixel_8' is waiting_for_adb") - and "instead of asking the user to connect a device" in s - for s in steps - ) - assert not any("Connect your Android phone" in s for s in steps) - assert not any("mobile_diagnose(attempt_fix=true)" in s for s in steps) - assert not any("launch_avd=" in s for s in steps) - - -def test_launch_avd_failure_is_required_step(temp_trace_env): - failed = _emulator_state( - EmulatorLaunchStage.FAILED, error="emulator binary crashed", stage_message="Failed" - ) - result = _run( - _no_device_probes(["Pixel_8"]), launch=AsyncMock(return_value=failed), launch_avd="Pixel_8" - ) - assert result["emulator"]["status"] == "failed" - assert any( - s == "[REQUIRED] Emulator 'Pixel_8' failed to launch: emulator binary crashed." - for s in result["next_steps"] - ) - - -# --------------------------------------------------------------------------- # -# verify_credentials -# --------------------------------------------------------------------------- # - - -def test_verify_credentials_reports_every_provider_without_leaking_keys(temp_trace_env): - validate = AsyncMock(return_value=(True, "verified")) - result = _run(_healthy_probes(), validate=validate, verify_credentials=True) - - assert result["verdict"] == "ready" - assert result["credentials"] == [ - {"provider": "google", "label": "Gemini", "valid": True, "message": "verified"}, - {"provider": "openai", "label": "ChatGPT", "valid": True, "message": "verified"}, - {"provider": "ocr", "label": "Vision OCR", "valid": True, "message": "verified"}, - ] - called = {call.args[0]: call.args[1] for call in validate.await_args_list} - assert called == {"google": "SECRET-GOOGLE", "openai": "SECRET-OPENAI", "ocr": "SECRET-OCR"} - assert all(call.kwargs["timeout"] == 12.0 for call in validate.await_args_list) - assert "SECRET" not in repr(result) - - -def test_verify_credentials_calls_endpoint_providers_by_url(temp_trace_env): - """A custom / Ollama / vLLM entry carries its base URL as the "key": the - check must hit that endpoint (no auth header) instead of validating the - URL as if it were an API key.""" - validate = AsyncMock(return_value=(True, "verified")) - cred = _probe( - "gemini_api_key", - ProbeStatus.PASS, - category=ProbeCategory.CREDENTIALS, - metadata={ - "providers": [ - { - "provider": "ollama", - "label": "Local Ollama", - "raw_key": "http://localhost:11434/v1", - }, - {"provider": "google", "label": "Gemini", "raw_key": "SECRET-GOOGLE"}, - ], - "api_keys": {}, - }, - ) - probes = [p for p in _healthy_probes() if p.id != "gemini_api_key"] + [cred] - _run(probes, validate=validate, verify_credentials=True) - - calls = {call.args[0]: call for call in validate.await_args_list} - assert calls["ollama"].args[1] == "EMPTY" - assert calls["ollama"].kwargs["base_url"] == "http://localhost:11434/v1" - assert calls["google"].args[1] == "SECRET-GOOGLE" - assert "base_url" not in calls["google"].kwargs - - -def test_invalid_primary_credential_blocks_and_redacts_message(temp_trace_env): - async def _validate(provider, api_key, timeout=12.0): - if provider == "google": - return False, f"Gemini API verification failed (400): key {api_key} invalid" - if provider == "ocr": - raise RuntimeError("boom SECRET-OCR") - return True, "ok" - - result = _run( - _healthy_probes(), - host=_host(env_file="/home/u/.env"), - validate=AsyncMock(side_effect=_validate), - verify_credentials=True, - ) - - assert result["verdict"] == "blocked" - assert "SECRET" not in repr(result) - google, openai, ocr = result["credentials"] - assert google["valid"] is False and "***" in google["message"] - assert openai["valid"] is True - assert ocr["valid"] is False and ocr["message"].startswith("verification raised RuntimeError") - steps = result["next_steps"] - required = next(s for s in steps if s.startswith("[REQUIRED] Gemini API key (google)")) - assert "failed live verification" in required - assert steps[steps.index(required) + 1].startswith(" Ask the user to add a provider key") - assert any("/home/u/.env" in s for s in steps) - assert any(s.startswith("[OPTIONAL] Vision OCR API key (ocr)") for s in steps) - assert steps[-1].startswith("After changing the env file") - assert "Gemini key verification" in result["summary"] - - -def test_invalid_secondary_credential_only_degrades(temp_trace_env): - async def _validate(provider, api_key, timeout=12.0): - return (provider != "openai", "ok" if provider != "openai" else "401 unauthorized") - - result = _run( - _healthy_probes(), validate=AsyncMock(side_effect=_validate), verify_credentials=True - ) - assert result["verdict"] == "degraded" - assert any(s.startswith("[OPTIONAL] ChatGPT API key (openai)") for s in result["next_steps"]) - - -# --------------------------------------------------------------------------- # -# Task / lock state -# --------------------------------------------------------------------------- # - - -def test_tasks_surface_active_and_queued_and_busy_device_step(temp_trace_env): - queued_ticket = { - "session_id": "trace-456", - "goal": "Queued goal", - "device_id": "pixel-1", - "device_serial": "pixel-1", - "adb_endpoint_id": None, - "pid": 888, - "token": "tok-2", - "ingress": "cli", - "status": "pending", - "created_at": 1700000000.0, - "start_time": 1700000000.0, - } - result = _run(_healthy_probes(), active_owners={"pixel-1": _owner()}, queued=[queued_ticket]) - - assert result["tasks"] == { - "active": [ - { - "device": "pixel-1", - "session_id": "trace-123", - "pid": 777, - "description": "Open settings and toggle wifi", - "ingress": "mcp", - "started_at": "2026-09-09T10:00:00+00:00", - } - ], - "queued": [ - { - "device": "pixel-1", - "session_id": "trace-456", - "pid": 888, - "description": "Queued goal", - "ingress": "cli", - "created_at": 1700000000.0, - } - ], - } - assert result["verdict"] == "ready" - busy = next(s for s in result["next_steps"] if s.startswith("Device 'pixel-1' is busy")) - assert "task trace-123 (pid 777)" in busy - assert 'mobile_manage_task(action="stop", trace_id="trace-123")' in busy - - -def test_busy_step_only_for_the_targeted_device(temp_trace_env): - probes = _healthy_probes() - probes[5].metadata["devices"].append({"serial": "pixel-2", "state": "device"}) - - result = _run(probes, active_owners={"pixel-2": _owner("pixel-2")}, device_serial="pixel-1") - assert not any("is busy" in s for s in result["next_steps"]) - - result = _run(probes, active_owners={"pixel-2": _owner("pixel-2")}, device_serial="pixel-2") - assert any(s.startswith("Device 'pixel-2' is busy") for s in result["next_steps"]) - - # Two ready devices and no requested serial: nothing to single out. - result = _run(probes, active_owners={"pixel-2": _owner("pixel-2")}) - assert not any("is busy" in s for s in result["next_steps"]) - - -# --------------------------------------------------------------------------- # -# attempt_fix -# --------------------------------------------------------------------------- # - - -def test_attempt_fix_heals_corrupted_keys_then_rechecks(temp_trace_env): - broken = _healthy_probes() - broken[5].metadata["adb_keys"] = {"is_corrupted": True} - broken[5].status = ProbeStatus.WARN - broken[5].summary = "ADB Key Corrupted" - healthy = _healthy_probes() - - run_all = AsyncMock(side_effect=[_report(broken), _report(healthy)]) - heal = AsyncMock(return_value={"success": True, "message": "keys regenerated"}) - restart = AsyncMock() - with ( - patch.object(diagnose.readiness_engine, "run_all", run_all), - patch.object(diagnose.readiness_engine, "heal_adb_keys", heal), - patch.object(diagnose.readiness_engine, "restart_adb_server", restart), - patch.object(diagnose.DeviceExecutionLock, "cleanup_stale_locks", return_value=0), - patch.object(diagnose.DeviceExecutionLock, "get_active_owners", return_value={}), - patch.object(diagnose.DeviceExecutionLock, "get_queued_tasks", return_value=[]), - patch.object(IntegrationHostProbe, "probe", AsyncMock(return_value=_host())), - patch.object( - diagnose, - "_helper_status", - lambda serial: {**_helper_healthy(serial), "serial": serial, "backend": "auto"}, - ), - ): - result = asyncio.run(mobile_diagnose(attempt_fix=True)) - - heal.assert_awaited_once() - restart.assert_not_awaited() # healing already restarted ADB - assert run_all.await_count == 2 - assert result["verdict"] == "ready" - assert result["fixes_applied"] == [ - {"fix": "heal_adb_keys", "success": True, "message": "keys regenerated", "skipped": False} - ] - assert any(s.startswith("Auto-fix heal_adb_keys applied") for s in result["next_steps"]) - - -def test_attempt_fix_restarts_adb_only_without_ready_device_or_active_task(temp_trace_env): - no_device = _no_device_probes([]) - restart = AsyncMock(return_value={"success": False, "message": "adb start-server failed"}) - - with patch.object(diagnose.readiness_engine, "restart_adb_server", restart): - result = _run(no_device, attempt_fix=True) - restart.assert_awaited_once() - assert result["fixes_applied"][0]["fix"] == "restart_adb_server" - assert result["fixes_applied"][0]["success"] is False - assert any("did not help" in s for s in result["next_steps"]) - - restart.reset_mock() - with patch.object(diagnose.readiness_engine, "restart_adb_server", restart): - result = _run(no_device, active_owners={"d": _owner("d")}, attempt_fix=True) - restart.assert_not_awaited() - assert result["fixes_applied"][0]["skipped"] is True - - # A ready device means there is nothing to restart, even with attempt_fix. - restart.reset_mock() - with patch.object(diagnose.readiness_engine, "restart_adb_server", restart): - result = _run(_healthy_probes(), attempt_fix=True) - restart.assert_not_awaited() - assert result["fixes_applied"] == [] - - -def test_attempt_fix_records_stale_lock_cleanup_only_when_something_was_removed(temp_trace_env): - run_all = AsyncMock(return_value=_report(_healthy_probes())) - - result = _run(_healthy_probes(), run_all=run_all, cleanup=0, attempt_fix=True) - assert result["fixes_applied"] == [] - assert run_all.await_count == 1 - - result = _run(_healthy_probes(), run_all=run_all, cleanup=2, attempt_fix=True) - assert result["fixes_applied"] == [ - { - "fix": "cleanup_stale_locks", - "success": True, - "skipped": False, - "message": "removed 2 stale device lock(s) / queue ticket(s)", - } - ] - assert run_all.await_count == 2 # lock cleanup does not change probe results: no re-run - assert any(s.startswith("Auto-fix cleanup_stale_locks applied") for s in result["next_steps"]) - - failing = MagicMock(side_effect=PermissionError("locked dir")) - result = _run(_healthy_probes(), cleanup=failing, attempt_fix=True) - assert result["fixes_applied"] == [ - { - "fix": "cleanup_stale_locks", - "success": False, - "skipped": False, - "message": "cleanup raised PermissionError: locked dir", - } - ] - - # Without attempt_fix the cleanup is never attempted. - failing.reset_mock() - _run(_healthy_probes(), cleanup=failing) - failing.assert_not_called() - - -# --------------------------------------------------------------------------- # -# probe_device -# --------------------------------------------------------------------------- # - - -def test_probe_device_ok_keeps_verdict_ready(temp_trace_env): - smoke = AsyncMock(return_value=_smoke_ok()) - result = _run(_healthy_probes(), smoke=smoke, probe_device=True) - - smoke.assert_awaited_once_with("pixel-1") - assert result["verdict"] == "ready" - assert result["device_probe"] == _smoke_ok() - assert not any("Device probe failed" in s for s in result["next_steps"]) - - -def test_probe_device_failure_blocks_with_fix_guidance(temp_trace_env): - smoke = AsyncMock( - return_value={ - "ok": False, - "serial": "pixel-1", - "elapsed_seconds": 8.1, - "screenshot_bytes": None, - "element_count": None, - "error": "uiautomator dump timed out after 8s", - "fix": ["Unlock the device.", "Run: adb -s pixel-1 shell input keyevent 82"], - } - ) - result = _run(_healthy_probes(), smoke=smoke, device_serial="pixel-1", probe_device=True) - - smoke.assert_awaited_once_with("pixel-1") - assert result["verdict"] == "blocked" - steps = result["next_steps"] - idx = steps.index( - "[REQUIRED] Device probe failed on pixel-1: uiautomator dump timed out after 8s" - ) - assert steps[idx + 1] == " Guidance: Unlock the device." - assert steps[idx + 2] == " Guidance: Run: adb -s pixel-1 shell input keyevent 82" - assert "Device probe: uiautomator dump timed out" in result["summary"] - - -def test_probe_device_is_skipped_without_a_ready_device(temp_trace_env): - smoke = AsyncMock() - result = _run(_no_device_probes([]), smoke=smoke, probe_device=True) - - smoke.assert_not_awaited() - assert result["device_probe"]["ok"] is False - assert "no authorized device" in result["device_probe"]["error"] - - -# --------------------------------------------------------------------------- # -# Timeout / logs -# --------------------------------------------------------------------------- # - - -def test_hung_probe_yields_blocked_verdict_instead_of_hanging(temp_trace_env, monkeypatch): - async def _never(): - await asyncio.sleep(10) - - monkeypatch.setattr(diagnose, "DIAGNOSIS_TIMEOUT_SECONDS", 0.05) - monkeypatch.setattr(diagnose, "collect_readiness", _never) - with ( - patch.object(diagnose.DeviceExecutionLock, "get_active_owners", return_value={}), - patch.object(diagnose.DeviceExecutionLock, "get_queued_tasks", return_value=[]), - ): - result = asyncio.run(mobile_diagnose()) - - assert result["verdict"] == "blocked" - assert result["checks"] == [] - assert result["next_steps"][:2] == ["Run: adb kill-server", "Run: adb start-server"] - assert "hung" in result["summary"] - for field in ("device", "credentials", "device_probe"): - assert result[field] is None - assert result["tasks"] == {"active": [], "queued": []} - - -def test_hung_device_probe_still_times_out(temp_trace_env, monkeypatch): - async def _hang(serial): - await asyncio.sleep(10) - - monkeypatch.setattr(diagnose, "DIAGNOSIS_TIMEOUT_SECONDS", 0.05) - result = _run(_healthy_probes(), smoke=AsyncMock(side_effect=_hang), probe_device=True) - assert result["verdict"] == "blocked" - assert "hung" in result["summary"] - - -def test_logs_surface_recent_errors_and_last_failed_task(temp_trace_env, monkeypatch): - monkeypatch.setattr(diagnose.env_utils, "get_project_root", lambda: temp_trace_env) - scratch = diagnose.Path(temp_trace_env) / "scratch" - scratch.mkdir() - (scratch / "mcp_stderr.log").write_text( - "info: booted\n\x1b[33mTraceback (most recent call last):\x1b[0m\nModuleNotFoundError: No module named 'x'\nall good\n", - encoding="utf-8", - ) - trace_store.init_trace("ok-trace", "fine", "Flash", None) - trace_store.update_trace_status("ok-trace", "completed") - trace_store.init_trace("bad-trace", "broken", "Flash", None) - trace_store.update_trace_status("bad-trace", "failed", error="adb: device offline") - with open(trace_store.get_trace_stderr_log_path("bad-trace"), "w", encoding="utf-8") as fh: - fh.write("starting runner\n") - fh.write("ERROR adb: device offline\n") - fh.write("Traceback (most recent call last):\n") - fh.write("RuntimeError: device offline\n") - - result = _run(_healthy_probes()) - - logs = result["logs"] - assert logs["recent_mcp_errors"] == [ - "Traceback (most recent call last):", - "ModuleNotFoundError: No module named 'x'", - ] - failed = logs["last_failed_task"] - assert failed["trace_id"] == "bad-trace" - assert failed["error"] == "adb: device offline" - assert failed["stderr_log"].endswith("stderr.log") - assert failed["recent_errors"] == [ - "ERROR adb: device offline", - "Traceback (most recent call last):", - "RuntimeError: device offline", - ] - - -# --------------------------------------------------------------------------- # -# Accessibility helper (UI hierarchy backend) -# --------------------------------------------------------------------------- # - - -def test_missing_helper_in_auto_mode_degrades_with_optional_step(temp_trace_env): - status = { - **_helper_healthy(), - "installed": False, - "installed_version": None, - "reachable": False, - } - result = _run(_healthy_probes(), helper_status=status) - - assert result["verdict"] == "degraded" - helper = result["device"] is None or result["device"]["accessibility_helper"] - assert helper - optional = [s for s in result["next_steps"] if s.startswith("[OPTIONAL] Accessibility helper")] - assert len(optional) == 1 and "is not installed" in optional[0] - assert " Run: uv run artemis helper install --serial pixel-1" in result["next_steps"] - assert any("attempt_fix=true" in s for s in result["next_steps"]) - result["_fake_helper"].provision.assert_not_called() - - -def test_missing_helper_in_helper_mode_blocks(temp_trace_env): - status = {**_helper_healthy(), "installed": False, "reachable": False} - result = _run(_healthy_probes(), helper_status=status, backend="helper") - - assert result["verdict"] == "blocked" - assert any(s.startswith("[REQUIRED] Accessibility helper") for s in result["next_steps"]) - - -def test_uiautomator_backend_ignores_helper_state(temp_trace_env): - status = {**_helper_healthy(), "installed": False, "reachable": False} - result = _run(_healthy_probes(), helper_status=status, backend="uiautomator") - - assert result["verdict"] == "ready" - assert not any("Accessibility helper" in s for s in result["next_steps"]) - - -def test_outdated_helper_reports_versions(temp_trace_env): - status = {**_helper_healthy(), "installed_version": 1, "outdated": True} - result = _run(_healthy_probes(), helper_status=status) - - step = next(s for s in result["next_steps"] if "Accessibility helper" in s) - assert "device has version 1, bundled is 2" in step - - -def test_attempt_fix_provisions_missing_helper_on_idle_device(temp_trace_env): - status = { - **_helper_healthy(), - "installed": False, - "installed_version": None, - "reachable": False, - } - result = _run(_healthy_probes(), helper_status=status, attempt_fix=True) - - result["_fake_helper"].provision.assert_called_once_with("pixel-1") - fix = next(f for f in result["fixes_applied"] if f["fix"] == "install_accessibility_helper") - assert fix["success"] is True and fix["skipped"] is False - assert "installed" in fix["message"] - - -def test_attempt_fix_skips_helper_install_while_task_holds_device(temp_trace_env): - status = {**_helper_healthy(), "installed": False, "reachable": False} - result = _run( - _healthy_probes(), - helper_status=status, - attempt_fix=True, - active_owners={"pixel-1": _owner()}, - ) - - result["_fake_helper"].provision.assert_not_called() - fix = next(f for f in result["fixes_applied"] if f["fix"] == "install_accessibility_helper") - assert fix["skipped"] is True - - -def test_attempt_fix_leaves_healthy_helper_alone(temp_trace_env): - result = _run(_healthy_probes(), attempt_fix=True) - - result["_fake_helper"].provision.assert_not_called() - assert not any(f["fix"] == "install_accessibility_helper" for f in result["fixes_applied"]) - - -def test_disabled_helper_step_includes_the_manual_path(temp_trace_env): - from artemis.runtime.helper_manager import MANUAL_ENABLE_PATH - - status = {**_helper_healthy(), "enabled": False, "reachable": False} - result = _run(_healthy_probes(), helper_status=status) - step = next(s for s in result["next_steps"] if "Accessibility helper" in s) - assert "service is disabled" in step - assert any(MANUAL_ENABLE_PATH in s for s in result["next_steps"]) - - -def test_newer_helper_than_bundle_is_not_a_finding(temp_trace_env): - status = {**_helper_healthy(), "installed_version": 9, "newer_than_bundled": True} - result = _run(_healthy_probes(), helper_status=status) - assert result["verdict"] == "ready" - assert not any("Accessibility helper" in s for s in result["next_steps"]) diff --git a/tests/unit/mcp/test_mcp_stdio_lifecycle.py b/tests/unit/mcp/test_mcp_stdio_lifecycle.py index 05dea8801..d375812b9 100644 --- a/tests/unit/mcp/test_mcp_stdio_lifecycle.py +++ b/tests/unit/mcp/test_mcp_stdio_lifecycle.py @@ -12,149 +12,23 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Unit and integration tests verifying MCP stdio handshake reliability, +"""Device subprocess stdin isolation and stdout purity for MCP stdio mode. -subprocess stdin isolation against pipe hijacking, and stdout purity. +The device MCP server runs over stdio, so every helper subprocess we spawn +(adb commands, the awake service, the uiAutomator client) must detach stdin +and the logging banner must stay on stderr so it never pollutes the JSON-RPC +stream on stdout. """ import io -import json -import os -import queue import subprocess import sys -import threading -import time from unittest.mock import MagicMock, patch -import pytest - from artemis.runtime.awake_lease import ScreenAwakeLease from artemis.runtime.awake_service import _run_awake_adb_command from third_party.mobile_use.clients import ui_automator_client from third_party.mobile_use.utils.logger import get_logger -from mcp_server.utils import device_utils, env_utils - - -def _readline_with_timeout(pipe, timeout: float) -> str | None: - """Read one line from a subprocess pipe with a timeout, portably. - - select.select only supports sockets on Windows, so poll via a reader thread - instead of selecting on the pipe handle. - """ - result: queue.Queue = queue.Queue() - - def _reader(): - try: - result.put(pipe.readline()) - except Exception: - result.put(b"") - - reader = threading.Thread(target=_reader, daemon=True) - reader.start() - try: - line = result.get(timeout=timeout) - except queue.Empty: - return None - if not line: - return None - return line.decode("utf-8").strip() - - -def test_mcp_stdio_handshake_immediate_input(): - """Verify that MCP server over stdio responds to an immediate initialize request - - without hanging, even when stdin data is pushed concurrently with process startup. - """ - project_root = os.path.dirname( - os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - ) - python_exe = sys.executable - - p = subprocess.Popen( - [python_exe, "-m", "mcp_server"], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env={ - "PYTHONUNBUFFERED": "1", - "PATH": os.environ.get("PATH", ""), - "PYTHONPATH": project_root, - # Windows: the interpreter's socket stack (Winsock/_overlapped), - # Path.home(), and tempfile handling need these system variables. - **{ - key: os.environ[key] - for key in ( - "SYSTEMROOT", - "SYSTEMDRIVE", - "WINDIR", - "TEMP", - "TMP", - "USERPROFILE", - "HOMEDRIVE", - "HOMEPATH", - "APPDATA", - "LOCALAPPDATA", - ) - if key in os.environ - }, - }, - cwd=project_root, - ) - - try: - # Immediately write the JSON-RPC initialize request into stdin - init_req = { - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "test-client", "version": "1.0.0"}, - }, - } - p.stdin.write(json.dumps(init_req).encode("utf-8") + b"\n") - p.stdin.flush() - - # Read initialize response with a strict timeout - init_resp_line = _readline_with_timeout(p.stdout, 6.0) - - assert init_resp_line is not None, ( - "MCP server failed to respond to initialize request within 6 seconds (deadlock detected)!" - ) - init_data = json.loads(init_resp_line) - assert init_data.get("id") == 1 - assert "result" in init_data - - # Follow up with initialized notification and tools/list request - notif = {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}} - tools_req = {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}} - p.stdin.write(json.dumps(notif).encode("utf-8") + b"\n") - p.stdin.write(json.dumps(tools_req).encode("utf-8") + b"\n") - p.stdin.flush() - - tools_resp_line = _readline_with_timeout(p.stdout, 4.0) - - assert tools_resp_line is not None, "MCP server failed to respond to tools/list request!" - tools_data = json.loads(tools_resp_line) - assert tools_data.get("id") == 2 - tools = tools_data.get("result", {}).get("tools", []) - tool_names = {t["name"] for t in tools} - assert { - "mobile_run_task", - "mobile_manage_task", - "mobile_get_device_state", - "mobile_inspect_trace", - "mobile_diagnose", - }.issubset(tool_names) - - finally: - p.terminate() - try: - p.wait(timeout=3) - except subprocess.TimeoutExpired: - p.kill() def test_awake_service_adb_command_isolates_stdin(): @@ -184,25 +58,6 @@ def test_awake_lease_run_isolates_stdin(): ) -def test_detached_process_kwargs_isolates_stdin(): - """Verify get_detached_process_kwargs always sets stdin=subprocess.DEVNULL.""" - kwargs = env_utils.get_detached_process_kwargs() - assert kwargs.get("stdin") == subprocess.DEVNULL, ( - "Expected stdin=subprocess.DEVNULL for detached background tasks!" - ) - - -def test_device_utils_isolates_stdin(): - """Verify device_utils subprocess calls always set stdin=subprocess.DEVNULL.""" - with patch("mcp_server.utils.device_utils.subprocess.run") as mock_run: - mock_run.return_value = MagicMock( - returncode=0, stdout="List of devices attached\n", stderr="" - ) - device_utils.get_connected_devices() - assert mock_run.called - assert mock_run.call_args.kwargs.get("stdin") == subprocess.DEVNULL - - def test_ui_automator_client_isolates_stdin(): """Verify ui_automator_client helper commands isolate stdin.""" with patch("third_party.mobile_use.clients.ui_automator_client.subprocess.run") as mock_run: diff --git a/tests/unit/mcp/test_mcp_tools.py b/tests/unit/mcp/test_mcp_tools.py deleted file mode 100644 index 0064ed9aa..000000000 --- a/tests/unit/mcp/test_mcp_tools.py +++ /dev/null @@ -1,657 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for Artemis MCP Tools.""" - -import inspect -import json -import os -import shutil -import tempfile -from types import SimpleNamespace -import uuid -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from mcp_server.tools import ( - mobile_get_device_state, - mobile_inspect_trace, - mobile_manage_task, - mobile_run_task, -) -from artemis.runtime import trace_store - - -@pytest.fixture -def temp_trace_env(monkeypatch): - temp_dir = tempfile.mkdtemp() - monkeypatch.setattr(trace_store, "TRACES_DIR", temp_dir) - monkeypatch.setenv("ARTEMIS_STANDALONE", "1") - yield temp_dir - shutil.rmtree(temp_dir, ignore_errors=True) - - -def test_tool_signatures(): - # mobile_run_task signature check - sig_run = inspect.signature(mobile_run_task) - assert "task_desc" in sig_run.parameters - assert "conversation_id" in sig_run.parameters - assert "model" in sig_run.parameters - assert "locked_app_package" in sig_run.parameters - assert "app_path" in sig_run.parameters - assert "expected_output_desc" in sig_run.parameters - assert "device_serial" in sig_run.parameters - assert "verification_level" in sig_run.parameters - assert "explorer_mode" in sig_run.parameters - assert sig_run.parameters["verification_level"].default is None - assert sig_run.parameters["explorer_mode"].default is None - # The tool description is the only schema an MCP caller sees. - assert "verification_level" in (mobile_run_task.__doc__ or "") - assert "explorer_mode" in (mobile_run_task.__doc__ or "") - - # mobile_manage_task signature check - sig_manage = inspect.signature(mobile_manage_task) - assert "action" in sig_manage.parameters - assert "trace_id" in sig_manage.parameters - assert "instruction" in sig_manage.parameters - - # mobile_get_device_state signature check - sig_device = inspect.signature(mobile_get_device_state) - assert "view_type" in sig_device.parameters - assert "device_serial" in sig_device.parameters - - # mobile_inspect_trace signature check - sig_inspect = inspect.signature(mobile_inspect_trace) - assert "action" in sig_inspect.parameters - assert "trace_id" in sig_inspect.parameters - assert "step_number" in sig_inspect.parameters - assert "query" in sig_inspect.parameters - assert "step_range" in sig_inspect.parameters - assert sig_inspect.parameters["max_results"].default == 5 - assert "'search'" in (mobile_inspect_trace.__doc__ or "") - - -def test_mobile_run_task_invalid_model(): - with pytest.raises(ValueError, match="Invalid model"): - mobile_run_task(task_desc="test", conversation_id="conv-1", model="invalid_model") - - -def test_mobile_run_task_reserves_and_passes_global_queue_ticket(temp_trace_env): - process = MagicMock(pid=43210) - with ( - patch( - "mcp_server.tools.task_runner.DeviceExecutionLock.reserve", - return_value="queue-ticket-1", - ) as reserve, - patch("mcp_server.tools.task_runner.DeviceExecutionLock.transfer_reservation") as transfer, - patch("mcp_server.tools.task_runner.subprocess.Popen", return_value=process) as popen, - ): - result = mobile_run_task( - task_desc="Open Settings", - conversation_id="conv-1", - model="Flash", - ) - - reserve.assert_called_once() - transfer.assert_called_once_with( - "queue-ticket-1", - 43210, - description="MCP task: Open Settings", - session_id=result["trace_id"], - ingress="mcp", - ) - assert result["trace_id"] - assert result["device_serial"] == "auto-select" - assert popen.call_args.kwargs["env"]["ARTEMIS_DEVICE_QUEUE_TICKET"] == "queue-ticket-1" - assert popen.call_args.kwargs["env"]["ARTEMIS_TASK_INGRESS"] == "mcp" - status = trace_store.read_status(result["trace_id"]) - assert status["queue_ticket"] == "queue-ticket-1" - assert status["device_serial"] is None - - -def test_mobile_run_task_with_device_serial(temp_trace_env): - process = MagicMock(pid=54321) - with ( - patch( - "mcp_server.tools.task_runner.DeviceExecutionLock.reserve", - return_value="queue-ticket-dev", - ) as reserve, - patch("mcp_server.tools.task_runner.DeviceExecutionLock.transfer_reservation") as transfer, - patch("mcp_server.tools.task_runner.subprocess.Popen", return_value=process) as popen, - # Hermetic device validation: the requested serial reports as ready - # regardless of what is attached to the host running the tests. - patch( - "artemis.runtime.device_pool.device_pool.try_list_devices", - return_value=[SimpleNamespace(serial="pixel-11-pro-001", state="device")], - ), - ): - result = mobile_run_task( - task_desc="Open Settings on target phone", - conversation_id="conv-2", - model="Flash", - device_serial="pixel-11-pro-001", - ) - - reserve.assert_called_once_with( - description="MCP task: Open Settings on target phone", - session_id=result["trace_id"], - ingress="mcp", - device_id="pixel-11-pro-001", - ) - transfer.assert_called_once_with( - "queue-ticket-dev", - 54321, - description="MCP task: Open Settings on target phone", - session_id=result["trace_id"], - ingress="mcp", - device_id="pixel-11-pro-001", - ) - assert result["device_serial"] == "pixel-11-pro-001" - cmd = popen.call_args.args[0] - assert "--device-serial" in cmd - assert cmd[cmd.index("--device-serial") + 1] == "pixel-11-pro-001" - assert popen.call_args.kwargs["env"]["ADB_DEVICE_SERIAL"] == "pixel-11-pro-001" - assert popen.call_args.kwargs["env"]["ARTEMIS_DEVICE_ID"] == "pixel-11-pro-001" - - status = trace_store.read_status(result["trace_id"]) - assert status["device_serial"] == "pixel-11-pro-001" - - -def test_mobile_run_task_dispatched_to_daemon(temp_trace_env, monkeypatch): - monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False) - with ( - patch( - "mcp_server.tools.task_runner.ensure_daemon_running", - return_value=(True, "http://127.0.0.1:8000"), - ), - patch( - "mcp_server.tools.task_runner.submit_task_to_daemon", - return_value={"status": "started", "tasks": [{"session_id": "daemon-sid-1"}]}, - ), - patch("mcp_server.tools.task_runner.subprocess.Popen") as popen, - ): - result = mobile_run_task( - task_desc="Open Settings via Daemon", - conversation_id="conv-daemon", - model="Flash", - ) - - popen.assert_not_called() - assert result["trace_id"] == "daemon-sid-1" - assert result["status"] == "running" - assert "enqueued via unified Artemis Daemon" in result["message"] - assert "stdout_log" in result - assert "stderr_log" in result - - -@pytest.mark.parametrize( - ("kwargs", "match"), - [ - ({"verification_level": "paranoid"}, "Invalid verification_level"), - ({"explorer_mode": "turbo"}, "Invalid explorer_mode"), - ], -) -def test_mobile_run_task_rejects_unknown_pro_tuning_before_creating_a_trace( - temp_trace_env, kwargs, match -): - with pytest.raises(ValueError, match=match): - mobile_run_task(task_desc="Open Settings", model="Pro", **kwargs) - # Rejected before init_trace: nothing was written to the trace store. - assert os.listdir(temp_trace_env) == [] - - -def test_mobile_run_task_forwards_pro_tuning_to_background_runner(temp_trace_env): - process = MagicMock(pid=777) - with ( - patch("mcp_server.tools.task_runner.DeviceExecutionLock.reserve", return_value="t"), - patch("mcp_server.tools.task_runner.DeviceExecutionLock.transfer_reservation"), - patch("mcp_server.tools.task_runner.subprocess.Popen", return_value=process) as popen, - ): - mobile_run_task( - task_desc="Audit the checkout flow", - model="Pro", - verification_level=" Strict ", - explorer_mode="ULTRA", - ) - - cmd = popen.call_args.args[0] - # Same flag spelling as `artemis run` / the admin-console worker, normalised values. - assert cmd[cmd.index("--verification-level") + 1] == "strict" - assert cmd[cmd.index("--explorer-pro-mode") + 1] == "ultra" - - -def test_mobile_run_task_omits_pro_tuning_flags_when_unset(temp_trace_env): - process = MagicMock(pid=778) - with ( - patch("mcp_server.tools.task_runner.DeviceExecutionLock.reserve", return_value="t"), - patch("mcp_server.tools.task_runner.DeviceExecutionLock.transfer_reservation"), - patch("mcp_server.tools.task_runner.subprocess.Popen", return_value=process) as popen, - ): - mobile_run_task(task_desc="Open Settings", model="Pro", verification_level=" ") - - cmd = popen.call_args.args[0] - assert "--verification-level" not in cmd - assert "--explorer-pro-mode" not in cmd - - -def test_mobile_run_task_forwards_pro_tuning_to_daemon(temp_trace_env, monkeypatch): - monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False) - with ( - patch( - "mcp_server.tools.task_runner.ensure_daemon_running", - return_value=(True, "http://127.0.0.1:8000"), - ), - patch( - "mcp_server.tools.task_runner.submit_task_to_daemon", - return_value={"status": "started", "tasks": [{"session_id": "daemon-sid-2"}]}, - ) as submit, - patch("mcp_server.tools.task_runner.subprocess.Popen") as popen, - ): - result = mobile_run_task( - task_desc="Audit via Daemon", - model="Pro", - verification_level="Checkpoints", - explorer_mode="pro", - ) - - popen.assert_not_called() - assert result["trace_id"] == "daemon-sid-2" - # JSON field spelling of /api/run: verification_level / explorer_mode. - assert submit.call_args.kwargs["verification_level"] == "checkpoints" - assert submit.call_args.kwargs["explorer_mode"] == "pro" - - -def test_mobile_manage_task_unknown_trace(temp_trace_env): - res = mobile_manage_task(action="status", trace_id="non-existent-trace") - assert res["status"] == "unknown" - assert "not found" in res["message"] - - -def test_mobile_manage_task_status_and_stop(temp_trace_env): - trace_id = str(uuid.uuid4()) - trace_store.init_trace(trace_id, "Test task", "Flash", "conv-1", device_serial="device-xyz") - - # Status check - status_res = mobile_manage_task(action="status", trace_id=trace_id) - assert status_res["trace_id"] == trace_id - assert status_res["status"] == "running" - assert status_res["model"] == "Flash" - assert status_res["device_serial"] == "device-xyz" - - # Stop without PID - stop_res = mobile_manage_task(action="stop", trace_id=trace_id) - assert stop_res["trace_id"] == trace_id - assert "PID" in stop_res["message"] or "Cannot stop" in stop_res["message"] - - -def test_mobile_manage_task_status_backfills_device_serial_from_db(temp_trace_env): - import sqlite3 - import os - - trace_id = str(uuid.uuid4()) - trace_store.init_trace(trace_id, "Test task without serial", "Flash", "conv-1") - - # Create dummy data_engine.db with session record - db_path = os.path.join(temp_trace_env, "data_engine.db") - conn = sqlite3.connect(db_path) - conn.execute( - "CREATE TABLE sessions (session_id TEXT PRIMARY KEY, device_info TEXT, pid INTEGER, start_time REAL)" - ) - conn.execute( - "INSERT INTO sessions VALUES (?, ?, ?, ?)", - (trace_id, json.dumps({"device_id": "auto-chosen-serial-777"}), 12345, 1000.0), - ) - conn.commit() - conn.close() - - status_res = mobile_manage_task(action="status", trace_id=trace_id) - assert status_res["device_serial"] == "auto-chosen-serial-777" - - -def test_mobile_manage_task_inject_instruction(temp_trace_env): - trace_id = str(uuid.uuid4()) - trace_store.init_trace(trace_id, "Test task", "Pro", "conv-1") - - # Missing instruction parameter - err_res = mobile_manage_task(action="inject_instruction", trace_id=trace_id) - assert "Missing required argument" in err_res["message"] - - # Valid injection - ok_res = mobile_manage_task( - action="inject_instruction", trace_id=trace_id, instruction="Scroll down slowly" - ) - assert "Successfully injected" in ok_res["message"] - - -@pytest.mark.asyncio -async def test_mobile_get_device_state_hierarchy_without_ocr(): - """XML hierarchy remains available when OCR is not configured.""" - controller = MagicMock() - controller.ctx.device.device_width = 1080 - controller.ctx.device.device_height = 2400 - controller.get_screen_data = AsyncMock( - return_value=SimpleNamespace( - base64="ZHVtbXk=", - elements=[ - { - "class": "android.widget.Button", - "text": "Start", - "bounds": "[100,500][400,600]", - } - ], - width=1080, - height=2400, - ) - ) - - with ( - patch("mcp_server.tools.device_state._get_controller", return_value=controller), - patch("mcp_server.tools.device_state.is_ocr_configured", return_value=False), - patch( - "mcp_server.tools.device_state.perform_ocr", new_callable=AsyncMock - ) as perform_ocr_mock, - ): - result = await mobile_get_device_state(view_type="hierarchy") - - assert "Start" in result - perform_ocr_mock.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_mobile_inspect_trace_invalid_action(): - res = await mobile_inspect_trace(action="invalid_action", trace_id="trace-123") - assert "error" in res - assert "not supported" in res["message"] - - -@pytest.mark.asyncio -async def test_mobile_get_device_state_passes_device_serial(): - """Verify that device_serial is forwarded to _get_controller.""" - controller = MagicMock() - controller.ctx.device.device_width = 1080 - controller.ctx.device.device_height = 2400 - controller.get_screen_data = AsyncMock( - return_value=SimpleNamespace( - base64="ZHVtbXk=", - elements=[], - width=1080, - height=2400, - ) - ) - - with ( - patch( - "mcp_server.tools.device_state._get_controller", return_value=controller - ) as get_ctrl_mock, - patch("mcp_server.tools.device_state.is_ocr_configured", return_value=False), - ): - await mobile_get_device_state(view_type="hierarchy", device_serial="device-serial-abc") - - get_ctrl_mock.assert_called_once_with(device_serial="device-serial-abc") - - -@pytest.mark.asyncio -async def test_mobile_inspect_trace_includes_device_serial(temp_trace_env): - import sqlite3 - - trace_id = str(uuid.uuid4()) - trace_store.init_trace( - trace_id, "Inspect test", "Flash", "conv-1", device_serial="pixel-target-999" - ) - - # Create dummy database and table - db_path = os.path.join(temp_trace_env, "data_engine.db") - conn = sqlite3.connect(db_path) - conn.execute( - "CREATE TABLE sessions (session_id TEXT PRIMARY KEY, start_time REAL, device_info TEXT)" - ) - conn.execute( - "CREATE TABLE steps (step_id TEXT PRIMARY KEY, session_id TEXT, step_number INTEGER, timestamp REAL, pre_image_name TEXT, post_image_name TEXT, summary TEXT, action_taken TEXT, operator_raw_thinking TEXT, last_execution_result TEXT, extra_metadata TEXT)" - ) - conn.execute( - "CREATE TABLE traces (trace_id TEXT PRIMARY KEY, step_id TEXT, session_id TEXT, type TEXT, name TEXT, status TEXT, timestamp REAL, duration REAL, payload TEXT)" - ) - conn.execute("INSERT INTO sessions VALUES (?, ?, ?)", (trace_id, 1000.0, None)) - conn.commit() - conn.close() - - res_summary = await mobile_inspect_trace(action="view_summary", trace_id=trace_id) - assert "**Device Serial:** `pixel-target-999`" in res_summary - - -def test_trace_store_device_serial(temp_trace_env): - trace_id = "test-store-serial" - data = trace_store.init_trace(trace_id, "desc", "Flash", "conv", device_serial="init-serial") - assert data["device_serial"] == "init-serial" - - data_updated = trace_store.update_trace_device_serial(trace_id, "updated-serial") - assert data_updated["device_serial"] == "updated-serial" - - read_back = trace_store.read_status(trace_id) - assert read_back["device_serial"] == "updated-serial" - - -@pytest.mark.asyncio -async def test_mcp_server_auto_registers_tools(): - """Verify that importing mcp_server.base auto-registers all mobile tools without manual tools import.""" - from mcp_server.base import mcp - - tools = await mcp.list_tools() - tool_names = {t.name for t in tools} - assert { - "mobile_run_task", - "mobile_manage_task", - "mobile_get_device_state", - "mobile_inspect_trace", - }.issubset(tool_names) - - -def test_mobile_manage_task_syncs_terminal_status_from_db(temp_trace_env): - import sqlite3 - - trace_id = str(uuid.uuid4()) - trace_store.init_trace(trace_id, "Running task to sync", "Flash", "conv-sync") - - db_path = os.path.join(temp_trace_env, "data_engine.db") - conn = sqlite3.connect(db_path) - cur = conn.cursor() - cur.execute( - """CREATE TABLE IF NOT EXISTS sessions ( - session_id TEXT PRIMARY KEY, - status TEXT, - pid INTEGER, - start_time REAL, - end_time REAL, - device_info TEXT - )""" - ) - cur.execute( - "INSERT INTO sessions (session_id, status, pid, start_time) VALUES (?, ?, ?, ?)", - (trace_id, "completed", 54321, 1000.0), - ) - conn.commit() - conn.close() - - res = mobile_manage_task(action="status", trace_id=trace_id) - assert res["status"] == "completed" - # Ensure status.json was persisted - saved_st = trace_store.read_status(trace_id) - assert saved_st["status"] == "completed" - assert saved_st["pid"] == 54321 - - -def test_mobile_manage_task_stop_via_daemon(temp_trace_env, monkeypatch): - monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False) - trace_id = str(uuid.uuid4()) - trace_store.init_trace(trace_id, "Daemon task to stop", "Flash", "conv-stop") - - with ( - patch("mcp_server.tools.task_manager.is_daemon_running", return_value=True), - patch("mcp_server.tools.task_manager.stop_task_on_daemon", return_value=True) as mock_stop, - ): - res = mobile_manage_task(action="stop", trace_id=trace_id) - mock_stop.assert_called_once_with(trace_id) - assert res["status"] == "cancelled" - assert trace_store.read_status(trace_id)["status"] == "cancelled" - - -def _seed_trace(temp_dir: str, trace_id: str, *, with_image: bool = True) -> str: - """Writes one Flash-style step (with a tool trace and a pre screenshot) - through the real storage layer, exactly as a live run would.""" - import io - - from PIL import Image - - from artemis.data_engine.models import ( - ImageRecord, - SessionMetadata, - StepRecord, - TraceRecord, - ) - from artemis.data_engine.storage import StorageManager - - storage = StorageManager(os.path.join(temp_dir, "data_engine.db"), temp_dir) - storage.create_session( - SessionMetadata(session_id=trace_id, initial_goal="Replay test", start_time=1000.0) - ) - image_name = None - if with_image: - image_name = "a" * 64 - images_dir = os.path.join(temp_dir, "images") - os.makedirs(images_dir, exist_ok=True) - buf = io.BytesIO() - Image.new("RGB", (1080, 2400), "white").save(buf, format="JPEG") - with open(os.path.join(images_dir, f"{image_name}.jpg"), "wb") as f: - f.write(buf.getvalue()) - storage.create_image( - ImageRecord( - image_name=image_name, - ocr_result=[{"text": "Save ocrneedle", "bounds": [0, 0, 10, 10]}], - ) - ) - step_id = str(uuid.uuid4()) - storage.create_step( - StepRecord( - step_id=step_id, - session_id=trace_id, - step_number=1, - timestamp=1012.0, - pre_image_name=image_name, - summary="Asked the explorer, then tapped Save.", - action_taken={"action": "click", "target": [540, 1200], "target_text": "Save"}, - operator_raw_thinking="Confirm the toggle first.", - last_execution_result={"status": "success"}, - extra_metadata={"width": 1080, "height": 2400}, - ) - ) - storage.create_trace( - TraceRecord( - trace_id=str(uuid.uuid4()), - session_id=trace_id, - step_id=step_id, - type="tool", - name="ask_explorer", - status="success", - timestamp=1011.0, - duration=0.8, - payload={"args": {"question": "toggle?"}, "result": "The toggle is ON. " * 100}, - ) - ) - return step_id - - -@pytest.mark.asyncio -async def test_mobile_inspect_trace_step_details_replays_tool_calls_for_flash(temp_trace_env): - """view_step_details works for Flash runs too and replays the recorded - tool calls through the same friendly-step path the live DataEngine uses.""" - trace_id = str(uuid.uuid4()) - trace_store.init_trace(trace_id, "Replay test", "Flash", "conv-1", device_serial="pixel-7") - _seed_trace(temp_trace_env, trace_id) - - res = await mobile_inspect_trace(action="view_step_details", trace_id=trace_id, step_number=1) - assert "error" not in res - assert res["device_serial"] == "pixel-7" - details = res["details"] - # The header carries the session clock (session start 1000.0, step at 1012.0). - assert "- **Step 1 (T+00:12)**" in details - assert "[Screen]: Asked the explorer, then tapped Save." in details - assert "Confirm the toggle first." in details - assert "`ask_explorer(" in details - assert "The toggle is ON. " * 100 in details - # Coordinates are replayed normalized, not as stored pixels. - assert "[540, 1200]" not in details - assert "Tapped 'Save'" in details - - missing = await mobile_inspect_trace( - action="view_step_details", trace_id=trace_id, step_number=9 - ) - assert missing["error"] == "Step not found" - - -@pytest.mark.asyncio -async def test_mobile_inspect_trace_step_screenshots_draw_the_overlay(temp_trace_env): - trace_id = str(uuid.uuid4()) - trace_store.init_trace(trace_id, "Shot test", "Flash", "conv-1", device_serial="pixel-7") - _seed_trace(temp_trace_env, trace_id) - - res = await mobile_inspect_trace( - action="view_step_screenshots", trace_id=trace_id, step_number=1 - ) - assert "error" not in res - assert res["before_screenshot"].startswith("file://") - assert res["before_screenshot"].endswith(f"{'a' * 64}.jpg") - assert res["after_screenshot"] is None - overlay = res["action_overlay_screenshot"] - assert overlay is not None - overlay_path = overlay[len("file://") :] - assert os.path.exists(overlay_path) - assert overlay_path.endswith("step_1_overlay.jpg") - # The overlay differs from the plain pre screenshot: the action was drawn. - with open(overlay_path, "rb") as f, open(res["before_screenshot"][7:], "rb") as g: - assert f.read() != g.read() - - # Second call reuses the file it already wrote. - again = await mobile_inspect_trace( - action="view_step_screenshots", trace_id=trace_id, step_number=1 - ) - assert again["action_overlay_screenshot"] == overlay - - no_step = await mobile_inspect_trace( - action="view_step_screenshots", trace_id=trace_id, step_number=None - ) - assert no_step["error"] == "Missing parameter" - - -@pytest.mark.asyncio -async def test_mobile_inspect_trace_search_uses_the_shared_history_search(temp_trace_env): - trace_id = str(uuid.uuid4()) - trace_store.init_trace(trace_id, "Search test", "Flash", "conv-1", device_serial="pixel-7") - _seed_trace(temp_trace_env, trace_id) - - res = await mobile_inspect_trace(action="search", trace_id=trace_id, query="toggle") - assert "error" not in res - assert res["device_serial"] == "pixel-7" - assert "[Step 1 " in res["results"] - assert "Screen: Asked the explorer, then tapped Save." in res["results"] - - # On-screen OCR text of the step's screenshot is part of the search surface. - res = await mobile_inspect_trace(action="search", trace_id=trace_id, query="ocrneedle") - assert "[Step 1 " in res["results"] - - ledger = await mobile_inspect_trace(action="search", trace_id=trace_id, step_range=[1, 1]) - assert "Action ledger for Steps 1–1" in ledger["results"] - - empty = await mobile_inspect_trace(action="search", trace_id=trace_id) - assert empty["error"] == "Missing parameter" diff --git a/tests/unit/mcp/test_mock_server.py b/tests/unit/mcp/test_mock_server.py new file mode 100644 index 000000000..02f85f483 --- /dev/null +++ b/tests/unit/mcp/test_mock_server.py @@ -0,0 +1,101 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""G62: the adb tool surface must be runnable with no adb server and no device. + +``artemis mcp --type mock`` (``artemis.mcp.mock_server.seed_mock_controllers``) is the offline +path the plugin's contract tests were missing: until now the only device-free surface was a +hand-written JS stub, so the py implementation itself could never be exercised offline. + +These tests pin the *mechanism*, not a copy of the tool list: seeding makes calls succeed while +``AdbClient`` is an intentional bomb, and un-seeded the same call fails. They also pin the +``compare_ui_frames`` shape to exactly what the py server emits -- a day of believing a stub +invented a ``ratio`` key that never existed upstream (changelogs #50/#51). +""" + +import json +import os + +import pytest + +from artemis.mcp import adb_server, mock_server + + +@pytest.fixture +def isolated_server_state(monkeypatch): + """Seed, then restore: the caches and ARTEMIS_DEVICE_ID are process globals.""" + saved_controllers = dict(adb_server._CONTROLLERS) + saved_global = adb_server._GLOBAL_CONTROLLER + saved_serial = os.environ.get("ARTEMIS_DEVICE_ID") + # No test may reach a real adb server: an un-seeded call has to fail on this bomb, and a + # seeded one has to succeed without touching the network at all. + monkeypatch.setattr( + adb_server, "AdbClient", lambda *a, **kw: pytest.fail("mock surface reached real adb") + ) + yield mock_server.seed_mock_controllers() + adb_server._CONTROLLERS.clear() + adb_server._CONTROLLERS.update(saved_controllers) + adb_server._GLOBAL_CONTROLLER = saved_global + if saved_serial is None: + os.environ.pop("ARTEMIS_DEVICE_ID", None) + else: + os.environ["ARTEMIS_DEVICE_ID"] = saved_serial + + +def test_without_seeding_the_surface_needs_adb(isolated_server_state): + """The short-circuit is load-bearing: un-seeded, the same call cannot resolve a device.""" + isolated_server_state # seeded by the fixture; undo it to observe the un-seeded behaviour + adb_server._GLOBAL_CONTROLLER = None + adb_server._CONTROLLERS.clear() + with pytest.raises(pytest.fail.Exception): + adb_server._get_controller() + + +@pytest.mark.asyncio +async def test_screenshot_and_action_run_offline(isolated_server_state): + shot = await adb_server.take_screenshot(None) + assert not str(shot).startswith("Error"), shot + assert str(shot)[:8] == "iVBORw0K" # a real PNG, not a canned placeholder + + assert await adb_server.tap(None, coordinates=[500, 500]) == "Success" + assert await adb_server.back(None) == "Success" + actions = [entry["action"] for entry in isolated_server_state.action_history] + assert actions == ["tap", "press_key"] + + +@pytest.mark.asyncio +async def test_hierarchy_offline_is_json_the_plugin_can_parse(isolated_server_state): + hierarchy = await adb_server.get_ui_hierarchy(None) + parsed = json.loads(hierarchy) + assert isinstance(parsed, list) + assert {"text", "bounds"} <= set(parsed[0]) + + +@pytest.mark.asyncio +async def test_mock_frames_feed_the_real_comparator(isolated_server_state): + """The offline loop yields decodable pixels, so effect verification works with no device. + + The shape of ``compare_ui_frames`` is already pinned in ``test_adb_server_contract.py``; what + is new here is that the *frames come from the mock surface itself* rather than from a + synthetic array -- a stub that only answered text could never satisfy this. + """ + before = await adb_server.take_screenshot(None) + after = await adb_server.take_screenshot(None) + verdict = json.loads(await adb_server.compare_ui_frames(before, after, action="tap")) + assert verdict == {"changed": False} + + await adb_server.tap(None, coordinates=[10, 10]) + changed = await adb_server.take_screenshot(None) + after_tap = json.loads(await adb_server.compare_ui_frames(before, changed, action="tap")) + assert isinstance(after_tap["changed"], bool) diff --git a/tests/unit/mcp/test_notifiers.py b/tests/unit/mcp/test_notifiers.py deleted file mode 100644 index b429bc597..000000000 --- a/tests/unit/mcp/test_notifiers.py +++ /dev/null @@ -1,234 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for MCP Multi-Environment Notifiers.""" - -import json -import os -import shutil -import tempfile -import pytest - -from mcp_server.notifiers import ( - BaseNotifier, - CompositeNotifier, - DesktopNotifier, - FileNotifier, - WebhookNotifier, - notify, -) -from artemis.runtime import trace_store - - -class DummyNotifier(BaseNotifier): - def __init__(self, available: bool = True, return_val: bool = True): - self._available = available - self._return_val = return_val - self.called_with = None - - @property - def name(self) -> str: - return "dummy" - - def is_available(self) -> bool: - return self._available - - def notify(self, conversation_id, message, title=None, event_type="completed", payload=None): - self.called_with = { - "conversation_id": conversation_id, - "message": message, - "title": title, - "event_type": event_type, - "payload": payload, - } - return self._return_val - - -def test_file_notifier(): - temp_dir = tempfile.mkdtemp() - try: - trace_id = "test-file-trace-123" - trace_dir = os.path.join(temp_dir, trace_id) - os.makedirs(trace_dir, exist_ok=True) - - notifier = FileNotifier() - with pytest.MonkeyPatch.context() as mp: - mp.setattr(trace_store, "TRACES_DIR", temp_dir) - success = notifier.notify( - conversation_id="conv-1", - message="Task completed", - title="Done", - event_type="completed", - payload={"trace_id": trace_id}, - ) - assert success is True - - log_file = os.path.join(trace_dir, "notifications.jsonl") - assert os.path.exists(log_file) - with open(log_file, encoding="utf-8") as f: - lines = f.readlines() - assert len(lines) == 1 - entry = json.loads(lines[0]) - assert entry["event_type"] == "completed" - assert entry["message"] == "Task completed" - finally: - shutil.rmtree(temp_dir, ignore_errors=True) - - -def test_webhook_notifier_not_configured(monkeypatch): - for var in WebhookNotifier.ENV_VARS: - monkeypatch.delenv(var, raising=False) - notifier = WebhookNotifier() - assert notifier.is_available() is False - assert notifier.notify("conv-1", "hello") is False - - -def test_desktop_notifier_disabled(monkeypatch): - monkeypatch.setenv("ARTEMIS_DESKTOP_NOTIFY", "false") - notifier = DesktopNotifier() - assert notifier.is_available() is False - - -def test_desktop_notifier_enabled_by_default(monkeypatch): - monkeypatch.delenv("ARTEMIS_DESKTOP_NOTIFY", raising=False) - monkeypatch.delenv("CI", raising=False) - import shutil - import sys - - if sys.platform == "linux": - monkeypatch.setattr( - shutil, "which", lambda cmd: "/usr/bin/notify-send" if cmd == "notify-send" else None - ) - elif sys.platform == "darwin": - monkeypatch.setattr( - shutil, "which", lambda cmd: "/usr/bin/osascript" if cmd == "osascript" else None - ) - notifier = DesktopNotifier() - assert notifier.is_available() is True - - -def test_script_notifier(monkeypatch): - from mcp_server.notifiers.script import ScriptNotifier - - monkeypatch.delenv("ARTEMIS_NOTIFY_CMD", raising=False) - monkeypatch.delenv("MCP_NOTIFY_COMMAND", raising=False) - notifier = ScriptNotifier() - assert notifier.is_available() is False - - monkeypatch.setenv("ARTEMIS_NOTIFY_CMD", "echo 'Notify: {title} - {message}'") - assert notifier.is_available() is True - res = notifier.notify("conv-123", "Task done", title="Success", payload={"trace_id": "t-1"}) - assert res is True - - -def test_composite_notifier_dispatch(): - d1 = DummyNotifier(available=True, return_val=True) - d2 = DummyNotifier(available=False, return_val=False) - d3 = DummyNotifier(available=True, return_val=True) - - composite = CompositeNotifier(notifiers=[d1, d2, d3]) - assert composite.is_available() is True - - result = composite.notify( - conversation_id="conv-abc", - message="Hello World", - title="Test Title", - event_type="completed", - payload={"key": "val"}, - ) - assert result is True - assert d1.called_with is not None - assert d1.called_with["conversation_id"] == "conv-abc" - assert d2.called_with is None - assert d3.called_with is not None - - -class BrokenNotifier(BaseNotifier): - @property - def name(self) -> str: - return "broken" - - def is_available(self) -> bool: - raise RuntimeError("Catastrophic error in is_available") - - def notify(self, conversation_id, message, title=None, event_type="completed", payload=None): - raise RuntimeError("Catastrophic error in notify") - - -def test_composite_notifier_fault_tolerance(): - broken = BrokenNotifier() - d1 = DummyNotifier(available=True, return_val=True) - composite = CompositeNotifier(notifiers=[broken, d1]) - - # Must never raise an exception even if a notifier crashes in is_available or notify - assert composite.is_available() is True - assert composite.notify("conv-test", "Message") is True - assert d1.called_with is not None - - -def test_global_notify_function(): - res = notify( - conversation_id="conv-xyz", - message="Test message", - payload={"trace_id": "test-trace"}, - ) - assert isinstance(res, bool) - - -def test_agentapi_notifier_candidate_recovery_and_retry(monkeypatch, tmp_path): - from mcp_server.notifiers.agentapi import AgentApiNotifier - import subprocess - - monkeypatch.delenv("ANTIGRAVITY_LS_ADDRESS", raising=False) - monkeypatch.delenv("ANTIGRAVITY_CSRF_TOKEN", raising=False) - - shared_file = tmp_path / ".test_jetski_env" - monkeypatch.setattr( - AgentApiNotifier, - "_get_candidate_envs", - lambda self, force_proc_scan=False: [ - ("localhost:9999", "bad-token"), - ("localhost:1234", "good-token"), - ], - ) - - saved_sessions = [] - monkeypatch.setattr( - AgentApiNotifier, - "_save_shared_env", - lambda self, addr, token: saved_sessions.append((addr, token)), - ) - monkeypatch.setattr( - AgentApiNotifier, - "_find_agentapi_path", - lambda self: "/mock/bin/agentapi", - ) - - calls = [] - - def mock_run(cmd, capture_output, text, check, timeout, env): - calls.append(env.get("ANTIGRAVITY_LS_ADDRESS")) - if env.get("ANTIGRAVITY_LS_ADDRESS") == "localhost:9999": - raise subprocess.CalledProcessError(1, cmd) - return subprocess.CompletedProcess(cmd, 0, stdout="success") - - monkeypatch.setattr(subprocess, "run", mock_run) - - notifier = AgentApiNotifier() - success = notifier.notify("conv-test-123", "Task done!") - assert success is True - assert calls == ["localhost:9999", "localhost:1234"] - assert saved_sessions == [("localhost:1234", "good-token")] - assert os.environ["ANTIGRAVITY_LS_ADDRESS"] == "localhost:1234" - assert os.environ["ANTIGRAVITY_CSRF_TOKEN"] == "good-token" diff --git a/tests/unit/mcp/test_spawn_watchdog.py b/tests/unit/mcp/test_spawn_watchdog.py deleted file mode 100644 index 64e6fc6b9..000000000 --- a/tests/unit/mcp/test_spawn_watchdog.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Tests for the spawn watchdog guarding mobile_run_task's standalone fallback.""" - -import json -import os -from unittest.mock import patch - -from mcp_server.tools import task_runner as spawn_tool - - -def _write_status(trace_dir: str, trace_id: str, status: str) -> None: - os.makedirs(trace_dir, exist_ok=True) - with open(os.path.join(trace_dir, "status.json"), "w", encoding="utf-8") as f: - json.dump({"trace_id": trace_id, "status": status}, f) - - -def _patch_trace_dir(tmp_path): - return patch.object(spawn_tool.trace_store, "TRACES_DIR", str(tmp_path)) - - -def test_watchdog_passes_when_runner_creates_logs(tmp_path): - trace_id = "wd-boot-ok" - trace_dir = tmp_path / trace_id - _write_status(str(trace_dir), trace_id, "running") - (trace_dir / "stdout.log").write_text("booted", encoding="utf-8") - - with _patch_trace_dir(tmp_path), patch.object(spawn_tool, "_kill_process_tree") as kill: - assert spawn_tool._watch_spawn( - trace_id, - pid=999999, - queue_ticket="t", - conversation_id=None, - deadline_seconds=0.2, - poll_interval=0.05, - ) - kill.assert_not_called() - - -def test_watchdog_skips_already_terminal_task(tmp_path): - trace_id = "wd-cancelled" - trace_dir = tmp_path / trace_id - _write_status(str(trace_dir), trace_id, "cancelled") - - with _patch_trace_dir(tmp_path), patch.object(spawn_tool, "_kill_process_tree") as kill: - assert spawn_tool._watch_spawn( - trace_id, - pid=999999, - queue_ticket="t", - conversation_id=None, - deadline_seconds=0.2, - poll_interval=0.05, - ) - kill.assert_not_called() - - -def test_watchdog_kills_and_fails_hung_runner(tmp_path): - trace_id = "wd-hung" - trace_dir = tmp_path / trace_id - _write_status(str(trace_dir), trace_id, "running") - - with ( - _patch_trace_dir(tmp_path), - patch.object(spawn_tool, "_kill_process_tree") as kill, - patch.object(spawn_tool.DeviceExecutionLock, "cancel_reservation") as cancel, - patch.object(spawn_tool, "notify") as notifier, - ): - assert not spawn_tool._watch_spawn( - trace_id, - pid=424242, - queue_ticket="ticket-1", - conversation_id="conv-1", - deadline_seconds=0.2, - poll_interval=0.05, - ) - - kill.assert_called_once_with(424242) - cancel.assert_called_once_with("ticket-1") - notifier.assert_called_once() - - with open(trace_dir / "status.json", encoding="utf-8") as f: - status = json.load(f) - assert status["status"] == "failed" - assert "no logs" in status.get("error", "") - - -def test_watchdog_tolerates_logs_appearing_at_deadline(tmp_path): - """Logs that appear between the last poll and the deadline must not cause a kill.""" - trace_id = "wd-late-boot" - trace_dir = tmp_path / trace_id - _write_status(str(trace_dir), trace_id, "running") - - real_read_status = spawn_tool.trace_store.read_status - calls = {"n": 0} - - def read_status_and_create_log(tid): - calls["n"] += 1 - if calls["n"] == 1: - (trace_dir / "stderr.log").write_text("late boot", encoding="utf-8") - return real_read_status(tid) - - with ( - _patch_trace_dir(tmp_path), - patch.object(spawn_tool.trace_store, "read_status", side_effect=read_status_and_create_log), - patch.object(spawn_tool, "_kill_process_tree") as kill, - ): - assert spawn_tool._watch_spawn( - trace_id, - pid=999999, - queue_ticket="t", - conversation_id=None, - deadline_seconds=0.15, - poll_interval=0.2, - ) - kill.assert_not_called() diff --git a/tests/unit/mcp/test_task_reconcile.py b/tests/unit/mcp/test_task_reconcile.py deleted file mode 100644 index 6579de305..000000000 --- a/tests/unit/mcp/test_task_reconcile.py +++ /dev/null @@ -1,150 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Regression tests for mobile_manage_task's state reconciliation. - -Guards the invariants restored after the reconcile rewrite: -- a DB terminal verdict never overrides a status.json that already reached its - own terminal state (a completed task must keep its result), -- except to correct a previously liveness-inferred failure, -- and a task without a recorded pid (daemon dispatch) is never inferred dead. -""" - -import sqlite3 -import time - -import pytest - -import mcp_server.tools.task_manager as task_manager -from mcp_server.tools.task_manager import _LIVENESS_FAILURE_ERROR, _reconcile_task_state - - -@pytest.fixture -def no_persistence(monkeypatch): - """Keep reconciliation in-memory: no DB, no status writes, no lock queries.""" - monkeypatch.setattr(task_manager, "_find_data_engine_db", lambda: None) - monkeypatch.setattr(task_manager, "_session_tracked_by_lock", lambda _tid: False) - monkeypatch.setattr(task_manager.trace_store, "write_status", lambda *_a, **_k: None) - monkeypatch.setattr(task_manager.trace_store, "update_trace_status", lambda *_a, **_k: None) - monkeypatch.setattr(task_manager, "notify", lambda *_a, **_k: None) - - -def _db_with_session(tmp_path, trace_id: str, status: str, pid: int | None = 4242): - db_path = tmp_path / "artemis.db" - conn = sqlite3.connect(db_path) - conn.execute( - "CREATE TABLE sessions (session_id TEXT, status TEXT, pid INTEGER, start_time REAL)" - ) - conn.execute("INSERT INTO sessions VALUES (?, ?, ?, ?)", (trace_id, status, pid, time.time())) - conn.commit() - conn.close() - return str(db_path) - - -@pytest.fixture -def use_db(tmp_path, monkeypatch, no_persistence): - def _install(trace_id: str, status: str, pid: int | None = 4242): - db_path = _db_with_session(tmp_path, trace_id, status, pid) - monkeypatch.setattr(task_manager, "_find_data_engine_db", lambda: db_path) - - return _install - - -def test_db_failed_never_overrides_completed_status(use_db): - use_db("t1", "failed") - status_data = {"status": "completed", "result": "the answer", "pid": 111} - - current_status, _pid, is_alive = _reconcile_task_state("t1", status_data) - - assert current_status == "completed" - assert status_data["status"] == "completed" - assert status_data["result"] == "the answer" - assert is_alive is False - - -def test_db_verdict_corrects_liveness_inferred_failure(use_db): - use_db("t2", "completed") - status_data = {"status": "failed", "error": _LIVENESS_FAILURE_ERROR, "pid": 111} - - current_status, _pid, _is_alive = _reconcile_task_state("t2", status_data) - - assert current_status == "completed" - assert status_data["error"] is None - - -def test_db_never_overrides_user_cancellation(use_db): - use_db("t3", "completed") - status_data = {"status": "cancelled", "pid": 111} - - current_status, _pid, _is_alive = _reconcile_task_state("t3", status_data) - - assert current_status == "cancelled" - - -def test_legacy_success_status_is_normalized_to_completed(no_persistence): - status_data = {"status": "success", "result": "the answer", "pid": 111} - - current_status, _pid, is_alive = _reconcile_task_state("t7", status_data) - - assert current_status == "completed" - assert status_data["status"] == "completed" - assert status_data["result"] == "the answer" - assert is_alive is False - - -def test_db_success_verdict_surfaces_as_completed(use_db): - use_db("t8", "success") - status_data = {"status": "running", "pid": 111} - - current_status, _pid, _is_alive = _reconcile_task_state("t8", status_data) - - assert current_status == "completed" - assert status_data["status"] == "completed" - - -def test_pidless_running_task_is_assumed_alive(no_persistence): - # Daemon dispatch writes no pid into status.json; liveness must not be - # inferred even long past the startup grace window. - status_data = {"status": "running", "start_time": time.time() - 3600} - - current_status, _pid, is_alive = _reconcile_task_state("t4", status_data) - - assert current_status == "running" - assert is_alive is True - assert status_data.get("error") is None - - -def test_dead_pid_past_grace_is_marked_failed(no_persistence, monkeypatch): - monkeypatch.setattr(task_manager, "_pid_alive", lambda _pid: False) - status_data = { - "status": "running", - "pid": 99999, - "start_time": time.time() - 3600, - } - - current_status, _pid, is_alive = _reconcile_task_state("t5", status_data) - - assert current_status == "failed" - assert status_data["error"] == _LIVENESS_FAILURE_ERROR - assert is_alive is False - - -def test_dead_pid_within_grace_is_assumed_alive(no_persistence, monkeypatch): - monkeypatch.setattr(task_manager, "_pid_alive", lambda _pid: False) - status_data = {"status": "running", "pid": 99999, "start_time": time.time()} - - current_status, _pid, is_alive = _reconcile_task_state("t6", status_data) - - assert current_status == "running" - assert is_alive is True diff --git a/tests/unit/memory/test_context_policy.py b/tests/unit/memory/test_context_policy.py deleted file mode 100644 index 6b88cc541..000000000 --- a/tests/unit/memory/test_context_policy.py +++ /dev/null @@ -1,282 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""ContextPolicy table (M4): byte-exact parity with the eight historical call -sites, config overrides, and the chunk hand-off to the compiled view.""" - -from types import SimpleNamespace -from unittest.mock import patch - -import pytest - -from artemis.memory.context_policy import ( - CONTEXT_POLICIES, - DIGEST_LEDGER_MARKER_TEMPLATE, - build_history_for, - load_chunk_blocks, - resolve_policy, -) -from artemis.utils.task_tree import build_plan_and_history - -HASH_A = "hash-a" -HASH_B = "hash-b" - -TASK_PLAN = f"- [x] First milestone \n- [/] Second milestone " - - -def _step(number: int, subgoal_hash: str, summary: str | None = None) -> dict: - return { - "step_id": f"id-{number}", - "step_number": number, - "relative_time": f"{number * 10}.0s", - "summary": summary or f"Step {number} summary text.", - "action_taken": {"action": "click", "target_text": f"button {number}"}, - "operator_raw_thinking": f"thinking {number}", - "last_execution_result": {"status": "dispatched"}, - "extra_metadata": {"subgoal_hash": subgoal_hash}, - } - - -STEPS = [ - _step(1, HASH_A), - _step(2, HASH_A), - _step(3, HASH_A), - _step(4, HASH_B), - _step(5, HASH_B), - _step(6, HASH_B), - _step(7, HASH_B), -] - - -# --------------------------------------------------------------------------- -# Golden parity: the policy table must reproduce every historical hard-coded -# call byte for byte (kwargs recorded verbatim from the 2026-09-01 call sites). -# --------------------------------------------------------------------------- - - -GOLDEN_CALLS = { - "operator": lambda: build_plan_and_history( - TASK_PLAN, - STEPS, - HASH_B, - last_n_detailed=1, - strict_milestone_pruning=True, - recent_window_size=3, - ), - "operator_cold_start": lambda: build_plan_and_history( - TASK_PLAN, - STEPS, - HASH_B, - min_summaries=len(STEPS), - last_n_detailed=1, - ), - "planner": lambda: build_plan_and_history( - "", - STEPS, - "default", - last_n_detailed=2, - strict_milestone_pruning=True, - recent_window_size=5, - ), - "outputter": lambda: build_plan_and_history( - TASK_PLAN, - STEPS, - HASH_B, - last_n_detailed=0, - min_summaries=len(STEPS), - strict_milestone_pruning=False, - ), - "history_analyzer": lambda: build_plan_and_history( - TASK_PLAN, - STEPS, - HASH_B, - last_n_detailed=1, - min_summaries=len(STEPS), - ), - "diagnoser": lambda: build_plan_and_history( - TASK_PLAN, - STEPS, - HASH_B, - keep_subgoal_hashes={HASH_B}, - ), - "committee": lambda: build_plan_and_history( - TASK_PLAN, - STEPS, - HASH_B, - keep_subgoal_hashes={HASH_B}, - last_n_detailed=3, - ), -} - -POLICY_CALLS = { - "operator": lambda: build_history_for("operator", TASK_PLAN, STEPS, HASH_B, last_n_detailed=1), - "operator_cold_start": lambda: build_history_for( - "operator_cold_start", TASK_PLAN, STEPS, HASH_B - ), - "planner": lambda: build_history_for("planner", "", STEPS, "default"), - "outputter": lambda: build_history_for("outputter", TASK_PLAN, STEPS, HASH_B), - "history_analyzer": lambda: build_history_for("history_analyzer", TASK_PLAN, STEPS, HASH_B), - "diagnoser": lambda: build_history_for( - "diagnoser", TASK_PLAN, STEPS, HASH_B, keep_subgoal_hashes={HASH_B} - ), - "committee": lambda: build_history_for( - "committee", TASK_PLAN, STEPS, HASH_B, keep_subgoal_hashes={HASH_B} - ), -} - - -@pytest.mark.parametrize("agent", sorted(GOLDEN_CALLS)) -def test_policy_table_reproduces_call_site_bytes(agent): - assert POLICY_CALLS[agent]() == GOLDEN_CALLS[agent]() - - -def test_every_declared_policy_is_golden_tested(): - assert set(GOLDEN_CALLS) == set(CONTEXT_POLICIES) - - -# --------------------------------------------------------------------------- -# Config overrides -# --------------------------------------------------------------------------- - - -def _config_with_policies(policies): - return SimpleNamespace(memory=SimpleNamespace(policies=policies)) - - -def test_resolve_policy_applies_config_override(): - cfg = _config_with_policies({"planner": {"last_n_detailed": 3, "bogus_field": 1}}) - with patch("artemis.config.load_agent_config", return_value=cfg): - policy = resolve_policy("planner") - assert policy.last_n_detailed == 3 - assert policy.strict_milestone_pruning is True # untouched fields survive - - -def test_resolve_policy_unknown_agent_raises(): - with pytest.raises(KeyError): - resolve_policy("no_such_agent") - - -# --------------------------------------------------------------------------- -# Chunk hand-off (§10 decision 4) -# --------------------------------------------------------------------------- - - -RENDERED_CHUNK = ( - "[Chunk 1 | Steps 1–3 | T+00:10 → T+00:30]\n" - "\n" - "① Synopsis & effects\n" - " What this segment was doing: warming up.\n" - "\n" - "② Compressed step summary\n" - " - Steps 1–3: opened the app and logged in\n" - "\n" - "③ Step action ledger\n" - "- Step 1 (T+00:10): click['button 1'] -> dispatched\n" - "- Step 2 (T+00:20): click['button 2'] -> dispatched\n" - "- Step 3 (T+00:30): click['button 3'] -> dispatched" -) - - -class _FakeEngine: - def __init__(self, rows): - self._rows = rows - - def get_history_chunks(self, **kwargs): - return self._rows - - -def _chunk_row(): - return SimpleNamespace( - start_step_number=1, - end_step_number=3, - rendered_text=RENDERED_CHUNK, - status="ready", - ) - - -def _transcript_cfg(enabled: bool): - return SimpleNamespace( - memory=SimpleNamespace(transcript=SimpleNamespace(enabled=enabled), policies={}) - ) - - -def test_flag_off_output_is_byte_identical_even_with_engine(): - engine = _FakeEngine([_chunk_row()]) - with patch("artemis.config.load_agent_config", return_value=_transcript_cfg(False)): - with_engine = build_history_for("outputter", TASK_PLAN, STEPS, HASH_B, engine=engine) - without_engine = build_history_for("outputter", TASK_PLAN, STEPS, HASH_B) - assert with_engine == without_engine - assert "[Chunk 1" not in with_engine - - -def test_flag_on_full_view_renders_chunk_block_and_drops_covered_steps(): - engine = _FakeEngine([_chunk_row()]) - with patch("artemis.config.load_agent_config", return_value=_transcript_cfg(True)): - out = build_history_for("outputter", TASK_PLAN, STEPS, HASH_B, engine=engine) - # Full view: the whole three-band block including the ③ ledger. - assert "[Chunk 1 | Steps 1–3" in out - assert "③ Step action ledger" in out - assert "- Step 2 (T+00:20)" in out - # Covered per-step summary lines are replaced by the chunk block. - assert "*Step 2 (Start: 20.0s)" not in out - # Steps outside the chunk range still render. - assert "Step 5" in out - - -def test_flag_on_digest_view_replaces_ledger_with_recall_marker(): - engine = _FakeEngine([_chunk_row()]) - cfg = _transcript_cfg(True) - with patch("artemis.config.load_agent_config", return_value=cfg): - out = build_history_for( - "diagnoser", - TASK_PLAN, - STEPS, - HASH_B, - keep_subgoal_hashes={HASH_A, HASH_B}, - engine=engine, - ) - assert "[Chunk 1 | Steps 1–3" in out - assert "② Compressed step summary" in out - marker = DIGEST_LEDGER_MARKER_TEMPLATE.format(start=1, end=3) - assert marker in out - assert "- Step 2 (T+00:20)" not in out # digest carries no ③ rows - - -def test_chunk_view_none_never_loads_chunks(): - engine = _FakeEngine([_chunk_row()]) - with patch("artemis.config.load_agent_config", return_value=_transcript_cfg(True)): - assert load_chunk_blocks(engine, None) is None - # The cold-start policy declares chunk_view=None: even with an engine - # and the flag on, its output carries no chunk blocks (the restored - # F region must stay a pure step rendering). - out = build_history_for("operator_cold_start", TASK_PLAN, STEPS, HASH_B, engine=engine) - assert CONTEXT_POLICIES["operator_cold_start"].chunk_view is None - assert "[Chunk 1" not in out - - -def test_most_recent_step_is_never_suppressed_by_a_chunk(): - engine = _FakeEngine( - [ - SimpleNamespace( - start_step_number=1, - end_step_number=7, # covers every step incl. the newest - rendered_text=RENDERED_CHUNK, - status="ready", - ) - ] - ) - with patch("artemis.config.load_agent_config", return_value=_transcript_cfg(True)): - out = build_history_for("outputter", TASK_PLAN, STEPS, HASH_B, engine=engine) - # The most-recent step must keep its own (detailed) rendering. - assert "Step 7" in out diff --git a/tests/unit/memory/test_history_chunking.py b/tests/unit/memory/test_history_chunking.py deleted file mode 100644 index f24c61289..000000000 --- a/tests/unit/memory/test_history_chunking.py +++ /dev/null @@ -1,2319 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""L2/L3 history chunk compression (history redesign §3.3, M3). - -Covers: the mechanical band-③ action ledger (per-step lines, FA recovery -sub-lines, verbatim never-evict user injections), the band-② interval coverage -machine check, the three-band chunk block structure, all triggers (real -milestone switch via stamped-hash change, pseudo-switch rollback protection, -segment size, fake-meter soft/hard thresholds), the sliding-window floor, the -tool-pair invariant at the F/A boundary, frozen-block byte stability between -compression events, checkpoint post-hoc annotation version bumps, era merging, -the extreme-layer recall-only period paragraph (step range + start/end -offsets + mechanically assembled synopsis, §10 decision 5 final review), and -step addressability (step number + ``T+mm:ss``) after every compression level -— degrading to period addressability at the extreme layer only. -""" - -import json -import re -from types import SimpleNamespace - -import pytest -from langchain_core.messages import AIMessage, HumanMessage, ToolMessage - -from artemis.memory.chunking import ( - CHUNK_PENDING_NOTE, - ChunkState, - EraState, - HistoryChunkManager, - StepCapsuleLens, - build_action_ledger, - render_era_block, - render_era_period_paragraph, - validate_interval_coverage, -) -from artemis.memory.step_memory import StepMemoryService -from artemis.memory.transcript import TranscriptLedger - -SESSION_START = 1000.0 - - -# --------------------------------------------------------------------------- -# Fixtures / helpers -# --------------------------------------------------------------------------- - - -def _step(i: int, subgoal: str = "hash-a", **overrides) -> dict: - step = { - "step_id": f"s{i}", - "step_number": i, - "timestamp": SESSION_START + 10.0 * i, - "summary": f"Visual transition of step {i}.", - "action_taken": {"action": "tap", "target_text": f"btn{i}", "coordinates": [i, i]}, - "last_execution_result": {"status": "dispatched"}, - "operator_raw_thinking": f"thinking {i}", - "interleaved_events": [], - "extra_metadata": {"subgoal_hash": subgoal}, - } - step.update(overrides) - return step - - -class FakeEngine: - def __init__(self, steps: list[dict], base_dir=None): - self.steps = steps - self.session_start_time = SESSION_START - self.current_session_id = "session-1" - self.chunk_writes: list[dict] = [] - self.traces: list[dict] = [] - if base_dir is not None: - self.base_dir = base_dir - - def get_agent_friendly_steps(self): - return self.steps - - def record_history_chunk(self, **kwargs): - self.chunk_writes.append(kwargs) - return f"chunk-row-{len(self.chunk_writes)}" - - def record_trace(self, **kwargs): - self.traces.append(kwargs) - return kwargs.get("trace_id") - - -class StubCapsuleService(StepMemoryService): - """Records capsule submissions without spawning asyncio tasks (sync tests). - - ``auto_resolve=True`` makes every submitted capsule ready immediately, so - the ready-gated swap fires at the same render that closed the segment — - the shape trigger/era/L3 tests need. The default (manual) form keeps - capsules pending until ``resolve`` is called, which is the gating tests' - lever. - """ - - def __init__(self, auto_resolve: bool = False): - super().__init__(ctx=None) - self.submitted: list[str] = [] - self.auto_resolve = auto_resolve - - def submit(self, key, payload, aliases=()): - self.submitted.append(str(key)) - self._step_inputs[key] = payload - self._failed.discard(key) - if self.auto_resolve: - self.resolve(key, _capsule(payload["start_step"], payload["end_step"])) - - def resolve(self, key, capsule: dict): - self._summaries[key] = json.dumps(capsule, ensure_ascii=False) - - -def _capsule(start: int, end: int) -> dict: - return { - "doing": "Working toward the login milestone.", - "did": "Walked the SMS-code login path", - "effect": "Logged-in state; note notes/login_flow.md records the code entry path.", - "entry_state": "Home screen, logged out", - "exit_state": "Settings page open", - "verified_facts": [f"fact-{start}"], - "unresolved": ["u1"], - "failed_paths": [], - "important_entities": ["account A"], - "intervals": [ - {"start_step": start, "end_step": end, "text": "carried out the segment's taps"} - ], - } - - -def _observation(i: int) -> HumanMessage: - return HumanMessage( - content=[ - {"type": "text", "text": f"# CURRENT OBSERVATION [T+00:{i % 60:02d}]"}, - {"type": "text", "text": "--- Current Screenshot ---"}, - {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,IMG_{i}"}}, - ] - ) - - -def _turn(i: int) -> list: - tool_call = {"name": "click", "args": {"target": i}, "id": f"tc{i}", "type": "tool_call"} - return [ - _observation(i), - AIMessage(content=f"thinking {i}", tool_calls=[tool_call]), - ToolMessage(tool_call_id=f"tc{i}", content="Action Recorded"), - ] - - -def _make( - steps: list[dict], - *, - min_active: int = 2, - max_steps: int = 12, - min_steps: int = 3, - target_tokens: int = 10**9, - max_chunks: int = 8, - max_eras: int | None = None, - meter=None, - auto_capsule: bool = False, - start_ratio: float = 0.35, -): - ledger = TranscriptLedger(step_memory=StepMemoryService(ctx=None)) - engine = FakeEngine(steps) - capsule = StubCapsuleService(auto_resolve=auto_capsule) - chunker = HistoryChunkManager( - engine=engine, - chunking_config=SimpleNamespace( - max_steps=max_steps, - min_steps=min_steps, - target_source_tokens=target_tokens, - model="test-model", - max_chunks=max_chunks, - max_eras=max_eras, - ), - transcript_config=SimpleNamespace( - context_budget_tokens=100_000, - start_ratio=start_ratio, - soft_ratio=0.7, - hard_ratio=0.9, - min_active_steps=min_active, - ), - capsule_service=capsule, - # No meter: the base comes from the ledger's operator-recorded prompt - # size (None until recorded), exactly like production wiring. - meter_getter=meter, - goal="TEST GOAL", - ) - ledger.attach_chunker(chunker) - return ledger, chunker, engine, capsule - - -def _run_turns(ledger, chunker, start: int, upto: int, hash_for=None): - """Mimic the real per-turn ordering: commit previous → render (chunker - hook) → stage this turn → stamp this turn's step (execution_check).""" - hash_for = hash_for or (lambda i: "hash-a") - rendered = None - for i in range(start, upto + 1): - ledger.commit_staged( - step_key=f"s{i - 1}" if i > 1 else None, - validator_result={"status": "dispatched"} if i > 1 else None, - ) - rendered = ledger.render([_observation(i)]) - ledger.stage_turn(_turn(i)) - chunker.on_step_stamped(f"s{i}", hash_for(i)) - return rendered - - -def _rendered_text(messages) -> str: - return "\n".join(str(getattr(m, "content", "")) for m in messages) - - -def _frozen_text(ledger) -> str: - return "\n".join(str(m.content) for m in ledger.frozen_blocks) - - -# --------------------------------------------------------------------------- -# Band ③ — mechanical action ledger -# --------------------------------------------------------------------------- - - -def test_action_ledger_lines_carry_step_number_offset_action_and_result(): - steps = [_step(1), _step(2, last_execution_result={"status": "failed", "error": "boom"})] - ledger_text = build_action_ledger(steps, SESSION_START) - lines = ledger_text.splitlines() - assert lines[0] == "- Step 1 (T+00:10): Tapped 'btn1' at [1, 1] -> dispatched" - assert lines[1].startswith("- Step 2 (T+00:20): Tapped 'btn2' at [2, 2] -> Error: boom") - - -def test_action_ledger_renders_incident_and_burst_on_the_step_line(): - """No repair agent: a blocked step's ledger line carries the execution - incident phrase, and a fast-action burst lists every member.""" - burst = [ - {"action": "tap", "target_text": "player", "coordinates": [5, 6]}, - {"action": "tap", "target_text": "Skip", "coordinates": [7, 8]}, - ] - steps = [ - _step( - 3, - action_taken=burst, - last_execution_result={ - "status": "failed", - "burst": True, - "execution": [ - {"action": "tap", "target_text": "player"}, - {"action": "tap", "target_text": "Skip", "attempts": ["Error: rejected"]}, - ], - "incident": { - "kind": "exec_error", - "category": "general", - "reason": "Error: rejected", - "action": burst[1], - "action_description": "Tapped 'Skip' at [7, 8]", - "action_index": 1, - "burst_size": 2, - "consecutive_failures": 1, - }, - }, - ) - ] - text = build_action_ledger(steps, SESSION_START) - lines = text.splitlines() - assert len(lines) == 1 - assert lines[0].startswith( - "- Step 3 (T+00:30): Fast-action burst (2 actions, unvetted):" - " Tapped 'player' at [5, 6] -> Tapped 'Skip' at [7, 8] -> Error: Execution failed" - " (general, consecutive failure #1) on burst action 2/2 `tap 'Skip' at [7, 8]`:" - " Error: rejected" - ) - assert "FA:" not in text - - -def test_action_ledger_preserves_injected_instruction_verbatim(): - steps = [ - _step(4, extra_metadata={"subgoal_hash": "h", "injected_instruction": "跳过弹窗,直接登录"}) - ] - full = build_action_ledger(steps, SESSION_START) - minimal = build_action_ledger(steps, SESSION_START, minimal=True) - expected = ' User @ Step 4: "跳过弹窗,直接登录"' - assert expected in full - assert expected in minimal # never-evict at every width - - -def test_action_ledger_minimal_width_keeps_step_and_time_addressability(): - steps = [_step(i) for i in range(1, 4)] - minimal = build_action_ledger(steps, SESSION_START, minimal=True) - for i in range(1, 4): - assert f"- Step {i} (T+00:{10 * i:02d}): Tapped 'btn{i}'" in minimal - assert "-> dispatched" not in minimal - - -# --------------------------------------------------------------------------- -# Band ② — machine-checked coverage + capsule parsing -# --------------------------------------------------------------------------- - - -def test_interval_coverage_requires_seamless_union(): - ok = [ - {"start_step": 3, "end_step": 5, "text": "a"}, - {"start_step": 6, "end_step": 6, "text": "b"}, - ] - assert validate_interval_coverage(ok, 3, 6) - gap = [ - {"start_step": 3, "end_step": 4, "text": "a"}, - {"start_step": 6, "end_step": 6, "text": "b"}, - ] - assert not validate_interval_coverage(gap, 3, 6) - overlap = [ - {"start_step": 3, "end_step": 5, "text": "a"}, - {"start_step": 5, "end_step": 6, "text": "b"}, - ] - assert not validate_interval_coverage(overlap, 3, 6) - short = [{"start_step": 3, "end_step": 5, "text": "a"}] - assert not validate_interval_coverage(short, 3, 6) - assert not validate_interval_coverage([], 3, 6) - - -def test_capsule_parse_rejects_coverage_gap_and_accepts_full_cover(): - lens = StepCapsuleLens(model_name="test", llm=object()) - payload = {"start_step": 3, "end_step": 6} - good = json.dumps(_capsule(3, 6)) - parsed = lens.parse_capsule(good, payload) - assert parsed is not None - assert parsed["verified_facts"] == ["fact-3"] - - bad = _capsule(3, 6) - bad["intervals"] = [{"start_step": 3, "end_step": 4, "text": "partial"}] - assert lens.parse_capsule(json.dumps(bad), payload) is None - assert lens.parse_capsule("not json", payload) is None - - -@pytest.mark.asyncio -async def test_capsule_lens_render_returns_none_on_gap_so_service_retries(): - class GapLLM: - async def ainvoke(self, messages): - capsule = _capsule(1, 2) - capsule["intervals"] = [{"start_step": 1, "end_step": 1, "text": "only one"}] - return SimpleNamespace(content=json.dumps(capsule)) - - lens = StepCapsuleLens(model_name="test", llm=GapLLM()) - payload = {"start_step": 1, "end_step": 2, "steps": []} - assert await lens.render("chunk:1-2", payload) is None - - class FullLLM: - async def ainvoke(self, messages): - return SimpleNamespace(content=json.dumps(_capsule(1, 2))) - - lens_ok = StepCapsuleLens(model_name="test", llm=FullLLM()) - result = await lens_ok.render("chunk:1-2", payload) - assert result is not None and json.loads(result)["doing"] - - -# --------------------------------------------------------------------------- -# Triggers -# --------------------------------------------------------------------------- - - -def _hashes(boundary_after: int): - return lambda i: "hash-a" if i <= boundary_after else "hash-b" - - -def test_milestone_switch_closes_previous_segment_whole_and_gates_swap(): - """Ready-gated swap: a milestone switch closes - the previous segment whole and dispatches its capsule, but the original - turns stay in the transcript until the capsule header is ready; the swap - happens at the first render after readiness.""" - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] - ledger, chunker, engine, capsule = _make(steps, min_active=2) - rendered = _run_turns(ledger, chunker, 1, 8, _hashes(4)) - - # Closed whole as ONE awaiting chunk; NOT swapped (capsule pending). - assert len(chunker.chunks) == 0 - assert len(chunker.awaiting_chunks) == 1 - chunk = chunker.awaiting_chunks[0] - assert (chunk.start_step_number, chunk.end_step_number) == (1, 4) - assert chunk.subgoal_hash == "hash-a" - assert chunk.status == "pending" - - # v1 pending row persisted with band ③ (recall/auxiliary views see it). - assert engine.chunk_writes[0]["version"] == 1 - assert engine.chunk_writes[0]["status"] == "pending" - assert "- Step 1 (T+00:10)" in engine.chunk_writes[0]["band3"] - - # Capsule dispatched in the background. - assert capsule.submitted == ["chunk:1-4"] - - # Original history retained verbatim: no chunk block, no frozen region, - # turn 1's observation still live in the rendered transcript. - text = _rendered_text(rendered) - assert "[Chunk 1" not in text - assert ledger.frozen_blocks == () - assert "# CURRENT OBSERVATION [T+00:01]" in text - - # Capsule ready → the next render swaps: frozen ①②③ block, originals out. - capsule.resolve("chunk:1-4", _capsule(1, 4)) - ledger.commit_staged(step_key="s8", validator_result={"status": "dispatched"}) - rendered_after = ledger.render([_observation(9)]) - assert len(chunker.chunks) == 1 - assert chunker.awaiting_chunks == () - frozen = _frozen_text(ledger) - assert "[Chunk 1 | Steps 1–4" in frozen - assert "① Synopsis & effects" in frozen - assert "③ Step action ledger" in frozen - assert CHUNK_PENDING_NOTE not in frozen - assert "# CURRENT OBSERVATION [T+00:01]" not in _rendered_text(rendered_after) - # The ready write bumped the DB version (append-only). - ready_rows = [w for w in engine.chunk_writes if w["status"] == "ready"] - assert ready_rows and ready_rows[0]["version"] == 2 - - -def test_plan_write_hint_without_stamp_change_is_discarded(): - """Pseudo-switch protection: a vetoed+rolled-back plan write queues a hint - that the next stamped step (same hash) discards — no chunk is created.""" - steps = [_step(i) for i in range(1, 9)] - ledger, chunker, engine, capsule = _make(steps, min_active=2) - - _run_turns(ledger, chunker, 1, 3) - chunker.queue_boundary_hint() # plan write claimed a completion... - assert chunker.boundary_hint_pending - _run_turns(ledger, chunker, 4, 8) # ...but stamps never change - - assert not chunker.boundary_hint_pending - assert len(chunker.chunks) == 0 - assert engine.chunk_writes == [] - assert ledger.frozen_blocks == () - - -def test_segment_size_threshold_triggers_without_milestone(): - steps = [_step(i) for i in range(1, 10)] - ledger, chunker, engine, _ = _make(steps, min_active=2, max_steps=4, auto_capsule=True) - _run_turns(ledger, chunker, 1, 9) - - # Single milestone, but the open segment exceeded max_steps. - assert len(chunker.chunks) >= 1 - assert chunker.chunks[0].start_step_number == 1 - total = sum(c.end_step_number - c.start_step_number + 1 for c in chunker.chunks) - assert total >= 4 - assert len(ledger.unchunked_turns()) >= 2 # floor respected - - -def test_source_token_threshold_triggers_chunk(): - steps = [_step(i) for i in range(1, 8)] - ledger, chunker, _, _ = _make( - steps, min_active=2, max_steps=100, target_tokens=10, auto_capsule=True - ) - _run_turns(ledger, chunker, 1, 7) - assert len(chunker.chunks) >= 1 - - -def test_soft_threshold_compresses_oldest_open_segment(): - steps = [_step(i) for i in range(1, 9)] - meter = {"value": None} - ledger, chunker, _, _ = _make( - steps, min_active=2, max_steps=100, meter=lambda: meter["value"], auto_capsule=True - ) - _run_turns(ledger, chunker, 1, 6) - assert len(chunker.chunks) == 0 # nothing due yet - - meter["value"] = 75_000 # ≥ 0.7 * 100k, < 0.9 * 100k - _run_turns(ledger, chunker, 7, 7) - assert len(chunker.chunks) == 1 - assert chunker.chunks[0].start_step_number == 1 - - -def test_min_active_floor_blocks_all_triggers(): - steps = [_step(i, "hash-a" if i <= 2 else "hash-b") for i in range(1, 6)] - ledger, chunker, engine, _ = _make(steps, min_active=5, max_steps=2, meter=lambda: 95_000) - _run_turns(ledger, chunker, 1, 6, _hashes(2)) - # Only 5 committed turns; the floor keeps everything raw. - assert len(chunker.chunks) == 0 - assert chunker.awaiting_chunks == () - assert engine.chunk_writes == [] - - -def test_compression_boundary_never_splits_tool_call_pairs(): - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] - ledger, chunker, _, _ = _make(steps, min_active=2, auto_capsule=True) - _run_turns(ledger, chunker, 1, 8, _hashes(4)) - assert len(chunker.chunks) == 1 - - rendered = ledger.render([_observation(99)]) - # Walk the rendered list: every AIMessage with tool_calls must be - # immediately followed by its ToolMessages. - for idx, msg in enumerate(rendered): - if isinstance(msg, AIMessage) and msg.tool_calls: - following = rendered[idx + 1 : idx + 1 + len(msg.tool_calls)] - assert [getattr(m, "tool_call_id", None) for m in following] == [ - tc["id"] for tc in msg.tool_calls - ] - # And no orphan ToolMessage right after the frozen blocks. - first_active = next( - ( - m - for m in rendered - if isinstance(m, (AIMessage, ToolMessage, HumanMessage)) - and "# CURRENT OBSERVATION" in str(getattr(m, "content", "")) - ), - None, - ) - assert first_active is not None - - -# --------------------------------------------------------------------------- -# Async capsule discipline: ready-gated swap, byte-stable frozen region -# --------------------------------------------------------------------------- - - -def test_frozen_region_stays_byte_stable_between_swap_events(): - """A pending closed segment never mutates the frozen region (its original - turns stay live); the region re-renders only when a swap event fires.""" - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)] - ledger, chunker, engine, capsule = _make(steps, min_active=2) - _run_turns(ledger, chunker, 1, 8, _hashes(4)) - - # Swap chunk 1 (Steps 1–4) once its capsule is ready. - capsule.resolve("chunk:1-4", _capsule(1, 4)) - ledger.commit_staged(step_key="s8", validator_result={"status": "dispatched"}) - ledger.render([_observation(9)]) - assert len(chunker.chunks) == 1 - frozen_before = _frozen_text(ledger) - assert "① Synopsis & effects" in frozen_before - assert "② Compressed step summary" in frozen_before - assert "- Steps 1–4: carried out the segment's taps" in frozen_before - - # A hash-c stretch closes the hash-b segment — capsule pending, so the - # frozen text stays byte-identical and the pending chunk does not swap. - ledger.stage_turn(_turn(9)) - chunker.on_step_stamped("s9", "hash-c") - _run_turns(ledger, chunker, 10, 12, lambda i: "hash-c") - assert len(chunker.awaiting_chunks) == 1 - pending = chunker.awaiting_chunks[0] - assert pending.status == "pending" - assert _frozen_text(ledger) == frozen_before - - # Its capsule readies: the next render swaps and re-renders the region. - capsule.resolve(pending.capsule_key, _capsule(5, 8)) - ledger.commit_staged(step_key="s12", validator_result={"status": "dispatched"}) - ledger.render([_observation(13)]) - assert len(chunker.chunks) == 2 - frozen_after = _frozen_text(ledger) - assert frozen_after != frozen_before - assert "[Chunk 2" in frozen_after - - # Ready writes bumped the DB versions (append-only). - ready_rows = [w for w in engine.chunk_writes if w["status"] == "ready"] - assert ready_rows and all(w["version"] == 2 for w in ready_rows) - - -def test_chunk_block_renders_three_bands_in_order(): - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)] - ledger, chunker, _, capsule = _make(steps, min_active=2) - _run_turns(ledger, chunker, 1, 8, _hashes(4)) - capsule.resolve("chunk:1-4", _capsule(1, 4)) - _run_turns(ledger, chunker, 9, 12, lambda i: "hash-c") # second event - - block = next(str(m.content) for m in ledger.frozen_blocks if "[Chunk 1" in str(m.content)) - # §3.3 structure: header, then ① → ② → ③ strictly in order. - i1 = block.index("① Synopsis & effects") - i2 = block.index("② Compressed step summary") - i3 = block.index("③ Step action ledger") - assert 0 < i1 < i2 < i3 - assert "What this segment was doing:" in block - assert "What was actually done:" in block - assert "Effects / left behind:" in block - # The effect line carries the notes left behind (from the capsule). - assert "notes/login_flow.md" in block - assert "Entry: Home screen, logged out" in block - assert "Exit: Settings page open" in block - assert "Verified: fact-1" in block - # ② lines carry step references; ③ is the untouched mechanical ledger. - assert "- Steps 1–4:" in block - assert "- Step 1 (T+00:10): Tapped 'btn1' at [1, 1] -> dispatched" in block - assert " ago" not in block - - -# --------------------------------------------------------------------------- -# Checkpoint post-hoc annotation -# --------------------------------------------------------------------------- - - -def test_checkpoint_annotation_bumps_version_and_rerenders_at_next_event(): - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)] - ledger, chunker, engine, capsule = _make(steps, min_active=2) - _run_turns(ledger, chunker, 1, 8, _hashes(4)) - # Ready gating: the closed segment is still awaiting its capsule — - # post-hoc annotations are independent of the header and land anyway. - chunk = chunker.awaiting_chunks[0] - version_before = chunk.version - frozen_before = _frozen_text(ledger) - - annotated = chunker.annotate_from_checkpoint( - "hash-a", - [ - { - "kind": "verify", - "item_text": "logged in", - "status": "failed", - "evidence": "no session", - } - ], - ) - assert annotated - assert chunk.version == version_before + 1 - # DB write is immediate... - assert engine.chunk_writes[-1]["version"] == chunk.version - assert engine.chunk_writes[-1]["band1"]["annotations"][0]["item_text"] == "logged in" - # ...but the frozen text only re-renders at the next compression event. - assert _frozen_text(ledger) == frozen_before - - capsule.resolve("chunk:1-4", _capsule(1, 4)) - _run_turns(ledger, chunker, 9, 12, lambda i: "hash-c") - frozen_after = _frozen_text(ledger) - assert "Post-hoc check results:" in frozen_after - assert "'logged in' → failed (no session)" in frozen_after - - -def test_checkpoint_annotation_survives_milestone_rename_via_alias_chain(): - """Chunk identity is the step range; the subgoal hash is a label resolved - through the alias chain — a renamed milestone still reaches its chunk.""" - import pathlib - import tempfile - - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] - with tempfile.TemporaryDirectory() as tmp: - base_dir = pathlib.Path(tmp) - (base_dir / "notes").mkdir() - (base_dir / "notes" / "subgoal_hash_chain.json").write_text( - json.dumps({"hash-a": "hash-a-renamed"}), encoding="utf-8" - ) - ledger, chunker, engine, _ = _make(steps, min_active=2) - engine.base_dir = base_dir - _run_turns(ledger, chunker, 1, 8, _hashes(4)) - assert chunker.awaiting_chunks[0].subgoal_hash == "hash-a" - - assert chunker.annotate_from_checkpoint( - "hash-a-renamed", - [{"kind": "verify", "item_text": "x", "status": "passed", "evidence": "ok"}], - ) - assert chunker.awaiting_chunks[0].annotations - - -def test_checkpoint_annotation_ignores_unmatched_subgoal(): - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] - ledger, chunker, engine, _ = _make(steps, min_active=2) - _run_turns(ledger, chunker, 1, 8, _hashes(4)) - writes_before = len(engine.chunk_writes) - assert not chunker.annotate_from_checkpoint( - "hash-unknown", [{"kind": "verify", "item_text": "x", "status": "failed", "evidence": "e"}] - ) - assert len(engine.chunk_writes) == writes_before - - -# --------------------------------------------------------------------------- -# Era merging + recall-only overflow + L3 fold -# --------------------------------------------------------------------------- - - -def _many_chunks( - chunk_count: int, - *, - max_chunks: int, - max_eras: int | None = None, - injected_step: int | None = None, -): - """Drive enough milestone switches to accumulate ``chunk_count`` chunks - of 2 steps each (min_active=2).""" - total_steps = chunk_count * 2 + 6 - hash_for = lambda i: f"hash-{(i - 1) // 2}" # noqa: E731 — switch every 2 steps - steps = [] - for i in range(1, total_steps + 1): - extra = {"subgoal_hash": hash_for(i)} - if injected_step == i: - extra["injected_instruction"] = "keep account A logged in" - steps.append(_step(i, hash_for(i), extra_metadata=extra)) - ledger, chunker, engine, capsule = _make( - steps, min_active=2, max_chunks=max_chunks, max_eras=max_eras, auto_capsule=True - ) - _run_turns(ledger, chunker, 1, total_steps, hash_for) - return ledger, chunker, engine, capsule - - -def test_era_merge_keeps_ledgers_and_merges_headers(): - ledger, chunker, _, capsule = _many_chunks(6, max_chunks=3) - assert len(chunker.chunks) <= 3 - assert len(chunker.eras) >= 1 - - merged_eras = [e for e in chunker.eras if not e.recall_only] - assert merged_eras - era_text = "\n".join( - str(m.content) for m in ledger.frozen_blocks if "① Merged synopsis" in str(m.content) - ) - assert "① Merged synopsis (structured fields, set-merged)" in era_text - assert "② Segment titles" in era_text - assert "③ Step action ledger" in era_text - # ③ is retained 1:1 for every era-folded chunk (recall-only overflow - # aside, covered separately): every step keeps its addressable line - # (step number + T+ time). - for era in merged_eras: - for chunk in era.chunks: - for n in range(chunk.start_step_number, chunk.end_step_number + 1): - assert f"- Step {n} (T+" in era_text - - -def test_era_overflow_compresses_oldest_era_to_period_paragraph(): - """§10 decision 5 (final review 2026-09-01): era overflow renders the - oldest era as a period paragraph — step range AND start/end session - offsets in the header, a non-empty mechanically assembled synopsis, the - recall guidance line, and verbatim user-injection lines.""" - ledger, chunker, _, _ = _many_chunks(14, max_chunks=2, injected_step=1) - assert len(chunker.eras) > 2 - recall_eras = [e for e in chunker.eras if e.recall_only] - assert recall_eras, "oldest eras should have overflowed to recall-only" - - frozen = _frozen_text(ledger) - era = recall_eras[0] - header = ( - f"[Era {era.ordinal} | Steps {era.start_step_number}–{era.end_step_number}" - f" | {era.chunks[0].start_offset} → {era.chunks[-1].end_offset}]" - ) - assert header in frozen - # The paragraph follows on the same line and is never empty. - match = re.search(re.escape(header) + r" (.+)", frozen) - assert match and match.group(1).strip() - # Mechanical assembly from band ①: doing survives verbatim, note - # references from effect are merged in, verified facts are quoted. - assert "Working toward the login milestone." in match.group(1) - assert "notes/login_flow.md" in match.group(1) - assert "Verified: " in match.group(1) - # Recall guidance line is retained. - assert ( - f" (Step-level ledger via search_history for steps" - f" {era.start_step_number}–{era.end_step_number})" - ) in frozen - # Never-evict: the user injection survives even the period paragraph. - assert 'User @ Step 1: "keep account A logged in"' in frozen - - -def _chunk_state( - ordinal: int, - start: int, - end: int, - *, - band1: dict | None = None, - milestone: str | None = None, - user_lines: tuple = (), -) -> ChunkState: - return ChunkState( - ordinal=ordinal, - start_step_number=start, - end_step_number=end, - start_step_id=f"s{start}", - end_step_id=f"s{end}", - source_step_ids=[f"s{i}" for i in range(start, end + 1)], - subgoal_hash=None, - milestone_label=milestone, - start_offset=f"T+{start:02d}:00", - end_offset=f"T+{end:02d}:59", - band3="", - minimal_index="", - user_lines=list(user_lines), - status="ready" if band1 else "pending", - band1=band1 or {}, - ) - - -def test_period_paragraph_falls_back_per_chunk_without_band1(): - """A pending (band-①-less) chunk contributes its milestone label — or its - step range when unlabeled — to the 'Did:' chain instead of vanishing.""" - era = EraState( - ordinal=7, - chunks=[ - _chunk_state(1, 1, 4, band1=_capsule(1, 4)), - _chunk_state(2, 5, 8, milestone="Checkout flow"), # pending, labeled - _chunk_state(3, 9, 12), # pending, unlabeled - ], - recall_only=True, - ) - paragraph = render_era_period_paragraph(era) - assert "Working toward the login milestone." in paragraph - assert "Checkout flow" in paragraph - assert "steps 9–12" in paragraph - assert "notes/login_flow.md" in paragraph - assert "fact-1" in paragraph - - -def test_period_paragraph_degrades_to_milestone_list_when_all_band1_missing(): - """With no band ① anywhere the paragraph degrades to the milestone-label - list + step ranges — never empty — and the block still carries the range/ - offset header, the recall guidance line, and verbatim user lines.""" - era = EraState( - ordinal=3, - chunks=[ - _chunk_state(1, 1, 4, milestone="Login"), - _chunk_state(2, 5, 8, user_lines=[' User @ Step 6: "stay logged in"']), - ], - recall_only=True, - ) - paragraph = render_era_period_paragraph(era) - assert paragraph.strip() - assert paragraph == "Milestones: Login (Steps 1–4); segment (Steps 5–8)." - - block = render_era_block(era) - assert block.startswith(f"[Era 3 | Steps 1–8 | T+01:00 → T+08:59] {paragraph}") - assert "(Step-level ledger via search_history for steps 1–8)" in block - assert 'User @ Step 6: "stay logged in"' in block - - -def test_independent_max_eras_cap_decouples_from_max_chunks(): - """``chunking.max_eras`` caps eras independently; None follows - ``max_chunks`` (the pre-M5 equal-value behavior, asserted by the - overflow test above).""" - # A generous era cap keeps every folded era's ledger despite max_chunks=2. - _, roomy, _, _ = _many_chunks(14, max_chunks=2, max_eras=50) - assert len(roomy.eras) > 2 - assert not [e for e in roomy.eras if e.recall_only] - - # A tight era cap overflows old eras even though max_chunks is large. - _, tight, _, _ = _many_chunks(14, max_chunks=2, max_eras=1) - assert [e for e in tight.eras if e.recall_only] - - -def _folded_steps(chunker) -> list[int]: - return [ - n - for era in chunker.eras - if era.recall_only - for c in era.chunks - for n in range(c.start_step_number, c.end_step_number + 1) - ] - - -def test_hard_threshold_folds_frozen_region_into_recall_only_era_with_step_index(): - """L3 is a recall-only era: the period paragraph (① fields mechanically - merged) plus the per-step minimal index of every folded chunk — step, - session offset and action phrase, results only via search_history.""" - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)] - meter = {"value": None} - ledger, chunker, _, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) - _run_turns(ledger, chunker, 1, 8, _hashes(4)) - capsule.resolve("chunk:1-4", _capsule(1, 4)) - - meter["value"] = 95_000 # ≥ 0.9 * 100k → hard; the tiny turns cannot save 5k - _run_turns(ledger, chunker, 9, 12, lambda i: "hash-c") - - assert chunker.chunks == () - assert chunker.eras and all(era.recall_only for era in chunker.eras) - frozen = _frozen_text(ledger) - assert "[Chunk" not in frozen - assert "[Era 1 | Steps 1–" in frozen - # The period paragraph carries the merged ① knowledge. - assert "Working toward the login milestone." in frozen - assert "Verified: fact-1" in frozen - assert "(Step-level ledger via search_history for steps" in frozen - # The minimal per-step index survives for every folded step. - folded = _folded_steps(chunker) - assert folded - for n in folded: - assert f"- Step {n} (T+" in frozen - assert "-> dispatched" not in frozen # minimal width: no results - - -def test_step_addressability_survives_every_compression_level(): - """After L2 chunks, era merges, and recall-only periods, - every compressed step's number and session-offset time stay directly - readable — the recall-only layer keeps the per-step minimal index — and - the period header pins the step range and start/end offsets.""" - ledger, chunker, _, _ = _many_chunks(6, max_chunks=3) - frozen = _frozen_text(ledger) - covered_steps = [ - n - for era in chunker.eras - for c in era.chunks - for n in range(c.start_step_number, c.end_step_number + 1) - ] + [n for c in chunker.chunks for n in range(c.start_step_number, c.end_step_number + 1)] - assert covered_steps - for n in covered_steps: - assert f"- Step {n} (T+" in frozen, f"step {n} lost its addressable line" - - # Extreme layer: recall-only eras stay period-addressable — the header - # regex pins step range and T+..→T+.. start/end offsets in place — and - # every member step keeps its index line. - overflow_ledger, overflow_chunker, _, _ = _many_chunks(14, max_chunks=2) - overflow_frozen = _frozen_text(overflow_ledger) - recall_eras = [e for e in overflow_chunker.eras if e.recall_only] - assert recall_eras - for era in recall_eras: - pattern = ( - rf"\[Era {era.ordinal} \| Steps {era.start_step_number}" - rf"–{era.end_step_number} \| T\+\d{{2,}}:\d{{2}} → T\+\d{{2,}}:\d{{2}}\]" - ) - assert re.search(pattern, overflow_frozen), ( - f"era {era.ordinal} lost its period-addressable header" - ) - for n in _folded_steps(overflow_chunker): - assert f"- Step {n} (T+" in overflow_frozen, f"folded step {n} lost its index line" - - -@pytest.mark.asyncio -async def test_capsule_retry_exhaustion_degrades_to_pending_chunk(): - """Bounded retries: a lens that never covers the range exhausts its retry - budget, the job enters the explicit failed state, and the chunk simply - stays pending — band ③ remains independently usable.""" - from artemis.memory.chunking import ChunkCapsuleService - - class NeverCoversLens(StepCapsuleLens): - def __init__(self): - super().__init__(model_name="test", llm=object()) - self.attempts = 0 - - async def render(self, key, payload): - self.attempts += 1 - return None - - lens = NeverCoversLens() - service = ChunkCapsuleService(ctx=None, lens=lens, retry_limit=1) - service.submit("chunk:1-4", {"start_step": 1, "end_step": 4, "steps": []}) - await service.flush() - - assert lens.attempts == 2 # 1 + retry_limit, never unbounded - assert service.has_failed("chunk:1-4") - assert service.get_summary("chunk:1-4") is None - - # A chunker harvesting this key leaves the chunk pending — and under - # ready gating the segment never swaps: the original turns stay live. - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] - ledger, chunker, _, _ = _make(steps, min_active=2) - _run_turns(ledger, chunker, 1, 8, _hashes(4)) - chunker._capsule_service = service # type: ignore[attr-defined] - chunker._harvest_capsules() - assert chunker.awaiting_chunks[0].status == "pending" - assert chunker.chunks == () - assert ledger.frozen_blocks == () - - -# --------------------------------------------------------------------------- -# Capsule availability hardening (2026-09-01 A/B root cause: the dedicated -# `chunking.model` endpoint was down all day, so every capsule attempt died -# and all 21 chunk headers stayed pending at version 1 forever) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_capsule_lens_falls_back_to_secondary_model_on_provider_outage(): - """A provider outage on the primary capsule model switches the attempt to - the configured fallback model instead of failing the capsule outright.""" - - class DeadPrimary: - def __init__(self): - self.calls = 0 - - async def ainvoke(self, messages): - self.calls += 1 - raise Exception( - "503 Service Unavailable. This model is currently experiencing high demand." - ) - - class HealthyFallback: - def __init__(self): - self.calls = 0 - - async def ainvoke(self, messages): - self.calls += 1 - return SimpleNamespace(content=json.dumps(_capsule(1, 2))) - - primary, fallback = DeadPrimary(), HealthyFallback() - lens = StepCapsuleLens( - model_name="dead-model", - llm=primary, - fallback_model_name="healthy-model", - fallback_llm=fallback, - ) - result = await lens.render("chunk:1-2", {"start_step": 1, "end_step": 2, "steps": []}) - assert result is not None and json.loads(result)["doing"] - assert primary.calls == 1 - assert fallback.calls == 1 - - -@pytest.mark.asyncio -async def test_capsule_outage_without_fallback_exhausts_to_failed(): - """The A/B breakage shape: one dead endpoint and no fallback fails every - attempt; the job exhausts to the explicit failed state (and the chunk - stays pending, per test_capsule_retry_exhaustion_degrades_to_pending_chunk).""" - from artemis.memory.chunking import ChunkCapsuleService - - class DeadLLM: - async def ainvoke(self, messages): - raise Exception("503 Service Unavailable") - - lens = StepCapsuleLens(model_name="dead", llm=DeadLLM()) - assert not lens.has_fallback - service = ChunkCapsuleService(ctx=None, lens=lens, retry_limit=1) - service._retry_delays = (0.0,) - service.submit("chunk:1-2", {"start_step": 1, "end_step": 2, "steps": []}) - await service.flush() - assert service.has_failed("chunk:1-2") - assert service.get_summary("chunk:1-2") is None - - -def test_capsule_fallback_model_resolution_google_only_and_not_primary(): - mgr = HistoryChunkManager( - capsule_service=StubCapsuleService(), - chunking_config=SimpleNamespace( - max_steps=12, - target_source_tokens=2000, - model="gemini-3.7-flash", - max_chunks=8, - ), - ) - - def ctx_with(provider, model): - return SimpleNamespace( - llm_config=SimpleNamespace( - summarizer=SimpleNamespace(fallback=SimpleNamespace(provider=provider, model=model)) - ) - ) - - assert ( - mgr._resolve_capsule_fallback_model(ctx_with("google", "gemini-3.6-flash")) - == "gemini-3.6-flash" - ) - # Non-google fallbacks cannot ride the raw google model path. - assert mgr._resolve_capsule_fallback_model(ctx_with("openai", "gpt-4o-mini")) is None - # A fallback identical to the primary adds nothing. - assert mgr._resolve_capsule_fallback_model(ctx_with("google", "gemini-3.7-flash")) is None - - -@pytest.mark.asyncio -async def test_flush_harvests_late_capsule_and_persists_ready_version(): - """Short-session backstop: a capsule that becomes ready after the last - render is harvested and persisted at flush; flush itself never swaps (no - freeze at shutdown), and the next render performs the swap normally.""" - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] - ledger, chunker, engine, capsule = _make(steps, min_active=2) - _run_turns(ledger, chunker, 1, 8, _hashes(4)) - assert chunker.awaiting_chunks[0].status == "pending" - v1_writes = len(engine.chunk_writes) - - capsule.resolve("chunk:1-4", _capsule(1, 4)) - await chunker.flush(0.1) - - chunk = chunker.awaiting_chunks[0] - assert chunk.status == "ready" - assert chunk.version == 2 - ready_rows = [w for w in engine.chunk_writes[v1_writes:] if w["status"] == "ready"] - assert ready_rows and ready_rows[-1]["version"] == 2 - # Flush drains and persists but never freezes the transcript. - assert ledger.frozen_blocks == () - - # A later render (e.g. after a process resume) swaps normally. - ledger.commit_staged(step_key="s8", validator_result={"status": "dispatched"}) - ledger.render([_observation(9)]) - assert chunker.chunks and chunker.chunks[0].status == "ready" - - -# --------------------------------------------------------------------------- -# Ready-gated swap: degradation ladder -# --------------------------------------------------------------------------- - - -def test_soft_pressure_alone_never_swaps_unready_segment(): - """Soft-threshold pressure closes segments but never swaps an unready one: - correctness over tokens — the original text stays until the header lands.""" - steps = [_step(i) for i in range(1, 9)] - meter = {"value": 75_000} # ≥ 0.7 * 100k, < 0.9 * 100k - ledger, chunker, _, _ = _make(steps, min_active=2, max_steps=100, meter=lambda: meter["value"]) - _run_turns(ledger, chunker, 1, 8) - - assert len(chunker.awaiting_chunks) >= 1 # soft pressure closed a portion - assert chunker.chunks == () # ...but nothing swapped - assert ledger.frozen_blocks == () # original text retained - - -def test_failed_capsule_redispatches_and_retains_original(): - """Failure rung of the ladder: an exhausted capsule job is re-dispatched at - the next trigger/pressure render while the original turns stay live.""" - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] - meter = {"value": None} - ledger, chunker, _, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) - _run_turns(ledger, chunker, 1, 8, _hashes(4)) - key = chunker.awaiting_chunks[0].capsule_key - assert capsule.submitted.count(key) == 1 - - # Bounded retries exhausted → explicit failed state, chunk stays pending. - capsule._failed.add(key) - meter["value"] = 75_000 # pressure render = a re-dispatch occasion - ledger.commit_staged(step_key="s8", validator_result={"status": "dispatched"}) - ledger.render([_observation(9)]) - - assert capsule.submitted.count(key) == 2 # re-dispatched - assert chunker.chunks == () # still not swapped - assert ledger.frozen_blocks == () # original text retained - - -def test_hard_threshold_force_swaps_pending_chunks_into_recall_only_era(): - """Emergency path: only the hard threshold may swap pending chunks — they - fold straight into a recall-only era (no chunk block ever), the period - paragraph degrades to the milestone/step-range list, and ③'s minimal - index survives for every folded step.""" - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] - meter = {"value": None} - ledger, chunker, _, _ = _make(steps, min_active=2, meter=lambda: meter["value"]) - _run_turns(ledger, chunker, 1, 8, _hashes(4)) - assert chunker.awaiting_chunks[0].status == "pending" - assert ledger.frozen_blocks == () - - meter["value"] = 95_000 # ≥ 0.9 * 100k → hard - ledger.commit_staged(step_key="s8", validator_result={"status": "dispatched"}) - ledger.render([_observation(9)]) - - assert chunker.awaiting_chunks == () - assert chunker.chunks == () - assert len(chunker.eras) == 1 and chunker.eras[0].recall_only - assert all(c.status == "pending" for c in chunker.eras[0].chunks) - frozen = _frozen_text(ledger) - assert "[Chunk" not in frozen and CHUNK_PENDING_NOTE not in frozen - assert "[Era 1 | Steps 1–" in frozen - assert "Milestones: segment (Steps 1–4)" in frozen # no band ① anywhere - folded = _folded_steps(chunker) - assert folded - for n in folded: - assert f"- Step {n} (T+" in frozen - assert len(ledger.unchunked_turns()) < 8 # turns actually consumed - - -# --------------------------------------------------------------------------- -# Minimum chunk length for size / pressure closes -# --------------------------------------------------------------------------- - - -def test_pressure_close_waits_for_min_steps_instead_of_one_turn_chunks(): - """Soft pressure with a single open segment: eligible turns age past the - floor one per render; they accumulate to min_steps before closing, so the - session does not spawn a one-turn chunk (one capsule call) per render.""" - steps = [_step(i) for i in range(1, 12)] - ledger, chunker, _, _ = _make(steps, min_active=2, min_steps=3, meter=lambda: 75_000) - _run_turns(ledger, chunker, 1, 5) # eligible = 2 at the step-5 render - assert chunker.awaiting_chunks == () - _run_turns(ledger, chunker, 6, 6) # eligible = 3 → one 3-turn chunk - assert [c.capsule_key for c in chunker.awaiting_chunks] == ["chunk:1-3"] - assert chunker.awaiting_chunks[0].trigger == "pressure" - _run_turns(ledger, chunker, 7, 8) # eligible beyond the claim: 1, then 2 - assert [c.capsule_key for c in chunker.awaiting_chunks] == ["chunk:1-3"] - _run_turns(ledger, chunker, 9, 9) - assert [c.capsule_key for c in chunker.awaiting_chunks] == ["chunk:1-3", "chunk:4-6"] - - -def test_size_trigger_waits_for_min_steps_even_when_one_turn_exceeds_the_tokens(): - steps = [_step(i) for i in range(1, 9)] - ledger, chunker, _, _ = _make(steps, min_active=2, min_steps=3, target_tokens=10) - _run_turns(ledger, chunker, 1, 4) # eligible = 1, already over the token target - assert chunker.awaiting_chunks == () - _run_turns(ledger, chunker, 5, 6) # eligible = 3 - assert [c.capsule_key for c in chunker.awaiting_chunks] == ["chunk:1-3"] - assert chunker.awaiting_chunks[0].trigger == "size" - - -def test_milestone_close_is_exempt_from_min_steps(): - steps = [_step(i, "hash-a" if i <= 1 else "hash-b") for i in range(1, 8)] - ledger, chunker, _, _ = _make(steps, min_active=2, min_steps=3) - _run_turns(ledger, chunker, 1, 4, _hashes(1)) # the 1-step segment aged past the floor - assert [c.capsule_key for c in chunker.awaiting_chunks] == ["chunk:1-1"] - assert chunker.awaiting_chunks[0].trigger == "milestone" - - -def test_hard_threshold_waives_min_steps(): - steps = [_step(i) for i in range(1, 8)] - meter = {"value": 75_000} - ledger, chunker, _, _ = _make(steps, min_active=2, min_steps=3, meter=lambda: meter["value"]) - _run_turns(ledger, chunker, 1, 4) # eligible = 1 under soft only: held back - assert chunker.awaiting_chunks == () - meter["value"] = 95_000 - _run_turns(ledger, chunker, 5, 5) # eligible = 2 < 3, but hard: closes (and folds) - assert len(chunker.eras) == 1 and chunker.eras[0].recall_only - assert chunker.eras[0].chunks[0].capsule_key == "chunk:1-2" - - -# --------------------------------------------------------------------------- -# Start gate (swap held below budget*start_ratio) and the context base -# --------------------------------------------------------------------------- - - -def test_start_gate_holds_ready_chunk_below_start_ratio_then_swaps(): - """A closed segment prepares its capsule, but the original turns are - replaced only once the operator's measured context reaches - budget*start_ratio; until then the ready chunk waits in the queue.""" - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 11)] - meter = {"value": 20_000} # < 0.35 * 100k - ledger, chunker, engine, _ = _make( - steps, min_active=2, meter=lambda: meter["value"], auto_capsule=True - ) - _run_turns(ledger, chunker, 1, 8, _hashes(4)) - - # Milestone closed steps 1–4 and the capsule is ready, yet nothing swapped. - assert [c.status for c in chunker.awaiting_chunks] == ["ready"] - assert chunker.chunks == () - assert ledger.frozen_blocks == () - assert len(ledger.unchunked_turns()) == 7 - # The timeline line stays running with a 'held' note carrying the gate. - held = [ - t for t in engine.traces if t["status"] == "running" and t["payload"]["args"].get("note") - ] - assert len(held) == 1 - args = held[0]["payload"]["args"] - assert args["note"] == "held" - assert (args["context_tokens"], args["context_budget"], args["swap_at_tokens"]) == ( - 20_000, - 100_000, - 35_000, - ) - - # Same render cadence, context now past the gate → the ready prefix swaps. - meter["value"] = 40_000 - _run_turns(ledger, chunker, 9, 9, _hashes(4)) - assert len(chunker.chunks) == 1 - assert chunker.chunks[0].start_step_number == 1 - assert chunker.awaiting_chunks == () - assert ledger.frozen_blocks != () - done = engine.traces[-1] - assert done["status"] == "success" - assert done["payload"]["args"]["context_tokens"] == 40_000 - ( - chunker.chunks[0].source_chars // 4 - ) + (chunker.chunks[0].summary_chars // 4) - - -def test_leading_stampless_turn_joins_the_next_segment_instead_of_pinning_the_queue(): - """A turn with no recorded step (a reply without a tool call) that lands - right after a chunk boundary must not close as a stampless one-turn - segment: such a chunk has no step records (``None``) and used to sit at - the head of the awaiting queue, so no L2 swap ever happened and the hard - line jumped straight to the fold. It joins the following segment.""" - steps = [_step(i) for i in range(1, 13)] - meter = {"value": 50_000} # above the start gate - ledger, chunker, _, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) - ledger.stage_turn([_observation(0), AIMessage(content="no tool call this turn")]) - _run_turns(ledger, chunker, 1, 7, _hashes(4)) # commits turn 0 without a step key - - assert [c.capsule_key for c in chunker.awaiting_chunks] == ["chunk:1-4"] - assert all(entry["chunk"] is not None for entry in chunker._awaiting) - capsule.resolve("chunk:1-4", _capsule(1, 4)) - _run_turns(ledger, chunker, 8, 8, _hashes(4)) - - assert [c.capsule_key for c in chunker.chunks] == ["chunk:1-4"] - assert chunker.awaiting_chunks == () - assert "[Chunk 1 | Steps 1–4" in _frozen_text(ledger) - assert "no tool call this turn" not in _rendered_text(ledger.render([_observation(9)])) - - -def test_partition_folds_leading_unstamped_turns_into_the_first_segment(): - ledger, chunker, _, _ = _make([_step(i) for i in range(1, 6)]) - chunker.on_step_stamped("s2", "a") - chunker.on_step_stamped("s3", "a") - chunker.on_step_stamped("s4", "b") - turns = [ - {"step_key": None}, - {"step_key": "s1"}, - {"step_key": "s2"}, - {"step_key": "s3"}, - {"step_key": "s4"}, - {"step_key": None}, - ] - segments = chunker._partition(turns) - assert [(h, [t["step_key"] for t in ts]) for h, ts in segments] == [ - ("a", [None, "s1", "s2", "s3"]), - ("b", ["s4", None]), - ] - # All unstamped: one stampless segment, never split. - assert chunker._partition([{"step_key": None}, {"step_key": None}]) == [ - (None, [{"step_key": None}, {"step_key": None}]) - ] - - -def test_start_gate_zero_restores_swap_on_ready(): - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] - ledger, chunker, _, _ = _make( - steps, min_active=2, meter=lambda: 1_000, auto_capsule=True, start_ratio=0.0 - ) - _run_turns(ledger, chunker, 1, 8, _hashes(4)) - assert len(chunker.chunks) == 1 - assert chunker.awaiting_chunks == () - - -def test_start_gate_unknown_base_falls_through_to_swap_on_ready(): - """No measured operator call (meter None, nothing recorded on the ledger): - the gate cannot decide and the legacy swap-on-ready behavior stands.""" - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] - ledger, chunker, _, _ = _make(steps, min_active=2, auto_capsule=True) - assert ledger.last_prompt_tokens is None - _run_turns(ledger, chunker, 1, 8, _hashes(4)) - assert len(chunker.chunks) == 1 - - -def _big_turn(i: int, chars: int = 4000) -> list: - tool_call = {"name": "click", "args": {"target": i}, "id": f"tc{i}", "type": "tool_call"} - return [ - _observation(i), - AIMessage(content=f"thinking {i} " + "x" * chars, tool_calls=[tool_call]), - ToolMessage(tool_call_id=f"tc{i}", content="Action Recorded"), - ] - - -def _run_big_turns(ledger, chunker, start: int, upto: int, hash_for): - for i in range(start, upto + 1): - ledger.commit_staged( - step_key=f"s{i - 1}" if i > 1 else None, - validator_result={"status": "dispatched"} if i > 1 else None, - ) - ledger.render([_observation(i)]) - ledger.stage_turn(_big_turn(i)) - chunker.on_step_stamped(f"s{i}", hash_for(i)) - - -def test_hard_threshold_swaps_ready_prefix_as_l2_when_that_is_enough(): - """Hard ladder rung 1: with a ready prefix whose L2 swap brings the - estimated context back under the hard line, the swap is an ordinary L2 - event — no fold, and the younger pending segment stays awaiting.""" - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)] - meter = {"value": None} - ledger, chunker, engine, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) - # Steps 1–4 (hash-a) close at the switch; steps 5–8 (hash-b) close at the - # next switch. Turns are ~1k tokens each so the L2 swap is worth ~4k. - _run_big_turns(ledger, chunker, 1, 10, lambda i: "a" if i <= 4 else ("b" if i <= 8 else "c")) - keys = [c.capsule_key for c in chunker.awaiting_chunks] - assert keys[0] == "chunk:1-4" - capsule.resolve("chunk:1-4", _capsule(1, 4)) # ready; chunk:5-8 stays pending - - meter["value"] = 92_000 # ≥ 0.9 * 100k; minus ~4k of turns → under the line - _run_big_turns(ledger, chunker, 11, 11, lambda i: "c") - - assert [c.capsule_key for c in chunker.chunks] == ["chunk:1-4"] - assert [c.capsule_key for c in chunker.awaiting_chunks] == ["chunk:5-8"] - frozen = _frozen_text(ledger) - assert "[Era" not in frozen # nothing folded - assert "[Chunk 1" in frozen - done = engine.traces[-1] - assert done["status"] == "success" - assert "forced" not in done["payload"]["args"] - assert done["payload"]["args"]["context_tokens"] < 90_000 - - -def test_hard_threshold_escalates_to_fold_when_l2_swap_is_not_enough(): - """Hard ladder rung 2: the ready prefix's L2 swap would not bring the - context under the line → everything closed force-swaps, pending included, - and the whole frozen region folds into one recall-only era whose index - lists every folded step — the pending chunk's steps too.""" - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)] - meter = {"value": None} - ledger, chunker, engine, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) - _run_turns(ledger, chunker, 1, 10, lambda i: "a" if i <= 4 else ("b" if i <= 8 else "c")) - capsule.resolve("chunk:1-4", _capsule(1, 4)) - - meter["value"] = 99_000 # tiny test turns cannot shave 9k tokens - _run_turns(ledger, chunker, 11, 11, lambda i: "c") - - assert chunker.chunks == () - assert chunker.awaiting_chunks == () - assert len(chunker.eras) == 1 and chunker.eras[0].recall_only - era = chunker.eras[0] - assert [c.capsule_key for c in era.chunks] == ["chunk:1-4", "chunk:5-8"] - assert [c.status for c in era.chunks] == ["ready", "pending"] - frozen = _frozen_text(ledger) - assert "[Chunk" not in frozen - assert "[Era 1 | Steps 1–8" in frozen - # The ready chunk's ① feeds the paragraph; the pending one falls back to - # its step range — and both keep every step's index line. - assert "Working toward the login milestone." in frozen - assert "steps 5–8" in frozen - for n in range(1, 9): - assert f"- Step {n} (T+" in frozen, f"folded step {n} lost its index line" - # Trace wording: only the chunk without a summary is a forced fold. - done = { - t["payload"]["args"]["start_step"]: t for t in engine.traces if t["status"] == "success" - } - assert "forced" not in done[1]["payload"]["args"] - assert "folded into the period summary" in done[1]["payload"]["result"] - assert done[5]["payload"]["args"]["forced"] is True - assert done[5]["payload"]["result"] == ( - "Context budget reached before the summary was ready;" - " 4 turns folded into the period summary." - ) - - -def test_hard_fold_is_monotonic_later_l2_swaps_append_after_the_folded_era(): - """After a hard fold, a later non-hard L2 swap appends a new chunk block - while the folded steps stay inside the recall-only era — the fold never - flips back to full chunk blocks.""" - steps = [_step(i) for i in range(1, 17)] - hash_for = lambda i: "a" if i <= 4 else ("b" if i <= 8 else ("c" if i <= 12 else "d")) # noqa: E731 - meter = {"value": None} - ledger, chunker, _, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) - _run_turns(ledger, chunker, 1, 10, hash_for) - capsule.resolve("chunk:1-4", _capsule(1, 4)) - meter["value"] = 99_000 - _run_turns(ledger, chunker, 11, 11, hash_for) # hard: folds 1–4 and 5–8 - assert chunker.chunks == () and len(chunker.eras) == 1 - folded_block = _frozen_text(ledger) - - # Pressure gone (above the start gate, below soft): the next milestone - # switch closes 9–12; once ready it swaps as an ordinary L2 chunk. - meter["value"] = 50_000 - _run_turns(ledger, chunker, 12, 15, hash_for) - pending = [c for c in chunker.awaiting_chunks if c.status == "pending"] - assert [c.capsule_key for c in pending] == ["chunk:9-12"] - capsule.resolve("chunk:9-12", _capsule(9, 12)) - _run_turns(ledger, chunker, 16, 16, hash_for) - - assert [c.capsule_key for c in chunker.chunks] == ["chunk:9-12"] - assert len(chunker.eras) == 1 and chunker.eras[0].recall_only - frozen = _frozen_text(ledger) - assert frozen.startswith(folded_block) # the folded era is byte-identical - assert "[Chunk 3 | Steps 9–12" in frozen - assert "[Chunk 1" not in frozen and "[Chunk 2" not in frozen - assert frozen.index("[Era 1") < frozen.index("[Chunk 3") - for n in range(1, 9): - assert f"- Step {n} (T+" in frozen - - -def test_hard_threshold_with_nothing_awaiting_folds_frozen_region_once(): - """Hard ladder last resort: the active window is already down to the - sliding-window floor (nothing eligible to close, nothing awaiting), yet - the prompt sits at the hard line — the existing L2 frozen region folds - into a recall-only era without consuming turns, and repeated hard renders - leave the frozen blocks byte-identical.""" - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)] - meter = {"value": 50_000} - ledger, chunker, _, _ = _make( - steps, min_active=2, meter=lambda: meter["value"], auto_capsule=True - ) - _run_turns(ledger, chunker, 1, 7, _hashes(4)) - # Chunk 1–4 swapped as L2 at the step-7 render; s5..s6 remain = the floor. - assert [c.capsule_key for c in chunker.chunks] == ["chunk:1-4"] - assert chunker.awaiting_chunks == () - assert "[Chunk 1" in _frozen_text(ledger) - assert len(ledger.unchunked_turns()) == 2 - - meter["value"] = 95_000 - ledger.render([_observation(8)]) # hard render, nothing eligible - frozen = _frozen_text(ledger) - assert "[Chunk 1" not in frozen and "[Era 1 | Steps 1–4" in frozen - assert chunker.chunks == () and chunker.eras[0].recall_only - for n in range(1, 5): - assert f"- Step {n} (T+" in frozen - assert len(ledger.unchunked_turns()) == 2 # no turns consumed - folded_blocks = ledger.frozen_blocks - ledger.render([_observation(8)]) - ledger.render([_observation(8)]) - assert ledger.frozen_blocks == folded_blocks # byte-stable across hard renders - assert _frozen_text(ledger) == frozen - - -def test_hard_threshold_overrides_start_gate(): - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] - ledger, chunker, _, _ = _make( - steps, min_active=2, meter=lambda: 95_000, auto_capsule=True, start_ratio=0.99 - ) - _run_turns(ledger, chunker, 1, 8, _hashes(4)) - assert chunker.awaiting_chunks == () - assert chunker.eras and all(era.recall_only for era in chunker.eras) - assert "[Era 1 | Steps 1–" in _frozen_text(ledger) - - -def test_context_base_reads_the_ledger_operator_measurement_not_the_session_meter(): - """Production wiring: no meter_getter; the base is what the operator - recorded on its own ledger. A session-wide meter reading from another - agent's call (the 2026-09-08 trace: a 2.7k lite call right after the - operator's 19.9k prompt) must not leak in.""" - from artemis.services.token_meter import get_meter - - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 11)] - ledger, chunker, _, _ = _make(steps, min_active=2, auto_capsule=True) - # Some other agent's small call lands on the session meter. - get_meter("session-1").record({"prompt_tokens": 2_700, "completion_tokens": 10}) - # The operator's own measured prompt is well below the gate. - ledger.record_prompt_tokens(20_000) - assert chunker._context_base_tokens(ledger) == 20_000 - _run_turns(ledger, chunker, 1, 8, _hashes(4)) - assert chunker.chunks == () # held: 20k < 35k - assert [c.status for c in chunker.awaiting_chunks] == ["ready"] - - # Operator prompt grows past the gate → swap at the next render. - ledger.record_prompt_tokens(36_000) - _run_turns(ledger, chunker, 9, 9, _hashes(4)) - assert len(chunker.chunks) == 1 - - -def test_ledger_record_prompt_tokens_ignores_missing_or_non_positive(): - ledger = TranscriptLedger(step_memory=StepMemoryService(ctx=None)) - ledger.record_prompt_tokens(None) - ledger.record_prompt_tokens(0) - ledger.record_prompt_tokens("x") - assert ledger.last_prompt_tokens is None - ledger.record_prompt_tokens(1234) - ledger.record_prompt_tokens(None) - assert ledger.last_prompt_tokens == 1234 - - -def test_held_note_is_fresh_after_a_retry(): - """A chunk whose capsule failed, was re-dispatched (``retrying`` note) and - then readied while the start gate still holds it gets a fresh ``held`` - note: the re-dispatch resets the once-per-chunk held marker.""" - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 14)] - meter = {"value": 20_000} # < 0.35 * 100k: the gate holds ready chunks - ledger, chunker, engine, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) - _run_turns(ledger, chunker, 1, 8, _hashes(4)) - entry = chunker._awaiting[0] - key = entry["chunk"].capsule_key - entry["held_announced"] = True # a stale marker must not survive a retry - - capsule._failed.add(key) - # The hash-b segment (5–8) ages past the floor at the step-11 render and - # closes whole: a trigger render, which is a re-dispatch occasion. - _run_turns(ledger, chunker, 9, 11, lambda i: "hash-c") - assert capsule.submitted.count(key) == 2 - assert entry["held_announced"] is False - notes = [t["payload"]["args"].get("note") for t in engine.traces if t["status"] == "running"] - assert notes[-1] == "retrying" - - capsule.resolve(key, _capsule(1, 4)) - _run_turns(ledger, chunker, 12, 12, lambda i: "hash-c") - assert chunker.chunks == () # still held below the gate - held = [ - t - for t in engine.traces - if t["status"] == "running" - and t["payload"]["args"].get("note") == "held" - and t["payload"]["args"]["start_step"] == 1 - ] - assert len(held) == 1 - assert engine.traces.index(held[0]) > max( - i for i, t in enumerate(engine.traces) if t["payload"]["args"].get("note") == "retrying" - ) - - -# --------------------------------------------------------------------------- -# Provider-less prompt estimate + session-calibrated chars→tokens ratio -# --------------------------------------------------------------------------- - - -def test_estimate_prompt_tokens_counts_text_images_and_tool_call_args(): - from artemis.memory.transcript import ( - IMAGE_BLOCK_TOKENS, - estimate_prompt_tokens, - measure_prompt_content, - ) - - messages = [ - HumanMessage(content="a" * 400), - HumanMessage( - content=[ - {"type": "text", "text": "b" * 80}, - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG"}}, - {"type": "image", "source": {"data": "IMG2"}}, - {"type": "thinking", "thinking": "c" * 20}, - ] - ), - AIMessage( - content="", - tool_calls=[{"name": "click", "args": {"x": 1}, "id": "tc1", "type": "tool_call"}], - ), - ] - text_chars, image_count = measure_prompt_content(messages) - other_block = len(str({"type": "thinking", "thinking": "c" * 20})) - assert image_count == 2 - assert text_chars == 400 + 80 + other_block + len('{"x": 1}') - assert estimate_prompt_tokens(messages) == text_chars // 4 + 2 * IMAGE_BLOCK_TOKENS - assert estimate_prompt_tokens([]) == 1 # never zero: an unknown base stays gated - - -def _measured_prompt(text_chars: int, image_count: int = 0, char: str = "a"): - """One HumanMessage in the shape ``measure_prompt_content`` reads: a text - block of ``text_chars`` characters and ``image_count`` image blocks.""" - blocks = [{"type": "text", "text": char * text_chars}] - blocks += [ - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,AAAA"}} - for _ in range(image_count) - ] - return [HumanMessage(content=blocks)] - - -def test_ledger_calibrates_chars_per_token_from_measured_prompts(): - """The first measured sample of a session goes through the whole-prompt - fallback: ``text_chars / (prompt_tokens - images * IMAGE_BLOCK_TOKENS)``, - clamped to [1.0, 6.0].""" - - def fresh(): - return TranscriptLedger(step_memory=StepMemoryService(ctx=None)) - - ledger = fresh() - assert ledger.chars_per_token == 4.0 - assert ledger.chars_to_tokens(1000) == 1000 // 4 - - # English-like prose at ~4 chars/token: the ratio is unchanged. - ledger.record_prompt_tokens(1000, messages=[HumanMessage(content="a" * 4000)]) - assert ledger.chars_per_token == pytest.approx(4.0) - assert ledger.chars_to_tokens(1000) == 250 - - # CJK-heavy prompt measured at ~1 token per char: the ratio drops to 1. - ledger = fresh() - ledger.record_prompt_tokens(4000, messages=[HumanMessage(content="中" * 4000)]) - assert ledger.chars_per_token == pytest.approx(1.0) - assert ledger.chars_to_tokens(1000) == 1000 - - # Images are subtracted at their flat cost before dividing. - ledger = fresh() - ledger.record_prompt_tokens(1000 + 2 * 258, messages=_measured_prompt(4000, 2)) - assert ledger.chars_per_token == pytest.approx(4.0) - - # Clamp bounds: [1.0, 6.0]. - ledger = fresh() - ledger.record_prompt_tokens(10_000, messages=[HumanMessage(content="x" * 100)]) - assert ledger.chars_per_token == 1.0 - ledger = fresh() - ledger.record_prompt_tokens(10, messages=[HumanMessage(content="x" * 10_000)]) - assert ledger.chars_per_token == 6.0 - - # Without messages only the base is recorded; the ratio is kept. - ledger.record_prompt_tokens(777) - assert ledger.last_prompt_tokens == 777 and ledger.chars_per_token == 6.0 - - -def test_ledger_first_sample_uses_the_whole_prompt_fallback(): - """Before any usable difference exists the whole-prompt quotient is the - only information available, so the first sample still applies it.""" - ledger = TranscriptLedger(step_memory=StepMemoryService(ctx=None)) - ledger.record_prompt_tokens(5000 + 3 * 258, messages=_measured_prompt(10_000, 3)) - assert ledger.chars_per_token == pytest.approx(2.0) - assert ledger.last_prompt_tokens == 5000 + 3 * 258 - - -def test_ledger_differential_calibration_cancels_constant_overhead(): - """Two same-image-count samples: the ratio is dchars/dtokens, and a large - constant term in prompt_tokens (tool schemas, image tiling; 9000 tokens - here) that the whole-prompt fallback mis-charges to the text cancels.""" - ledger = TranscriptLedger(step_memory=StepMemoryService(ctx=None)) - overhead = 9000 - ledger.record_prompt_tokens(overhead + 4000, messages=_measured_prompt(10_000, 2)) - fallback = 10_000 / (overhead + 4000 - 2 * 258) - assert fallback < 1.0 # the fallback mis-reads ~0.8 and clamps to 1.0 - assert ledger.chars_per_token == 1.0 - - # +10000 chars, +4000 tokens, same two images: 2.5 exactly. - ledger.record_prompt_tokens(overhead + 8000, messages=_measured_prompt(20_000, 2)) - assert ledger.chars_per_token == pytest.approx(2.5) - - -def test_ledger_image_count_change_replaces_reference_without_updating(): - ledger = TranscriptLedger(step_memory=StepMemoryService(ctx=None)) - ledger.record_prompt_tokens(4000, messages=_measured_prompt(10_000, 2)) - # First usable pair (+10000 chars / +5000 tokens): pooled ratio 2.0. - ledger.record_prompt_tokens(9000, messages=_measured_prompt(20_000, 2)) - assert ledger.chars_per_token == pytest.approx(2.0) - - # Different image count: no ratio update, but it becomes the reference. - ledger.record_prompt_tokens(14_000, messages=_measured_prompt(30_000, 3)) - assert ledger.chars_per_token == pytest.approx(2.0) - - # The next 3-image sample differences against the 3-image reference - # (+6000 chars / +2000 tokens), not against the 2-image one: pooled - # (10000 + 6000) / (5000 + 2000). - ledger.record_prompt_tokens(16_000, messages=_measured_prompt(36_000, 3)) - assert ledger.chars_per_token == pytest.approx(16_000 / 7000) - - -def test_ledger_small_moves_hold_the_reference_until_they_accumulate(): - """A sample that moved less than the delta floor (in either direction) - neither updates the ratio nor replaces the reference: the movement keeps - accumulating against the held reference until it is measurable.""" - from artemis.memory.transcript import MIN_CALIBRATION_DELTA_CHARS - - ledger = TranscriptLedger(step_memory=StepMemoryService(ctx=None)) - ledger.record_prompt_tokens(2500, messages=_measured_prompt(10_000, 1)) - ledger.record_prompt_tokens(7500, messages=_measured_prompt(20_000, 1)) - assert ledger.chars_per_token == pytest.approx(2.0) - - # Below the floor: no update, even though this pair's own quotient (1.0) - # would differ wildly from the current ratio. - small = MIN_CALIBRATION_DELTA_CHARS - 1 - ledger.record_prompt_tokens(7500 + small, messages=_measured_prompt(20_000 + small, 1)) - assert ledger.chars_per_token == pytest.approx(2.0) - # A slight shrink in characters with a few more tokens (turn jitter) is - # held the same way rather than resetting the baseline. - ledger.record_prompt_tokens(7500 + small + 100, messages=_measured_prompt(20_000 + 100, 1)) - assert ledger.chars_per_token == pytest.approx(2.0) - - # Reaching the floor differences against the HELD 20000-char / 7500-token - # reference (+2000 chars / +2000 tokens), not the previous small sample: - # pooled (10000 + 2000) / (5000 + 2000). Consecutive differencing would - # have seen only +1900 chars here and never calibrated. - ledger.record_prompt_tokens( - 9500, messages=_measured_prompt(20_000 + MIN_CALIBRATION_DELTA_CHARS, 1) - ) - assert ledger.chars_per_token == pytest.approx(12_000 / 7000) - - -def test_ledger_token_shrink_resets_the_reference(): - """When the measured prompt shrinks in tokens (a compaction or scrub - landed) the sample becomes a fresh baseline without touching the pooled - ratio; text that grows at zero token cost is unusable and also resets.""" - ledger = TranscriptLedger(step_memory=StepMemoryService(ctx=None)) - ledger.record_prompt_tokens(2500, messages=_measured_prompt(10_000, 1)) - ledger.record_prompt_tokens(7500, messages=_measured_prompt(20_000, 1)) - assert ledger.chars_per_token == pytest.approx(2.0) - - # Compaction: fewer tokens. Ratio untouched, baseline moves here. - ledger.record_prompt_tokens(6000, messages=_measured_prompt(15_000, 1)) - assert ledger.chars_per_token == pytest.approx(2.0) - # +2000 chars / +500 tokens against the new baseline: pooled 12000 / 5500. - ledger.record_prompt_tokens(6500, messages=_measured_prompt(17_000, 1)) - assert ledger.chars_per_token == pytest.approx(12_000 / 5500) - - # Grew past the floor at zero token cost: not a measurement; new baseline. - ledger.record_prompt_tokens(6500, messages=_measured_prompt(19_000, 1)) - assert ledger.chars_per_token == pytest.approx(12_000 / 5500) - ledger.record_prompt_tokens(7500, messages=_measured_prompt(21_000, 1)) - assert ledger.chars_per_token == pytest.approx(14_000 / 6500) - - -def test_ledger_pools_deltas_rather_than_taking_the_last_pair(): - ledger = TranscriptLedger(step_memory=StepMemoryService(ctx=None)) - ledger.record_prompt_tokens(10_000, messages=_measured_prompt(10_000, 1)) - # Pair 1: +10000 chars / +5000 tokens (2.0). - ledger.record_prompt_tokens(15_000, messages=_measured_prompt(20_000, 1)) - assert ledger.chars_per_token == pytest.approx(2.0) - # Pair 2: +2000 chars / +2000 tokens (1.0). Pooled: 12000 / 7000. - ledger.record_prompt_tokens(17_000, messages=_measured_prompt(22_000, 1)) - assert ledger.chars_per_token == pytest.approx(12_000 / 7000) - assert ledger.chars_per_token != pytest.approx(1.0) - - # A non-positive/None value neither disturbs the pooled ratio nor the - # remembered reference: the next sample still differences against the - # 22000-char one (+4000 / +1000 -> pooled 16000 / 8000). - ledger.record_prompt_tokens(None, messages=_measured_prompt(50_000, 1)) - ledger.record_prompt_tokens(0, messages=_measured_prompt(50_000, 1)) - assert ledger.chars_per_token == pytest.approx(12_000 / 7000) - ledger.record_prompt_tokens(18_000, messages=_measured_prompt(26_000, 1)) - assert ledger.chars_per_token == pytest.approx(2.0) - - -def test_ledger_calibration_regression_on_real_operator_trace(): - """Thirteen consecutive Gemini Operator prompts from a real 2026-09 run - (session 28377d06, five full-size 1080x2424 screenshots on every call, - tool schemas bound). Differencing against a held reference pools three - measurable spans; the per-turn moves in between (a few hundred chars, - some slightly negative with a few hundred more tokens) are held, not - dropped. The whole-prompt quotient for the last call reads 1.62 - chars/token; the tail's own rate is denser, so the old estimator would - have UNDER-counted the tail and fired the size trigger late here.""" - from artemis.memory.transcript import IMAGE_BLOCK_TOKENS - - samples = [ # (text_chars, images, prompt_tokens) straight from the trace store - (33262, 5, 20392), - (33954, 5, 21231), # +692 chars: held - (36094, 5, 22853), # +2832 chars / +2461 tokens vs 33262: pooled - (36918, 5, 23332), # held - (37519, 5, 23511), # held - (38437, 5, 24345), # +2343 / +1492 vs 36094: pooled - (38917, 5, 24741), # held - (39332, 5, 25650), # held - (42111, 5, 26248), # +3674 / +1903 vs 38437: pooled - (43153, 5, 26910), # held - (43023, 5, 27605), # chars down, tokens up (turn jitter): held - (43027, 5, 27869), # held - (43638, 5, 28176), # held - ] - ledger = TranscriptLedger(step_memory=StepMemoryService(ctx=None)) - for chars, images, tokens in samples: - ledger.record_prompt_tokens(tokens, messages=_measured_prompt(chars, images)) - - pooled = (2832 + 2343 + 3674) / (2461 + 1492 + 1903) - assert ledger.chars_per_token == pytest.approx(pooled) - assert ledger.chars_per_token == pytest.approx(1.51, abs=0.01) - - chars, images, tokens = samples[-1] - naive = chars / (tokens - images * IMAGE_BLOCK_TOKENS) - assert naive == pytest.approx(1.62, abs=0.01) - assert ledger.chars_per_token != pytest.approx(naive, abs=0.05) - assert ledger.last_prompt_tokens == tokens - - -def test_calibrated_ratio_scales_the_size_trigger(): - """At a calibrated 1 char/token the size trigger closes a segment at about - a quarter of the characters it needed at the default 4 chars/token.""" - steps = [_step(i) for i in range(1, 6)] - - def eligible_chars(ledger): - turns = ledger.unchunked_turns() - return ledger.turn_text_chars(turns[: len(turns) - 2]) # min_active=2 - - # Default ratio: the eligible text (two ~4k-char turns once turn 5 is - # staged) is worth ~chars/4 tokens, under the 5k target → nothing closes. - ledger, chunker, _, _ = _make( - steps, min_active=2, min_steps=2, max_steps=100, target_tokens=5000 - ) - ledger.record_prompt_tokens(1000, messages=[HumanMessage(content="a" * 4000)]) - _run_big_turns(ledger, chunker, 1, 5, lambda i: "hash-a") - chars = eligible_chars(ledger) - assert chars // 4 < 5000 <= chars - assert chunker.awaiting_chunks == () - - # Calibrated to 1 char/token: the same text is worth ~chars tokens → closes. - ledger, chunker, _, _ = _make( - steps, min_active=2, min_steps=2, max_steps=100, target_tokens=5000 - ) - ledger.record_prompt_tokens(4000, messages=[HumanMessage(content="中" * 4000)]) - assert ledger.chars_per_token == pytest.approx(1.0) - _run_big_turns(ledger, chunker, 1, 5, lambda i: "hash-a") - assert [c.trigger for c in chunker.awaiting_chunks] == ["size"] - - -# --------------------------------------------------------------------------- -# Timeline announcement (compress_history trace) -# --------------------------------------------------------------------------- - - -def test_compression_announces_one_tool_line_from_dispatch_to_swap(): - """Dispatch and swap share a trace ID and report the change in token usage.""" - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] - meter = {"value": 60_000} - ledger, chunker, engine, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) - _run_turns(ledger, chunker, 1, 8, _hashes(4)) - - assert len(engine.traces) == 1 - running = engine.traces[0] - assert (running["type"], running["name"], running["status"]) == ( - "tool", - "compress_history", - "running", - ) - args = running["payload"]["args"] - assert (args["start_step"], args["end_step"], args["steps"]) == (1, 4, 4) - assert args["trigger"] == "milestone" - assert args["source_tokens"] > 0 - assert "context_tokens" not in args - - capsule.resolve("chunk:1-4", _capsule(1, 4)) - ledger.commit_staged(step_key="s8", validator_result={"status": "dispatched"}) - ledger.render([_observation(9)]) - - done = engine.traces[-1] - assert done["trace_id"] == running["trace_id"] - assert done["status"] == "success" - assert done["duration"] is not None and done["duration"] >= 0 - done_args = done["payload"]["args"] - assert done_args["source_tokens"] == args["source_tokens"] - assert done_args["summary_tokens"] > 0 - assert done_args["context_budget"] == 100_000 - assert done_args["context_estimated"] is True - assert done_args["context_tokens"] == ( - 60_000 - done_args["source_tokens"] + done_args["summary_tokens"] - ) - assert "forced" not in done_args - - -def test_forced_swap_announces_fold_without_a_ratio(): - """Forced folds report estimated context usage without a summary size.""" - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] - meter = {"value": None} - ledger, chunker, engine, _ = _make(steps, min_active=2, meter=lambda: meter["value"]) - _run_turns(ledger, chunker, 1, 8, _hashes(4)) - assert any(t["status"] == "running" for t in engine.traces) - - meter["value"] = 95_000 - ledger.commit_staged(step_key="s8", validator_result={"status": "dispatched"}) - ledger.render([_observation(9)]) # pressure closes the open tail too, then force-swaps all - - running_ids = {t["trace_id"] for t in engine.traces if t["status"] == "running"} - done = [t for t in engine.traces if t["status"] == "success"] - assert done and {t["trace_id"] for t in done} <= running_ids - for trace in done: - assert trace["payload"]["args"]["forced"] is True - assert trace["payload"]["args"]["summary_tokens"] is None - assert "folded into the period summary" in trace["payload"]["result"] - last_args = done[-1]["payload"]["args"] - assert last_args["context_budget"] == 100_000 - assert 0 < last_args["context_tokens"] < 95_000 - # The prompt-size figure appears once per swap event, on the last line. - assert all("context_tokens" not in t["payload"]["args"] for t in done[:-1]) - - -def test_compression_trace_carries_a_plain_language_phase_on_every_path(): - """``args.phase`` says what the compression is doing without touching the - trace status the rest of the UI keys on: summarizing (segment closed, - capsule generating — also while retrying), ready (capsule on hand but the - start gate holds the swap), applied (compressed block replaced the raw - turns), failed (capsule generation gave up, full record kept).""" - from artemis.memory.chunking import COMPRESSION_PHASES - - assert COMPRESSION_PHASES == ("summarizing", "ready", "applied", "failed") - - steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 15)] - meter = {"value": 20_000} # < 0.35 * 100k: the start gate holds ready chunks - ledger, chunker, engine, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) - _run_turns(ledger, chunker, 1, 8, _hashes(4)) - chunk = chunker.awaiting_chunks[0] - key = chunk.capsule_key - - def mine(): - return [ - (t["status"], t["payload"]["args"]["phase"]) - for t in engine.traces - if t["trace_id"] == chunk.trace_id - ] - - # Segment closed, capsule dispatched → summarizing. - assert mine() == [("running", "summarizing")] - - # Capsule generation exhausted → failed phase on the same trace. - chunker._on_capsule_failed(key) - assert mine()[-1] == ("failed", "failed") - - # Re-dispatch at the next trigger render → summarizing again (note: retrying). - capsule._failed.add(key) - _run_turns(ledger, chunker, 9, 11, lambda i: "hash-c") - assert capsule.submitted.count(key) == 2 - assert mine()[-1] == ("running", "summarizing") - retrying = [t for t in engine.traces if t["trace_id"] == chunk.trace_id][-1] - assert retrying["payload"]["args"]["note"] == "retrying" - - # Capsule ready while the start gate holds → ready phase, status still running. - capsule.resolve(key, _capsule(1, 4)) - _run_turns(ledger, chunker, 12, 12, lambda i: "hash-c") - assert chunker.chunks == () - assert mine()[-1] == ("running", "ready") - held = [t for t in engine.traces if t["trace_id"] == chunk.trace_id][-1] - assert held["payload"]["args"]["note"] == "held" - - # Context past the gate → the swap applies the summary. - meter["value"] = 40_000 - _run_turns(ledger, chunker, 13, 13, lambda i: "hash-c") - assert [c.start_step_number for c in chunker.chunks] == [1] - assert mine()[-1] == ("success", "applied") - - # Every announcement on every trace carries a known phase. - assert all(t["payload"]["args"]["phase"] in COMPRESSION_PHASES for t in engine.traces) - - -# --------------------------------------------------------------------------- -# Band ① note-linkage machine check -# --------------------------------------------------------------------------- - - -def test_capsule_note_coverage_is_machine_checked(): - """Every note key written during the segment must surface in the band-① - text; a missing key fails the attempt (regenerate), same rank as the ② - interval-coverage check. Segments without notes are unaffected.""" - lens = StepCapsuleLens(model_name="test", llm=object()) - payload = { - "start_step": 3, - "end_step": 6, - "steps": [ - { - "step_number": 3, - "note_writes": [{"tool": "save_note", "key": "login_flow", "gist": "code entry"}], - }, - { - "step_number": 5, - "note_writes": [{"tool": "append_note", "key": "prices", "gist": "totals"}], - }, - ], - } - capsule = _capsule(3, 6) # effect mentions notes/login_flow.md only - - # 'prices' is missing from every band-① field → judged failed. - assert lens.parse_capsule(json.dumps(capsule), payload) is None - - covered = dict( - capsule, - effect=capsule["effect"] + " Also appended notes/prices.md with the totals.", - ) - parsed = lens.parse_capsule(json.dumps(covered), payload) - assert parsed is not None - assert "prices" in parsed["effect"] - - # A no-notes segment keeps the original acceptance behavior. - no_notes = {"start_step": 3, "end_step": 6, "steps": []} - assert lens.parse_capsule(json.dumps(capsule), no_notes) is not None - - -# --------------------------------------------------------------------------- -# Formal lens interface (M1 difference-b closure) -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_lens_backed_service_uses_lens_render(): - from artemis.memory.step_memory import StepLens - - class EchoLens(StepLens): - name = "echo" - - async def render(self, key, payload): - return f"summary-for-{key}" - - service = StepMemoryService(ctx=None, lens=EchoLens()) - service.submit("k1", {"step_number": 1}) - await service.flush() - assert service.get_summary("k1") == "summary-for-k1" - assert not service.has_failed("k1") - - -def test_chunk_lists_every_step_of_a_multi_action_turn(): - """A Flash turn that executed several actions records one step per action - and registers them all on the committed turn (``step_keys``); the chunk's - band-③ ledger must list each of them, never only the turn's first step.""" - steps = [_step(i) for i in range(1, 4)] - _ledger, chunker, _engine, _capsule = _make(steps) - turns = [ - {"step_key": "s1", "step_keys": ["s1", "s2"], "start": 0, "end": 4}, - {"step_key": "s3", "start": 4, "end": 7}, # legacy shape: step_key only - ] - chunk = chunker._create_chunk(turns, None, {s["step_id"]: s for s in steps}) - assert chunk is not None - assert chunk.source_step_ids == ["s1", "s2", "s3"] - assert chunk.start_step_number == 1 and chunk.end_step_number == 3 - assert "- Step 2 (T+00:20)" in chunk.band3 - - -# --------------------------------------------------------------------------- -# Capsule source = the operator's own transcript -# --------------------------------------------------------------------------- - - -def _rich_turn(i: int) -> list: - """A turn that asks the explorer before acting, with a UI list and a - native thinking block — the parts the old per-step projection dropped.""" - from artemis.memory.transcript import PRO_UI_LIST_MARKER - - return [ - HumanMessage( - content=[ - {"type": "text", "text": f"# CURRENT OBSERVATION [T+00:{i % 60:02d}]"}, - {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,IMG_{i}"}}, - {"type": "text", "text": f"{PRO_UI_LIST_MARKER}\n[0] Buy button"}, - ] - ), - AIMessage( - content=[ - {"type": "thinking", "thinking": "need the price first"}, - {"type": "text", "text": "Ask the explorer for the price."}, - ], - tool_calls=[ - { - "name": "ask_explorer", - "args": {"question": "price?"}, - "id": f"tcx{i}", - "type": "tool_call", - } - ], - ), - ToolMessage(tool_call_id=f"tcx{i}", content="Price shown: ¥39"), - AIMessage( - content=f"thinking {i}", - tool_calls=[ - {"name": "click", "args": {"target": i}, "id": f"tc{i}", "type": "tool_call"} - ], - ), - ToolMessage(tool_call_id=f"tc{i}", content="Action Recorded"), - ] - - -def _payload_with_step(capsule: StubCapsuleService, step_number: int) -> dict: - for payload in capsule._step_inputs.values(): - if any(s.get("step_number") == step_number for s in payload.get("steps", [])): - return payload - raise AssertionError(f"no capsule payload carries step {step_number}") - - -def test_capsule_payload_carries_each_turn_transcript_as_the_operator_saw_it(): - """The segment capsule digests exactly the text it replaces: every turn's - post-scrub transcript — reasoning (native thinking included), every tool - call with its arguments and returned result, the validator result — not - a hand-picked projection. Scrubbed parts (UI list, screenshot) are gone - from the source too, because the operator no longer saw them either.""" - steps = [_step(i) for i in range(1, 7)] - ledger, chunker, _engine, capsule = _make(steps, min_active=2, max_steps=3) - for i in range(1, 7): - ledger.commit_staged( - step_key=f"s{i - 1}" if i > 1 else None, - validator_result={"status": "dispatched"} if i > 1 else None, - ) - ledger.render([_observation(i)]) - ledger.stage_turn(_rich_turn(i) if i == 2 else _turn(i)) - chunker.on_step_stamped(f"s{i}", "hash-a") - - payload = _payload_with_step(capsule, 2) - turn = next(t for t in payload["turns"] if t["steps"][0]["step_number"] == 2) - transcript = turn["transcript"] - - # What the operator saw during that turn, verbatim and in order. - assert "(thinking) need the price first" in transcript - assert "Ask the explorer for the price." in transcript - assert '[tool call] ask_explorer({"question": "price?"})' in transcript - assert "[tool result ask_explorer]\nPrice shown: ¥39" in transcript - assert '[tool call] click({"target": 2})' in transcript - assert "--- Action Execution Result" in transcript and "Status: dispatched" in transcript - assert transcript.index("Price shown") < transcript.index("click(") - # What the scrub edge already removed from the operator's view. - assert "Buy button" not in transcript and "IMG_2" not in transcript - - # The lens request renders the turn block: step facts, then the transcript. - lens = StepCapsuleLens(model_name="test", llm=object()) - text = lens.build_messages(payload)[1].content - assert "## Step 2 (T+00:20)" in text - assert "- Step 2 (T+00:20): " in text and "Visual transition of step 2." in text - assert "Transcript as seen by the operator during this turn:" in text - assert text.index("Recorded steps:") < text.index("Price shown: ¥39") - assert "Reasoning excerpt" not in text # the transcript supersedes the excerpt - - -def test_chunk_without_ledger_falls_back_to_per_step_projection(): - """A chunk built without a live ledger (no transcripts) keeps the - per-step fallback rendering so the capsule can still be produced.""" - steps = [_step(i) for i in range(1, 3)] - _ledger, chunker, _engine, capsule = _make(steps) - turns = [{"step_key": f"s{i}", "step_keys": [f"s{i}"], "start": 0, "end": 3} for i in (1, 2)] - chunk = chunker._create_chunk(turns, None, {s["step_id"]: s for s in steps}) - assert chunk is not None - payload = capsule._step_inputs[chunk.capsule_key] - assert "turns" not in payload - - text = StepCapsuleLens(model_name="test", llm=object()).build_messages(payload)[1].content - assert "## Step 1 (T+00:10)" in text and "## Step 2 (T+00:20)" in text - assert "Reasoning excerpt: thinking 1" in text - assert "Transcript as seen by the operator" not in text - - -def test_turn_transcript_is_capped_in_place_inside_the_capsule_request(): - lens = StepCapsuleLens(model_name="test", llm=object()) - cap = StepCapsuleLens.MAX_TURN_TRANSCRIPT_CHARS - step = {"step_number": 4, "offset": "T+00:40", "action": "tap", "outcome": "dispatched"} - payload = { - "start_step": 4, - "end_step": 4, - "steps": [step], - "turns": [{"steps": [step], "transcript": "x" * (cap + 500)}], - } - text = lens.build_messages(payload)[1].content - assert "x" * (cap + 1) not in text - assert "500 characters cut here" in text - - -def test_capped_transcript_keeps_its_tail_so_the_validator_result_survives(): - """The cut lands in the middle: the head (observation + reasoning) and the - tail (the validator result that closes every turn) both stay verbatim.""" - cap = StepCapsuleLens.MAX_TURN_TRANSCRIPT_CHARS - tail = "\n[observation]\n--- Action Execution Result (T+09:59) ---\nStatus: failed\nError: boom" - transcript = "HEAD-START " + "h" * (cap + 3000) + tail - capped = StepCapsuleLens._cap_transcript(transcript) - assert capped.startswith("HEAD-START ") - assert capped.endswith(tail) - assert "characters cut here" in capped - assert len(capped) <= cap + 200 # cap plus the announcement line - - -def test_visual_transition_line_is_skipped_when_the_transcript_already_has_it(): - """The resolved visual summary sits verbatim in the transcript once the - scrub edge replaced the screenshot; only an unresolved turn (placeholder - still there) gets the DataEngine summary as a separate line.""" - from artemis.agents.flash.context_compressor import HISTORY_SUMMARY_PREFIX - - step = { - "step_number": 4, - "offset": "T+00:40", - "action": "tap", - "outcome": "dispatched", - "visual_summary": "The cart badge changed from 0 to 1.", - } - resolved = f"[observation]\n{HISTORY_SUMMARY_PREFIX}The cart badge changed from 0 to 1." - unresolved = "[observation]\n[screenshot]" - for transcript, expected_count in ((resolved, 1), (unresolved, 1), ("", 1)): - payload = {"start_step": 4, "end_step": 4, "steps": [step]} - if transcript: - payload["turns"] = [{"steps": [step], "transcript": transcript}] - text = StepCapsuleLens(model_name="test", llm=object()).build_messages(payload)[1].content - assert text.count("The cart badge changed from 0 to 1.") == expected_count, transcript - resolved_text = ( - StepCapsuleLens(model_name="test", llm=object()) - .build_messages( - { - "start_step": 4, - "end_step": 4, - "steps": [step], - "turns": [{"steps": [step], "transcript": resolved}], - } - )[1] - .content - ) - assert "Visual transition:" not in resolved_text - unresolved_text = ( - StepCapsuleLens(model_name="test", llm=object()) - .build_messages( - { - "start_step": 4, - "end_step": 4, - "steps": [step], - "turns": [{"steps": [step], "transcript": unresolved}], - } - )[1] - .content - ) - assert "Visual transition: The cart badge" in unresolved_text - - -def test_size_trigger_measures_the_rendered_transcript_the_capsule_receives(): - """``turn_text_chars`` sizes the exact text the lens gets — tool-call - arguments included — so the source-token trigger and the capsule payload - never disagree about how big a segment is.""" - steps = [_step(i) for i in range(1, 5)] - ledger, chunker, _engine, capsule = _make(steps, min_active=1, target_tokens=10**9) - _run_turns(ledger, chunker, 1, 4) - turns = ledger.unchunked_turns() - assert ledger.turn_text_chars(turns) == sum(len(ledger.turn_transcript(t)) for t in turns) - assert ledger.turn_text_chars(turns[:1]) == len(ledger.turn_transcript(turns[0])) - assert '[tool call] click({"target": 1})' in ledger.turn_transcript(turns[0]) - - -@pytest.mark.asyncio -async def test_ready_capsule_releases_its_turn_transcripts(): - """Once a capsule landed the job is never re-run, so the service drops the - bulky ``turns`` from the retained payload (the flat step facts stay).""" - from artemis.memory.chunking import ChunkCapsuleService - - class EchoLens(StepCapsuleLens): - async def render(self, key, payload): - return json.dumps(_capsule(1, 1)) - - service = ChunkCapsuleService(None, EchoLens(model_name="test", llm=object())) - payload = { - "start_step": 1, - "end_step": 1, - "step_number": 1, - "steps": [{"step_number": 1}], - "turns": [{"steps": [{"step_number": 1}], "transcript": "big text"}], - } - service.submit("chunk:1-1", payload) - await service.flush() - assert service.has_summary("chunk:1-1") - assert "turns" not in service.get_job_payload("chunk:1-1") - assert service.get_job_payload("chunk:1-1")["steps"] == [{"step_number": 1}] - - -def test_multi_action_turn_renders_one_block_with_every_recorded_step(): - lens = StepCapsuleLens(model_name="test", llm=object()) - steps = [ - {"step_number": n, "offset": f"T+00:{n}0", "action": f"tap btn{n}", "outcome": "dispatched"} - for n in (7, 8) - ] - payload = {"start_step": 7, "end_step": 8, "steps": steps} - payload["turns"] = [{"steps": steps, "transcript": "[operator]\nfired two taps"}] - text = lens.build_messages(payload)[1].content - assert "## Steps 7–8 (T+00:70 → T+00:80)" in text - assert "- Step 7 (T+00:70): tap btn7 -> dispatched" in text - assert "- Step 8 (T+00:80): tap btn8 -> dispatched" in text - assert "fired two taps" in text - - -# --------------------------------------------------------------------------- -# Band ② must not restate band ③ -# --------------------------------------------------------------------------- - - -def test_band2_coordinates_are_machine_checked(): - """A coordinate literal in any band-② interval text fails the attempt - (regenerate), same rank as the coverage check: band ③ already carries - every action's target, so ② repeating it is the ledger written twice.""" - from artemis.memory.chunking import band2_intervals_carry_coordinates - - lens = StepCapsuleLens(model_name="test", llm=object()) - payload = {"start_step": 3, "end_step": 6, "steps": []} - - with_coords = dict( - _capsule(3, 6), - intervals=[ - {"start_step": 3, "end_step": 5, "text": "opened three sections in turn"}, - {"start_step": 6, "end_step": 6, "text": "tapped 'Battery' at [320, 399]"}, - ], - ) - assert band2_intervals_carry_coordinates(with_coords["intervals"]) - assert lens.parse_capsule(json.dumps(with_coords), payload) is None - - swipe_literal = dict( - _capsule(3, 6), - intervals=[{"start_step": 3, "end_step": 6, "text": "swiped [556, 289, 556, 124]"}], - ) - assert lens.parse_capsule(json.dumps(swipe_literal), payload) is None - - behavior_only = dict( - _capsule(3, 6), - intervals=[ - { - "start_step": 3, - "end_step": 6, - "text": "Opened 'Apps' and 'Battery' and returned to the main list each time;" - " the Battery page showed 100% and 3 rows.", - } - ], - ) - assert not band2_intervals_carry_coordinates(behavior_only["intervals"]) - assert lens.parse_capsule(json.dumps(behavior_only), payload) is not None - - -def test_capsule_prompt_forbids_band2_coordinates_and_sets_a_length_target(): - """The prompt carries the ② contract (behavior + observed result, no - coordinates, homogeneous runs merged) and the ①+② soft length target, - while keeping the preserve-don't-abstract rule.""" - prompt = StepCapsuleLens(model_name="test", llm=object())._prompt - assert "NEVER include coordinates" in prompt - assert "NEVER narrate step by step" in prompt - assert "Merge homogeneous consecutive actions" in prompt - assert "one third of the source text" in prompt - assert "Preserve, don't abstract" in prompt diff --git a/tests/unit/memory/test_step_memory_service.py b/tests/unit/memory/test_step_memory_service.py deleted file mode 100644 index 4ea5e4fbc..000000000 --- a/tests/unit/memory/test_step_memory_service.py +++ /dev/null @@ -1,263 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for the shared StepMemoryService scheduling runtime (M1).""" - -import asyncio -from unittest.mock import Mock -import uuid - -import pytest - -from artemis.data_engine.context_vars import CURRENT_NODE_NAME, CURRENT_TRACE_ID -from artemis.data_engine.trace import detached_trace -from artemis.memory.step_memory import StepLens, StepMemoryService - - -class _ScriptedService(StepMemoryService): - """StepMemoryService with a scriptable attempt function and status log.""" - - def __init__(self, attempt_fn, **kwargs): - super().__init__(Mock(), **kwargs) - self._attempt_fn = attempt_fn - self.status_events: list[tuple] = [] - - async def _attempt(self, key): - return await self._attempt_fn(self, key) - - def _on_status(self, key, status): - self.status_events.append((key, status)) - - -@pytest.mark.asyncio -async def test_zero_blocking_submit_and_flush(): - """submit() returns immediately; flush() drains the background job.""" - release = asyncio.Event() - - async def attempt(svc, key): - await release.wait() - svc._summaries[key] = f"summary-for-{key}" - return True - - service = _ScriptedService(attempt) - service.submit("step-1", {"step_number": 1}) - - # Zero-blocking: the job cannot have completed yet (its gate is closed). - assert service.is_pending("step-1") - assert not service.has_summary("step-1") - - release.set() - await service.flush() - - assert service.get_summary("step-1") == "summary-for-step-1" - assert not service.is_pending("step-1") - - -@pytest.mark.asyncio -async def test_bounded_retry_enters_failed_state(): - """A permanently failing job stops after 1 + retry_limit attempts.""" - attempts = 0 - - async def attempt(svc, key): - nonlocal attempts - attempts += 1 - return False - - service = _ScriptedService(attempt, retry_limit=2) - service._retry_delays = (0.0,) - service.submit("step-x", {"step_number": 7}) - await service.flush() - - assert attempts == 3 - assert service.has_failed("step-x") - assert not service.has_summary("step-x") - # The failed job still reads as pending (lossless semantics downstream). - assert service.is_pending("step-x") - assert service.status_events == [("step-x", "pending"), ("step-x", "failed")] - - -@pytest.mark.asyncio -async def test_step_id_keying_with_tool_call_alias(): - """Jobs keyed by step id remain queryable through tool_call_id aliases.""" - - async def attempt(svc, key): - svc._summaries[key] = "canonical summary" - return True - - service = _ScriptedService(attempt) - service.submit("uuid-123", {"step_number": 4}, aliases=("tc-abc",)) - await service.flush() - - assert service.resolve_key("tc-abc") == "uuid-123" - assert service.has_job("tc-abc") - assert service.has_summary("tc-abc") - assert service.get_summary("tc-abc") == "canonical summary" - assert service.get_summary("uuid-123") == "canonical summary" - assert service.get_job_payload("tc-abc") == {"step_number": 4} - assert not service.has_failed("tc-abc") - - -@pytest.mark.asyncio -async def test_max_concurrency_bounds_parallel_attempts(): - """With max_concurrency=1, two jobs never run their attempts concurrently.""" - active = 0 - max_active = 0 - - async def attempt(svc, key): - nonlocal active, max_active - active += 1 - max_active = max(max_active, active) - await asyncio.sleep(0.01) - active -= 1 - svc._summaries[key] = "done" - return True - - service = _ScriptedService(attempt, max_concurrency=1) - service.submit("a", {"step_number": 1}) - service.submit("b", {"step_number": 2}) - await service.flush() - - assert service.has_summary("a") and service.has_summary("b") - assert max_active == 1 - - -@pytest.mark.asyncio -async def test_flush_timeout_cancels_stragglers(): - """flush() cancels jobs that outlive the timeout without failing them.""" - - async def attempt(svc, key): - await asyncio.sleep(30) - return True - - service = _ScriptedService(attempt) - service.submit("slow", {"step_number": 1}) - await service.flush(timeout_seconds=0.05) - - assert service.is_pending("slow") - assert not service.has_failed("slow") - assert service._pending_tasks["slow"].done() - - -@pytest.mark.asyncio -async def test_flush_uses_configured_default_timeout(): - """flush() with no argument honors the constructor's flush_timeout_s.""" - - async def attempt(svc, key): - await asyncio.sleep(30) - return True - - service = _ScriptedService(attempt, flush_timeout_s=0.05) - service.submit("slow", {"step_number": 1}) - await asyncio.wait_for(service.flush(), timeout=5) - - assert service._pending_tasks["slow"].done() - - -@pytest.mark.asyncio -async def test_lens_job_runs_detached_from_the_submitters_trace_span(): - """A job submitted while an operator step span is current (the task - inherits that context) must not see the span: whatever the lens records - can never be attributed to the caller's step. The caller's own context is - untouched, and the job's node label names the lens.""" - seen: list[tuple] = [] - - class _Lens(StepLens): - name = "probe" - - async def render(self, key, payload): - seen.append((CURRENT_TRACE_ID.get(), CURRENT_NODE_NAME.get())) - return "rendered" - - service = StepMemoryService(Mock(), lens=_Lens()) - operator_span = uuid.uuid4() - token = CURRENT_TRACE_ID.set(operator_span) - token_name = CURRENT_NODE_NAME.set("operator") - try: - service.submit("step-1", {"step_number": 1}) - await service.flush() - assert CURRENT_TRACE_ID.get() == operator_span - assert CURRENT_NODE_NAME.get() == "operator" - finally: - CURRENT_TRACE_ID.reset(token) - CURRENT_NODE_NAME.reset(token_name) - - assert service.get_summary("step-1") == "rendered" - assert seen == [(None, "lens:probe")] - - -@pytest.mark.asyncio -async def test_lens_less_subclass_attempt_is_detached_too(): - """The detachment lives in the runner, so a subclass with an inlined - ``_attempt`` (the Flash visual summarizer shape) gets it for free.""" - seen: list = [] - - async def attempt(svc, key): - seen.append(CURRENT_TRACE_ID.get()) - svc._summaries[key] = "ok" - return True - - service = _ScriptedService(attempt) - token = CURRENT_TRACE_ID.set(uuid.uuid4()) - try: - service.submit("s", {"step_number": 1}) - await service.flush() - finally: - CURRENT_TRACE_ID.reset(token) - assert seen == [None] - assert service.trace_node_name == "lens:_scriptedservice" - - -def test_detached_trace_restores_context_on_exception(): - span = uuid.uuid4() - token = CURRENT_TRACE_ID.set(span) - token_name = CURRENT_NODE_NAME.set("operator") - try: - with pytest.raises(RuntimeError): - with detached_trace("lens:x"): - assert CURRENT_TRACE_ID.get() is None - assert CURRENT_NODE_NAME.get() == "lens:x" - raise RuntimeError("boom") - assert CURRENT_TRACE_ID.get() == span - assert CURRENT_NODE_NAME.get() == "operator" - with detached_trace(): # no node label: the name is left alone - assert CURRENT_NODE_NAME.get() == "operator" - finally: - CURRENT_TRACE_ID.reset(token) - CURRENT_NODE_NAME.reset(token_name) - - -@pytest.mark.asyncio -async def test_resubmit_clears_failed_state(): - """Re-submitting a failed job gives it a fresh retry budget.""" - behaviors = {"fail": True} - - async def attempt(svc, key): - if behaviors["fail"]: - return False - svc._summaries[key] = "second time lucky" - return True - - service = _ScriptedService(attempt, retry_limit=0) - service._retry_delays = (0.0,) - service.submit("job", {"step_number": 1}) - await service.flush() - assert service.has_failed("job") - - behaviors["fail"] = False - service._retry_counts.clear() - service.submit("job", {"step_number": 1}) - await service.flush() - - assert not service.has_failed("job") - assert service.get_summary("job") == "second time lucky" diff --git a/tests/unit/memory/test_transcript_ledger.py b/tests/unit/memory/test_transcript_ledger.py deleted file mode 100644 index db48ded9b..000000000 --- a/tests/unit/memory/test_transcript_ledger.py +++ /dev/null @@ -1,734 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for the Pro session transcript ledger (history redesign §3.2, M2). - -Covers the four-region discipline: S-region byte stability across turns, the -append-only active region, the single depth-K scrub edge in the Pro message -shape (UI list / plan recitation strip, ephemeral-block deletion and the -in-place screenshot swap in one pass; grace / placeholder / freeze — the M1 -race regressions re-run against HumanMessage observations keyed by step id), -``T+mm:ss`` session-offset timestamps, the tool-call/response pairing -invariant, the cold-start restored-history block, and the silent-turn flag. -""" - -import json -import re - -import pytest -from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage - -from artemis.memory.step_memory import StepMemoryService -from artemis.memory.transcript import ( - EXECUTION_RESULT_MARKER, - PLAN_RECITATION_MARKER, - PRO_UI_LIST_MARKER, - RESTORED_HISTORY_HEADER, - TranscriptLedger, - format_session_offset, -) - -OFFSET_RE = re.compile(r"T\+\d{2,}:\d{2}") - - -def _service() -> StepMemoryService: - return StepMemoryService(ctx=None) - - -def _observation(i: int) -> HumanMessage: - return HumanMessage( - content=[ - {"type": "text", "text": f"# CURRENT OBSERVATION [T+00:0{i % 10}]"}, - {"type": "text", "text": f"{PLAN_RECITATION_MARKER}\n- [/] milestone {i}"}, - {"type": "text", "text": "--- Current Screenshot ---"}, - {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,IMG_{i}"}}, - {"type": "text", "text": f"{PRO_UI_LIST_MARKER}\n[1] button {i}"}, - ] - ) - - -def _turn(i: int, tool_call_count: int = 1) -> list: - messages: list = [_observation(i)] - if tool_call_count: - tool_calls = [ - {"name": "click", "args": {"target": n + 1}, "id": f"tc{i}-{n}", "type": "tool_call"} - for n in range(tool_call_count) - ] - messages.append(AIMessage(content=f"thinking {i}", tool_calls=tool_calls)) - for tc in tool_calls: - messages.append(ToolMessage(tool_call_id=tc["id"], content="Action Recorded")) - return messages - - -def _play_turn(ledger: TranscriptLedger, i: int, *, prev_key=None, prev_result=None, **turn_kwargs): - """Mimic the operator's real ordering: commit previous, render, stage.""" - ledger.commit_staged(step_key=prev_key, validator_result=prev_result) - turn = _turn(i, **turn_kwargs) - rendered = ledger.render([turn[0]]) - ledger.stage_turn(turn) - return rendered - - -def _fingerprint(msg) -> str: - return json.dumps(msg.content, sort_keys=True, default=str) - - -def _has_image(msg) -> bool: - return any(isinstance(b, dict) and b.get("type") in ("image_url", "image") for b in msg.content) - - -def test_format_session_offset(): - assert format_session_offset(0) == "T+00:00" - assert format_session_offset(83) == "T+01:23" - # Minutes never wrap into hours: byte-stable monotonic labels. - assert format_session_offset(3725) == "T+62:05" - assert format_session_offset(-5) == "T+00:00" - - -def test_static_prefix_set_once_and_byte_stable(): - ledger = TranscriptLedger(step_memory=_service()) - system = SystemMessage(content="STATIC SYSTEM PROMPT") - ledger.set_static_prefix([system]) - - with pytest.raises(RuntimeError): - ledger.set_static_prefix([SystemMessage(content="other")]) - - fingerprints = set() - for i in range(1, 6): - rendered = _play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None) - assert rendered[0] is system - fingerprints.add(rendered[0].content) - assert fingerprints == {"STATIC SYSTEM PROMPT"} - - -def test_active_region_is_append_only(): - ledger = TranscriptLedger(step_memory=_service()) - seen_ids: list[int] = [] - for i in range(1, 5): - _play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None) - active = ledger.active_messages - # The previously observed prefix is unchanged in identity and order. - assert [id(m) for m in active[: len(seen_ids)]] == seen_ids - seen_ids = [id(m) for m in active] - assert ledger.turn_count == 3 # 3 committed, 1 still staged - - -def test_validator_result_message_carries_session_offset(): - ledger = TranscriptLedger(step_memory=_service()) - _play_turn(ledger, 1) - _play_turn( - ledger, - 2, - prev_key="step-1", - prev_result={"status": "dispatched", "execution": [{"action": "tap", "attempts": ["ok"]}]}, - ) - - result_messages = [ - m - for m in ledger.active_messages - if isinstance(m, HumanMessage) and str(m.content).find(EXECUTION_RESULT_MARKER) >= 0 - ] - assert len(result_messages) == 1 - text = result_messages[0].content[0]["text"] - assert OFFSET_RE.search(text), text - assert "Status: dispatched" in text - assert "ago" not in text - - -def test_validator_result_skipped_for_actionless_turn(): - ledger = TranscriptLedger(step_memory=_service()) - _play_turn(ledger, 1) - _play_turn(ledger, 2, prev_key="step-1", prev_result=None) - assert not any(EXECUTION_RESULT_MARKER in str(m.content) for m in ledger.active_messages) - - -def test_recitation_and_ui_list_stripped_at_the_text_edge_and_screenshot_at_k(): - """Two edges (text at depth 1, screenshot at K=3): the plan recitation and - UI list leave as soon as a newer observation exists, the screenshot stays - until depth K and is then resolved in place; only the live tail ever - carries an indexed element list.""" - ledger = TranscriptLedger(step_memory=_service(), image_scrub_depth=3) - _play_turn(ledger, 1) - rendered = _play_turn(ledger, 2, prev_key="step-1") - obs1 = ledger.active_messages[0] - # Turn 1 is now depth 2: text edge passed, screenshot kept. - assert PLAN_RECITATION_MARKER not in str(obs1.content) - assert PRO_UI_LIST_MARKER not in str(obs1.content) - assert _has_image(obs1) - original_obs1 = _fingerprint(obs1) - - rendered = _play_turn(ledger, 3, prev_key="step-2") - # Turn 1 reached depth 3: the screenshot is resolved. - assert not _has_image(obs1) - assert _fingerprint(obs1) != original_obs1 - # Turn 2 (depth 2): text edge passed, screenshot still there; the live tail keeps both. - obs2 = next(m for m in ledger.active_messages if "OBSERVATION [T+00:02]" in str(m.content)) - assert PLAN_RECITATION_MARKER not in str(obs2.content) - assert PRO_UI_LIST_MARKER not in str(obs2.content) - assert _has_image(obs2) - tail_text = str(rendered[-1].content) - assert PLAN_RECITATION_MARKER in tail_text - assert PRO_UI_LIST_MARKER in tail_text - - -def test_depth_k_image_resolved_to_ready_visual_summary(): - service = _service() - service._step_inputs["step-1"] = {"step_number": 1} - service._summaries["step-1"] = "Objective visual transition 1." - - ledger = TranscriptLedger(step_memory=service, image_scrub_depth=3) - for i in range(1, 5): - _play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None) - - first_obs = ledger.active_messages[0] - assert not _has_image(first_obs) - # (i) The summary sits where the image was, the label above it is gone, - # recitation and UI list are stripped: exactly two blocks remain. - assert first_obs.content == [ - {"type": "text", "text": "# CURRENT OBSERVATION [T+00:01]"}, - { - "type": "text", - "text": "--- Historical Visual Transition ---\nObjective visual transition 1.", - }, - ] - assert "--- Current Screenshot ---" not in str(first_obs.content) - - -def test_pending_grace_then_placeholder_never_backfilled(): - service = _service() - service._step_inputs["step-1"] = {"step_number": 1} # pending job, no summary - - ledger = TranscriptLedger(step_memory=service, image_scrub_depth=2, pending_grace_steps=1) - # Enough turns to push turn 1 past K + grace. - for i in range(1, 7): - _play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None) - - first_obs = ledger.active_messages[0] - assert not _has_image(first_obs) - assert "[visual summary pending; evidence at DataEngine step 1]" in str(first_obs.content) - frozen = _fingerprint(first_obs) - - # A late summary must never mutate the frozen message. - service._summaries["step-1"] = "Too late." - _play_turn(ledger, 7, prev_key="step-6") - assert _fingerprint(ledger.active_messages[0]) == frozen - - -def test_failed_summary_becomes_unavailable_placeholder(): - service = _service() - service._step_inputs["step-1"] = {"step_number": 1} - service._failed.add("step-1") - - ledger = TranscriptLedger(step_memory=service, image_scrub_depth=2, pending_grace_steps=5) - for i in range(1, 4): - _play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None) - - first_obs = ledger.active_messages[0] - assert not _has_image(first_obs) - assert "[visual summary unavailable; evidence at DataEngine step 1]" in str(first_obs.content) - - -def test_tool_call_response_pairs_are_never_split(): - ledger = TranscriptLedger(step_memory=_service(), image_scrub_depth=2) - for i in range(1, 7): - _play_turn( - ledger, - i, - prev_key=f"step-{i - 1}" if i > 1 else None, - prev_result={"status": "dispatched"} if i > 1 else None, - tool_call_count=2, - ) - - active = list(ledger.active_messages) - for idx, msg in enumerate(active): - if isinstance(msg, AIMessage) and msg.tool_calls: - following = active[idx + 1 : idx + 1 + len(msg.tool_calls)] - assert [getattr(m, "tool_call_id", None) for m in following] == [ - tc["id"] for tc in msg.tool_calls - ], f"tool-call pairing split at active index {idx}" - - -def test_restored_history_only_seeds_an_empty_ledger(): - ledger = TranscriptLedger(step_memory=_service()) - ledger.set_restored_history(f"{RESTORED_HISTORY_HEADER} steps 1-9 ...") - assert ledger.has_restored_history - - with pytest.raises(RuntimeError): - ledger.set_restored_history("again") - - ledger.set_static_prefix([SystemMessage(content="S")]) - rendered = _play_turn(ledger, 1) - # Order: S region, then the frozen restored block, then the live tail. - assert rendered[0].content == "S" - assert RESTORED_HISTORY_HEADER in str(rendered[1].content) - assert "# CURRENT OBSERVATION" in str(rendered[-1].content) - - ledger2 = TranscriptLedger(step_memory=_service()) - _play_turn(ledger2, 1) - ledger2.commit_staged(step_key="step-1") - with pytest.raises(RuntimeError): - ledger2.set_restored_history("late") - - -def test_stage_twice_commits_the_forgotten_turn(): - ledger = TranscriptLedger(step_memory=_service()) - ledger.stage_turn(_turn(1)) - ledger.stage_turn(_turn(2)) - assert ledger.turn_count == 1 - assert ledger.has_staged_turn - - -def test_no_ago_wording_in_ledger_output(): - ledger = TranscriptLedger(step_memory=_service()) - for i in range(1, 4): - _play_turn( - ledger, - i, - prev_key=f"step-{i - 1}" if i > 1 else None, - prev_result={"status": "dispatched"} if i > 1 else None, - ) - blob = " ".join(str(m.content) for m in ledger.active_messages) - assert " ago" not in blob - - -# --------------------------------------------------------------------------- -# Session-start anchoring and multi-step turns (Flash profile reuse) -# --------------------------------------------------------------------------- - - -def test_session_start_anchors_offsets_to_the_data_engine_clock(): - """With a ``session_start`` epoch the ledger reads the wall clock, so the - ``T+mm:ss`` labels are relative to the recorded session start (the same - origin as the chunk ledger lines and the video analyzer's timeline).""" - now = {"t": 1000.0} - ledger = TranscriptLedger(step_memory=_service(), clock=lambda: now["t"], session_start=940.0) - assert ledger.elapsed_label() == "T+01:00" - now["t"] = 1075.5 - assert ledger.elapsed_label() == "T+02:15" - - -def test_without_session_start_the_ledger_keeps_its_own_origin(): - now = {"t": 500.0} - ledger = TranscriptLedger(step_memory=_service(), clock=lambda: now["t"]) - assert ledger.elapsed_label() == "T+00:00" - now["t"] = 512.0 - assert ledger.elapsed_label() == "T+00:12" - - -def test_commit_records_every_step_key_of_a_multi_action_turn(): - ledger = TranscriptLedger(step_memory=_service()) - ledger.stage_turn(_turn(1, tool_call_count=2)) - ledger.commit_staged(step_key="s1", extra_step_keys=["s2", "s1", "s3"]) - turn = ledger.unchunked_turns()[0] - assert turn["step_key"] == "s1" - assert turn["step_keys"] == ["s1", "s2", "s3"] - - ledger.stage_turn(_turn(2)) - ledger.commit_staged(step_key=None) - assert ledger.unchunked_turns()[1]["step_keys"] == [] - - -# --------------------------------------------------------------------------- -# Turn transcript rendering (chunk-capsule source) -# --------------------------------------------------------------------------- - - -def test_render_turn_transcript_is_lossless_over_text_thinking_tools_and_results(): - from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage - - from artemis.memory.transcript import SCREENSHOT_PLACEHOLDER, render_turn_transcript - - messages = [ - HumanMessage( - content=[ - {"type": "text", "text": "# CURRENT OBSERVATION [T+01:00]"}, - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,IMG"}}, - ] - ), - AIMessage( - content=[ - {"type": "thinking", "thinking": "compare the two prices"}, - {"type": "text", "text": "I will recall the earlier price."}, - ], - tool_calls=[ - { - "name": "search_history", - "args": {"query": "price"}, - "id": "c1", - "type": "tool_call", - } - ], - ), - ToolMessage(tool_call_id="c1", content="Step 3: price ¥39", status="success"), - ToolMessage( - tool_call_id="c2", name="adb_shell", content="permission denied", status="error" - ), - ToolMessage(tool_call_id="c9", content="orphan result"), - SystemMessage(content="You have reached the maximum number of tool calls"), - AIMessage( - content="tap now", - tool_calls=[{"name": "click", "args": {"target": [0.5, 0.5]}, "id": "c3"}], - ), - ] - text = render_turn_transcript(messages) - assert text.splitlines()[0] == "[observation]" - assert "# CURRENT OBSERVATION [T+01:00]" in text - assert SCREENSHOT_PLACEHOLDER in text and "IMG" not in text - assert "[operator]\n(thinking) compare the two prices\nI will recall the earlier price." in text - assert '[tool call] search_history({"query": "price"})' in text - # A Pro ToolMessage carries no name: resolved through the turn's own call. - assert "[tool result search_history]\nStep 3: price ¥39" in text - assert "[tool result adb_shell (error)]\npermission denied" in text - assert "[tool result c9]\norphan result" in text # unresolvable: id shown - assert "[system]\nYou have reached the maximum number of tool calls" in text - assert '[tool call] click({"target": [0.5, 0.5]})' in text - # Order is message order. - assert text.index("search_history(") < text.index("price ¥39") < text.index("tap now") - - -def test_ledger_turn_transcript_reflects_the_scrubbed_active_region(): - """The transcript handed to chunk compression is the turn as it stands - after the scrub edge: the UI list stripped, the validator result present.""" - from langchain_core.messages import AIMessage, HumanMessage, ToolMessage - - from artemis.memory.transcript import PRO_UI_LIST_MARKER, TranscriptLedger - - ledger = TranscriptLedger(clock=lambda: 0.0) - - def turn(i: int) -> list: - return [ - HumanMessage( - content=[ - {"type": "text", "text": f"# CURRENT OBSERVATION [T+00:0{i}]"}, - {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,IMG{i}"}}, - {"type": "text", "text": f"{PRO_UI_LIST_MARKER}\n[0] Button {i}"}, - ] - ), - AIMessage( - content=f"thought {i}", - tool_calls=[{"name": "click", "args": {}, "id": f"t{i}"}], - ), - ToolMessage(tool_call_id=f"t{i}", content="Action Recorded"), - ] - - ledger.stage_turn(turn(1)) - ledger.commit_staged(step_key="s1", validator_result={"status": "dispatched"}) - ledger.stage_turn(turn(2)) - ledger.commit_staged(step_key="s2", validator_result={"status": "failed", "error": "boom"}) - ledger.render([HumanMessage(content=[{"type": "text", "text": "tail"}])]) - - first, second = ledger.unchunked_turns() - text = ledger.turn_transcript(first) - assert "thought 1" in text and "[tool call] click({})" in text - assert "Button 1" not in text # depth-1 strip already applied - assert "--- Action Execution Result (T+00:00) ---\nStatus: dispatched" in text - assert "Status: failed" in ledger.turn_transcript(second) - assert "thought 2" not in text # a turn transcript never leaks into another turn - - -# --------------------------------------------------------------------------- -# Occupancy-driven screenshot depth -# --------------------------------------------------------------------------- - - -def _image_turns(ledger) -> list[int]: - """1-based turn numbers whose observation still carries its screenshot.""" - return [ - i + 1 - for i, msg in enumerate( - m for m in ledger.active_messages if "OBSERVATION" in str(m.content) - ) - if _has_image(msg) - ] - - -def _banded_ledger(**kwargs) -> TranscriptLedger: - return TranscriptLedger( - step_memory=_service(), - image_scrub_depth=3, - image_scrub_depth_relaxed=6, - context_budget_tokens=100_000, - start_ratio=0.35, - **kwargs, - ) - - -def test_relaxed_depth_below_start_gate_keeps_more_screenshots(): - """Below the start gate the scrub edge sits at the relaxed depth. Depth K - scrubs the K-th most recent screenshot with the live tail at depth 1, so - K=6 keeps four historical screenshots in view (K=3 keeps one). An unknown - occupancy counts as below the gate.""" - ledger = _banded_ledger() - assert ledger.occupancy is None - assert ledger.effective_image_scrub_depth == 6 - for i in range(1, 10): - _play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None) - # At the render of turn 9 its observation is the live tail (depth 1); - # committed turns 8..5 are depths 2..5 and turn 4 (depth 6) is scrubbed. - ledger.record_prompt_tokens(20_000) # 20% < 35% - assert ledger.effective_image_scrub_depth == 6 - assert _image_turns(ledger) == [5, 6, 7, 8] - - tight = TranscriptLedger(step_memory=_service(), image_scrub_depth=3) - for i in range(1, 10): - _play_turn(tight, i, prev_key=f"step-{i - 1}" if i > 1 else None) - assert _image_turns(tight) == [8] - - -def test_crossing_the_start_gate_tightens_once_and_relaxing_never_backfills(): - ledger = _banded_ledger() - for i in range(1, 10): - _play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None) - assert _image_turns(ledger) == [5, 6, 7, 8] - - # Past the gate: the next render scrubs the extra depths in one pass - # (turns 5–8 were depths 3–6 at that render; only turn 9 at depth 2 stays). - ledger.record_prompt_tokens(40_000) # 40% >= 35% - assert ledger.effective_image_scrub_depth == 3 - _play_turn(ledger, 10, prev_key="step-9") - assert _image_turns(ledger) == [9] - - # Back under the gate: nothing is restored; new turns accumulate again - # until four historical screenshots are in view. - ledger.record_prompt_tokens(20_000) - assert ledger.effective_image_scrub_depth == 6 - _play_turn(ledger, 11, prev_key="step-10") - assert _image_turns(ledger) == [9, 10] - _play_turn(ledger, 12, prev_key="step-11") - assert _image_turns(ledger) == [9, 10, 11] - _play_turn(ledger, 13, prev_key="step-12") - assert _image_turns(ledger) == [9, 10, 11, 12] - _play_turn(ledger, 14, prev_key="step-13") # turn 9 reaches depth 6 - assert _image_turns(ledger) == [10, 11, 12, 13] - - -def test_no_band_configuration_keeps_the_fixed_depth(): - ledger = TranscriptLedger(step_memory=_service(), image_scrub_depth=3) - ledger.record_prompt_tokens(1) - assert ledger.effective_image_scrub_depth == 3 - relaxed_only = TranscriptLedger( - step_memory=_service(), image_scrub_depth=3, image_scrub_depth_relaxed=6 - ) - assert relaxed_only.effective_image_scrub_depth == 3 # no gate → never relaxed - - -def test_scrub_edge_runs_before_the_chunker_at_render(): - """The chunker sees the active window after the scrub edge advanced, so a - chunk closing over turns older than the floor never captures a live - screenshot in its source transcript.""" - ledger = _banded_ledger() - seen: list[list[int]] = [] - - class Probe: - def on_render(self, led): - seen.append(_image_turns(led)) - - ledger.attach_chunker(Probe()) - for i in range(1, 9): - _play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None) - # At the render of turn 8 the scrub edge (depth 6) had already resolved - # turn 3 (depth 6); the probe saw turns 4–7 with images, never turn 3. - assert seen[-1] == [4, 5, 6, 7] - - -# --------------------------------------------------------------------------- -# Ephemeral blocks and stable repeated renders -# --------------------------------------------------------------------------- - - -def _ready_service(n: int) -> StepMemoryService: - service = _service() - for i in range(1, n + 1): - service._step_inputs[f"step-{i}"] = {"step_number": i} - service._summaries[f"step-{i}"] = f"Visual transition {i}." - return service - - -def test_ephemeral_blocks_vanish_at_the_text_edge_and_not_before(): - """(ii) Per-turn notices marked through ``mark_ephemeral`` stay only while - the observation is the live tail; they are deleted at the text edge - together with the strip, before the screenshot is resolved at K.""" - from artemis.memory.transcript import EPHEMERAL_BLOCKS_KEY, mark_ephemeral - - ledger = TranscriptLedger(step_memory=_ready_service(6), image_scrub_depth=3) - - def turn_with_notice(i: int) -> list: - turn = _turn(i) - obs = turn[0] - obs.content = [ - obs.content[0], - {"type": "text", "text": f"[Reminder for turn {i}: state your reasoning]"}, - *obs.content[1:], - {"type": "text", "text": f"[User guidance for turn {i}]"}, - ] - mark_ephemeral(obs, [1, len(obs.content) - 1]) - return turn - - ledger.commit_staged() - ledger.render([turn_with_notice(1)[0]]) - ledger.stage_turn(turn_with_notice(1)) - for i in range(2, 4): - ledger.commit_staged(step_key=f"step-{i - 1}") - ledger.render([_observation(i)]) - ledger.stage_turn(turn_with_notice(i)) - - obs1 = ledger.active_messages[0] - # At the render of turn 3, turn 1 is depth 3 (K): rewritten and frozen. - assert obs1.content == [ - {"type": "text", "text": "# CURRENT OBSERVATION [T+00:01]"}, - {"type": "text", "text": "--- Historical Visual Transition ---\nVisual transition 1."}, - ] - assert EPHEMERAL_BLOCKS_KEY not in obs1.additional_kwargs - # Turn 2 is depth 2: past the text edge, its notices are gone while the - # screenshot is still there; the consumed indices are cleared. - obs2 = next(m for m in ledger.active_messages if "OBSERVATION [T+00:02]" in str(m.content)) - assert "[Reminder for turn 2: state your reasoning]" not in str(obs2.content) - assert "[User guidance for turn 2]" not in str(obs2.content) - assert _has_image(obs2) - assert EPHEMERAL_BLOCKS_KEY not in obs2.additional_kwargs - - -def test_rendering_past_the_edge_is_byte_identical_and_prefix_stable(): - """(iii) A render with nothing new is byte-identical to the previous one, - and across turns exactly one message — the observation reaching depth K — - differs from the previous render's prefix, so the request prefix before - it is what the provider's prompt cache already holds.""" - ledger = TranscriptLedger(step_memory=_ready_service(12), image_scrub_depth=3) - ledger.set_static_prefix([SystemMessage(content="STATIC")]) - - def snapshot(rendered: list) -> list[str]: - return [_fingerprint(m) for m in rendered[:-1]] # everything but the live tail - - previous: list[str] = [] - for i in range(1, 11): - rendered = _play_turn( - ledger, - i, - prev_key=f"step-{i - 1}" if i > 1 else None, - prev_result={"status": "dispatched"} if i > 1 else None, - ) - current = snapshot(rendered) - changed = [idx for idx, fp in enumerate(previous) if current[idx] != fp] - if i >= 3: - # The live tail is depth 1 and committed turn i-1 depth 2, so the - # turn reaching depth 3 is i-2; its observation heads its span. - expected = next( - idx - for idx, m in enumerate(rendered[:-1]) - if f"OBSERVATION [T+00:0{(i - 2) % 10}]" in str(m.content) - ) - assert changed == [expected], (i, changed) - else: - assert changed == [], (i, changed) - previous = current - - # Same state, rendered again: byte-identical, including the tail. - tail = [_observation(11)] - again = [_fingerprint(m) for m in ledger.render(tail)] - assert again == [_fingerprint(m) for m in ledger.render(tail)] - assert again[:-1] == previous - - -# --------------------------------------------------------------------------- -# Silent-turn flag -# --------------------------------------------------------------------------- - - -def _commit(messages: list) -> TranscriptLedger: - ledger = TranscriptLedger(step_memory=_service()) - ledger.stage_turn(messages) - ledger.commit_staged(step_key="s1") - return ledger - - -def test_last_turn_silent_when_ai_messages_show_no_text(): - click = [{"name": "click", "args": {"target": 1}, "id": "c1", "type": "tool_call"}] - assert _commit([_observation(1), AIMessage(content="", tool_calls=click)]).last_turn_silent - assert _commit([_observation(1), AIMessage(content=" \n", tool_calls=click)]).last_turn_silent - assert _commit( - [ - _observation(1), - AIMessage(content=[{"type": "thinking", "thinking": "hidden"}], tool_calls=click), - ] - ).last_turn_silent - assert _commit( - [ - _observation(1), - AIMessage(content=[{"type": "text", "text": " "}], tool_calls=click), - ToolMessage(tool_call_id="c1", content="Action Recorded"), - AIMessage(content=[], tool_calls=click), - ] - ).last_turn_silent - - -def test_last_turn_not_silent_with_visible_text_or_without_ai_message(): - click = [{"name": "click", "args": {"target": 1}, "id": "c1", "type": "tool_call"}] - assert not _commit( - [_observation(1), AIMessage(content="I tap it.", tool_calls=click)] - ).last_turn_silent - assert not _commit( - [ - _observation(1), - AIMessage( - content=[{"type": "thinking", "thinking": "x"}, {"type": "text", "text": "Tap."}], - tool_calls=click, - ), - ] - ).last_turn_silent - # One silent call followed by a spoken one: the turn spoke. - assert not _commit( - [ - _observation(1), - AIMessage(content="", tool_calls=click), - ToolMessage(tool_call_id="c1", content="ok"), - AIMessage(content="Now the real move.", tool_calls=click), - ] - ).last_turn_silent - # No AI message at all is not a silent turn. - assert not _commit([_observation(1)]).last_turn_silent - # A tool result with empty text does not count as an AI message. - assert not _commit( - [_observation(1), ToolMessage(tool_call_id="c1", content="")] - ).last_turn_silent - - -def test_last_turn_silent_tracks_each_commit(): - ledger = TranscriptLedger(step_memory=_service()) - click = [{"name": "click", "args": {}, "id": "c1", "type": "tool_call"}] - assert ledger.last_turn_silent is False - ledger.stage_turn([_observation(1), AIMessage(content="", tool_calls=click)]) - ledger.commit_staged(step_key="s1") - assert ledger.last_turn_silent is True - ledger.stage_turn([_observation(2), AIMessage(content="spoken", tool_calls=click)]) - ledger.commit_staged(step_key="s2") - assert ledger.last_turn_silent is False - - -def test_last_turn_silent_recognises_streamed_ai_message_chunks(): - """Streamed replies are AIMessageChunk (type "AIMessageChunk"), not "ai".""" - from langchain_core.messages import AIMessageChunk, HumanMessage - - from artemis.memory.transcript import TranscriptLedger, render_turn_transcript - - ledger = TranscriptLedger() - ledger.stage_turn([HumanMessage(content="obs"), AIMessageChunk(content=[])]) - ledger.commit_staged(step_key=None, validator_result=None) - assert ledger.last_turn_silent is True - - ledger.stage_turn([HumanMessage(content="obs"), AIMessageChunk(content="I see the list.")]) - ledger.commit_staged(step_key=None, validator_result=None) - assert ledger.last_turn_silent is False - - rendered = render_turn_transcript([AIMessageChunk(content="I see the list.")]) - assert rendered.startswith("[operator]") diff --git a/tests/unit/runtime/test_awake_service.py b/tests/unit/runtime/test_awake_service.py index 9956f5d2c..6c7d4b598 100644 --- a/tests/unit/runtime/test_awake_service.py +++ b/tests/unit/runtime/test_awake_service.py @@ -159,6 +159,15 @@ def test_disconnect_reconciliation_stops_heartbeat_and_allows_reenrollment( assert configure.call_count == 2 +@patch.dict( + "os.environ", + { + "ARTEMIS_KEEP_DEVICE_AWAKE": "true", + "ARTEMIS_CLOUD_MODE": "", + "ARTEMIS_DEVICE_ID": "", + "ADB_DEVICE_SERIAL": "", + }, +) @patch("artemis.runtime.device_pool.device_pool.get_claimed_serials") @patch("artemis.runtime.awake_service.AdbClient") def test_discovery_keeps_only_pool_claimed_devices(adb_client, claimed): @@ -174,6 +183,15 @@ def test_discovery_keeps_only_pool_claimed_devices(adb_client, claimed): assert _discover_connected_device_ids() == ["device-1", "device-2"] +@patch.dict( + "os.environ", + { + "ARTEMIS_KEEP_DEVICE_AWAKE": "true", + "ARTEMIS_CLOUD_MODE": "", + "ARTEMIS_DEVICE_ID": "", + "ADB_DEVICE_SERIAL": "", + }, +) @patch( "artemis.runtime.device_pool.device_pool.get_claimed_serials", return_value=set(), diff --git a/tests/unit/runtime/test_cancel_requests.py b/tests/unit/runtime/test_cancel_requests.py deleted file mode 100644 index 433a0a8d8..000000000 --- a/tests/unit/runtime/test_cancel_requests.py +++ /dev/null @@ -1,93 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -import json -import time - -import pytest - -from artemis.runtime import cancel_requests as cr - - -@pytest.fixture(autouse=True) -def isolated_dir(tmp_path, monkeypatch): - directory = tmp_path / "cancel-requests" - monkeypatch.setattr(cr, "get_temp_dir", lambda _subfolder=None: directory) - return directory - - -def test_session_marker_roundtrip(isolated_dir): - assert not cr.is_cancel_requested(session_id="s-1") - - written = cr.request_cancel(session_id="s-1", reason="test") - - assert len(written) == 1 - assert written[0].parent == isolated_dir - assert cr.is_cancel_requested(session_id="s-1") - - cr.clear_cancel_request(session_id="s-1") - - assert not cr.is_cancel_requested(session_id="s-1") - - -def test_pid_marker_ignores_recycled_pid(): - cr.request_cancel(pid=4242, process_created_at=1000.0) - - assert cr.is_cancel_requested(pid=4242, process_created_at=1000.4) - assert not cr.is_cancel_requested(pid=4242, process_created_at=2000.0) - - # A marker without a creation time applies to whoever holds the pid. - cr.request_cancel(pid=4343) - assert cr.is_cancel_requested(pid=4343, process_created_at=5.0) - - -def test_expired_markers_are_ignored_and_purged(): - path = cr.request_cancel(session_id="old")[0] - payload = json.loads(path.read_text(encoding="utf-8")) - payload["requested_at"] = time.time() - cr.MARKER_TTL_SECONDS - 5 - path.write_text(json.dumps(payload), encoding="utf-8") - - assert not cr.is_cancel_requested(session_id="old") - assert cr.purge_expired() == 1 - assert not path.exists() - - -def test_unreadable_pid_marker_is_not_trusted(isolated_dir): - isolated_dir.mkdir(parents=True, exist_ok=True) - (isolated_dir / "pid-77.cancel").write_text("{not json", encoding="utf-8") - - assert not cr.is_cancel_requested(pid=77, process_created_at=1.0) - - -@pytest.mark.asyncio -async def test_watch_delivers_once_and_clears_marker(): - delivered: list[int] = [] - watcher = asyncio.create_task( - cr.watch_for_cancel_request( - lambda: delivered.append(1), - session_id="w-1", - pid=0, - poll_seconds=0.05, - ) - ) - await asyncio.sleep(0.12) - assert not delivered - assert not watcher.done() - - cr.request_cancel(session_id="w-1") - - assert await asyncio.wait_for(watcher, timeout=2) is True - assert delivered == [1] - assert not cr.is_cancel_requested(session_id="w-1") diff --git a/tests/unit/runtime/test_daemon_client.py b/tests/unit/runtime/test_daemon_client.py deleted file mode 100644 index 67a90d3bb..000000000 --- a/tests/unit/runtime/test_daemon_client.py +++ /dev/null @@ -1,202 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json -from unittest.mock import MagicMock, patch -import pytest - -from artemis.runtime.daemon_client import ( - ensure_daemon_running, - is_daemon_running, - is_standalone_forced, - spawn_daemon, - submit_task_to_daemon, -) - - -def test_is_standalone_forced(monkeypatch): - monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False) - assert is_standalone_forced() is False - - monkeypatch.setenv("ARTEMIS_STANDALONE", "1") - assert is_standalone_forced() is True - - monkeypatch.setenv("ARTEMIS_STANDALONE", "true") - assert is_standalone_forced() is True - - monkeypatch.setenv("ARTEMIS_STANDALONE", "0") - assert is_standalone_forced() is False - - -def test_is_daemon_running_success(): - mock_resp = MagicMock() - mock_resp.status = 200 - mock_resp.__enter__.return_value = mock_resp - - with patch("urllib.request.urlopen", return_value=mock_resp): - assert is_daemon_running() is True - - -def test_is_daemon_running_failure(): - with patch("urllib.request.urlopen", side_effect=OSError("Connection refused")): - assert is_daemon_running() is False - - -def test_spawn_daemon(tmp_path): - import sys - - mock_proc = MagicMock() - mock_proc.pid = 54321 - with ( - patch("subprocess.Popen", return_value=mock_proc) as mock_popen, - patch( - "artemis.runtime.daemon_client.daemon_log_path", - return_value=tmp_path / "daemon-8000.log", - ), - ): - proc = spawn_daemon(host="127.0.0.1", port=9123) - assert proc is mock_proc - assert mock_popen.called - # The daemon must launch via `python -m`, never through the console-script - # shim (a resident artemis.exe would block uv sync reinstalls on Windows). - cmd = mock_popen.call_args.args[0] - assert cmd[0] == sys.executable - assert cmd[1:3] == ["-m", "apps.admin_console.server"] - assert cmd[3:] == ["--host", "127.0.0.1", "--port", "9123"] - - -def test_ensure_daemon_running_when_already_active(): - with patch("artemis.runtime.daemon_client.is_daemon_running", return_value=True): - ok, base_url = ensure_daemon_running(host="127.0.0.1", port=8000) - assert ok is True - assert base_url == "http://127.0.0.1:8000" - - -def test_ensure_daemon_running_standalone_override(monkeypatch): - monkeypatch.setenv("ARTEMIS_STANDALONE", "1") - ok, base_url = ensure_daemon_running() - assert ok is False - assert base_url is None - - -def test_ensure_daemon_running_auto_spawns(): - with ( - patch("artemis.runtime.daemon_client.is_daemon_running", side_effect=[False, True]), - patch("artemis.runtime.daemon_client.spawn_daemon") as mock_spawn, - ): - ok, base_url = ensure_daemon_running(timeout=1.0, wait_ready=True) - assert ok is True - assert base_url == "http://127.0.0.1:8000" - mock_spawn.assert_called_once() - - -def test_submit_task_to_daemon(): - mock_resp = MagicMock() - mock_resp.status = 200 - mock_resp.read.return_value = b'{"status": "queued", "tasks": [{"session_id": "test-sess"}]}' - mock_resp.__enter__.return_value = mock_resp - - with patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen: - res = submit_task_to_daemon( - "test goal", - profile="flash", - device_serial="pixel-8", - session_id="custom-sess-789", - ingress="mcp", - conversation_id="conv-123", - ) - assert res is not None - assert res["status"] == "queued" - assert res["tasks"][0]["session_id"] == "test-sess" - - # Verify request payload - req = mock_urlopen.call_args[0][0] - data = json.loads(req.data.decode("utf-8")) - assert data["goal"] == "test goal" - assert data["profile"] == "flash" - assert data["device_serial"] == "pixel-8" - assert data["session_id"] == "custom-sess-789" - assert data["ingress"] == "mcp" - assert data["conversation_id"] == "conv-123" - - -def _daemon_ok_response(body: bytes) -> MagicMock: - mock_resp = MagicMock() - mock_resp.status = 200 - mock_resp.read.return_value = body - mock_resp.__enter__.return_value = mock_resp - return mock_resp - - -def test_submit_task_to_daemon_forwards_pro_tuning_knobs(): - mock_resp = _daemon_ok_response(b'{"status": "queued", "tasks": [{"session_id": "s1"}]}') - with patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen: - submit_task_to_daemon( - "audit goal", - profile="pro", - verification_level="strict", - explorer_mode="ultra", - ) - data = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8")) - # JSON field names match RunRequest on /api/run. - assert data["verification_level"] == "strict" - assert data["explorer_mode"] == "ultra" - - -def test_submit_task_to_daemon_pro_tuning_defaults_to_null(): - mock_resp = _daemon_ok_response(b'{"status": "queued", "tasks": [{"session_id": "s1"}]}') - with patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen: - submit_task_to_daemon("plain goal", profile="flash") - data = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8")) - assert data["verification_level"] is None - assert data["explorer_mode"] is None - - -def test_submit_batch_to_daemon_forwards_pro_tuning_knobs(): - from artemis.runtime.daemon_client import submit_batch_to_daemon - - mock_resp = _daemon_ok_response( - b'{"status": "queued", "tasks": [{"session_id": "a"}, {"session_id": "b"}]}' - ) - with patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen: - res = submit_batch_to_daemon( - ["goal a", "goal b"], - profile="pro", - verification_level="checkpoints", - explorer_mode="pro", - ) - data = json.loads(mock_urlopen.call_args[0][0].data.decode("utf-8")) - assert res is not None and len(res["tasks"]) == 2 - assert data["goals"] == ["goal a", "goal b"] - assert data["verification_level"] == "checkpoints" - assert data["explorer_mode"] == "pro" - - -def test_stop_task_on_daemon(): - from artemis.runtime.daemon_client import stop_task_on_daemon - - mock_resp = MagicMock() - mock_resp.status = 200 - mock_resp.read.return_value = b'{"status": "stopped", "session_id": "test-sess-stop"}' - mock_resp.__enter__.return_value = mock_resp - - with patch("urllib.request.urlopen", return_value=mock_resp) as mock_urlopen: - assert stop_task_on_daemon("test-sess-stop") is True - req = mock_urlopen.call_args[0][0] - data = json.loads(req.data.decode("utf-8")) - assert data["session_id"] == "test-sess-stop" - - mock_resp.read.return_value = b'{"status": "no_running_task"}' - with patch("urllib.request.urlopen", return_value=mock_resp): - assert stop_task_on_daemon("non-existent-sess") is False diff --git a/tests/unit/runtime/test_device_lock.py b/tests/unit/runtime/test_device_lock.py index 008f4256a..f2b7dd82e 100644 --- a/tests/unit/runtime/test_device_lock.py +++ b/tests/unit/runtime/test_device_lock.py @@ -418,3 +418,18 @@ def test_safe_unlink_and_replace_retry(tmp_path): src.write_text("world") assert DeviceExecutionLock._safe_replace(src, dst) is True assert dst.read_text() == "world" + + +def test_reserve_prefixes_increase_even_when_the_clock_stalls(tmp_path): + """FIFO order lives in the filename prefix; two reservations inside one + coarse clock tick must not fall back to random uuid ordering.""" + queue_dir = tmp_path / "artemis-global-device.queue" + + first_ticket = DeviceExecutionLock.reserve("first submitted task") + second_ticket = DeviceExecutionLock.reserve("second submitted task") + + names = sorted(path.name for path in queue_dir.glob("*.wait")) + assert len(names) == 2 + assert names[0].endswith(f"-{first_ticket}.wait") + assert names[1].endswith(f"-{second_ticket}.wait") + assert names[0][:20] != names[1][:20] diff --git a/tests/unit/runtime/test_server_process_lifecycle.py b/tests/unit/runtime/test_server_process_lifecycle.py deleted file mode 100644 index 606bc8c07..000000000 --- a/tests/unit/runtime/test_server_process_lifecycle.py +++ /dev/null @@ -1,268 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for Artemis Server Lifecycle Management and API Endpoints.""" - -import asyncio -import json -import os -from unittest.mock import MagicMock, patch - -import pytest -from httpx import ASGITransport, AsyncClient - -from apps.admin_console import server as server_module -from apps.admin_console.server import LIFECYCLE_TOKEN, app -from artemis.runtime.server_lifecycle import ( - clear_server_info, - find_server_pids, - get_server_status, - is_port_in_use, - read_server_info, - request_graceful_shutdown, - stop_server, - write_server_info, -) - - -def test_write_read_clear_server_info(tmp_path, monkeypatch): - """Verify write, read, and clear round-trip for server metadata file.""" - fake_info_file = tmp_path / "test_server.json" - monkeypatch.setattr( - "artemis.runtime.server_lifecycle.get_server_info_file", lambda: fake_info_file - ) - - # Initial read should be None - assert read_server_info() is None - - # Write info - written = write_server_info(port=8888, host="127.0.0.1", pid=99999) - assert written == fake_info_file - assert fake_info_file.exists() - - # Read back - info = read_server_info() - assert info is not None - assert info["pid"] == 99999 - assert info["port"] == 8888 - assert info["host"] == "127.0.0.1" - assert "started_at" in info - - # A stop targeting another port must not erase this server's metadata. - clear_server_info(port=9999) - assert fake_info_file.exists() - - # Clear matching info - clear_server_info(port=8888) - assert not fake_info_file.exists() - assert read_server_info() is None - - -def test_find_server_pids_from_metadata(tmp_path, monkeypatch): - """Verify find_server_pids discovers PID saved in metadata file.""" - fake_info_file = tmp_path / "test_server.json" - fake_info_file.write_text( - json.dumps({"pid": os.getpid(), "port": 8000, "started_at": 100.0}), - encoding="utf-8", - ) - monkeypatch.setattr( - "artemis.runtime.server_lifecycle.get_server_info_file", lambda: fake_info_file - ) - monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", lambda port, **k: True) - - pids = find_server_pids(8000) - assert os.getpid() in pids - - -def test_get_server_status_offline(monkeypatch): - """Verify get_server_status returns correct structure when server is offline.""" - monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", lambda port, **k: False) - monkeypatch.setattr("artemis.runtime.server_lifecycle.find_server_pids", lambda port: []) - monkeypatch.setattr("artemis.runtime.server_lifecycle.read_server_info", lambda: None) - - status = get_server_status(8000) - assert status["running"] is False - assert status["port"] == 8000 - assert status["pids"] == [] - assert status["url"] is None - - -def test_get_server_status_online(monkeypatch): - """Verify get_server_status returns correct structure when server is online.""" - monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", lambda port, **k: True) - monkeypatch.setattr("artemis.runtime.server_lifecycle.find_server_pids", lambda port: [12345]) - monkeypatch.setattr( - "artemis.runtime.server_lifecycle.read_server_info", - lambda: {"pid": 12345, "port": 8000, "started_at": 1000.0}, - ) - - status = get_server_status(8000) - assert status["running"] is True - assert status["port"] == 8000 - assert status["pids"] == [12345] - assert status["active_pid"] == 12345 - assert status["url"] == "http://localhost:8000" - assert status["admin_url"] == "http://localhost:8000/admin" - assert status["uptime_seconds"] is not None - - -def test_stop_server_when_not_running(monkeypatch): - """Verify stop_server returns early when no server is running.""" - monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", lambda port, **k: False) - monkeypatch.setattr("artemis.runtime.server_lifecycle.find_server_pids", lambda port: []) - - success, msg, stopped = stop_server(8000) - assert success is True - assert stopped == [] - assert "No active server" in msg - - -def test_server_main_parses_non_default_bind_address(monkeypatch): - run_server = MagicMock() - monkeypatch.setattr(server_module, "run_ui_server", run_server) - - server_module.main(["--host", "127.0.0.1", "--port", "9123"]) - - run_server.assert_called_once_with(host="127.0.0.1", port=9123) - - -def test_request_graceful_shutdown_uses_metadata_token(monkeypatch): - response = MagicMock(status=202) - response.__enter__.return_value = response - monkeypatch.setattr( - "artemis.runtime.server_lifecycle.read_server_info", - lambda: {"port": 9123, "lifecycle_token": "local-secret"}, - ) - urlopen = MagicMock(return_value=response) - monkeypatch.setattr("artemis.runtime.server_lifecycle.urllib.request.urlopen", urlopen) - - assert request_graceful_shutdown(9123) is True - request = urlopen.call_args.args[0] - assert request.full_url == "http://127.0.0.1:9123/api/system/shutdown" - assert request.get_header("X-artemis-lifecycle-token") == "local-secret" - - -def test_stop_server_prefers_graceful_shutdown(monkeypatch): - monkeypatch.setattr("artemis.runtime.server_lifecycle.find_server_pids", lambda port: [12345]) - monkeypatch.setattr( - "artemis.runtime.server_lifecycle.request_graceful_shutdown", lambda port, timeout: True - ) - monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", lambda port: False) - monkeypatch.setattr("artemis.runtime.server_lifecycle._any_pid_alive", lambda pids: False) - terminate = MagicMock() - monkeypatch.setattr( - "artemis.runtime.server_lifecycle.ProcessSupervisor.terminate_tree", terminate - ) - monkeypatch.setattr( - "artemis.runtime.server_lifecycle.DeviceExecutionLock.cleanup_stale_locks", lambda: 0 - ) - monkeypatch.setattr("artemis.runtime.server_lifecycle.clear_server_info", lambda **kwargs: None) - - success, msg, stopped = stop_server(9123) - - assert success is True - assert stopped == [12345] - assert "gracefully" in msg - terminate.assert_not_called() - - -def test_stop_server_terminates_tree(monkeypatch): - """Verify stop_server calls ProcessSupervisor.terminate_tree and returns stopped pids.""" - terminated_pids = [] - - monkeypatch.setattr("artemis.runtime.server_lifecycle.find_server_pids", lambda port: [12345]) - port_in_use = [True, False] - - def mock_in_use(port, **kwargs): - if port_in_use: - return port_in_use.pop(0) - return False - - monkeypatch.setattr("artemis.runtime.server_lifecycle.is_port_in_use", mock_in_use) - monkeypatch.setattr( - "artemis.runtime.server_lifecycle.request_graceful_shutdown", lambda port, timeout: False - ) - monkeypatch.setattr( - "artemis.runtime.supervisor.ProcessSupervisor.terminate_tree", - lambda pid, timeout_seconds=4.0: terminated_pids.append(pid), - ) - monkeypatch.setattr( - "artemis.runtime.device_lock.DeviceExecutionLock.cleanup_stale_locks", - lambda: 1, - ) - reconcile = MagicMock(return_value=1) - monkeypatch.setattr("artemis.runtime.server_lifecycle._reconcile_orphaned_sessions", reconcile) - - success, msg, stopped = stop_server(8000) - assert success is True - assert stopped == [12345] - assert 12345 in terminated_pids - assert "stopped" in msg.lower() - assert "Reconciled 1 interrupted session" in msg - reconcile.assert_called_once_with() - - -@pytest.mark.asyncio -async def test_api_server_status(): - """Verify GET /api/system/server-status returns server runtime metadata.""" - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://localhost") as client: - response = await client.get("/api/system/server-status") - assert response.status_code == 200 - data = response.json() - assert "port" in data - assert "running" in data - assert "current_pid" in data - - -@pytest.mark.asyncio -async def test_api_restart_endpoint(): - """Verify POST /api/system/restart accepts request and schedules restart.""" - with patch("threading.Thread") as mock_thread: - mock_instance = MagicMock() - mock_thread.return_value = mock_instance - - async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://localhost" - ) as client: - response = await client.post("/api/system/restart") - assert response.status_code == 200 - data = response.json() - assert data["status"] == "restarting" - assert ( - "reconnection" in data["message"].lower() or "restarting" in data["message"].lower() - ) - mock_thread.assert_called_once() - mock_instance.start.assert_called_once() - - -@pytest.mark.asyncio -async def test_api_shutdown_endpoint(): - """Verify shutdown is authenticated and drives Uvicorn's graceful exit flag.""" - fake_server = MagicMock(should_exit=False) - app.state.uvicorn_server = fake_server - - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://localhost") as client: - denied = await client.post("/api/system/shutdown") - assert denied.status_code == 403 - - response = await client.post( - "/api/system/shutdown", - headers={"X-Artemis-Lifecycle-Token": LIFECYCLE_TOKEN}, - ) - assert response.status_code == 202 - assert response.json()["status"] == "shutting_down" - await asyncio.sleep(0.1) - - assert fake_server.should_exit is True - app.state.uvicorn_server = None diff --git a/tests/unit/runtime/test_supervisor.py b/tests/unit/runtime/test_supervisor.py deleted file mode 100644 index 438157ffc..000000000 --- a/tests/unit/runtime/test_supervisor.py +++ /dev/null @@ -1,27 +0,0 @@ -from unittest.mock import MagicMock, patch - -from artemis.runtime.supervisor import ProcessSupervisor - - -def test_verified_termination_rejects_reused_pid(): - process = MagicMock() - process.create_time.return_value = 200.0 - with ( - patch("psutil.Process", return_value=process), - patch.object(ProcessSupervisor, "terminate_tree") as terminate_tree, - ): - assert ProcessSupervisor.terminate_tree_verified(1234, 100.0) is False - - terminate_tree.assert_not_called() - - -def test_verified_termination_stops_matching_process_tree(): - process = MagicMock() - process.create_time.return_value = 100.0 - with ( - patch("psutil.Process", return_value=process), - patch.object(ProcessSupervisor, "terminate_tree", return_value=True) as terminate_tree, - ): - assert ProcessSupervisor.terminate_tree_verified(1234, 100.0) is True - - terminate_tree.assert_called_once_with(1234, timeout_seconds=3.0) diff --git a/tests/unit/runtime/test_trace_store.py b/tests/unit/runtime/test_trace_store.py deleted file mode 100644 index fb5c2953c..000000000 --- a/tests/unit/runtime/test_trace_store.py +++ /dev/null @@ -1,230 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for the shared trace store (artemis.runtime.trace_store).""" - -import glob -import logging -import os -import shutil -import tempfile -import threading -import uuid -import pytest - -from artemis.runtime import trace_store - - -@pytest.fixture -def temp_trace_env(monkeypatch): - temp_dir = tempfile.mkdtemp() - monkeypatch.setattr(trace_store, "TRACES_DIR", temp_dir) - yield temp_dir - shutil.rmtree(temp_dir, ignore_errors=True) - - -def test_init_and_read_status(temp_trace_env): - trace_id = str(uuid.uuid4()) - task_desc = "Test open settings" - model = "Flash" - conv_id = "test-conv-123" - - init_res = trace_store.init_trace(trace_id, task_desc, model, conv_id) - assert init_res["trace_id"] == trace_id - assert init_res["status"] == "running" - assert init_res["model"] == "Flash" - - status_data = trace_store.read_status(trace_id) - assert status_data is not None - assert status_data["trace_id"] == trace_id - assert status_data["task_desc"] == task_desc - assert status_data["conversation_id"] == conv_id - - -def test_update_trace_status(temp_trace_env): - trace_id = str(uuid.uuid4()) - trace_store.init_trace(trace_id, "Test task", "Pro", "conv-456") - - updated = trace_store.update_trace_status( - trace_id=trace_id, - status="completed", - result={"success": True}, - ) - assert updated is not None - assert updated["status"] == "completed" - assert updated["result"] == {"success": True} - assert updated["end_time"] is not None - - failed = trace_store.update_trace_status( - trace_id=trace_id, - status="failed", - error="App crashed", - ) - assert failed is not None - assert failed["status"] == "failed" - assert failed["error"] == "App crashed" - - -def test_update_trace_status_normalizes_success_alias(temp_trace_env): - trace_id = str(uuid.uuid4()) - trace_store.init_trace(trace_id, "Test task", "Flash", "conv-789") - - updated = trace_store.update_trace_status(trace_id=trace_id, status="success") - assert updated is not None - assert updated["status"] == "completed" - assert updated["end_time"] is not None - - persisted = trace_store.read_status(trace_id) - assert persisted["status"] == "completed" - - -def test_read_nonexistent_status(temp_trace_env): - non_existent = str(uuid.uuid4()) - assert trace_store.read_status(non_existent) is None - - -def test_leftover_atomic_temp_file_does_not_affect_reads(temp_trace_env): - """A crash between temp-write and replace leaves a .tmp file that readers ignore.""" - trace_id = str(uuid.uuid4()) - trace_store.init_trace(trace_id, "Crash sim", "Flash") - - status_path = trace_store.get_status_path(trace_id) - # Simulate a writer that crashed after writing its temp file. - with open(f"{status_path}.{uuid.uuid4().hex}.tmp", "w", encoding="utf-8") as f: - f.write('{"status": "half-writ') - - data = trace_store.read_status(trace_id) - assert data is not None - assert data["status"] == "running" - assert data["task_desc"] == "Crash sim" - - -def test_failed_atomic_replace_preserves_original(temp_trace_env): - """If the atomic replace fails, the previous status.json stays intact.""" - from unittest.mock import patch - - trace_id = str(uuid.uuid4()) - trace_store.init_trace(trace_id, "Original", "Flash") - - def broken_replace(src, dst): - raise OSError("simulated replace failure") - - with patch.object(trace_store, "_replace_with_retry", broken_replace): - with pytest.raises(OSError): - trace_store.write_status(trace_id, {"status": "clobbered"}) - - data = trace_store.read_status(trace_id) - assert data is not None - assert data["task_desc"] == "Original" - # The failed writer's temp file must not linger. - assert glob.glob(os.path.join(trace_store.get_trace_dir(trace_id), "*.tmp")) == [] - - -def test_corrupt_status_is_quarantined_and_read_returns_none(temp_trace_env, caplog): - trace_id = str(uuid.uuid4()) - trace_store.init_trace(trace_id, "Corrupt me", "Flash") - - status_path = trace_store.get_status_path(trace_id) - with open(status_path, "w", encoding="utf-8") as f: - f.write('{"status": "running", "trace_id"') # torn write - - with caplog.at_level(logging.WARNING, logger="artemis.runtime.trace_store"): - assert trace_store.read_status(trace_id) is None - - assert any("Corrupt status.json" in rec.message for rec in caplog.records) - assert os.path.exists(f"{status_path}.corrupt") - assert not os.path.exists(status_path) - - -def test_missing_status_stays_silent(temp_trace_env, caplog): - with caplog.at_level(logging.WARNING, logger="artemis.runtime.trace_store"): - assert trace_store.read_status(str(uuid.uuid4())) is None - assert caplog.records == [] - - -def test_update_on_corrupt_status_logs_and_drops(temp_trace_env, caplog): - trace_id = str(uuid.uuid4()) - trace_store.init_trace(trace_id, "Corrupt update", "Flash") - - status_path = trace_store.get_status_path(trace_id) - with open(status_path, "w", encoding="utf-8") as f: - f.write("not json at all") - - with caplog.at_level(logging.WARNING, logger="artemis.runtime.trace_store"): - assert trace_store.update_trace_status(trace_id, "completed") is None - - assert any("Dropping status update" in rec.message for rec in caplog.records) - # Updating a trace that never existed stays a silent no-op. - caplog.clear() - with caplog.at_level(logging.WARNING, logger="artemis.runtime.trace_store"): - assert trace_store.update_trace_status(str(uuid.uuid4()), "completed") is None - assert not any("Dropping status update" in rec.message for rec in caplog.records) - - -def test_concurrent_read_modify_write_does_not_lose_updates(temp_trace_env, caplog): - """Interleaved RMW updates from two writers must not lose each other's fields.""" - trace_id = str(uuid.uuid4()) - trace_store.init_trace(trace_id, "Concurrency", "Flash") - iterations = 25 - failures: list[BaseException] = [] - - def set_errors(): - try: - for i in range(iterations): - assert ( - trace_store.update_trace_status(trace_id, "running", error=f"err-{i}") - is not None - ) - except BaseException as exc: # noqa: BLE001 - surfaced in the main thread - failures.append(exc) - - def set_serials(): - try: - for i in range(iterations): - assert trace_store.update_trace_device_serial(trace_id, f"serial-{i}") is not None - except BaseException as exc: # noqa: BLE001 - surfaced in the main thread - failures.append(exc) - - threads = [threading.Thread(target=set_errors), threading.Thread(target=set_serials)] - with caplog.at_level(logging.WARNING, logger="artemis.runtime.trace_store"): - for t in threads: - t.start() - for t in threads: - t.join(timeout=30.0) - - assert failures == [] - # The lock is bounded by design: after _LOCK_TIMEOUT_SECONDS a writer - # proceeds last-writer-wins (with a WARNING) rather than deadlocking. On a - # heavily loaded machine that documented path can be taken, and then a - # lost field is expected rather than a serialization bug. - lock_degraded = [rec.message for rec in caplog.records if "trace status lock" in rec.message] - if lock_degraded: - pytest.skip( - f"status lock degraded under load, serialization not exercised: {lock_degraded[0]}" - ) - final = trace_store.read_status(trace_id) - assert final is not None - # Every serialized RMW preserves the other writer's latest field, so both - # final values must survive regardless of which thread finished last. - assert final["error"] == f"err-{iterations - 1}" - assert final["device_serial"] == f"serial-{iterations - 1}" - assert final["status"] == "running" - - -def test_trace_paths(temp_trace_env): - trace_id = "test-paths-id" - trace_dir = trace_store.get_trace_dir(trace_id) - assert trace_store.get_trace_notes_dir(trace_id) == os.path.join(trace_dir, "notes") - assert trace_store.get_trace_stdout_log_path(trace_id) == os.path.join(trace_dir, "stdout.log") - assert trace_store.get_trace_stderr_log_path(trace_id) == os.path.join(trace_dir, "stderr.log") diff --git a/tests/unit/scripts/test_doc_table_sizes.py b/tests/unit/scripts/test_doc_table_sizes.py new file mode 100644 index 000000000..99c60ceda --- /dev/null +++ b/tests/unit/scripts/test_doc_table_sizes.py @@ -0,0 +1,68 @@ +"""Locks for the 体量 measurement in scripts/check_doc_tables.py (changelogs #45). + +The size column is a derived fact that had already drifted twice by hand-copying. +The third drift came from the environment instead of from a person: the table was +refreshed on a CRLF worktree, and every row went stale once those same files were +rewritten as LF. These tests pin the measurement to line-ending-independent bytes +and pin that a row disagreeing with its file is still reported. + +The samples are hundreds of lines wide on purpose: KB is printed to one decimal, so +a one-step difference is 102.4 bytes and a short sample measures the same either way. +The first draft of this file used a 4-line body and passed against the unnormalized +measurement - caught by mutation check, recorded in changelogs #45. +""" + +import importlib.util +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "scripts" / "check_doc_tables.py" +LINE = b"line-0000\n" # 10 bytes LF, 11 as CRLF + + +@pytest.fixture(scope="module") +def ruler(): + spec = importlib.util.spec_from_file_location("check_doc_tables", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def write_doc(tmp_path: Path, name: str, body: bytes) -> Path: + path = tmp_path / name + path.write_bytes(body) + return path + + +def test_size_does_not_track_line_endings(ruler, tmp_path): + body = LINE * 400 + lf = write_doc(tmp_path, "lf.md", body) + crlf = write_doc(tmp_path, "crlf.md", body.replace(b"\n", b"\r\n")) + assert ruler.measured([lf]) == ruler.measured([crlf]) + assert ruler.measured([lf]) == (400, "3.9") + + +def test_multi_file_row_is_a_sum_across_line_endings(ruler, tmp_path): + first = write_doc(tmp_path, "a.md", LINE * 300) + second = write_doc(tmp_path, "b.md", (LINE * 500).replace(b"\n", b"\r\n")) + lines, kb = ruler.measured([first, second]) + assert lines == 800 + # 800 rows x 10 bytes normalized, not 800 x 11: the CRs must not reach the sum + assert float(kb) == pytest.approx(8000 / 1024, abs=0.05) + + +def test_stale_row_is_reported(ruler): + text = ruler.read(ruler.DOC_README) + lines = text.split("\n") + rows = ruler.size_rows(lines) + assert rows, "the 体量 table moved - this lock would prove nothing" + cells = [c.strip() for c in lines[rows[0]].strip().strip("|").split("|")] + stale = "\n".join( + ln.replace(cells[1], "999 行 / 1.0 KB", 1) if i == rows[0] else ln + for i, ln in enumerate(lines) + ) + problems, _ = ruler.check_sizes(stale, ruler.DOC_README) + assert problems, "a row that disagrees with its file must be reported" diff --git a/tests/unit/scripts/test_spike_reset.py b/tests/unit/scripts/test_spike_reset.py new file mode 100644 index 000000000..1d7c79f56 --- /dev/null +++ b/tests/unit/scripts/test_spike_reset.py @@ -0,0 +1,55 @@ +"""Dry-run locks for scripts/spike_reset.py (G43). + +The reset procedure itself is the deliverable: these tests pin the exact adb +invocations (UI/platform commands only, never `settings put`) so the +procedure cannot drift silently. Live runs need an emulator and are covered +by the P1-live acceptance, not here. +""" + +import subprocess +import sys +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parents[3] / "scripts" / "spike_reset.py" + +EXPECTED_COMMANDS = [ + "shell am start", + "category.HOME", + "shell dumpsys window", + "shell cmd uimode night no", + "exec-out screencap -p", + "shell settings get secure ui_night_mode", +] + + +def run_dry(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT), "--dry-run", *args], + capture_output=True, + text=True, + ) + + +def test_dry_run_lists_every_step() -> None: + proc = run_dry("--task", "T2") + assert proc.returncode == 0, proc.stderr + for fragment in EXPECTED_COMMANDS: + assert fragment in proc.stdout, fragment + + +def test_dry_run_never_writes_settings() -> None: + for task in ("T1", "T2"): + proc = run_dry("--task", task) + assert proc.returncode == 0, proc.stderr + assert "settings put" not in proc.stdout + assert "never from settings" in proc.stdout + + +def test_missing_serial_fails_fast_without_dry_run(monkeypatch) -> None: + monkeypatch.delenv("ADB_DEVICE_SERIAL", raising=False) + proc = subprocess.run( + [sys.executable, str(SCRIPT), "--task", "T1"], + capture_output=True, + text=True, + ) + assert proc.returncode == 2 diff --git a/tests/unit/sdk/test_agent_config_builder_tuning.py b/tests/unit/sdk/test_agent_config_builder_tuning.py deleted file mode 100644 index ad2004112..000000000 --- a/tests/unit/sdk/test_agent_config_builder_tuning.py +++ /dev/null @@ -1,123 +0,0 @@ -"""SDK entry point for the Pro-profile tuning knobs. - -``AgentConfigBuilder.with_verification_level`` mirrors ``--verification-level`` -and ``with_pro_config(verification_level=..., explorer_mode=...)`` carries both -knobs in one call; both resolve through the single preset table in -``artemis.config``. -""" - -import pytest - -from artemis.config import VERIFICATION_LEVEL_PRESETS, checker_overrides_for_level -from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder - - -def test_with_verification_level_off_disables_the_checker(): - cfg = AgentConfigBuilder().with_verification_level("off").build() - assert cfg.disable_checker is True - - -def test_with_verification_level_final_keeps_factory_layering(): - cfg = AgentConfigBuilder().with_verification_level("final").build() - assert cfg.disable_checker is False - assert cfg.disable_midway_checks is True - assert cfg.disable_final_check is False - - -def test_with_verification_level_checkpoints_enables_midway_checks(): - cfg = AgentConfigBuilder().with_verification_level("checkpoints").build() - assert cfg.disable_checker is False - assert cfg.disable_midway_checks is False - assert cfg.disable_final_check is False - # Unspecified fields keep the artemis.jsonc values. - assert cfg.assert_failure_policy == "continue" - - -def test_with_verification_level_strict_applies_the_full_preset(): - cfg = AgentConfigBuilder().with_verification_level("strict").build() - preset = VERIFICATION_LEVEL_PRESETS["strict"] - assert cfg.disable_checker is False - assert cfg.disable_midway_checks is False - assert cfg.disable_final_check is False - assert cfg.assert_failure_policy == preset["assert_failure_policy"] - assert cfg.checkpoint_max_repairs == preset["checkpoint_max_repairs"] - assert cfg.final_check_max_attempts == preset["final_check_max_attempts"] - assert cfg.checker_max_iterations == preset["max_iterations"] - - -def test_with_verification_level_is_case_and_whitespace_insensitive(): - cfg = AgentConfigBuilder().with_verification_level(" STRICT ").build() - assert cfg.assert_failure_policy == "halt" - - -def test_with_verification_level_rejects_unknown_preset(): - builder = AgentConfigBuilder() - with pytest.raises(ValueError, match="Unknown verification level"): - builder.with_verification_level("paranoid") - # The builder is untouched by a rejected call. - assert builder.build().disable_checker is False - - -def test_with_verification_level_matches_the_shared_preset_table(): - for level in VERIFICATION_LEVEL_PRESETS: - cfg = AgentConfigBuilder().with_verification_level(level).build() - overrides = checker_overrides_for_level(level) - assert cfg.disable_checker is (not overrides["enabled"]) - if "midway_checks" in overrides: - assert cfg.disable_midway_checks is (not overrides["midway_checks"]) - - -def test_with_verification_level_is_chainable_and_explicit_checker_wins(): - cfg = AgentConfigBuilder().with_verification_level("strict").with_checker(enabled=False).build() - assert cfg.disable_checker is True - # The preset's other fields survive the later master switch. - assert cfg.assert_failure_policy == "halt" - - -def test_with_pro_config_carries_both_knobs(): - cfg = ( - AgentConfigBuilder() - .with_pro_config(verification_level="checkpoints", explorer_mode="ultra") - .build() - ) - assert cfg.disable_checker is False - assert cfg.disable_midway_checks is False - assert cfg.explorer.pro_mode == "ultra" - # Flash-profile perception is untouched by the Pro knob. - assert cfg.explorer.flash_mode == "flash" - - -def test_with_pro_config_checker_switch_wins_over_verification_level(): - cfg = AgentConfigBuilder().with_pro_config(verification_level="strict", checker=False).build() - assert cfg.disable_checker is True - - -def test_with_explorer_pro_mode_is_the_explorer_mode_knob(monkeypatch): - monkeypatch.delenv("ARTEMIS_EXPLORER_VERSION", raising=False) - cfg = AgentConfigBuilder().with_explorer(pro_mode="pro").build() - assert cfg.explorer.pro_mode == "pro" - assert cfg.get_explorer_version(agent_name=None, profile="pro") == "pro" - # The shipped per-agent override is empty, so the Pro agents follow the knob. - assert cfg.get_explorer_version(agent_name="operator") == "pro" - assert cfg.get_explorer_version(agent_name="validator") == "pro" - - -def test_with_flash_config_explorer_mode_is_the_flash_profile_knob(monkeypatch): - monkeypatch.delenv("ARTEMIS_EXPLORER_VERSION", raising=False) - cfg = AgentConfigBuilder().with_flash_config(explorer_mode="pro").build() - assert cfg.flash.explorer_mode == "pro" - assert cfg.explorer.flash_mode == "pro" - assert cfg.get_explorer_version(agent_name="flash") == "pro" - # Pro-profile perception is untouched by the Flash knob. - assert cfg.get_explorer_version(agent_name="operator") == "flash" - - -def test_with_explorer_versions_is_an_advanced_per_agent_override(monkeypatch): - monkeypatch.delenv("ARTEMIS_EXPLORER_VERSION", raising=False) - cfg = ( - AgentConfigBuilder() - .with_explorer(pro_mode="flash", versions={"validator": "ultra"}) - .build() - ) - assert cfg.get_explorer_version(agent_name="validator") == "ultra" - assert cfg.get_explorer_version(agent_name="operator") == "flash" diff --git a/tests/unit/sdk/test_agent_external_cancel.py b/tests/unit/sdk/test_agent_external_cancel.py deleted file mode 100644 index f883191b1..000000000 --- a/tests/unit/sdk/test_agent_external_cancel.py +++ /dev/null @@ -1,46 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""A cancel marker dropped by another process must cancel the Agent's task.""" - -import asyncio - -import pytest - -from artemis.runtime import cancel_requests -from artemis.sdk.agent import Agent - - -@pytest.mark.asyncio -async def test_external_cancel_marker_cancels_running_task(tmp_path, monkeypatch): - monkeypatch.setattr(cancel_requests, "get_temp_dir", lambda _subfolder=None: tmp_path / "c") - monkeypatch.setenv("ARTEMIS_CANCEL_POLL_SECONDS", "0.05") - - agent = Agent.__new__(Agent) - agent._session_id = "sess-cancel" - - async def _work() -> None: - await asyncio.sleep(30) - - agent._current_task = asyncio.create_task(_work()) - watcher = asyncio.create_task(agent._watch_external_cancel("demo")) - await asyncio.sleep(0.15) - assert not agent._current_task.done() - - cancel_requests.request_cancel(session_id="sess-cancel", reason="stop from UI") - - with pytest.raises(asyncio.CancelledError): - await asyncio.wait_for(agent._current_task, timeout=2) - await asyncio.wait_for(watcher, timeout=2) - assert not cancel_requests.is_cancel_requested(session_id="sess-cancel") diff --git a/tests/unit/sdk/test_agent_trace_finalization.py b/tests/unit/sdk/test_agent_trace_finalization.py deleted file mode 100644 index 82000b56c..000000000 --- a/tests/unit/sdk/test_agent_trace_finalization.py +++ /dev/null @@ -1,144 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from artemis.sdk.agent import Agent -from artemis.context import DeviceContext, DevicePlatform -from artemis.runtime.device_lock import DeviceBusyError -from third_party.mobile_use.sdk.types.exceptions import AgentError - - -@pytest.mark.asyncio -async def test_trace_finalization_failure_does_not_escape(): - agent = object.__new__(Agent) - agent._finalize_tracing = AsyncMock(side_effect=ValueError("bad recording manifest")) - task = MagicMock(status="completed") - task.get_name.return_value = "successful-task" - context = MagicMock() - - await agent._finalize_tracing_safely(task=task, context=context) - - agent._finalize_tracing.assert_awaited_once_with(task=task, context=context) - - -@pytest.mark.asyncio -async def test_agent_clean_disconnects_the_shared_ui_client(): - agent = object.__new__(Agent) - agent._initialized = True - agent._ui_adb_client = MagicMock() - - await agent.clean() - - agent._ui_adb_client.disconnect.assert_called_once_with() - assert agent._initialized is False - - -@pytest.mark.asyncio -async def test_device_context_uses_adb_size_without_starting_ui_client(): - agent = object.__new__(Agent) - agent._adb_client = MagicMock() - agent._adb_client.device.return_value.window_size.return_value = (1080, 2424) - agent._ui_adb_client = MagicMock() - - from artemis.context import DevicePlatform - - context = await agent._get_device_context("device-123", DevicePlatform.ANDROID) - - assert context.device_width == 1080 - assert context.device_height == 2424 - agent._ui_adb_client.get_screen_data.assert_not_called() - - -@pytest.mark.asyncio -async def test_task_that_never_acquires_queue_does_not_create_trace_session(): - agent = Agent() - agent._initialized = True - agent._device_context = DeviceContext( - host_platform="WINDOWS", - mobile_platform=DevicePlatform.ANDROID, - device_id="device-123", - device_width=1080, - device_height=2424, - ) - agent._adb_client = MagicMock() - agent._ui_adb_client = MagicMock() - agent._prepare_tracing = MagicMock() - - with patch( - "artemis.runtime.DeviceExecutionLock.acquire", - side_effect=DeviceBusyError("queue cancelled"), - ): - with pytest.raises(DeviceBusyError, match="queue cancelled"): - await agent.run_task(goal="queued task", profile="flash") - - agent._prepare_tracing.assert_not_called() - - -@pytest.mark.asyncio -async def test_secure_keyguard_is_rejected_without_guessing_credentials(): - agent = object.__new__(Agent) - agent._device_context = MagicMock(device_id="device-123") - agent._adb_client = MagicMock() - agent._adb_client.device.return_value.shell.return_value = ( - 'User "Owner" (id=0): deviceLocked=1\nUser "Work" (id=10): deviceLocked=1\n' - ) - - with pytest.raises(AgentError, match="Unlock the device manually"): - await agent._ensure_device_unlocked() - - -@pytest.mark.asyncio -async def test_locked_work_profile_does_not_block_unlocked_device_owner(): - agent = object.__new__(Agent) - agent._device_context = MagicMock(device_id="device-123") - agent._adb_client = MagicMock() - agent._adb_client.device.return_value.shell.return_value = ( - 'User "Owner" (id=0): deviceLocked=0\nUser "Work" (id=10): deviceLocked=1\n' - ) - - await agent._ensure_device_unlocked() - - -@pytest.mark.asyncio -async def test_agent_inherits_session_id_from_env_and_propagates_to_tracing(monkeypatch): - import uuid - - canonical_sid = "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d" - monkeypatch.setenv("ARTEMIS_SESSION_ID", canonical_sid) - - agent = Agent() - assert agent._session_id == canonical_sid - - task = MagicMock() - task.id = "fallback-id" - task.get_name.return_value = "task-name" - task.request.goal = "test goal" - task.request.profile = "flash" - task.request.task_name = "task-name" - task.request.trace_path = "traces" - - context = MagicMock() - context.device = None - with patch("artemis.sdk.agent.DataEngine") as mock_data_engine_cls: - mock_data_engine = MagicMock() - mock_data_engine_cls.return_value = mock_data_engine - - agent._prepare_tracing(task=task, context=context) - - mock_data_engine.start_session.assert_called_once() - call_kwargs = mock_data_engine.start_session.call_args.kwargs - assert call_kwargs["session_id"] == uuid.UUID(canonical_sid) diff --git a/tests/unit/sdk/test_run_tuning_summary.py b/tests/unit/sdk/test_run_tuning_summary.py deleted file mode 100644 index 57c914878..000000000 --- a/tests/unit/sdk/test_run_tuning_summary.py +++ /dev/null @@ -1,41 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""The Pro tuning summary the SDK persists with every session's device_info.""" - -from artemis.sdk.agent import run_tuning_summary -from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder - - -def test_flash_runs_persist_no_tuning(): - config = AgentConfigBuilder().build(validate_profiles=False) - assert run_tuning_summary(config, "flash") is None - - -def test_pro_runs_persist_the_two_launcher_sliders(): - config = ( - AgentConfigBuilder() - .with_verification_level("checkpoints") - .with_explorer(pro_mode="ultra") - .build(validate_profiles=False) - ) - assert run_tuning_summary(config, "pro") == { - "verification_level": "checkpoints", - "explorer_mode": "ultra", - } - - -def test_pro_summary_reflects_checker_switched_off(): - config = AgentConfigBuilder().with_verification_level("off").build(validate_profiles=False) - assert run_tuning_summary(config, "pro")["verification_level"] == "off" diff --git a/tests/unit/services/test_token_meter_ratio.py b/tests/unit/services/test_token_meter_ratio.py deleted file mode 100644 index 6605fc024..000000000 --- a/tests/unit/services/test_token_meter_ratio.py +++ /dev/null @@ -1,111 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Session and per-source cache-hit ratios. - -A session whose operator calls never hit the provider's prompt cache must be -visible without reading every ``llm_usage`` trace: the session snapshot and -each source's totals carry ``cached_ratio`` (cached / prompt tokens). -""" - -import logging -from unittest.mock import Mock -from uuid import uuid4 - -from langchain_core.messages import AIMessage - -from artemis.services.token_meter import ( - SessionTokenMeter, - cached_ratio, - format_session_summary, - get_meter, - log_session_summary, - record_llm_usage, -) - - -def _msg(prompt: int, cached: int = 0) -> AIMessage: - usage = {"input_tokens": prompt, "output_tokens": 10, "total_tokens": prompt + 10} - if cached: - usage["input_token_details"] = {"cache_read": cached} - return AIMessage(content="ok", usage_metadata=usage) - - -def test_cached_ratio_is_zero_without_prompt_tokens(): - assert cached_ratio(0, 0) == 0.0 - assert cached_ratio(50, 0) == 0.0 - assert cached_ratio(250, 400) == 0.625 - - -def test_meter_tracks_cached_ratio_per_session_and_per_source(): - meter = SessionTokenMeter("s") - meter.record({"prompt_tokens": 1000, "cached_tokens": 0}, source="google:operator") - meter.record({"prompt_tokens": 1000, "cached_tokens": 0}, source="google:operator") - snapshot = meter.record( - {"prompt_tokens": 200, "cached_tokens": 150}, - source="lens:step_capsule:gemini", - update_last_prompt=False, - ) - - # Session ratio blends every call; the call's own source rides along. - assert snapshot["session_cached_ratio"] == round(150 / 2200, 3) - assert snapshot["source_cached_ratio"] == 0.75 - assert meter.last_prompt_tokens == 1000 - - summary = meter.summary() - assert summary["session_id"] == "s" - assert summary["sources"]["google:operator"] == { - "llm_calls": 2, - "prompt_tokens": 2000, - "cached_tokens": 0, - "cache_hit_calls": 0, - "cached_ratio": 0.0, # the operator never hit the cache: visible at a glance - } - assert summary["sources"]["lens:step_capsule:gemini"]["cached_ratio"] == 0.75 - - -def test_record_llm_usage_payload_carries_both_ratios(): - engine = Mock() - engine.current_session_id = uuid4() - engine.current_step_id = None - - record_llm_usage(engine, _msg(1000), source="google:op") - payload = record_llm_usage(engine, _msg(1000, cached=800), source="google:op") - - assert payload["session_cached_ratio"] == 0.4 - assert payload["source_cached_ratio"] == 0.4 - # A source-less call still reports the session ratio, never a stale source one. - payload = record_llm_usage(engine, _msg(1000)) - assert payload["session_cached_ratio"] == round(800 / 3000, 3) - assert "source_cached_ratio" not in payload - - -def test_session_summary_logs_once_per_metered_session(caplog): - session_id = f"ratio-{uuid4()}" - assert log_session_summary(session_id) is None # never metered: nothing to say - - meter = get_meter(session_id) - meter.record({"prompt_tokens": 500, "cached_tokens": 0}, source="google:op") - meter.record({"prompt_tokens": 100, "cached_tokens": 90}, source="lens:visual") - - line = format_session_summary(session_id) - assert f"LLM usage for session {session_id}: 2 calls, 600 prompt tokens, 90 cached" in line - assert "cached_ratio=0.150" in line - assert "google:op: 1 calls, cached_ratio=0.000" in line - assert "lens:visual: 1 calls, cached_ratio=0.900" in line - - with caplog.at_level(logging.INFO, logger="artemis.services.token_meter"): - summary = log_session_summary(session_id) - assert summary is not None and summary["session_cached_ratio"] == 0.15 - assert any(line in record.getMessage() for record in caplog.records) diff --git a/tests/unit/test_bootstrap_scripts_encoding.py b/tests/unit/test_bootstrap_scripts_encoding.py deleted file mode 100644 index d0b90c145..000000000 --- a/tests/unit/test_bootstrap_scripts_encoding.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Keep bootstrap scripts ASCII for Windows PowerShell 5.1 compatibility. - -BOM-less scripts are decoded using the system ANSI code page. UTF-8 symbols -can become quote delimiters under cp1252 and cause parse errors. -""" - -from pathlib import Path - -import pytest - -SCRIPTS_DIR = Path(__file__).resolve().parents[2] / "scripts" -POWERSHELL_SCRIPTS = sorted(SCRIPTS_DIR.glob("*.ps1")) - - -def test_powershell_scripts_are_discovered(): - assert POWERSHELL_SCRIPTS, f"no .ps1 scripts found under {SCRIPTS_DIR}" - - -@pytest.mark.parametrize("script", POWERSHELL_SCRIPTS, ids=lambda p: p.name) -def test_powershell_script_is_pure_ascii(script): - raw = script.read_bytes() - offending = [] - line_no = 1 - for index, byte in enumerate(raw): - if byte == 0x0A: - line_no += 1 - elif byte > 0x7F: - offending.append((line_no, index, hex(byte))) - - assert not offending, ( - f"{script.name} contains non-ASCII bytes at (line, offset, byte) " - f"{offending[:10]}; Windows PowerShell 5.1 decodes BOM-less .ps1 files " - "with the system ANSI code page. Use ASCII status labels." - ) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 11e9077ab..dcec90c7b 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -12,677 +12,62 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Unit tests for ARTEMIS Unified CLI application.""" +"""Unit tests for the contracted device-action CLI. + +Only ``doctor``, ``mcp`` and ``helper`` remain after the smart agent stack and +web console moved to the DSH host. The autonomous ``run``/``batch``/``trace`` +commands and the server lifecycle/restart commands are gone, and the MCP +``--install`` / ``--generate-config`` IDE injection moved to the host. +""" + +import json from typer.testing import CliRunner + from artemis.interfaces.cli.main import app runner = CliRunner() +# --------------------------------------------------------------------------- +# Top-level command surface +# --------------------------------------------------------------------------- + + def test_cli_help(): - """Verify top-level CLI help returns status 0 and lists core subcommands.""" + """The top-level help lists exactly the device subcommands.""" result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 - assert "Artemis: Autonomous Multimodal Android Agent" in result.output - assert "run" in result.output - assert "batch" in result.output - assert "server" in result.output - assert "trace" in result.output - assert "mcp" in result.output + assert "device-action" in result.output + for name in ("doctor", "mcp", "helper"): + assert name in result.output def test_cli_version(): - """Verify --version returns version banner.""" + """--version prints the device-action core banner.""" result = runner.invoke(app, ["--version"]) assert result.exit_code == 0 - assert "Artemis Agent Platform" in result.output - - -def test_cli_run_help(): - """Verify 'artemis run --help' displays execution options.""" - result = runner.invoke(app, ["run", "--help"]) - assert result.exit_code == 0 - assert "--profile" in result.output - assert "--locked-app" in result.output - assert "--traces-path" in result.output - assert "--verification-level" in result.output - assert "--explorer-pro-mode" in result.output - - -def test_cli_batch_help(): - """Verify 'artemis batch --help' displays batch options.""" - result = runner.invoke(app, ["batch", "--help"]) - assert result.exit_code == 0 - assert "--file" in result.output - assert "--delay" in result.output - assert "--verification-level" in result.output - assert "--explorer-pro-mode" in result.output - - -def test_cli_batch_forwards_pro_tuning_in_standalone_mode(monkeypatch): - """`artemis batch --standalone` threads both knobs into run_batch_tasks.""" - import artemis.interfaces.cli.commands.batch as batch_module - - captured: dict = {} - - async def fake_run_batch_tasks(tasks, **kwargs): - captured["tasks"] = tasks - captured.update(kwargs) - - monkeypatch.setattr(batch_module, "run_batch_tasks", fake_run_batch_tasks) - result = runner.invoke( - app, - [ - "batch", - "--standalone", - "--profile", - "pro", - "--verification-level", - "strict", - "--explorer-pro-mode", - "ultra", - "Open Settings", - ], - ) - assert result.exit_code == 0, result.output - assert captured["tasks"] == ["Open Settings"] - assert captured["profile_name"] == "pro" - assert captured["verification_level"] == "strict" - assert captured["explorer_pro_mode"] == "ultra" - - -def test_cli_batch_forwards_pro_tuning_to_daemon(monkeypatch): - """Daemon-routed batches carry the knobs as /api/run JSON fields.""" - import artemis.runtime as runtime - - monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False) - captured: dict = {} - - def fake_submit_batch(goals, **kwargs): - captured["goals"] = goals - captured.update(kwargs) - return {"tasks": [{"session_id": "sid-1", "goal": goals[0]}]} - - monkeypatch.setattr(runtime, "ensure_daemon_running", lambda **_: (True, "http://x:1")) - monkeypatch.setattr(runtime, "submit_batch_to_daemon", fake_submit_batch) - monkeypatch.setattr(runtime, "wait_for_daemon_task", lambda *_, **__: {"status": "completed"}) - - result = runner.invoke( - app, - ["batch", "--verification-level", "checkpoints", "--explorer-pro-mode", "pro", "Goal A"], - ) - assert result.exit_code == 0, result.output - assert captured["goals"] == ["Goal A"] - assert captured["verification_level"] == "checkpoints" - assert captured["explorer_mode"] == "pro" - - -def test_run_batch_tasks_applies_pro_tuning_to_agent_config(monkeypatch): - """The standalone batch runner applies the knobs on the AgentConfig builder.""" - from unittest.mock import AsyncMock, MagicMock - - import artemis.interfaces.cli.commands.batch as batch_module - - fake_builder = MagicMock() - fake_builders = MagicMock() - fake_builders.AgentConfig.with_default_profile.return_value = fake_builder - fake_agent = MagicMock() - fake_agent.init = AsyncMock() - fake_agent.run_task = AsyncMock(return_value="ok") - fake_agent.clean = AsyncMock() - - monkeypatch.setattr(batch_module, "initialize_llm_config", lambda: MagicMock()) - monkeypatch.setattr(batch_module, "AgentProfile", MagicMock()) - monkeypatch.setattr(batch_module, "Builders", fake_builders) - monkeypatch.setattr(batch_module, "Agent", MagicMock(return_value=fake_agent)) - - import asyncio - - asyncio.run( - batch_module.run_batch_tasks( - ["Goal A"], - profile_name="pro", - delay_seconds=0, - verification_level="strict", - explorer_pro_mode="ultra", - ) - ) - fake_builder.with_verification_level.assert_called_once_with("strict") - fake_builder.with_explorer.assert_called_once_with(pro_mode="ultra") - fake_agent.run_task.assert_awaited_once_with(goal="Goal A", profile="pro") + assert "ARTEMIS device-action core" in result.output -def test_cli_trace_help(): - """Verify 'artemis trace --help' lists trace subcommands.""" - result = runner.invoke(app, ["trace", "--help"]) - assert result.exit_code == 0 - assert "list" in result.output - assert "view" in result.output +def test_removed_commands_are_unknown(): + """The smart-agent and server-lifecycle commands no longer exist.""" + for command in ("run", "batch", "trace", "server", "restart", "stop", "status", "ui", "init"): + result = runner.invoke(app, [command, "--help"]) + assert result.exit_code != 0, command def test_cli_mcp_help(): - """Verify 'artemis mcp --help' lists server options.""" + """'artemis mcp --help' lists the device server types and transport options.""" result = runner.invoke(app, ["mcp", "--help"]) assert result.exit_code == 0 - assert "--type" in result.output - assert "--generate-config" in result.output - - -def test_cli_mcp_generate_config(): - """Verify 'artemis mcp --generate-config cursor' produces valid configuration.""" - result = runner.invoke(app, ["mcp", "--generate-config", "cursor"]) - assert result.exit_code == 0 - assert "mcpServers" in result.output - assert "mcp_server" in result.output - - -def test_cli_mcp_generate_config_antigravity(): - """Verify current Antigravity uses only documented, load-safe fields.""" - from artemis.interfaces.cli.commands.mcp import _get_config_snippet - - result = runner.invoke(app, ["mcp", "--generate-config", "antigravity"]) - assert result.exit_code == 0 - assert "mcpServers" in result.output - server_config = _get_config_snippet("antigravity", "python", "/project")["mcpServers"][ - "artemis" - ] - assert server_config["disabledTools"] == [] - assert "tools" not in server_config - - legacy_config = _get_config_snippet("jetski", "python", "/project")["mcpServers"]["artemis"] - assert set(legacy_config["tools"]) == { - "mobile_run_task", - "mobile_manage_task", - "mobile_get_device_state", - "mobile_inspect_trace", - "mobile_diagnose", - } - assert all(tool["eager"] is True for tool in legacy_config["tools"].values()) - - -def test_cli_mcp_generate_config_all(): - """Verify 'artemis mcp --generate-config all' includes every supported client.""" - result = runner.invoke(app, ["mcp", "--generate-config", "all"]) - assert result.exit_code == 0 - assert "antigravity" in result.output - assert "cursor" in result.output - assert "windsurf" in result.output - assert "claude" in result.output - assert "vscode" in result.output - assert "cline" in result.output - assert "roo" in result.output - assert "codex" in result.output - - -def test_cli_mcp_generate_config_codex(): - """Verify Codex initializes the server and enables every Artemis tool.""" - from artemis.interfaces.cli.commands.mcp import _get_config_snippet - - result = runner.invoke(app, ["mcp", "--generate-config", "codex"]) - assert result.exit_code == 0 - assert "[mcp_servers.artemis]" in result.output - assert "[mcp_servers.artemis.env]" in result.output - assert "enabled = true" in result.output - assert "required = true" in result.output - assert "startup_timeout_sec = 120" in result.output - assert "enabled_tools" in result.output - assert "mobile_run_task" in result.output - assert "mobile_manage_task" in result.output - server_config = _get_config_snippet("codex", "python", "/project")["mcp_servers"]["artemis"] - assert server_config["enabled_tools"] == [ - "mobile_run_task", - "mobile_manage_task", - "mobile_get_device_state", - "mobile_inspect_trace", - "mobile_diagnose", - ] - - -def test_cli_mcp_install_antigravity(tmp_path, monkeypatch): - """Verify 'artemis mcp --install antigravity' writes configuration and global rules into target files.""" - monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) - monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path)) - result = runner.invoke(app, ["mcp", "--install", "antigravity"]) - assert result.exit_code == 0 - assert "Successfully installed ARTEMIS MCP server configuration & rules" in result.output - jetski_file = tmp_path / ".gemini" / "jetski" / "mcp_config.json" - assert jetski_file.exists() - import json - - data = json.loads(jetski_file.read_text()) - assert "artemis" in data["mcpServers"] - assert "mobile_run_task" in data["mcpServers"]["artemis"]["tools"] - assert "PYTHONPATH" in data["mcpServers"]["artemis"]["env"] - - current_file = tmp_path / ".gemini" / "config" / "mcp_config.json" - current_data = json.loads(current_file.read_text()) - assert current_data["mcpServers"]["artemis"]["disabledTools"] == [] - assert "tools" not in current_data["mcpServers"]["artemis"] - - # Verify global rule file installed - gemini_md = tmp_path / ".gemini" / "GEMINI.md" - assert gemini_md.exists() - assert "Mobile Testing Mindset" in gemini_md.read_text(encoding="utf-8") - rule_file = tmp_path / ".gemini" / "rules" / "artemis.md" - assert rule_file.exists() - assert "Mobile Testing Mindset" in rule_file.read_text(encoding="utf-8") - - -def test_cli_mcp_install_all(tmp_path, monkeypatch): - """Verify 'artemis mcp --install all' installs configs and global rules to all supported IDE locations.""" - monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) - monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming")) - monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path)) - result = runner.invoke(app, ["mcp", "--install", "all"]) - assert result.exit_code == 0 - assert "Successfully installed ARTEMIS MCP server configuration & rules" in result.output - assert (tmp_path / ".cursor" / "mcp.json").exists() - assert (tmp_path / ".codeium" / "windsurf" / "mcp_config.json").exists() - assert (tmp_path / ".openclaw" / "openclaw.json").exists() - assert (tmp_path / ".claude.json").exists() - assert (tmp_path / ".codex" / "config.toml").exists() - - import json - - cursor_data = json.loads((tmp_path / ".cursor" / "mcp.json").read_text()) - assert cursor_data["mcpServers"]["artemis"]["command"] - - windsurf_data = json.loads((tmp_path / ".codeium" / "windsurf" / "mcp_config.json").read_text()) - assert windsurf_data["mcpServers"]["artemis"]["command"] - - claude_data = json.loads((tmp_path / ".claude.json").read_text()) - assert claude_data["mcpServers"]["artemis"]["type"] == "stdio" - - from artemis.interfaces.cli.commands.mcp import _get_vscode_user_dir - - vscode_data = json.loads((_get_vscode_user_dir() / "mcp.json").read_text()) - assert vscode_data["servers"]["artemis"]["type"] == "stdio" - assert "mcpServers" not in vscode_data - - copilot_data = json.loads((tmp_path / ".copilot" / "mcp-config.json").read_text()) - assert copilot_data["servers"]["artemis"]["type"] == "stdio" - - openclaw_data = json.loads((tmp_path / ".openclaw" / "openclaw.json").read_text()) - assert openclaw_data["mcp"]["servers"]["artemis"]["enabled"] is True - - cline_data = json.loads( - (tmp_path / ".cline" / "data" / "settings" / "cline_mcp_settings.json").read_text() - ) - assert cline_data["mcpServers"]["artemis"]["disabled"] is False - - roo_data = json.loads((tmp_path / ".roo" / "mcp.json").read_text()) - assert roo_data["mcpServers"]["artemis"]["disabled"] is False - - # Verify global rule files installed across IDEs - assert (tmp_path / ".gemini" / "GEMINI.md").exists() - assert (tmp_path / ".gemini" / "rules" / "artemis.md").exists() - assert (tmp_path / ".cursorrules").exists() - assert (tmp_path / ".cursor" / "rules" / "artemis.mdc").exists() - # Claude Code loads both CLAUDE.md and rules/*.md, so rules are installed - # only as the standalone rule file to avoid duplicated context. - assert not (tmp_path / ".claude" / "CLAUDE.md").exists() - assert (tmp_path / ".claude" / "rules" / "artemis.md").exists() - assert (tmp_path / ".codeium" / "windsurf" / "memories" / "global_rules.md").exists() - assert (tmp_path / ".codeium" / "windsurf" / "rules" / "artemis.md").exists() - assert (tmp_path / ".vscode" / "rules" / "artemis.md").exists() - assert (tmp_path / ".clinerules").exists() - assert (tmp_path / ".cline" / "rules" / "artemis.md").exists() - assert (tmp_path / ".roorules").exists() - assert (tmp_path / ".roo" / "rules" / "artemis.md").exists() - assert (tmp_path / ".openclaw" / "OPENCLAW.md").exists() - assert (tmp_path / ".openclaw" / "rules" / "artemis.md").exists() - assert (tmp_path / ".codex" / "AGENTS.md").exists() - - -def test_cli_mcp_install_claude_migrates_legacy_claude_md(tmp_path, monkeypatch): - """Verify the claude target removes a previously injected CLAUDE.md block while preserving user content.""" - monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) - monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming")) - monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path)) - - claude_dir = tmp_path / ".claude" - claude_dir.mkdir(parents=True) - claude_md = claude_dir / "CLAUDE.md" - claude_md.write_text( - "# My own instructions\n\n" - "\nold injected rules\n" - "\n", - encoding="utf-8", - ) - - result = runner.invoke(app, ["mcp", "--install", "claude"]) - assert result.exit_code == 0 - - remaining = claude_md.read_text(encoding="utf-8") - assert "My own instructions" in remaining - assert "ARTEMIS MOBILE TESTING RULES" not in remaining - rule_file = claude_dir / "rules" / "artemis.md" - assert rule_file.exists() - assert "Mobile Testing Mindset" in rule_file.read_text(encoding="utf-8") - - -def test_cli_mcp_install_claude_deletes_block_only_claude_md(tmp_path, monkeypatch): - """Verify a CLAUDE.md consisting solely of the injected block is deleted outright.""" - monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) - monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming")) - monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path)) - - claude_dir = tmp_path / ".claude" - claude_dir.mkdir(parents=True) - claude_md = claude_dir / "CLAUDE.md" - claude_md.write_text( - "\nold injected rules\n" - "\n", - encoding="utf-8", - ) - - result = runner.invoke(app, ["mcp", "--install", "claude"]) - assert result.exit_code == 0 - assert not claude_md.exists() - assert (claude_dir / "rules" / "artemis.md").exists() - - -def test_cli_mcp_install_claude_refuses_unparseable_claude_json(tmp_path, monkeypatch): - """Verify an unparseable ~/.claude.json is left untouched instead of being rewritten.""" - monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) - monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming")) - monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path)) - - corrupt = "{ this is not json at all" - claude_json = tmp_path / ".claude.json" - claude_json.write_text(corrupt, encoding="utf-8") - - result = runner.invoke(app, ["mcp", "--install", "claude"]) - assert result.exit_code == 0 - assert claude_json.read_text(encoding="utf-8") == corrupt - - -def test_cli_mcp_install_codex_preserves_config_and_is_idempotent(tmp_path, monkeypatch): - """Verify Codex TOML merging preserves other servers and updates only Artemis.""" - monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) - monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path)) - codex_dir = tmp_path / ".codex" - codex_dir.mkdir(parents=True) - config_file = codex_dir / "config.toml" - config_file.write_text( - 'model = "test-model"\n\n' - '[mcp_servers.existing]\ncommand = "echo"\n\n' - '[mcp_servers.artemis]\ncommand = "old-command"\n', - encoding="utf-8", - ) - - result = runner.invoke(app, ["mcp", "--install", "codex"]) - assert result.exit_code == 0 - result2 = runner.invoke(app, ["mcp", "--install", "codex"]) - assert result2.exit_code == 0 - - import tomllib - - config_text = config_file.read_text(encoding="utf-8") - data = tomllib.loads(config_text) - assert data["model"] == "test-model" - assert data["mcp_servers"]["existing"]["command"] == "echo" - assert data["mcp_servers"]["artemis"]["args"] == ["-m", "mcp_server"] - assert data["mcp_servers"]["artemis"]["enabled"] is True - assert data["mcp_servers"]["artemis"]["required"] is True - assert data["mcp_servers"]["artemis"]["startup_timeout_sec"] == 120 - assert data["mcp_servers"]["artemis"]["enabled_tools"] == [ - "mobile_run_task", - "mobile_manage_task", - "mobile_get_device_state", - "mobile_inspect_trace", - "mobile_diagnose", - ] - assert data["mcp_servers"]["artemis"]["env"]["PYTHONPATH"] == str(tmp_path) - assert config_text.count("# BEGIN ARTEMIS MCP CONFIG") == 1 - - agents_file = codex_dir / "AGENTS.md" - agents_text = agents_file.read_text(encoding="utf-8") - assert "Mobile Testing Mindset" in agents_text - assert agents_text.count("") == 1 - - -def test_cli_mcp_install_jsonc_and_backup(tmp_path, monkeypatch): - """Verify merging configuration preserves valid JSONC and creates .bak for unparseable JSON.""" - monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) - monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path)) - - cursor_dir = tmp_path / ".cursor" - cursor_dir.mkdir(parents=True, exist_ok=True) - mcp_json = cursor_dir / "mcp.json" - - # 1. Valid JSONC with comments and trailing comma - mcp_json.write_text( - '{\n // My existing server\n "mcpServers": {\n "test": {"command": "echo"},\n }\n}', - encoding="utf-8", - ) - result = runner.invoke(app, ["mcp", "--install", "cursor"]) - assert result.exit_code == 0 - import json - - data = json.loads(mcp_json.read_text(encoding="utf-8")) - assert "test" in data["mcpServers"] - assert "artemis" in data["mcpServers"] - - # 2. Corrupt / unparseable file triggers backup - mcp_json.write_text("INVALID JSON DATA {{{{", encoding="utf-8") - result2 = runner.invoke(app, ["mcp", "--install", "cursor"]) - assert result2.exit_code == 0 - backup_file = cursor_dir / "mcp.json.bak" - assert backup_file.exists() - assert "INVALID JSON DATA" in backup_file.read_text(encoding="utf-8") - - -def test_cli_mcp_install_refuses_rewrite_when_backup_fails(tmp_path, monkeypatch): - """Backup failure for an unparseable config must abort the rewrite instead of destroying it.""" - monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) - monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path)) - - cursor_dir = tmp_path / ".cursor" - cursor_dir.mkdir(parents=True, exist_ok=True) - mcp_json = cursor_dir / "mcp.json" - original = "INVALID JSON DATA {{{{" - mcp_json.write_text(original, encoding="utf-8") - - import pathlib - - real_write_text = pathlib.Path.write_text - - def failing_bak_write(self, *args, **kwargs): - if str(self).endswith(".bak"): - raise OSError("disk full") - return real_write_text(self, *args, **kwargs) - - monkeypatch.setattr("pathlib.Path.write_text", failing_bak_write) - - runner.invoke(app, ["mcp", "--install", "cursor"]) - - assert mcp_json.read_text(encoding="utf-8") == original - assert not (cursor_dir / "mcp.json.bak").exists() - - -def test_cli_mcp_install_openclaw_migrates_legacy_plugin_shape(tmp_path, monkeypatch): - """Verify reinstalling OpenClaw replaces the obsolete plugin wrapper with mcp.servers.""" - monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) - monkeypatch.setattr("mcp_server.utils.env_utils.get_project_root", lambda: str(tmp_path)) - config_path = tmp_path / ".openclaw" / "openclaw.json" - config_path.parent.mkdir(parents=True) - config_path.write_text( - '{"plugins":{"artemis_mcp":{"enabled":true},"keep":{"enabled":true}}}', - encoding="utf-8", - ) - - result = runner.invoke(app, ["mcp", "--install", "openclaw"]) - assert result.exit_code == 0 - - import json - - data = json.loads(config_path.read_text(encoding="utf-8")) - assert "artemis_mcp" not in data["plugins"] - assert data["plugins"]["keep"]["enabled"] is True - assert data["mcp"]["servers"]["artemis"]["enabled"] is True - assert data["mcp"]["servers"]["artemis"]["command"] - - -def test_cli_restart_help(): - """Verify 'artemis restart --help' displays lifecycle options.""" - result = runner.invoke(app, ["restart", "--help"]) - assert result.exit_code == 0 - assert "--port" in result.output - assert "--host" in result.output - assert "--force" in result.output - assert "--daemon" in result.output - assert "--open" in result.output - - -def test_cli_stop_help(): - """Verify 'artemis stop --help' displays stop options.""" - result = runner.invoke(app, ["stop", "--help"]) - assert result.exit_code == 0 - assert "--port" in result.output - assert "--force" in result.output - - -def test_cli_status_help(): - """Verify 'artemis status --help' displays status options.""" - result = runner.invoke(app, ["status", "--help"]) - assert result.exit_code == 0 - assert "--port" in result.output - - -def test_cli_status_offline(monkeypatch): - """Verify 'artemis status' reports offline when port is unused.""" - from artemis.runtime import server_lifecycle - - monkeypatch.setattr(server_lifecycle, "is_port_in_use", lambda port, **kwargs: False) - monkeypatch.setattr(server_lifecycle, "find_server_pids", lambda port: []) - monkeypatch.setattr(server_lifecycle, "read_server_info", lambda: None) - - result = runner.invoke(app, ["status", "--port", "59998"]) - assert result.exit_code == 0 - assert "OFFLINE" in result.output or "STOPPED" in result.output - - -def test_cli_status_online(monkeypatch): - """Verify 'artemis status' reports online details when server is active.""" - from artemis.runtime import server_lifecycle - - monkeypatch.setattr(server_lifecycle, "is_port_in_use", lambda port, **kwargs: True) - monkeypatch.setattr(server_lifecycle, "find_server_pids", lambda port: [12345]) - monkeypatch.setattr( - server_lifecycle, - "read_server_info", - lambda: {"pid": 12345, "port": 8000, "started_at": 1000.0, "cwd": "/tmp"}, - ) - - result = runner.invoke(app, ["status", "--port", "8000"]) - assert result.exit_code == 0 - assert "ONLINE" in result.output or "RUNNING" in result.output - assert "12345" in result.output - - -def test_cli_stop_command(monkeypatch): - """Verify 'artemis stop' invokes stop_server with given parameters.""" - from artemis.interfaces.cli.commands import server_lifecycle as sl_cmd - - mock_called = {} - - def mock_stop(port, timeout=4.0, force=False): - mock_called["port"] = port - mock_called["force"] = force - return True, "Artemis server stopped (PID: 12345).", [12345] - - monkeypatch.setattr(sl_cmd, "find_server_pids", lambda port: [12345]) - monkeypatch.setattr(sl_cmd, "stop_server", mock_stop) - - result = runner.invoke(app, ["stop", "--port", "8000", "--force"]) - assert result.exit_code == 0 - assert mock_called["port"] == 8000 - assert mock_called["force"] is True - assert "stopped successfully" in result.output.lower() or "PID: 12345" in result.output - - -def test_cli_restart_command(monkeypatch): - """Verify 'artemis restart' stops previous server and spawns a detached daemon by default.""" - from unittest.mock import MagicMock - - from artemis.interfaces.cli.commands import server_lifecycle as sl_cmd - from artemis.runtime import server_lifecycle - - stopped = {} - spawned = {} - - def mock_stop(port, timeout=4.0, force=False): - stopped["port"] = port - return True, "Stopped server", [12345] - - def mock_spawn(host, port): - spawned["host"] = host - spawned["port"] = port - proc = MagicMock() - proc.pid = 54321 - proc.poll.return_value = None - return proc - - monkeypatch.setattr(server_lifecycle, "find_server_pids", lambda port: [12345]) - monkeypatch.setattr(sl_cmd, "stop_server", mock_stop) - monkeypatch.setattr(sl_cmd, "spawn_daemon", mock_spawn) - monkeypatch.setattr(sl_cmd, "is_daemon_running", lambda **kwargs: True) - monkeypatch.setattr(sl_cmd, "ensure_showcase_built", lambda console: None) - - result = runner.invoke(app, ["restart", "--port", "8888", "--no-open"]) - assert result.exit_code == 0 - assert stopped["port"] == 8888 - assert spawned["port"] == 8888 - assert "PID: 54321" in result.output - - -def test_cli_restart_foreground_command(monkeypatch): - """Verify 'artemis restart --foreground' runs the server attached via ui_command.""" - from artemis.interfaces.cli.commands import server_lifecycle as sl_cmd - from artemis.runtime import server_lifecycle - - stopped = {} - ui_called = {} - - def mock_stop(port, timeout=4.0, force=False): - stopped["port"] = port - return True, "Stopped server", [12345] - - def mock_ui(host, port, open_browser, reload): - ui_called["host"] = host - ui_called["port"] = port - ui_called["open_browser"] = open_browser - - monkeypatch.setattr(server_lifecycle, "find_server_pids", lambda port: [12345]) - monkeypatch.setattr(sl_cmd, "stop_server", mock_stop) - monkeypatch.setattr(sl_cmd, "ui_command", mock_ui) - - result = runner.invoke(app, ["restart", "--port", "8888", "--no-open", "--foreground"]) - assert result.exit_code == 0 - assert stopped["port"] == 8888 - assert ui_called["port"] == 8888 - assert ui_called["open_browser"] is False - - -def test_cli_server_lifecycle_aliases(monkeypatch): - """Verify 'artemis server status/stop/restart' subcommands are accessible.""" - from artemis.runtime import server_lifecycle - - monkeypatch.setattr(server_lifecycle, "is_port_in_use", lambda port, **kwargs: False) - monkeypatch.setattr(server_lifecycle, "find_server_pids", lambda port: []) - monkeypatch.setattr(server_lifecycle, "read_server_info", lambda: None) - - result = runner.invoke(app, ["server", "status", "--port", "59998"]) - assert result.exit_code == 0 - assert "OFFLINE" in result.output or "STOPPED" in result.output - - help_result = runner.invoke(app, ["server", "--help"]) - assert help_result.exit_code == 0 - assert "restart" in help_result.output - assert "stop" in help_result.output - assert "status" in help_result.output + for flag in ("--type", "--transport", "--host", "--port"): + assert flag in result.output + for kind in ("adb", "mock", "xml"): + assert kind in result.output + # The IDE config injection moved to the DSH host. + assert "--generate-config" not in result.output + assert "--install" not in result.output # --------------------------------------------------------------------------- @@ -723,32 +108,20 @@ def _probe( def _all_pass_probes(): + """The three surviving device probes, green, in CHECK_ORDER.""" return [ - _probe("vision_ocr_key", blocker=False, summary="Not Configured (Optional)"), - _probe("toolchain", blocker=False), - _probe("android_adb", metadata={"installed": True, "adb_keys": {"is_corrupted": False}}), - _probe( - "gemini_api_key", - summary="Active (Gemini)", - description="Gemini credential is active (sk-RAW...cdef).", - metadata={ - "current_key": _RAW_SECRET, - "api_keys": {"google": _RAW_SECRET}, - "providers": [{"raw_key": _RAW_SECRET, "key": _RAW_SECRET}], - }, - ), - _probe("system_config"), _probe("python_runtime"), + _probe("android_adb", metadata={"installed": True, "adb_keys": {"is_corrupted": False}}), + _probe("toolchain", blocker=False), ] -def _install_doctor_fakes(monkeypatch, probes, host=None, *, heal_result=None): - """Patch the engine, the host probe, and the CLI-only extra rows.""" - from unittest.mock import AsyncMock +def _install_doctor_fakes(monkeypatch, probes, *, heal_result=None): + """Patch the readiness engine and disable the CLI-only helper row.""" import time + from unittest.mock import AsyncMock from artemis.core.diagnostics.engine import readiness_engine - from artemis.core.diagnostics.probes.host_probe import IntegrationHostProbe from artemis.core.diagnostics.schema import ProbeStatus, SystemReadinessReport import artemis.interfaces.cli.commands.doctor as doctor_module @@ -766,39 +139,54 @@ def _install_doctor_fakes(monkeypatch, probes, host=None, *, heal_result=None): monkeypatch.setattr(readiness_engine, "run_all", run_all) heal = AsyncMock(return_value=heal_result or {"success": True, "message": "Keys regenerated."}) monkeypatch.setattr(readiness_engine, "heal_adb_keys", heal) - host_probe = AsyncMock( - return_value=host or _probe("integration_host", summary="Host Ready", description="host ok") - ) - monkeypatch.setattr(IntegrationHostProbe, "probe", host_probe) - monkeypatch.setattr( - doctor_module, - "_npm_row", - lambda: doctor_module.ExtraRow( - key="nodejs_npm", - title="Node.js / npm", - status="pass", - status_markup="[bold green]✔ Installed[/bold green]", - summary="Installed", - detail="/usr/bin/npm", - ), - ) - monkeypatch.setattr( - doctor_module, - "_showcase_row", - lambda: doctor_module.ExtraRow( - key="showcase_ui", - title="Showcase UI", - status="missing", - status_markup="[bold yellow]○ Not Compiled[/bold yellow]", - summary="Not Compiled", - detail="Run ./start.sh or artemis ui to auto-compile.", - ), + # No real device or accessibility helper in the CLI tests. + monkeypatch.setattr(doctor_module, "_helper_row", lambda results: None) + return {"run_all": run_all, "heal": heal} + + +def test_cli_doctor_footer_advertises_only_existing_commands(monkeypatch): + """G73: a readiness hint must not name a command this CLI no longer has. + + The footer used to say `artemis run "..."` after ``run`` was deleted. Deriving + the allowed names from the app itself keeps this lock from rotting when the + command surface changes again. + """ + import re + + import typer.main + from typer.core import TyperGroup + + group = typer.main.get_command(app) + assert isinstance(group, TyperGroup) + registered = set(group.commands) + + def advertised(text: str) -> set[str]: + """Every `artemis ` the output tells the user to run.""" + return set(re.findall(r"artemis\s+([a-z][a-z_-]+)", text)) + + _install_doctor_fakes(monkeypatch, _all_pass_probes()) + ready = runner.invoke(app, ["doctor"]) + assert ready.exit_code == 0, ready.output + assert advertised(ready.output) <= registered, ready.output + assert "artemis mcp --type adb" in ready.output + + _install_doctor_fakes( + monkeypatch, + [ + _probe("python_runtime"), + _probe( + "android_adb", metadata={"installed": True, "adb_keys": {"is_corrupted": False}} + ), + _probe("toolchain", blocker=False, status="fail", summary="scrcpy missing"), + ], ) - return {"run_all": run_all, "heal": heal, "host": host_probe} + degraded = runner.invoke(app, ["doctor"]) + assert advertised(degraded.output) <= registered, degraded.output + assert "artemis mcp --type adb" in degraded.output def test_cli_doctor_all_pass_renders_table_in_fix_order(monkeypatch): - """All-green run: engine rows in fix order, extras last, exit 0, ready footer.""" + """All-green run: engine rows in fix order, exit 0, ready footer.""" _install_doctor_fakes(monkeypatch, _all_pass_probes()) result = runner.invoke(app, ["doctor"]) @@ -806,22 +194,12 @@ def test_cli_doctor_all_pass_renders_table_in_fix_order(monkeypatch): out = result.output assert "Artemis System & Environment Doctor" in out assert "Details & Recommendations" in out - assert out.count("✔ OK") == 7 - assert "Node.js / npm" in out and "Showcase UI" in out + assert out.count("✔ OK") == 3 + assert "Node.js / npm" not in out and "Showcase UI" not in out assert "All system checks passed" in out - assert 'artemis run "Open Settings and check Battery level"' in out - - order = [ - "Title python_runtime", - "Title system_config", - "Title integration_host", - "Title gemini_api_key", - "Title android_adb", - "Title toolchain", - "Title vision_ocr_key", - "Node.js / npm", - "Showcase UI", - ] + assert "artemis mcp --type adb" in out + + order = ["Title python_runtime", "Title android_adb", "Title toolchain"] positions = [out.index(name) for name in order] assert positions == sorted(positions) @@ -861,8 +239,6 @@ def test_cli_doctor_blocker_failure_shows_run_lines_and_splits_chains(monkeypatc def test_cli_doctor_optional_failure_is_degraded_not_blocked(monkeypatch): """Non-blocker FAIL renders as Optional, keeps exit 0, and yields 'degraded'.""" - import json - probes = [p for p in _all_pass_probes() if p.id != "toolchain"] probes.append( _probe( @@ -887,8 +263,17 @@ def test_cli_doctor_optional_failure_is_degraded_not_blocked(monkeypatch): def test_cli_doctor_never_prints_raw_keys_from_metadata(monkeypatch): - """Probe metadata (which carries raw credentials) must not reach the terminal or JSON.""" - _install_doctor_fakes(monkeypatch, _all_pass_probes()) + """Probe metadata (which may carry raw credentials) never reaches output.""" + probes = [ + _probe("python_runtime"), + _probe( + "android_adb", + description="ADB credential is active (sk-RAW...cdef).", + metadata={"installed": True, "raw_key": _RAW_SECRET}, + ), + _probe("toolchain", blocker=False), + ] + _install_doctor_fakes(monkeypatch, probes) table = runner.invoke(app, ["doctor"]) assert table.exit_code == 0, table.output @@ -903,8 +288,6 @@ def test_cli_doctor_never_prints_raw_keys_from_metadata(monkeypatch): def test_cli_doctor_json_shape_and_ready_verdict(monkeypatch): """--json emits the documented document shape and nothing else.""" - import json - _install_doctor_fakes(monkeypatch, _all_pass_probes()) result = runner.invoke(app, ["doctor", "--json"]) @@ -913,39 +296,32 @@ def test_cli_doctor_json_shape_and_ready_verdict(monkeypatch): assert set(doc) == {"verdict", "checks", "extras", "fixes"} assert doc["verdict"] == "ready" assert doc["fixes"] == [] + assert doc["extras"] == {} assert [c["id"] for c in doc["checks"]] == [ "python_runtime", - "system_config", - "integration_host", - "gemini_api_key", "android_adb", "toolchain", - "vision_ocr_key", ] for check in doc["checks"]: assert set(check) == {"id", "title", "status", "required", "summary", "detail", "fix"} assert check["status"] == "pass" assert check["fix"] == [] - assert set(doc["extras"]) == {"nodejs_npm", "showcase_ui"} - assert doc["extras"]["nodejs_npm"]["status"] == "pass" - assert doc["extras"]["showcase_ui"]["status"] == "missing" def test_cli_doctor_json_blocked_verdict_and_exit_code(monkeypatch): """A blocker that is not PASS makes the verdict 'blocked' and the exit code 1.""" - import json - - probes = [p for p in _all_pass_probes() if p.id != "gemini_api_key"] + probes = [p for p in _all_pass_probes() if p.id != "android_adb"] probes.append( _probe( - "gemini_api_key", + "android_adb", status="fail", - summary="Key Missing", - description="No LLM credential found.", + summary="ADB Missing", + description="No platform-tools on PATH.", actions=[ - ("command", "Run Artemis Init", "artemis init"), - ("link", "Get Key", "https://aistudio.google.com/app/apikey"), + ("command", "Install platform-tools", "winget install Google.PlatformTools"), + ("link", "Docs", "https://developer.android.com/tools/adb"), ], + metadata={"installed": False}, ) ) _install_doctor_fakes(monkeypatch, probes) @@ -954,48 +330,19 @@ def test_cli_doctor_json_blocked_verdict_and_exit_code(monkeypatch): assert result.exit_code == 1, result.output doc = json.loads(result.output) assert doc["verdict"] == "blocked" - key_check = next(c for c in doc["checks"] if c["id"] == "gemini_api_key") - assert key_check["status"] == "fail" - assert key_check["required"] is True - assert key_check["fix"] == [ - {"type": "command", "label": "Run Artemis Init", "payload": "artemis init"}, - {"type": "link", "label": "Get Key", "payload": "https://aistudio.google.com/app/apikey"}, + adb_check = next(c for c in doc["checks"] if c["id"] == "android_adb") + assert adb_check["status"] == "fail" + assert adb_check["required"] is True + assert adb_check["fix"] == [ + { + "type": "command", + "label": "Install platform-tools", + "payload": "winget install Google.PlatformTools", + }, + {"type": "link", "label": "Docs", "payload": "https://developer.android.com/tools/adb"}, ] -def test_cli_doctor_host_warn_degrades_and_keeps_the_fix(monkeypatch): - """A host WARN is not a blocker (the runner works around it): the doctor - exits 0 with a degraded verdict and still prints the fix.""" - host = _probe( - "integration_host", - status="warn", - blocker=False, - summary="Host Warning", - description="MCP server runs on the wrong interpreter.", - actions=[("command", "Regenerate", "uv run artemis mcp --install cursor")], - ) - _install_doctor_fakes(monkeypatch, _all_pass_probes(), host=host) - - result = runner.invoke(app, ["doctor"]) - assert result.exit_code == 0, result.output - assert "⚠ Host Warning" in result.output - assert "Run: uv run artemis mcp --install cursor" in result.output - - -def test_cli_doctor_host_fail_blocks_and_uses_summary(monkeypatch): - host = _probe( - "integration_host", - status="fail", - summary="Host Misconfigured", - description="traces directory is not writable.", - ) - _install_doctor_fakes(monkeypatch, _all_pass_probes(), host=host) - - result = runner.invoke(app, ["doctor"]) - assert result.exit_code == 1, result.output - assert "✖ Host Misconfigured" in result.output - - def test_cli_doctor_fix_heals_corrupted_keys_and_sweeps_locks(monkeypatch): """--fix heals ADB keys when the probe reports corruption and sweeps stale locks.""" from artemis.runtime.device_lock import DeviceExecutionLock @@ -1040,3 +387,86 @@ def test_cli_doctor_fix_skips_heal_when_keys_are_healthy(monkeypatch): fakes["heal"].assert_not_awaited() assert "nothing to repair" in result.output assert "No stale device locks" in result.output + + +# --------------------------------------------------------------------------- +# artemis helper smoke (end-to-end device observation smoke test) +# --------------------------------------------------------------------------- + + +def _patch_smoke(monkeypatch, verdict): + import artemis.interfaces.cli.commands.helper as helper_module + from artemis.core.diagnostics import device_smoke as smoke_module + + async def fake_smoke(device_serial=None, timeout_seconds=20.0): + report = dict(verdict) + report["serial"] = device_serial + return report + + monkeypatch.setattr(helper_module, "_resolve_serial", lambda _s: "emulator-5554") + monkeypatch.setattr(smoke_module, "smoke_test_device", fake_smoke) + + +def test_cli_helper_smoke_ok_renders_verdict(monkeypatch): + """A passing observation exits 0 and shows the screenshot/element counts.""" + _patch_smoke( + monkeypatch, + { + "ok": True, + "elapsed_seconds": 1.2, + "screenshot_bytes": 42000, + "element_count": 17, + "hierarchy_backend": "helper", + "error": None, + "fix": [], + }, + ) + result = runner.invoke(app, ["helper", "smoke"]) + assert result.exit_code == 0, result.output + assert "SMOKE OK" in result.output + assert "42000" in result.output and "17" in result.output + + +def test_cli_helper_smoke_failure_shows_guidance_and_exits_1(monkeypatch): + """A placeholder/hang verdict exits 1 and surfaces the error and repair steps.""" + _patch_smoke( + monkeypatch, + { + "ok": False, + "elapsed_seconds": 20.0, + "screenshot_bytes": 67, + "element_count": 0, + "hierarchy_backend": None, + "error": "Screenshot capture failed (driver returned a 67-byte placeholder image)", + "fix": [ + "Unlock the phone and keep the screen on.", + "Restart the on-device UIAutomator server.", + ], + }, + ) + result = runner.invoke(app, ["helper", "smoke"]) + assert result.exit_code == 1, result.output + assert "SMOKE FAILED" in result.output + assert "placeholder" in result.output + assert "Unlock the phone" in result.output + + +def test_cli_helper_smoke_json_emits_verdict(monkeypatch): + """--json prints the raw smoke verdict and keeps the exit code.""" + _patch_smoke( + monkeypatch, + { + "ok": True, + "elapsed_seconds": 0.5, + "screenshot_bytes": 1000, + "element_count": 3, + "hierarchy_backend": "uiautomator", + "error": None, + "fix": [], + }, + ) + result = runner.invoke(app, ["helper", "smoke", "--json"]) + assert result.exit_code == 0, result.output + doc = json.loads(result.output) + assert doc["ok"] is True and doc["serial"] == "emulator-5554" + assert doc["element_count"] == 3 and doc["hierarchy_backend"] == "uiautomator" diff --git a/tests/unit/test_config_package.py b/tests/unit/test_config_package.py deleted file mode 100644 index 5b8c56ad8..000000000 --- a/tests/unit/test_config_package.py +++ /dev/null @@ -1,849 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for the unified artemis.config package.""" - -from pathlib import Path -import tempfile -from unittest.mock import MagicMock, patch -import pytest -from pydantic import SecretStr - -from artemis.config import ( - CONFIG_DIR, - ROOT_DIR, - AgentGlobalConfig, - LLMConfig, - OutputConfig, - Settings, - cleanup_temp_dir, - clear_ipc_port, - clear_ls_address, - deep_merge_llm_config, - get_app_dir, - get_config_path, - get_data_engine_db_path, - get_default_llm_config, - get_default_traces_path, - get_temp_dir, - get_traces_dir, - load_agent_config, - read_ipc_port, - read_ls_address, - record_events, - write_ipc_port, - write_ls_address, -) - - -def test_paths_and_directories(): - """Verify central path resolution.""" - assert ROOT_DIR.exists() - assert (ROOT_DIR / "artemis").exists() - assert CONFIG_DIR.exists() - assert (CONFIG_DIR / "artemis.jsonc").exists() - - app_dir = get_app_dir() - assert isinstance(app_dir, Path) - - traces_path = get_default_traces_path() - assert isinstance(traces_path, Path) - assert get_traces_dir() == traces_path - - db_path = get_data_engine_db_path() - assert db_path.name == "data_engine.db" - - temp_dir = get_temp_dir("test_subfolder") - assert temp_dir.exists() - assert temp_dir.name == "test_subfolder" - - -def test_config_file_resolver(): - """Test get_config_path for unified artemis.jsonc.""" - artemis_path = get_config_path("artemis.jsonc") - assert artemis_path.exists() - - with pytest.raises(FileNotFoundError): - get_config_path("non_existent_config_12345.json") - - -def test_settings_and_api_key_fallbacks(monkeypatch): - """Test Settings model, API key fallbacks, and get/set methods.""" - monkeypatch.delenv("GOOGLE_API_KEY", raising=False) - monkeypatch.delenv("GEMINI_API_KEY", raising=False) - monkeypatch.delenv("GCP_API_KEY", raising=False) - monkeypatch.delenv("OPENAI_API_KEY", raising=False) - - custom_settings = Settings( - _env_file=None, - GOOGLE_API_KEY=None, - GEMINI_API_KEY=SecretStr("gemini_key_123"), - OPENAI_API_KEY=SecretStr("openai_key_456"), - OCR_API_KEY=SecretStr("ocr_key_789"), - ) - # Automatic fallback should populate GOOGLE_API_KEY from GEMINI_API_KEY - assert custom_settings.GOOGLE_API_KEY is not None - assert custom_settings.GOOGLE_API_KEY.get_secret_value() == "gemini_key_123" - assert custom_settings.get_api_key("ocr").get_secret_value() == "ocr_key_789" - - assert custom_settings.get_api_key("google").get_secret_value() == "gemini_key_123" - assert custom_settings.get_api_key("gemini").get_secret_value() == "gemini_key_123" - assert custom_settings.get_api_key("openai").get_secret_value() == "openai_key_456" - assert custom_settings.get_api_key("nonexistent") is None - - # Test dynamic set_api_key - custom_settings.set_api_key("xai", "xai_test_key", persist_to_env=False) - assert custom_settings.get_api_key("xai").get_secret_value() == "xai_test_key" - - -def test_placeholder_api_key_filtering(monkeypatch): - """Test that default template placeholders are filtered out and treated as unconfigured.""" - from artemis.config.settings import is_placeholder_key - - assert is_placeholder_key("your_gemini_api_key_here") is True - assert is_placeholder_key("your_google_cloud_vision_api_key_here") is True - assert is_placeholder_key("your_openai_api_key_here") is True - assert is_placeholder_key("your_custom_key_here") is True - assert is_placeholder_key("") is True - assert is_placeholder_key("[api_key]") is True - assert is_placeholder_key("API_KEY") is True - assert is_placeholder_key("") is True - assert is_placeholder_key(None) is True - assert is_placeholder_key("AIzaSyValidGeminiKey12345") is False - - for env_k in ( - "GOOGLE_API_KEY", - "GEMINI_API_KEY", - "GCP_API_KEY", - "OPENAI_API_KEY", - "ANTHROPIC_API_KEY", - "OPEN_ROUTER_API_KEY", - "XAI_API_KEY", - "OCR_API_KEY", - "VISION_API_KEY", - "API_KEY", - ): - monkeypatch.delenv(env_k, raising=False) - - placeholder_settings = Settings( - _env_file=None, - GEMINI_API_KEY=SecretStr("your_gemini_api_key_here"), - OPENAI_API_KEY=SecretStr("your_openai_api_key_here"), - OCR_API_KEY=SecretStr("your_google_cloud_vision_api_key_here"), - ) - assert placeholder_settings.get_api_key("google") is None - assert placeholder_settings.get_api_key("gemini") is None - assert placeholder_settings.get_api_key("openai") is None - assert placeholder_settings.get_api_key("ocr") is None - - -def test_llm_config_parsing_and_merging(): - """Test LLMConfig parsing, agent querying, and deep merging.""" - llm_cfg = get_default_llm_config() - assert isinstance(llm_cfg, LLMConfig) - assert llm_cfg.planner.provider in ("google", "openai", "openrouter", "xai", "vertexai") - assert llm_cfg.get_agent("planner") is not None - assert llm_cfg.get_utils("hopper") is not None - - # Deep merge overrides - overrides = { - "planner": { - "model": "custom-model-v1", - "temperature": 0.7, - } - } - merged = deep_merge_llm_config(llm_cfg, overrides) - assert merged.planner.model == "custom-model-v1" - assert merged.planner.temperature == 0.7 - - -def test_agent_config_loading(): - """Test AgentGlobalConfig parsing from agent_config.json / artemis.jsonc.""" - agent_cfg = load_agent_config() - assert isinstance(agent_cfg, AgentGlobalConfig) - # The per-agent override ships empty so the profile knobs decide; caching - # ships unset so each tier applies its own default (off pro, on ultra). - assert agent_cfg.explorer_versions == {} - assert agent_cfg.explorer.default_version == "flash" - assert agent_cfg.explorer.flash_mode == "flash" - assert agent_cfg.explorer.pro_mode == "flash" - assert agent_cfg.explorer.caching is None - assert "explorer" in agent_cfg.denylisted_tools - assert agent_cfg.video_analyzer.enable_ledger is True - assert agent_cfg.planner_validation.enabled is True - assert agent_cfg.committee.enabled is False - assert agent_cfg.committee.debate_rounds == 2 - assert agent_cfg.checker.enabled is True - assert agent_cfg.checker.max_iterations == 20 - assert agent_cfg.checker.midway_checks is False - assert agent_cfg.checker.final_check is True - assert agent_cfg.checker.assert_failure_policy == "continue" - assert agent_cfg.checker.device_probes is True - assert agent_cfg.outputter.enabled is True - assert agent_cfg.outputter.force_synthesis is False - assert agent_cfg.flash.max_turns == 0 - assert agent_cfg.flash.explorer_mode == "flash" - assert agent_cfg.pro.explorer.mode == "flash" - assert agent_cfg.pro.checker.enabled is True - assert agent_cfg.pro.committee.enabled is False - assert agent_cfg.pro.planner_validation.enabled is True - assert agent_cfg.pro.video_analyzer.enable_ledger is True - - -def test_output_config_and_recording(): - """Test OutputConfig behavior and event recording.""" - cfg = OutputConfig(output_description="Output a valid JSON summary") - assert cfg.needs_structured_format() is True - - cfg_disabled = OutputConfig(output_description="Output JSON", enable_outputter=False) - assert cfg_disabled.needs_structured_format() is False - - cfg_forced = OutputConfig(force_synthesis=True) - assert cfg_forced.needs_structured_format() is True - - with tempfile.TemporaryDirectory() as tmpdir: - output_file = Path(tmpdir) / "events.json" - events_data = [{"event": "test_event", "status": "ok"}] - record_events(output_file, events_data) - assert output_file.exists() - assert "test_event" in output_file.read_text(encoding="utf-8") - - -def test_runtime_state_and_ipc(tmp_path, monkeypatch): - """Test IPC port and LS address state helpers.""" - import urllib.request - - monkeypatch.setattr(urllib.request, "urlopen", MagicMock(side_effect=Exception("offline"))) - ipc_state_file = tmp_path / ".artemis_ipc_port" - monkeypatch.setattr("artemis.config.runtime.get_ipc_port_file", lambda: ipc_state_file) - monkeypatch.setattr("artemis.config.runtime.ROOT_DIR", tmp_path) - monkeypatch.setattr("artemis.config.runtime.get_app_dir", lambda: tmp_path) - # Test IPC port - write_ipc_port(49152) - assert read_ipc_port() == 49152 - clear_ipc_port() - assert read_ipc_port() is None - - # Test LS address - write_ls_address("localhost:12345") - assert read_ls_address() == "localhost:12345" - clear_ls_address() - - # Test temp dir cleanup - test_temp_sub = get_temp_dir("test_cleanup_dir") - test_temp_file = test_temp_sub / "temp_marker.txt" - test_temp_file.write_text("temporary data") - assert test_temp_file.exists() - - deleted = cleanup_temp_dir("test_cleanup_dir") - assert deleted >= 1 - assert not test_temp_file.exists() - - -def test_planner_validation_builder_and_milestones(): - """Test AgentConfigBuilder methods for planner validation and milestone drift detection.""" - from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder - from artemis.utils.plan_grammar import milestones_changed, parse_plan - - # Default builder inherits from artemis.jsonc (enabled=True) - builder = AgentConfigBuilder() - cfg = builder.build() - assert cfg.disable_planner_validation is False - # The dead similarity-threshold knob is gone: validation has no tunable - # trigger, every milestone text change is reviewed. - assert not hasattr(cfg, "planner_validation_threshold") - - # Fluent enabling - cfg_enabled = AgentConfigBuilder().with_planner_validation(enabled=True).build() - assert cfg_enabled.disable_planner_validation is False - - # Fluent disabling - cfg_disabled = AgentConfigBuilder().with_disable_planner_validation(True).build() - assert cfg_disabled.disable_planner_validation is True - - # Milestone drift detection is threshold-free: any text change counts, - # status-only flips never do (see artemis.utils.plan_grammar) - before = parse_plan("- [ ] Tap Login button\n- [ ] Enter password") - after_minor = parse_plan("- [ ] Tap the Login button\n- [ ] Enter password") - after_status = parse_plan("- [x] Tap Login button\n- [/] Enter password") - assert milestones_changed(before, after_minor) is True - assert milestones_changed(before, after_status) is False - - -@pytest.mark.asyncio -async def test_committee_builder_and_graph_mounting(): - """Test AgentConfigBuilder committee methods and graph mounting.""" - from artemis.context import ArtemisContext, DeviceContext, DevicePlatform, ExecutionSetup - from artemis.graph.graph import get_graph - from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder - - # Default: disabled - cfg_default = AgentConfigBuilder().build() - assert cfg_default.enable_committee is False - assert cfg_default.committee_debate_rounds == 2 - - # Enabled via builder - cfg_enabled = AgentConfigBuilder().with_committee(enabled=True, debate_rounds=3).build() - assert cfg_enabled.enable_committee is True - assert cfg_enabled.committee_debate_rounds == 3 - - # Test mounting in graph - device = DeviceContext( - host_platform="LINUX", - mobile_platform=DevicePlatform.ANDROID, - device_id="dummy", - device_width=1080, - device_height=2400, - ) - ctx_disabled = ArtemisContext( - device=device, - execution_setup=ExecutionSetup(enable_committee=False), - ) - graph_disabled = await get_graph(ctx_disabled) - op_node_disabled = graph_disabled.nodes.get("operator") - assert op_node_disabled is not None - # Verify ask_committee is not mounted when disabled - op_tools_disabled = [t.name for t in op_node_disabled.bound.afunc.tools] - assert "ask_committee" not in op_tools_disabled - - ctx_enabled = ArtemisContext( - device=device, - execution_setup=ExecutionSetup(enable_committee=True, committee_debate_rounds=3), - ) - graph_enabled = await get_graph(ctx_enabled) - op_node_enabled = graph_enabled.nodes.get("operator") - assert op_node_enabled is not None - # ask_committee is deliberately never mounted on the Operator (it sits outside - # the pre-decision / turn-ending tool contract), even when the flag is on. - op_tools_enabled = [t.name for t in op_node_enabled.bound.afunc.tools] - assert "ask_committee" not in op_tools_enabled - - -def test_checker_builder_and_context_propagation(): - """Test AgentConfigBuilder methods and context propagation for checker.""" - from unittest.mock import MagicMock - from artemis.context import ArtemisContext, DeviceContext, DevicePlatform - from artemis.sdk.agent import Agent - from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder - - # Default builder inherits from artemis.jsonc (enabled=True, midway off, final on) - builder = AgentConfigBuilder() - cfg = builder.build() - assert cfg.disable_checker is False - assert cfg.checker_max_iterations == 20 - assert cfg.disable_midway_checks is True - assert cfg.disable_final_check is False - - # Fluent enabling with the new two-gate knobs - cfg_enabled = ( - AgentConfigBuilder() - .with_checker( - enabled=True, - max_iterations=25, - midway_checks=True, - final_check=False, - checkpoint_max_repairs=1, - assert_failure_policy="halt", - device_probes=False, - ) - .build() - ) - assert cfg_enabled.disable_checker is False - assert cfg_enabled.checker_max_iterations == 25 - assert cfg_enabled.disable_midway_checks is False - assert cfg_enabled.disable_final_check is True - assert cfg_enabled.checkpoint_max_repairs == 1 - assert cfg_enabled.assert_failure_policy == "halt" - assert cfg_enabled.disable_device_probes is True - - # Fluent disabling - cfg_disabled = AgentConfigBuilder().with_disable_checker(True).build() - assert cfg_disabled.disable_checker is True - - # Test propagation to ExecutionSetup via Agent._prepare_tracing - agent = Agent(config=cfg_enabled) - device = DeviceContext( - host_platform="LINUX", - mobile_platform=DevicePlatform.ANDROID, - device_id="dummy", - device_width=1080, - device_height=2400, - ) - ctx = ArtemisContext(device=device) - mock_task = MagicMock() - mock_task.get_name.return_value = "test_task" - mock_task.request.record_trace = False - mock_task.request.name = "test_task" - mock_task.request.profile = None - with patch("artemis.sdk.agent.DataEngine"): - agent._prepare_tracing(mock_task, ctx) - - assert ctx.execution_setup is not None - assert ctx.execution_setup.disable_checker is False - assert ctx.execution_setup.checker_max_iterations == 25 - assert ctx.execution_setup.disable_final_check is True - assert ctx.execution_setup.checkpoint_max_repairs == 1 - assert ctx.execution_setup.assert_failure_policy == "halt" - assert ctx.execution_setup.disable_device_probes is True - # Effective gate semantics: master alias off + individual gates - assert ctx.execution_setup.midway_checks_enabled is True - assert ctx.execution_setup.final_check_enabled is False - - -def test_factory_default_verification_layering(): - """Contract: out of the box, the verification stack is layered as - final check ON / planner validation (ratchet) ON / midway checks OFF.""" - from artemis.config.agent import AgentGlobalConfig, CheckerConfig, PlannerValidationConfig - from artemis.context import ExecutionSetup - from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder - - # Config-model factory defaults - assert CheckerConfig().enabled is True - assert CheckerConfig().midway_checks is False - assert CheckerConfig().final_check is True - assert PlannerValidationConfig().enabled is True - - # Bare ExecutionSetup carries the same layering, including the effective - # gate semantics (master on + midway off => final review runs, no midway - # checkpoint ever spawns). - setup = ExecutionSetup() - assert setup.disable_checker is False - assert setup.disable_midway_checks is True - assert setup.disable_final_check is False - assert setup.disable_planner_validation is False - assert setup.final_check_enabled is True - assert setup.midway_checks_enabled is False - assert setup.checks_enabled is True - - # Builder default (fed by config/artemis.jsonc) agrees - cfg = AgentConfigBuilder().build() - assert cfg.disable_checker is False - assert cfg.disable_midway_checks is True - assert cfg.disable_final_check is False - assert cfg.disable_planner_validation is False - - # Shipped artemis.jsonc agrees with the model factory defaults - agent_cfg = load_agent_config() - assert agent_cfg.checker.enabled is True - assert agent_cfg.checker.midway_checks is False - assert agent_cfg.checker.final_check is True - assert agent_cfg.planner_validation.enabled is True - assert AgentGlobalConfig().checker.enabled is True - - -def test_explorer_builder_and_resolution(monkeypatch): - """Test AgentConfigBuilder explorer methods and multi-tier resolution logic.""" - from artemis.context import ArtemisContext, DeviceContext, DevicePlatform - from artemis.sdk.agent import Agent - from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder - from artemis.tools.explorer_tool import resolve_explorer_version - - monkeypatch.delenv("ARTEMIS_EXPLORER_VERSION", raising=False) - - # Default builder inherits from artemis.jsonc (default="flash", flash_mode="flash", - # pro_mode="flash", caching unset, no per-agent override). - builder = AgentConfigBuilder() - cfg = builder.build() - assert cfg.explorer.default_version == "flash" - assert cfg.explorer.flash_mode == "flash" - assert cfg.explorer.pro_mode == "flash" - assert cfg.explorer.caching is None - assert cfg.explorer_versions == {} - - # With the shipped (empty) override the profile knobs actually win: the - # Pro agents follow pro_mode and the Flash runner follows flash_mode. - cfg_pro_ultra = AgentConfigBuilder().with_explorer(pro_mode="ultra").build() - assert cfg_pro_ultra.get_explorer_version(agent_name="operator") == "ultra" - assert cfg_pro_ultra.get_explorer_version(agent_name="validator") == "ultra" - assert cfg_pro_ultra.get_explorer_version(agent_name="flash") == "flash" - cfg_flash_pro = AgentConfigBuilder().with_explorer(flash_mode="pro").build() - assert cfg_flash_pro.get_explorer_version(agent_name="flash") == "pro" - assert cfg_flash_pro.get_explorer_version(agent_name="operator") == "flash" - - # Fluent configuration with with_explorer - cfg_custom = ( - AgentConfigBuilder() - .with_explorer( - version="pro", - flash_mode="flash", - pro_mode="ultra", - caching=False, - versions={"operator": "ultra", "validator": "pro"}, - ) - .build() - ) - assert cfg_custom.explorer.default_version == "pro" - assert cfg_custom.explorer.flash_mode == "flash" - assert cfg_custom.explorer.pro_mode == "ultra" - assert cfg_custom.explorer.caching is False - assert cfg_custom.explorer_versions["operator"] == "ultra" - assert cfg_custom.explorer_versions["validator"] == "pro" - - # Fluent configuration with with_explorer_version shorthand - cfg_shorthand = AgentConfigBuilder().with_explorer_version("ultra").build() - assert cfg_shorthand.explorer.default_version == "ultra" - - # Context setup for resolution tests - device = DeviceContext( - host_platform="LINUX", - mobile_platform=DevicePlatform.ANDROID, - device_id="dummy", - device_width=1080, - device_height=2400, - ) - ctx = ArtemisContext(device=device, agent_config=cfg_custom) - - # 1. Explicit override takes highest priority - assert resolve_explorer_version(ctx, explicit_version="flash") == "flash" - assert resolve_explorer_version(ctx, explicit_version="pro") == "pro" - assert resolve_explorer_version(ctx, explicit_version="ultra") == "ultra" - - # 2. Environment variable override - monkeypatch.setenv("ARTEMIS_EXPLORER_VERSION", "ultra") - assert resolve_explorer_version(ctx) == "ultra" - monkeypatch.delenv("ARTEMIS_EXPLORER_VERSION", raising=False) - - # 3. Per-agent mapping in explorer_versions - assert resolve_explorer_version(ctx, agent_or_profile_name="operator") == "ultra" - assert resolve_explorer_version(ctx, agent_or_profile_name="validator") == "pro" - - # 4. Pro profile vs Flash profile resolution - cfg_profiled = ( - AgentConfigBuilder() - .with_explorer(flash_mode="flash", pro_mode="pro", default_version="flash", versions={}) - .build() - ) - ctx_profiled = ArtemisContext(device=device, agent_config=cfg_profiled) - assert resolve_explorer_version(ctx_profiled, agent_or_profile_name="flash") == "flash" - assert resolve_explorer_version(ctx_profiled, agent_or_profile_name="flash_runner") == "flash" - assert resolve_explorer_version(ctx_profiled, agent_or_profile_name="pro") == "pro" - assert resolve_explorer_version(ctx_profiled, agent_or_profile_name="operator") == "pro" - - # 5. Propagation to ExecutionSetup via Agent._prepare_tracing - agent = Agent(config=cfg_custom) - mock_task = MagicMock() - mock_task.get_name.return_value = "explorer_test_task" - mock_task.request.record_trace = False - mock_task.request.name = "explorer_test_task" - mock_task.request.profile = None - with patch("artemis.sdk.agent.DataEngine"): - agent._prepare_tracing(mock_task, ctx) - - assert ctx.execution_setup is not None - assert ctx.execution_setup.explorer_version == "pro" - assert ctx.execution_setup.explorer_flash_mode == "flash" - assert ctx.execution_setup.explorer_pro_mode == "ultra" - assert ctx.execution_setup.explorer_caching is False - - -def test_outputter_builder_and_context_propagation(): - """Test AgentConfigBuilder outputter methods and propagation to ExecutionSetup.""" - from artemis.context import ArtemisContext, DeviceContext, DevicePlatform - from artemis.sdk.agent import Agent - from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder - - # Default builder inherits from artemis.jsonc (enabled=True, force_synthesis=False) - builder = AgentConfigBuilder() - cfg = builder.build() - assert cfg.disable_outputter is False - assert cfg.outputter.enabled is True - assert cfg.outputter.force_synthesis is False - - # Fluent configuration with with_outputter - cfg_custom = AgentConfigBuilder().with_outputter(enabled=True, force_synthesis=True).build() - assert cfg_custom.disable_outputter is False - assert cfg_custom.outputter.enabled is True - assert cfg_custom.outputter.force_synthesis is True - - # Fluent disabling with with_disable_outputter - cfg_disabled = AgentConfigBuilder().with_disable_outputter(True).build() - assert cfg_disabled.disable_outputter is True - assert cfg_disabled.outputter.enabled is False - - # Test propagation to ExecutionSetup via Agent._prepare_tracing - device = DeviceContext( - host_platform="LINUX", - mobile_platform=DevicePlatform.ANDROID, - device_id="dummy", - device_width=1080, - device_height=2400, - ) - ctx = ArtemisContext(device=device) - agent = Agent(config=cfg_custom) - mock_task = MagicMock() - mock_task.get_name.return_value = "outputter_test_task" - mock_task.request.record_trace = False - mock_task.request.name = "outputter_test_task" - mock_task.request.profile = None - mock_task.request.goal = "Test outputter propagation" - with patch("artemis.sdk.agent.DataEngine"): - agent._prepare_tracing(mock_task, ctx) - - assert ctx.execution_setup is not None - assert ctx.execution_setup.disable_outputter is False - assert ctx.execution_setup.outputter.enabled is True - assert ctx.execution_setup.outputter.force_synthesis is True - - -def test_categorized_flash_and_pro_profile_builders(): - """Test with_flash_config and with_pro_config fluent builders and bidirectional sync.""" - from artemis.config.agent import AgentGlobalConfig - from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder - - # 1. Test with_flash_config - cfg_flash = AgentConfigBuilder().with_flash_config(max_turns=15, explorer_mode="flash").build() - assert cfg_flash.flash.max_turns == 15 - assert cfg_flash.flash.explorer_mode == "flash" - assert cfg_flash.explorer.flash_mode == "flash" - - # 2. Test with_pro_config - cfg_pro = ( - AgentConfigBuilder() - .with_pro_config( - explorer_mode="ultra", - planner_validation=True, - committee=True, - checker=True, - video_ledger=False, - ) - .build() - ) - assert cfg_pro.pro.explorer.mode == "ultra" - assert cfg_pro.explorer.pro_mode == "ultra" - assert cfg_pro.disable_planner_validation is False - assert cfg_pro.enable_committee is True - assert cfg_pro.disable_checker is False - assert cfg_pro.enable_video_ledger is False - - # 3. Test AgentGlobalConfig bidirectional synchronization - raw_dict = { - "flash": {"max_turns": 25, "explorer_mode": "flash"}, - "pro": { - "explorer": {"mode": "pro", "caching": False}, - "checker": {"enabled": True, "max_iterations": 10}, - "committee": {"enabled": True, "debate_rounds": 4}, - }, - } - synced = AgentGlobalConfig.model_validate(raw_dict) - assert synced.flash.max_turns == 25 - assert synced.explorer.flash_mode == "flash" - assert synced.explorer.pro_mode == "pro" - assert synced.explorer.caching is False - assert synced.checker.enabled is True - assert synced.checker.max_iterations == 10 - assert synced.committee.enabled is True - assert synced.committee.debate_rounds == 4 - - -def test_agent_config_environment_variable_overrides(monkeypatch): - """Test environment variable overrides for AgentGlobalConfig switches.""" - from artemis.config.agent import load_agent_config - - monkeypatch.setenv("ARTEMIS_CHECKER_ENABLED", "true") - monkeypatch.setenv("ARTEMIS_COMMITTEE_ENABLED", "1") - monkeypatch.setenv("ARTEMIS_PLANNER_VALIDATION_ENABLED", "yes") - monkeypatch.setenv("ARTEMIS_OUTPUTTER_ENABLED", "false") - monkeypatch.setenv("ARTEMIS_VIDEO_LEDGER_ENABLED", "0") - - cfg = load_agent_config() - assert cfg.checker.enabled is True - assert cfg.pro.checker.enabled is True - assert cfg.committee.enabled is True - assert cfg.pro.committee.enabled is True - assert cfg.planner_validation.enabled is True - assert cfg.pro.planner_validation.enabled is True - assert cfg.outputter.enabled is False - assert cfg.video_analyzer.enable_ledger is False - assert cfg.pro.video_analyzer.enable_ledger is False - - -def test_consolidated_workspace_and_admin_paths(): - """Verify consolidated workspace and admin paths exported from artemis.config.""" - from artemis.config import ( - DB_PATH, - IMAGES_DIR, - PAUSE_FILE, - REPLAY_BASE_DIR, - ROOT_DIR, - TEST_DATA_DIR, - TEST_OUTPUTS_DIR, - TRACES_PATH, - WORKSPACE_ROOT, - get_images_dir, - get_pause_file, - get_replay_dir, - get_test_data_dir, - get_test_outputs_dir, - ) - - assert WORKSPACE_ROOT == ROOT_DIR - assert PAUSE_FILE == get_pause_file() - assert PAUSE_FILE.name == ".artemis_paused" - assert REPLAY_BASE_DIR == get_replay_dir() - assert TEST_DATA_DIR == get_test_data_dir() - assert TEST_OUTPUTS_DIR == get_test_outputs_dir() - assert IMAGES_DIR == get_images_dir() - assert DB_PATH.name == "data_engine.db" - assert TRACES_PATH.name == "traces" - - -def test_admin_console_config_facade_backward_compatibility(): - """Verify apps.admin_console.core.config continues to re-export consolidated paths.""" - from apps.admin_console.core.config import ( - DB_PATH, - IMAGES_DIR, - PAUSE_FILE, - REPLAY_BASE_DIR, - TEST_DATA_DIR, - TEST_OUTPUTS_DIR, - TRACES_PATH, - WORKSPACE_ROOT, - init_ls_address, - ) - import artemis.config as ac - - assert WORKSPACE_ROOT == ac.WORKSPACE_ROOT - assert PAUSE_FILE == ac.PAUSE_FILE - assert REPLAY_BASE_DIR == ac.REPLAY_BASE_DIR - assert TEST_DATA_DIR == ac.TEST_DATA_DIR - assert TEST_OUTPUTS_DIR == ac.TEST_OUTPUTS_DIR - assert IMAGES_DIR == ac.IMAGES_DIR - assert DB_PATH == ac.DB_PATH - assert TRACES_PATH == ac.TRACES_PATH - assert callable(init_ls_address) - - -class TestVerificationLevelPresets: - """The coarse Checker presets behind ``--verification-level``.""" - - def test_every_preset_is_a_valid_checker_override(self): - from artemis.config import ( - DEFAULT_VERIFICATION_LEVEL, - VERIFICATION_LEVEL_PRESETS, - CheckerConfig, - checker_overrides_for_level, - ) - - assert DEFAULT_VERIFICATION_LEVEL in VERIFICATION_LEVEL_PRESETS - for level in VERIFICATION_LEVEL_PRESETS: - cfg = CheckerConfig(**checker_overrides_for_level(level)) - assert isinstance(cfg, CheckerConfig) - - def test_presets_form_a_monotonic_ladder(self): - from artemis.config import CheckerConfig, checker_overrides_for_level - - off = CheckerConfig(**checker_overrides_for_level("off")) - final = CheckerConfig(**checker_overrides_for_level("final")) - checkpoints = CheckerConfig(**checker_overrides_for_level("checkpoints")) - strict = CheckerConfig(**checker_overrides_for_level("strict")) - - assert off.enabled is False - assert final.enabled and final.final_check and not final.midway_checks - # ``final`` is the factory layering: identical to a default CheckerConfig. - assert final == CheckerConfig() - assert checkpoints.midway_checks and checkpoints.final_check - assert checkpoints.assert_failure_policy == "continue" - assert strict.midway_checks and strict.final_check - assert strict.assert_failure_policy == "halt" - assert strict.checkpoint_max_repairs > checkpoints.checkpoint_max_repairs - assert strict.final_check_max_attempts > checkpoints.final_check_max_attempts - assert strict.max_iterations > checkpoints.max_iterations - - def test_level_lookup_is_case_and_whitespace_insensitive(self): - from artemis.config import checker_overrides_for_level - - assert checker_overrides_for_level(" STRICT ") == checker_overrides_for_level("strict") - # A copy is returned so callers cannot mutate the shared preset table. - overrides = checker_overrides_for_level("off") - overrides["enabled"] = True - assert checker_overrides_for_level("off")["enabled"] is False - - @pytest.mark.parametrize("bad", [None, "", "maximum", "ultra"]) - def test_unknown_level_raises(self, bad): - from artemis.config import checker_overrides_for_level - - with pytest.raises(ValueError, match="Unknown verification level"): - checker_overrides_for_level(bad) - - def test_level_preset_survives_explicit_master_switch(self): - """``--verification-level strict --disable-checker`` keeps the master switch off.""" - from artemis.config import checker_overrides_for_level - from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder - - builder = AgentConfigBuilder() - builder.with_checker(**checker_overrides_for_level("strict")) - assert builder._disable_checker is False - assert builder._disable_midway_checks is False - assert builder._assert_failure_policy == "halt" - builder.with_checker(enabled=False) - assert builder._disable_checker is True - # The preset's finer-grained fields survive the master switch flip. - assert builder._assert_failure_policy == "halt" - - @pytest.mark.parametrize("level", ["off", "final", "checkpoints", "strict"]) - def test_effective_config_classifies_back_onto_the_ladder(self, level): - from artemis.config import ( - CheckerConfig, - checker_overrides_for_level, - verification_level_for_checker, - ) - - cfg = CheckerConfig(**checker_overrides_for_level(level)) - assert verification_level_for_checker(cfg) == level - # A config with both gates switched off is "off" even if enabled=True. - assert ( - verification_level_for_checker( - CheckerConfig(enabled=True, midway_checks=False, final_check=False) - ) - == "off" - ) - - -class TestRunTuningSummary: - """``run_tuning_for_profile``: the per-run tuning persisted with a session.""" - - def test_flash_has_no_tuning(self): - from artemis.config import CheckerConfig, ExplorerConfig, run_tuning_for_profile - - kwargs = {"checker": CheckerConfig(), "explorer": ExplorerConfig()} - assert run_tuning_for_profile("flash", **kwargs) is None - assert run_tuning_for_profile(None, **kwargs) is None - - def test_pro_reports_both_sliders(self): - from artemis.config import ( - CheckerConfig, - ExplorerConfig, - checker_overrides_for_level, - run_tuning_for_profile, - ) - - summary = run_tuning_for_profile( - " Pro ", - checker=CheckerConfig(**checker_overrides_for_level("strict")), - explorer=ExplorerConfig(pro_mode="ultra"), - ) - assert summary == {"verification_level": "strict", "explorer_mode": "ultra"} - - def test_pro_defaults_match_launcher_defaults(self): - from artemis.config import ( - CheckerConfig, - ExplorerConfig, - run_tuning_for_profile, - verification_level_for_checker, - ) - - checker, explorer = CheckerConfig(), ExplorerConfig() - summary = run_tuning_for_profile("pro", checker=checker, explorer=explorer) - assert summary["verification_level"] == verification_level_for_checker(checker) - assert summary["explorer_mode"] == explorer.resolve(profile="pro") diff --git a/tests/unit/test_context.py b/tests/unit/test_context.py deleted file mode 100644 index 4149de26d..000000000 --- a/tests/unit/test_context.py +++ /dev/null @@ -1,77 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -import pytest -from artemis.context import ArtemisContext, DeviceContext, DevicePlatform - - -@pytest.mark.asyncio -async def test_artemis_context_waits_for_background_tasks(): - device = DeviceContext( - host_platform="LINUX", - mobile_platform=DevicePlatform.ANDROID, - device_id="dummy", - device_width=1080, - device_height=2400, - ) - - context = ArtemisContext(device=device) - - task_run = False - - async def dummy_task(): - nonlocal task_run - await asyncio.sleep(0.1) - task_run = True - - async with context: - task = asyncio.create_task(dummy_task()) - context.background_tasks.append(task) - - # After exiting the context, the task should have run to completion - assert task_run is True - - -@pytest.mark.asyncio -async def test_artemis_context_cancels_background_tasks_after_grace_period(): - device = DeviceContext( - host_platform="LINUX", - mobile_platform=DevicePlatform.ANDROID, - device_id="dummy", - device_width=1080, - device_height=2400, - ) - context = ArtemisContext( - device=device, - background_task_grace_period_seconds=0.01, - ) - - task_cancelled = False - - async def stuck_summary(): - nonlocal task_cancelled - try: - await asyncio.Event().wait() - finally: - task_cancelled = True - - async with context: - task = asyncio.create_task(stuck_summary(), name="stuck_summary") - context.background_tasks.append(task) - - assert task.done() - assert task.cancelled() - assert task_cancelled is True - assert context.background_tasks == [] diff --git a/tests/unit/test_controller_factory.py b/tests/unit/test_controller_factory.py deleted file mode 100644 index 15e9361c0..000000000 --- a/tests/unit/test_controller_factory.py +++ /dev/null @@ -1,34 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import MagicMock, patch - -from artemis.context import ArtemisContext -from artemis.controllers.controller_factory import get_controller -from artemis.controllers.unified_controller import UnifiedMobileController - - -def test_get_controller_reuses_context_scoped_controller(): - ctx = MagicMock(spec=ArtemisContext) - ctx._mobile_controller = None - controller = object.__new__(UnifiedMobileController) - - with patch( - "artemis.controllers.controller_factory.create_device_controller", - return_value=controller, - ) as create: - assert get_controller(ctx) is controller - assert get_controller(ctx) is controller - - create.assert_called_once_with(ctx) diff --git a/tests/unit/test_credentials_validator.py b/tests/unit/test_credentials_validator.py deleted file mode 100644 index 8ae63d8a6..000000000 --- a/tests/unit/test_credentials_validator.py +++ /dev/null @@ -1,80 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import pytest -from unittest.mock import AsyncMock, patch, MagicMock -from artemis.utils.credentials_validator import validate_api_key, _extract_error_message - - -def test_extract_error_message(): - resp_google = MagicMock() - resp_google.json.return_value = {"error": {"message": "API key invalid."}} - assert _extract_error_message(resp_google) == "API key invalid." - - resp_openai = MagicMock() - resp_openai.json.return_value = {"message": "Incorrect API key."} - assert _extract_error_message(resp_openai) == "Incorrect API key." - - resp_fallback = MagicMock() - resp_fallback.json.side_effect = ValueError("JSON parse error") - resp_fallback.text = "Raw error text" - assert _extract_error_message(resp_fallback) == "Raw error text" - - -@pytest.mark.asyncio -async def test_validate_empty_and_placeholder(): - is_valid, msg = await validate_api_key("google", "") - assert not is_valid - assert "empty" in msg.lower() - - is_valid, msg = await validate_api_key("google", "API_KEY") - assert not is_valid - assert "placeholder" in msg.lower() - - -@pytest.mark.asyncio -async def test_validate_gemini_success(): - with patch("httpx.AsyncClient.get", new_callable=AsyncMock) as mock_get: - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_get.return_value = mock_resp - - is_valid, msg = await validate_api_key("google", "valid_key_12345") - assert is_valid - assert "verified successfully" in msg - - -@pytest.mark.asyncio -async def test_validate_gemini_failure(): - with patch("httpx.AsyncClient.get", new_callable=AsyncMock) as mock_get: - mock_resp = MagicMock() - mock_resp.status_code = 400 - mock_resp.json.return_value = {"error": {"message": "API key not valid."}} - mock_get.return_value = mock_resp - - is_valid, msg = await validate_api_key("google", "bad_key_12345") - assert not is_valid - assert "API key not valid" in msg - - -@pytest.mark.asyncio -async def test_validate_ocr_success(): - with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_post.return_value = mock_resp - - is_valid, msg = await validate_api_key("ocr", "valid_ocr_key_12345") - assert is_valid - assert "verified successfully" in msg diff --git a/tests/unit/test_gemini_role.py b/tests/unit/test_gemini_role.py deleted file mode 100644 index b045db8e9..000000000 --- a/tests/unit/test_gemini_role.py +++ /dev/null @@ -1,79 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from google import genai -from google.genai import types -from google.genai.errors import APIError -import pytest - -pytestmark = pytest.mark.skip( - reason="Exploratory live API verification test for Gemini SDK role support" -) - - -@pytest.fixture -def client(): - """Initializes the Gemini client. Requires GEMINI_API_KEY in environment.""" - import os - from artemis.config.settings import is_placeholder_key - - key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY") - if not key or is_placeholder_key(key): - pytest.skip( - "Valid GEMINI_API_KEY / GOOGLE_API_KEY not configured for live Gemini role tests" - ) - try: - return genai.Client() - except Exception as e: - pytest.skip(f"Could not initialize genai.Client: {e}") - - -def test_gemini_role_tool_fails(client): - """Confirm that using role='tool' results in a 400 APIError from Gemini.""" - contents = [ - types.Content( - role="tool", - parts=[types.Part.from_text(text="This is a test payload for tool execution.")], - ) - ] - - with pytest.raises(APIError) as exc_info: - client.models.generate_content( - model="gemini-3.7-flash", - contents=contents, - ) - - # Assert it failed specifically due to the invalid role string - error_msg = str(exc_info.value) - assert "Invalid role string" in error_msg or "400" in error_msg - - -def test_gemini_role_user_succeeds(client): - """Confirm that using role='user' (which is the correct fix) succeeds.""" - contents = [ - types.Content( - role="user", - parts=[ - types.Part.from_text(text="Acknowledge this message with 'ok' and nothing else.") - ], - ) - ] - - # This should not raise any exceptions - response = client.models.generate_content( - model="gemini-3.7-flash", - contents=contents, - ) - - assert response.text is not None diff --git a/tests/unit/test_google_provider.py b/tests/unit/test_google_provider.py deleted file mode 100644 index f0f7066e9..000000000 --- a/tests/unit/test_google_provider.py +++ /dev/null @@ -1,146 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Provider identity and Gemini capability table.""" - -from artemis.llm.google import ( - gemini_version, - is_agentic_video_auto_eligible, - is_gemini_model, - is_google_chat_model, - is_google_family_provider, - is_google_provider, - resolve_video_processing, - strip_provider_prefix, - supports_agentic_video, - supports_thinking_level, -) -from artemis.llm.router import ModelProvider -import pytest - - -@pytest.mark.parametrize( - "value, google, family", - [ - ("google", True, True), - ("Gemini", True, True), - (ModelProvider.GOOGLE, True, True), - (ModelProvider.GEMINI, True, True), - ("vertexai", False, True), - ("vertex-ai", False, True), - (ModelProvider.VERTEX_AI, False, True), - ("openai", False, False), - ("anthropic", False, False), - (None, False, False), - ("", False, False), - ], -) -def test_provider_identity_tiers(value, google, family): - assert is_google_provider(value) is google - assert is_google_family_provider(value) is family - - -def test_google_chat_model_is_detected_by_class_name(): - class ChatGoogleGenerativeAI: # noqa: N801 - mirrors the real class name - pass - - class ChatVertexAI: - pass - - class ChatOpenAI: - pass - - assert is_google_chat_model(ChatGoogleGenerativeAI()) is True - assert is_google_chat_model(ChatVertexAI()) is True - assert is_google_chat_model(ChatOpenAI()) is False - assert is_google_chat_model(None) is False - - -def test_model_name_helpers(): - assert strip_provider_prefix("google/gemini-3.8-flash") == "gemini-3.8-flash" - assert strip_provider_prefix("gemini-3.8-flash") == "gemini-3.8-flash" - assert strip_provider_prefix(None) == "" - assert is_gemini_model("openrouter/google/gemini-3.7-flash") is True - assert is_gemini_model("claude-sonnet-5") is False - assert gemini_version("gemini-3.8-flash") == (3, 8) - assert gemini_version("gemini-3.5-flash-lite-preview") == (3, 5) - # Variants still report their family version (thinking-level gate). - assert gemini_version("gemini-3.8-flash-image") == (3, 8) - assert gemini_version("gemini-2.5-flash-tts") == (2, 5) - assert gemini_version("gemini-robotics-er-2-preview") is None - - -@pytest.mark.parametrize( - "model, expected", - [ - ("gemini-3.8-flash", True), - ("gemini-3.5-flash-lite", True), - ("gemini-2.5-flash", False), - ("gemini-2.0-flash", False), - ("gemini-1.5-pro", False), - ("gemini-robotics-er-2-preview", True), - ("gpt-5", True), - ], -) -def test_supports_thinking_level(model, expected): - assert supports_thinking_level(model) is expected - - -@pytest.mark.parametrize( - "model, supported, auto", - [ - ("gemini-3.8-flash", True, True), - ("gemini-3.7-flash", True, True), - ("gemini-3.6-flash", True, True), - ("google/gemini-3.9-flash", True, True), - ("gemini-3.5-flash-lite", True, False), - ("gemini-3.5-flash", False, False), - ("gemini-3.8-pro", False, False), - ("gemini-2.5-flash", False, False), - ("gemini-robotics-er-2-preview", False, False), - ("claude-sonnet-5", False, False), - # Release suffixes stay in the base family. - ("models/gemini-3.7-flash-preview-09-2026", True, True), - ("gemini-3.8-flash-latest", True, True), - ("gemini-3.8-flash-001", True, True), - ("gemini-3.8-flash-exp", True, True), - ("gemini-3.5-flash-lite-preview", True, False), - # Variant models carry their own capabilities and never inherit. - ("gemini-3.8-flash-image", False, False), - ("gemini-3.8-flash-tts", False, False), - ("gemini-3.8-flash-live", False, False), - ("gemini-3.8-flash-native-audio", False, False), - ("gemini-3.8-flash-image-preview", False, False), - ("gemini-3.8-flash-8b", False, False), - ], -) -def test_agentic_video_capability(model, supported, auto): - assert supports_agentic_video(model) is supported - assert is_agentic_video_auto_eligible(model) is auto - - -def test_resolve_video_processing_honours_knob_and_capability(): - assert resolve_video_processing("auto", "gemini-3.8-flash") == "agentic" - assert resolve_video_processing("auto", "gemini-3.5-flash-lite") == "static" - assert resolve_video_processing("auto", "gemini-2.5-flash") == "static" - assert resolve_video_processing("agentic", "gemini-3.5-flash-lite") == "agentic" - assert resolve_video_processing("agentic", "gemini-2.5-flash") == "static" - assert resolve_video_processing("static", "gemini-3.8-flash") == "static" - # Explicit "agentic" is honoured on Flash-Lite but never on a variant. - assert resolve_video_processing("agentic", "gemini-3.8-flash-image") == "static" - assert resolve_video_processing("auto", "gemini-3.8-flash-tts") == "static" - # Unknown or non-string knobs behave like "auto". - assert resolve_video_processing(None, "gemini-3.8-flash") == "agentic" - assert resolve_video_processing(object(), "gemini-3.8-flash") == "agentic" - assert resolve_video_processing("bogus", "gemini-3.5-flash-lite") == "static" diff --git a/tests/unit/test_google_usage.py b/tests/unit/test_google_usage.py deleted file mode 100644 index d2a93201a..000000000 --- a/tests/unit/test_google_usage.py +++ /dev/null @@ -1,106 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Token-usage normalization across the LangChain, generate_content and Interactions shapes.""" - -from types import SimpleNamespace - -from artemis.llm.google import normalize_usage, usage_from_message -from langchain_core.messages import AIMessage - - -def test_langchain_usage_metadata_shape(): - usage = normalize_usage( - { - "input_tokens": 1200, - "output_tokens": 80, - "total_tokens": 1280, - "input_token_details": {"cache_read": 900}, - "output_token_details": {"reasoning": 30}, - } - ) - assert usage == { - "prompt_tokens": 1200, - "completion_tokens": 80, - "total_tokens": 1280, - "cached_tokens": 900, - "thought_tokens": 30, - "tool_use_tokens": 0, - } - - -def test_generate_content_usage_metadata_object_shape(): - raw = SimpleNamespace( - prompt_token_count=500, - candidates_token_count=25, - total_token_count=560, - cached_content_token_count=400, - thoughts_token_count=35, - tool_use_prompt_token_count=None, - ) - assert normalize_usage(raw) == { - "prompt_tokens": 500, - "completion_tokens": 25, - "total_tokens": 560, - "cached_tokens": 400, - "thought_tokens": 35, - "tool_use_tokens": 0, - } - - -def test_interactions_usage_shape_counts_tool_use_and_thoughts(): - raw = SimpleNamespace( - total_input_tokens=191, - total_output_tokens=655, - total_tokens=10571, - total_cached_tokens=0, - total_thought_tokens=12, - total_tool_use_tokens=9725, - ) - assert normalize_usage(raw) == { - "prompt_tokens": 191, - "completion_tokens": 655, - "total_tokens": 10571, - "cached_tokens": 0, - "thought_tokens": 12, - "tool_use_tokens": 9725, - } - - -def test_total_is_derived_when_missing_and_empty_payloads_return_none(): - assert normalize_usage({"prompt_tokens": 3, "completion_tokens": 4})["total_tokens"] == 7 - assert normalize_usage(None) is None - assert normalize_usage({}) is None - assert normalize_usage({"prompt_tokens": "not-a-number"}) is None - assert normalize_usage(SimpleNamespace()) is None - - -def test_usage_from_message_prefers_langchain_then_response_metadata(): - msg = AIMessage(content="ok") - msg.usage_metadata = {"input_tokens": 10, "output_tokens": 2, "total_tokens": 12} - assert usage_from_message(msg)["prompt_tokens"] == 10 - - fallback = AIMessage(content="ok") - fallback.response_metadata = {"token_usage": {"prompt_tokens": 7, "completion_tokens": 1}} - assert usage_from_message(fallback) == { - "prompt_tokens": 7, - "completion_tokens": 1, - "total_tokens": 8, - "cached_tokens": 0, - "thought_tokens": 0, - "tool_use_tokens": 0, - } - - assert usage_from_message(AIMessage(content="none")) is None - assert usage_from_message(object()) is None diff --git a/tests/unit/test_hierarchy_backend_visibility.py b/tests/unit/test_hierarchy_backend_visibility.py deleted file mode 100644 index f6b332d01..000000000 --- a/tests/unit/test_hierarchy_backend_visibility.py +++ /dev/null @@ -1,385 +0,0 @@ -"""The user is told which UI-hierarchy backend serves a run, everywhere they look. - -Covers the agent's announcement fan-out (startup progress, log trace, session -device_info, status.json), the provisioning progress stage, the run_outcome -environment block, the `mobile_manage_task` field, the `artemis helper` CLI -(`--all`, status rendering) and the `artemis doctor` helper row. -""" - -from __future__ import annotations - -import json -from types import SimpleNamespace -from unittest.mock import MagicMock, patch - -import pytest -from typer.testing import CliRunner - -from artemis.clients.screen_client_factory import FallbackScreenClient -from artemis.runtime import trace_store -from artemis.runtime.helper_manager import HelperSession, ProvisionResult -from artemis.sdk.agent import Agent - - -# --------------------------------------------------------------------------- # -# helpers -# --------------------------------------------------------------------------- # - - -def _composite(helper_version: str = "1.1.2") -> FallbackScreenClient: - helper = MagicMock() - helper.session = HelperSession( - serial="dev", - local_port=41000, - transport_id="1", - version_code=4, - version_name=helper_version, - owns_forward=True, - ) - helper.get_hierarchy.return_value = "" - client = FallbackScreenClient( - "dev", helper=helper, uiautomator_factory=MagicMock(), state_probe=lambda _s: "device" - ) - return client - - -def _agent_with(client) -> Agent: - agent = object.__new__(Agent) - agent._ui_adb_client = client - return agent - - -def _engine(): - engine = MagicMock() - engine.current_session_id = "sess-1" - engine.get_relative_time = lambda ts: "T+00:05" - return engine - - -# --------------------------------------------------------------------------- # -# Agent announcement fan-out -# --------------------------------------------------------------------------- # - - -def test_announce_first_backend_reaches_every_sink(tmp_path, monkeypatch): - monkeypatch.setattr(trace_store, "TRACES_DIR", str(tmp_path)) - trace_store.init_trace("sess-1", "goal", "flash") - client = _composite() - client.get_hierarchy() # helper becomes active - agent = _agent_with(client) - engine = _engine() - context = SimpleNamespace(data_engine=engine) - - with patch("artemis.sdk.agent.publish_startup_progress") as progress: - agent._announce_hierarchy_backend(context, "sess-1", None, "helper", None) - - stage, message = progress.call_args.args[:2] - assert stage == "hierarchy_backend" - assert message == "UI hierarchy source: Artemis accessibility helper v1.1.2" - assert progress.call_args.kwargs["backend"] == "helper" - - trace_call = engine.record_trace.call_args.kwargs - assert trace_call["name"] == "hierarchy_backend" and trace_call["type"] == "log" - assert trace_call["payload"]["helper_version"] == "1.1.2" - assert trace_call["payload"]["level"] == "INFO" - - info = engine.update_session_device_info.call_args.kwargs - assert info["hierarchy_backend"] == "helper" - assert info["hierarchy_backend_note"].startswith("UI hierarchy source: the Artemis") - - status = trace_store.read_status("sess-1") - assert status["hierarchy_backend"] == "helper" - assert "accessibility helper v1.1.2" in status["hierarchy_backend_note"] - - -def test_announce_switch_is_a_warning_with_the_reason(tmp_path, monkeypatch): - monkeypatch.setattr(trace_store, "TRACES_DIR", str(tmp_path)) - client = _composite() - agent = _agent_with(client) - engine = _engine() - context = SimpleNamespace(data_engine=engine) - - with patch("artemis.sdk.agent.publish_startup_progress") as progress: - agent._announce_hierarchy_backend( - context, "sess-1", "helper", "uiautomator", "HelperUnavailable: tunnel gone" - ) - - stage, message = progress.call_args.args[:2] - assert stage == "hierarchy_backend_changed" - assert message == ( - "UI hierarchy source switched from Artemis accessibility helper to UIAutomator2 " - "because HelperUnavailable: tunnel gone" - ) - assert engine.record_trace.call_args.kwargs["payload"]["level"] == "WARNING" - # No status.json for this session: silently skipped, nothing raised. - assert trace_store.read_status("sess-1") is None - - -def test_announce_survives_sink_failures(monkeypatch, tmp_path): - monkeypatch.setattr(trace_store, "TRACES_DIR", str(tmp_path)) - client = _composite() - agent = _agent_with(client) - engine = _engine() - engine.record_trace.side_effect = RuntimeError("db closed") - with patch("artemis.sdk.agent.publish_startup_progress", side_effect=OSError("no ipc")): - agent._announce_hierarchy_backend( - SimpleNamespace(data_engine=engine), "sess-1", None, "uiautomator", None - ) - - -@pytest.mark.asyncio -async def test_connect_publishes_install_stage_and_registers_listener(): - client = _composite() - - def fake_connect(on_event=None): - on_event("installing", {"serial": "dev", "version_name": "1.1.2", "to_version": 4}) - client._set_active("helper") - - client.connect = fake_connect # type: ignore[method-assign] - agent = _agent_with(client) - engine = _engine() - context = SimpleNamespace(data_engine=engine) - - with ( - patch("artemis.sdk.agent.publish_startup_progress") as progress, - patch.object(trace_store, "update_trace_fields"), - ): - await agent._connect_screen_client(context, "sess-1") - stages = [c.args[0] for c in progress.call_args_list] - assert stages == ["uiautomator", "helper_install", "uiautomator_ready", "hierarchy_backend"] - install_msg = progress.call_args_list[1].args[1] - assert ( - "first time" in install_msg and "v1.1.2" in install_msg and "3 seconds" in install_msg - ) - - # A later fallback goes through the registered listener. - progress.reset_mock() - client._set_active("uiautomator", "RuntimeError: tunnel gone") - assert progress.call_args.args[0] == "hierarchy_backend_changed" - - -@pytest.mark.asyncio -async def test_connect_without_provisioning_client_takes_no_callback(): - plain = MagicMock(spec=["connect", "get_hierarchy"]) - plain.connect = MagicMock() # signature without on_event - agent = _agent_with(plain) - with ( - patch("artemis.sdk.agent.publish_startup_progress"), - patch.object(trace_store, "update_trace_fields"), - ): - await agent._connect_screen_client(SimpleNamespace(data_engine=None), "sess-1") - plain.connect.assert_called_once_with() - - -@pytest.mark.asyncio -async def test_reused_agent_records_backend_changes_only_for_current_session(): - client = _composite() - agent = _agent_with(client) - context = SimpleNamespace(data_engine=None) - with ( - patch("artemis.sdk.agent.publish_startup_progress") as progress, - patch.object(trace_store, "update_trace_fields"), - ): - await agent._connect_screen_client(context, "sess-1") - client._set_active("uiautomator", "tunnel gone") - client.disconnect() - await agent._connect_screen_client(context, "sess-2") - assert len(client.backend_history) == 1 - progress.reset_mock() - client._set_active("uiautomator", "tunnel gone again") - assert progress.call_count == 1 - assert progress.call_args.kwargs["session_id"] == "sess-2" - - -# --------------------------------------------------------------------------- # -# trace_store / engine / task manager -# --------------------------------------------------------------------------- # - - -def test_update_trace_fields_merges_and_ignores_missing(tmp_path, monkeypatch): - monkeypatch.setattr(trace_store, "TRACES_DIR", str(tmp_path)) - assert trace_store.update_trace_fields("nope", hierarchy_backend="helper") is None - trace_store.init_trace("t1", "goal", "pro") - data = trace_store.update_trace_fields("t1", hierarchy_backend="helper", extra=1) - assert data["hierarchy_backend"] == "helper" and data["extra"] == 1 - assert data["task_desc"] == "goal" - - -def test_update_session_device_info_merges_fields(): - from artemis.data_engine.engine import DataEngine - - engine = object.__new__(DataEngine) - engine.current_session_id = "s" - session = SimpleNamespace(device_info={"device_id": "dev"}) - engine.storage = MagicMock() - engine.storage.get_session.return_value = session - engine.update_session_device_info(hierarchy_backend="helper", hierarchy_backend_note="n") - engine.storage.update_session.assert_called_once_with(session) - assert session.device_info == { - "device_id": "dev", - "hierarchy_backend": "helper", - "hierarchy_backend_note": "n", - } - - -def test_manage_task_status_exposes_hierarchy_backend(tmp_path, monkeypatch): - from mcp_server.tools import task_manager - - monkeypatch.setattr(trace_store, "TRACES_DIR", str(tmp_path)) - trace_store.init_trace("t9", "goal", "flash", device_serial="dev") - trace_store.update_trace_fields( - "t9", hierarchy_backend="uiautomator", hierarchy_backend_note="switched" - ) - with patch.object(task_manager, "_reconcile_status", lambda *a, **k: None, create=True): - result = task_manager.mobile_manage_task(action="status", trace_id="t9") - assert result["hierarchy_backend"] == "uiautomator" - assert result["hierarchy_backend_note"] == "switched" - - -# --------------------------------------------------------------------------- # -# run_outcome environment block -# --------------------------------------------------------------------------- # - - -def test_run_outcome_extra_carries_environment(tmp_path): - from artemis.graph.checkpoints import read_run_outcome, write_run_outcome - from artemis.clients.screen_client_factory import hierarchy_backend_summary - - client = _composite() - client.get_hierarchy() - client._set_active("uiautomator", "RuntimeError: gone") - outcome = MagicMock() - outcome.model_dump.return_value = {"task_status": "completed"} - write_run_outcome(tmp_path, outcome, {"environment": hierarchy_backend_summary(client)}) - data = read_run_outcome(tmp_path) - assert data["environment"]["backend"] == "uiautomator" - assert data["environment"]["helper_version"] == "1.1.2" - assert data["environment"]["switches"][0]["reason"] == "RuntimeError: gone" - - -# --------------------------------------------------------------------------- # -# CLI: artemis helper -# --------------------------------------------------------------------------- # - - -def _status(**overrides): - base = { - "package": "com.artemis.helper", - "installed": True, - "installed_version": 4, - "bundled_version": 4, - "bundled_apk_present": True, - "outdated": False, - "newer_than_bundled": False, - "enabled": True, - "forward_port": None, - "tunnel": "probe", - "reachable": True, - "reported_version": 4, - "transport_id": "1", - "session": None, - } - base.update(overrides) - return base - - -def test_helper_status_explains_probe_tunnel_and_newer_build(monkeypatch): - from artemis.interfaces.cli.commands import helper as helper_cli - - monkeypatch.setattr( - helper_cli.helper_manager, - "status", - lambda serial: _status(installed_version=9, newer_than_bundled=True), - ) - result = CliRunner().invoke(helper_cli.helper_app, ["status", "--serial", "dev"]) - assert result.exit_code == 0, result.output - assert "temporary forward" in result.output - assert "newer than the bundled v4" in result.output - - -def test_helper_install_all_targets_idle_devices_only(monkeypatch): - from artemis.interfaces.cli.commands import helper as helper_cli - - devices = [ - SimpleNamespace(serial="a", is_busy=False), - SimpleNamespace(serial="b", is_busy=True), - SimpleNamespace(serial="c", is_busy=False), - ] - monkeypatch.setattr(helper_cli.device_pool, "get_ready_devices", lambda: devices) - calls = [] - - def provision(serial, force=False, on_event=None): - calls.append(serial) - on_event("installing", {"version_name": "1.1.2"}) - return ProvisionResult( - ok=True, action="installed", installed_version=4, bundled_version=4, enabled=True - ) - - monkeypatch.setattr(helper_cli.helper_manager, "provision", provision) - result = CliRunner().invoke(helper_cli.helper_app, ["install", "--all"]) - assert result.exit_code == 0, result.output - assert calls == ["a", "c"] - assert "a: installing v1.1.2..." in result.output - - -def test_helper_install_prints_manual_path_on_enable_failure(monkeypatch): - from artemis.interfaces.cli.commands import helper as helper_cli - from artemis.runtime.helper_manager import MANUAL_ENABLE_PATH - - monkeypatch.setattr( - helper_cli.helper_manager, - "provision", - lambda serial, force=False, on_event=None: ProvisionResult( - ok=False, - action="installed", - installed_version=4, - bundled_version=4, - enabled=False, - error=f"This device rejected enabling the accessibility service from adb. {MANUAL_ENABLE_PATH}.", - ), - ) - result = CliRunner().invoke(helper_cli.helper_app, ["install", "--serial", "dev"]) - assert result.exit_code == 1 - assert "Settings > Accessibility" in result.output - - -# --------------------------------------------------------------------------- # -# CLI: artemis doctor row -# --------------------------------------------------------------------------- # - - -def _adb_probe(serials): - return SimpleNamespace( - id="android_adb", - metadata={"devices": [{"serial": s, "state": "device"} for s in serials]}, - ) - - -def test_doctor_row_suggests_preinstall_when_missing(monkeypatch): - from artemis.interfaces.cli.commands import doctor - - with patch( - "artemis.runtime.helper_manager.helper_manager.status", - return_value=_status(installed=False, reachable=False), - ): - row = doctor._helper_row([_adb_probe(["dev"])]) - assert row.status == "missing" and row.summary == "Not installed" - assert "artemis helper install --serial dev" in row.detail - assert "about 3 s" in row.detail - - -def test_doctor_row_ready_mentions_uninstall(monkeypatch): - from artemis.interfaces.cli.commands import doctor - - with patch("artemis.runtime.helper_manager.helper_manager.status", return_value=_status()): - row = doctor._helper_row([_adb_probe(["dev"])]) - assert row.status == "pass" and "v4 on dev" in row.summary - assert "artemis helper uninstall --serial dev" in row.detail - - -def test_doctor_row_absent_for_multiple_devices_or_uiautomator_backend(monkeypatch): - from artemis.interfaces.cli.commands import doctor - - assert doctor._helper_row([_adb_probe(["a", "b"])]) is None - monkeypatch.setenv("ARTEMIS_HIERARCHY_BACKEND", "uiautomator") - assert doctor._helper_row([_adb_probe(["a"])]) is None diff --git a/tests/unit/test_hierarchy_fidelity.py b/tests/unit/test_hierarchy_fidelity.py index 5f233e486..61dda774d 100644 --- a/tests/unit/test_hierarchy_fidelity.py +++ b/tests/unit/test_hierarchy_fidelity.py @@ -17,7 +17,6 @@ from artemis.clients.screen_client_factory import FallbackScreenClient from artemis.core.diagnostics.hierarchy_parity import compare_dumps from artemis.utils import ui_filter -from artemis.utils.visualization import format_minimal_list_with_elements from third_party.mobile_use.clients.ui_automator_client import ( UIAutomatorClient, parse_hierarchy_xml_to_elements, @@ -65,28 +64,6 @@ def test_negative_bounds_keep_their_coordinates_and_get_clipped(): assert filtered[0]["parsed_bounds"] == {"left": 0, "top": 0, "right": 300, "bottom": 60} -# --------------------------------------------------------------------------- # -# Hints and errors reach the model -# --------------------------------------------------------------------------- # - - -def test_minimal_list_shows_hint_for_empty_inputs_and_errors(): - elements = parse_hierarchy_xml_to_elements( - _xml( - _node(text="", hint="Search settings", **{"class": "android.widget.EditText"}), - _node(text="abc", error="Password too short", bounds="[0,200][100,300]"), - _node(text="", bounds="[0,400][100,500]"), # nothing to say: stays out - ) - ) - text, items, labels = format_minimal_list_with_elements(elements[1:], 1000, 1000) - lines = text.splitlines() - assert lines[0] == "[1] Hint: 'Search settings' | Bounds: [0,0][100,100]" - assert lines[1] == "[2] Text: 'abc' | Bounds: [0,200][100,300] | Error: 'Password too short'" - assert len(lines) == 2 and labels == ["1", "2"] - assert items[0]["is_hint"] is True and items[0]["text"] == "Search settings" - assert items[1]["error"] == "Password too short" - - # --------------------------------------------------------------------------- # # Empty helper answer degrades instead of showing a blank screen # --------------------------------------------------------------------------- # diff --git a/tests/unit/test_llm_gateway.py b/tests/unit/test_llm_gateway.py deleted file mode 100644 index 73c8eb730..000000000 --- a/tests/unit/test_llm_gateway.py +++ /dev/null @@ -1,284 +0,0 @@ -"""Tests for the LLM gateway: complete(), classified recovery, and fallback.""" - -import asyncio -from types import SimpleNamespace - -from langchain_core.messages import AIMessage, AIMessageChunk -import pytest - -from artemis.llm.reliability import LLMExhaustedError, LLMPermanentError -from artemis.services import llm as llm_service -from artemis.services.llm import ( - RobustChatModelWrapper, - acomplete, - acomplete_structured, - with_fallback, -) -from third_party.mobile_use.llm_service import WAITING_MESSAGE, LLMWaitNotice - - -@pytest.fixture(autouse=True) -def _isolate_gateway_state(monkeypatch, tmp_path): - llm_service._ENDPOINT_BREAKER._states.clear() - llm_service._NON_STREAMING_ENDPOINTS.clear() - monkeypatch.setattr(llm_service, "PAUSE_FILE", tmp_path / ".artemis_paused") - monkeypatch.setattr( - llm_service, - "retry_policy_for", - lambda category: SimpleNamespace(max_attempts=2, delay_for=lambda attempt: 0.0), - ) - monkeypatch.setattr(llm_service.settings, "LLM_PAUSE_TIMEOUT_SECONDS", 1.0, raising=False) - yield - llm_service._ENDPOINT_BREAKER._states.clear() - llm_service._NON_STREAMING_ENDPOINTS.clear() - - -@pytest.mark.asyncio -async def test_mid_stream_failure_retries_without_duplicating_chunks(): - class BreaksMidStream: - def __init__(self): - self.astream_calls = 0 - - async def astream(self, *args, **kwargs): - self.astream_calls += 1 - if self.astream_calls == 1: - yield AIMessageChunk(content="Hello") - raise RuntimeError("503 service unavailable") - yield AIMessageChunk(content="Hello") - yield AIMessageChunk(content=" world") - - base = BreaksMidStream() - wrapper = RobustChatModelWrapper(base) - result = await wrapper.complete([]) - - assert base.astream_calls == 2 - # The partial first attempt was discarded entirely: no duplicated prefix. - assert result.content == "Hello world" - - -@pytest.mark.asyncio -async def test_auth_error_fails_fast_without_retry_or_pause(tmp_path): - class AuthFails: - def __init__(self): - self.astream_calls = 0 - self.ainvoke_calls = 0 - - async def astream(self, *args, **kwargs): - self.astream_calls += 1 - raise RuntimeError("401 unauthorized: invalid api key") - yield # pragma: no cover - makes this an async generator - - async def ainvoke(self, *args, **kwargs): - self.ainvoke_calls += 1 - return AIMessage(content="should never happen") - - base = AuthFails() - wrapper = RobustChatModelWrapper(base) - with pytest.raises(LLMPermanentError): - await wrapper.complete([]) - - assert base.astream_calls == 1 - assert base.ainvoke_calls == 0 - assert not llm_service.PAUSE_FILE.exists() - - -@pytest.mark.asyncio -async def test_stream_unsupported_downgrades_loudly_and_is_remembered(): - class NoStreaming: - def __init__(self): - self.astream_calls = 0 - self.ainvoke_calls = 0 - - async def astream(self, *args, **kwargs): - self.astream_calls += 1 - raise NotImplementedError("streaming is not supported") - yield # pragma: no cover - - async def ainvoke(self, *args, **kwargs): - self.ainvoke_calls += 1 - return AIMessage(content="ok") - - base = NoStreaming() - wrapper = RobustChatModelWrapper(base) - - first = await wrapper.complete([]) - assert first.content == "ok" - assert wrapper._endpoint_key() in llm_service._NON_STREAMING_ENDPOINTS - - second = await wrapper.complete([]) - assert second.content == "ok" - # The capability was cached: streaming is not probed again. - assert base.astream_calls == 1 - assert base.ainvoke_calls == 2 - - -@pytest.mark.asyncio -async def test_exhausted_retries_pause_then_raise_on_deadline(): - class AlwaysUnavailable: - def __init__(self): - self.calls = 0 - - async def astream(self, *args, **kwargs): - self.calls += 1 - raise RuntimeError("503 service unavailable") - yield # pragma: no cover - - wrapper = RobustChatModelWrapper(AlwaysUnavailable()) - with pytest.raises(LLMExhaustedError): - await wrapper.complete([]) - # The pause file was written for the pause window, then cleaned up. - assert not llm_service.PAUSE_FILE.exists() - - -@pytest.mark.asyncio -async def test_with_fallback_takes_over_without_pausing(): - class AlwaysUnavailable: - async def astream(self, *args, **kwargs): - raise RuntimeError("503 service unavailable") - yield # pragma: no cover - - wrapper = RobustChatModelWrapper(AlwaysUnavailable()) - - async def fallback_call(): - return AIMessage(content="fallback answer") - - result = await with_fallback( - main_call=lambda: wrapper.complete([]), - fallback_call=fallback_call, - ) - assert result.content == "fallback answer" - # Because a fallback existed, exhaustion handed over instead of pausing. - assert not llm_service.PAUSE_FILE.exists() - - -@pytest.mark.asyncio -async def test_with_fallback_refuses_to_hide_bad_requests(): - class CodedError(Exception): - code = 400 - - calls = {"fallback": 0} - - async def main_call(): - raise CodedError("malformed tool schema") - - async def fallback_call(): - calls["fallback"] += 1 - return "hidden bug" - - with pytest.raises(CodedError): - await with_fallback(main_call=main_call, fallback_call=fallback_call) - assert calls["fallback"] == 0 - - -@pytest.mark.asyncio -async def test_with_fallback_clears_fallback_flag_when_cancelled(): - seen = {} - - async def main_call(): - seen["during"] = llm_service._FALLBACK_AVAILABLE.get() - raise asyncio.CancelledError - - async def fallback_call(): # pragma: no cover - must not run - raise AssertionError("fallback must not run on cancellation") - - with pytest.raises(asyncio.CancelledError): - await with_fallback(main_call=main_call, fallback_call=fallback_call) - assert seen["during"] is True - assert llm_service._FALLBACK_AVAILABLE.get() is False - - -@pytest.mark.asyncio -async def test_timeout_wrapper_enforces_hard_timeout(): - notices = [] - wait = LLMWaitNotice(notify=notices.append, on_timeout=notices.append, poll_interval=0.01) - - with pytest.raises(TimeoutError): - await wait(asyncio.sleep(10), timeout_seconds=0.01, hard_timeout=0.05) - assert notices == [WAITING_MESSAGE, "LLM call timed out after 0.05 seconds."] - - -@pytest.mark.asyncio -async def test_timeout_wrapper_holds_clock_while_paused(): - wait = LLMWaitNotice( - notify=lambda _: None, on_timeout=lambda _: None, hold=lambda: True, poll_interval=0.01 - ) - - async def slow(): - await asyncio.sleep(0.1) - return "done" - - # The 0.02s hard timeout never runs out while the task is held (paused). - assert await wait(slow(), timeout_seconds=0.01, hard_timeout=0.02) == "done" - - -@pytest.mark.asyncio -async def test_acomplete_structured_corrective_reask(): - class CorrectsItself: - def __init__(self): - self.calls = [] - - async def ainvoke(self, messages, **kwargs): - self.calls.append(messages) - if len(self.calls) == 1: - return AIMessage(content="oops, here is prose instead of JSON") - return AIMessage(content='{"a": 1}') - - base = CorrectsItself() - result = await acomplete_structured(base, []) - assert result == {"a": 1} - assert len(base.calls) == 2 - correction_text = str(base.calls[1][-1].content) - assert "could not be parsed" in correction_text - - -@pytest.mark.asyncio -async def test_acomplete_compat_path_for_plain_models(): - class PlainModel: - async def astream(self, *args, **kwargs): - yield AIMessageChunk(content="a") - yield AIMessageChunk(content="b") - - result = await acomplete(PlainModel(), []) - assert result.content == "ab" - - class InvokeOnly: - async def ainvoke(self, *args, **kwargs): - return AIMessage(content="direct") - - result = await acomplete(InvokeOnly(), []) - assert result.content == "direct" - - -@pytest.mark.asyncio -async def test_mid_stream_failure_records_stream_reset_payload(monkeypatch): - recorded_events = [] - - def mock_record_event(name, payload, *, status="retrying"): - recorded_events.append((name, payload, status)) - - monkeypatch.setattr(llm_service, "_record_llm_event", mock_record_event) - - class BreaksMidStream: - def __init__(self): - self.calls = 0 - - async def astream(self, *args, **kwargs): - self.calls += 1 - if self.calls == 1: - yield AIMessageChunk(content="Partial thoughts...") - raise RuntimeError("503 high demand spike") - yield AIMessageChunk(content="Recovered output") - - base = BreaksMidStream() - wrapper = RobustChatModelWrapper(base) - result = await wrapper.complete([]) - - assert result.content == "Recovered output" - resets = [e for e in recorded_events if e[0] == "llm_stream_reset"] - assert len(resets) == 1 - event_name, payload, status = resets[0] - assert event_name == "llm_stream_reset" - assert payload["action"] == "discard" - assert payload["reason"] == "mid_stream_failure" - assert "stream_exec_id" in payload - assert "lower API priority" in payload["message"] diff --git a/tests/unit/test_llm_grounding.py b/tests/unit/test_llm_grounding.py deleted file mode 100644 index c99b01d27..000000000 --- a/tests/unit/test_llm_grounding.py +++ /dev/null @@ -1,164 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for ModelFactory instantiation and grounding tool wiring.""" - -from artemis.llm.router import ModelEndpoint, ModelFactory, ModelProvider - - -def test_model_factory_anthropic_instantiation(): - """Verify Anthropic Claude model instantiation with thinking budget.""" - anth_ep = ModelEndpoint( - provider=ModelProvider.ANTHROPIC, - model_name="claude-3-7-sonnet-20250219", - api_key="sk-ant-test-key", - reasoning_effort="high", - ) - model = ModelFactory.create_model(anth_ep) - assert model.model == "claude-3-7-sonnet-20250219" - assert getattr(model, "thinking", None) == {"type": "enabled", "budget_tokens": 32768} - - -def test_model_factory_openai_instantiation(): - """Verify OpenAI model instantiation with reasoning effort.""" - oai_ep = ModelEndpoint( - provider=ModelProvider.OPENAI, - model_name="o3-mini", - api_key="sk-test-key", - reasoning_effort="medium", - ) - model = ModelFactory.create_model(oai_ep) - assert model.model_name == "o3-mini" - assert getattr(model, "reasoning_effort", None) == "medium" - - -def test_robust_chat_model_wrapper_grounding_google(): - """Verify RobustChatModelWrapper auto-injects google_search and server-side tool config for Gemini.""" - from artemis.services.llm import RobustChatModelWrapper - from langchain_core.tools import tool - - @tool - def dummy_tool(query: str) -> str: - """A test tool.""" - return query - - class MockGeminiModel: - def __init__(self): - self.bound_tools = [] - self.bound_kwargs = {} - - def bind_tools(self, tools, *args, **kwargs): - self.bound_tools = tools - self.bound_kwargs = kwargs - return self - - # Case 1: enable_grounding=True automatically attaches google_search and tool_config - ep_google = ModelEndpoint( - provider=ModelProvider.GOOGLE, - model_name="gemini-2.5-flash", - enable_grounding=True, - ) - wrapper = RobustChatModelWrapper(MockGeminiModel(), endpoint=ep_google) - bound = wrapper.bind_tools([dummy_tool]) - - assert any(isinstance(t, dict) and "google_search" in t for t in bound.base_model.bound_tools) - assert bound.base_model.bound_kwargs.get("tool_config") == { - "include_server_side_tool_invocations": True - } - - # Case 2: Explicit google_search in tools automatically ensures tool_config - ep_google_no_ground = ModelEndpoint( - provider=ModelProvider.GOOGLE, - model_name="gemini-2.5-flash", - enable_grounding=False, - ) - wrapper_explicit = RobustChatModelWrapper(MockGeminiModel(), endpoint=ep_google_no_ground) - bound_explicit = wrapper_explicit.bind_tools([dummy_tool, {"google_search": {}}]) - - assert any( - isinstance(t, dict) and "google_search" in t for t in bound_explicit.base_model.bound_tools - ) - assert bound_explicit.base_model.bound_kwargs.get("tool_config") == { - "include_server_side_tool_invocations": True - } - - -def test_robust_chat_model_wrapper_grounding_non_google(): - """Verify RobustChatModelWrapper ignores grounding and strips google_search for non-Google providers.""" - from artemis.services.llm import RobustChatModelWrapper - from langchain_core.tools import tool - - @tool - def dummy_tool(query: str) -> str: - """A test tool.""" - return query - - class MockOpenAIModel: - def __init__(self): - self.bound_tools = [] - self.bound_kwargs = {} - - def bind_tools(self, tools, *args, **kwargs): - self.bound_tools = tools - self.bound_kwargs = kwargs - return self - - ep_openai = ModelEndpoint( - provider=ModelProvider.OPENAI, - model_name="gpt-4o", - enable_grounding=True, - ) - wrapper = RobustChatModelWrapper(MockOpenAIModel(), endpoint=ep_openai) - bound = wrapper.bind_tools([dummy_tool, {"google_search": {}}]) - - # Should ignore grounding and strip {"google_search": {}} - assert len(bound.base_model.bound_tools) == 1 - assert bound.base_model.bound_tools[0].name == "dummy_tool" - assert "tool_config" not in bound.base_model.bound_kwargs - - -def test_chat_google_generative_ai_process_tool_config_patch(): - """Verify patched ChatGoogleGenerativeAI preserves include_server_side_tool_invocations even with tool_choice.""" - from artemis.llm.router import ModelFactory - from langchain_google_genai import ChatGoogleGenerativeAI - from langchain_core.tools import tool - - @tool - def dummy_tool(query: str) -> str: - """A test tool.""" - return query - - # Trigger patching via Google model creation - ep = ModelEndpoint(provider=ModelProvider.GOOGLE, model_name="gemini-2.5-flash", api_key="fake") - model = ModelFactory.create_model(ep) - assert isinstance(model, ChatGoogleGenerativeAI) - - formatted_tools = model._format_tools([dummy_tool, {"google_search": {}}], None) - - # 1. With tool_choice="auto" - config_auto = model._process_tool_config("auto", None, formatted_tools) - assert config_auto is not None - assert config_auto.include_server_side_tool_invocations is True - - # 2. With tool_choice="any" - config_any = model._process_tool_config("any", None, formatted_tools) - assert config_any is not None - assert config_any.include_server_side_tool_invocations is True - - # 3. With explicit tool_config - config_explicit = model._process_tool_config( - None, {"include_server_side_tool_invocations": True}, formatted_tools - ) - assert config_explicit is not None - assert config_explicit.include_server_side_tool_invocations is True diff --git a/tests/unit/test_llm_pause.py b/tests/unit/test_llm_pause.py deleted file mode 100644 index 2ccd41f23..000000000 --- a/tests/unit/test_llm_pause.py +++ /dev/null @@ -1,185 +0,0 @@ -import asyncio -import logging - -import pytest - -from artemis.services import llm - - -@pytest.mark.asyncio -async def test_timeout_wrapper_cancels_llm_call_when_caller_is_cancelled(): - started = asyncio.Event() - child_cancelled = asyncio.Event() - - async def pending_llm_call(): - started.set() - try: - await asyncio.Event().wait() - except asyncio.CancelledError: - child_cancelled.set() - raise - - invocation = asyncio.create_task( - llm.invoke_llm_with_timeout_message( - pending_llm_call(), - timeout_seconds=60, - ) - ) - await started.wait() - invocation.cancel() - - with pytest.raises(asyncio.CancelledError): - await invocation - - assert child_cancelled.is_set() - - -class _RecordingEngine: - current_step_id = "step-7" - current_session_id = "session-1" - - def __init__(self): - self.trace_kwargs = None - self.published = None - - def record_trace(self, **kwargs): - self.trace_kwargs = kwargs - return "trace-1" - - def _publish(self, event_type, payload): - self.published = (event_type, payload) - - -def test_pause_error_is_persisted_and_published(monkeypatch, tmp_path): - engine = _RecordingEngine() - monkeypatch.setattr(llm, "_CURRENT_DATA_ENGINE", engine) - monkeypatch.setattr(llm.settings, "TRACES_PATH", str(tmp_path / "traces")) - - pause_file = llm._handle_llm_pause_and_resume(RuntimeError("503 high demand")) - - assert pause_file.read_text() == "LLM Error: 503 high demand" - assert engine.trace_kwargs == { - "type": "llm_call", - "name": "llm_pause", - "payload": {"error": "503 high demand", "pause": True}, - "step_id": "step-7", - "status": "failed", - } - event_type, payload = engine.published - assert event_type == "task_paused" - assert payload["error"] == "503 high demand" - assert payload["step_id"] == "step-7" - assert isinstance(payload["timestamp"], float) - - -def test_provider_sdk_retry_is_persisted_and_published(monkeypatch): - engine = _RecordingEngine() - monkeypatch.setattr(llm, "_CURRENT_DATA_ENGINE", engine) - record = logging.LogRecord( - name="google_genai._api_client", - level=logging.INFO, - pathname=__file__, - lineno=1, - msg=( - "Retrying google.genai request in 1.18 seconds as it raised " - "ServerError: 503 UNAVAILABLE: high demand" - ), - args=(), - exc_info=None, - ) - - request_token = llm._ACTIVE_LLM_REQUEST.set( - { - "request_id": "request-1", - "provider": "google", - "started_at": 100.0, - "retries": [], - } - ) - try: - llm._ProviderRetryTelemetryHandler().emit(record) - finally: - llm._ACTIVE_LLM_REQUEST.reset(request_token) - - assert engine.trace_kwargs["type"] == "llm_call" - assert engine.trace_kwargs["name"] == "llm_retry" - assert engine.trace_kwargs["status"] == "retrying" - assert engine.trace_kwargs["payload"] == { - "error": "ServerError: 503 UNAVAILABLE: high demand", - "delay": 1.18, - "provider": "google", - "source": "provider_sdk", - "recoverable": True, - "request_id": "request-1", - "scheduled_at": engine.trace_kwargs["payload"]["scheduled_at"], - } - event_type, payload = engine.published - assert event_type == "llm_retrying" - assert payload["trace_id"] == "trace-1" - assert payload["delay"] == 1.18 - assert payload["error"].startswith("ServerError: 503") - - -def test_provider_sdk_retry_is_hidden_for_non_gemini_request(monkeypatch): - engine = _RecordingEngine() - monkeypatch.setattr(llm, "_CURRENT_DATA_ENGINE", engine) - record = logging.LogRecord( - name="google_genai._api_client", - level=logging.INFO, - pathname=__file__, - lineno=1, - msg="Retrying request in 2 seconds as it raised 503 UNAVAILABLE", - args=(), - exc_info=None, - ) - request_token = llm._ACTIVE_LLM_REQUEST.set( - { - "request_id": "request-openai", - "provider": "openai", - "started_at": 100.0, - "retries": [], - } - ) - try: - llm._ProviderRetryTelemetryHandler().emit(record) - finally: - llm._ACTIVE_LLM_REQUEST.reset(request_token) - - assert engine.trace_kwargs is None - assert engine.published is None - - -def test_terminal_failure_aggregates_observed_retries_and_total_wait(monkeypatch, tmp_path): - engine = _RecordingEngine() - monkeypatch.setattr(llm, "_CURRENT_DATA_ENGINE", engine) - monkeypatch.setattr(llm.settings, "TRACES_PATH", str(tmp_path / "traces")) - monkeypatch.setattr(llm.time, "time", lambda: 120.0) - retries = [ - { - "error": "503 high demand", - "delay": 10.0, - "provider": "google", - "source": "provider_sdk", - "request_id": "request-2", - "scheduled_at": 101.0, - "timestamp": 101.0, - } - ] - request_token = llm._ACTIVE_LLM_REQUEST.set( - { - "request_id": "request-2", - "provider": "google", - "started_at": 100.0, - "retries": retries, - } - ) - try: - llm._handle_llm_pause_and_resume(RuntimeError("503 high demand")) - finally: - llm._ACTIVE_LLM_REQUEST.reset(request_token) - - payload = engine.trace_kwargs["payload"] - assert payload["request_id"] == "request-2" - assert payload["provider"] == "google" - assert payload["waited_seconds"] == 20.0 - assert payload["retries"] == retries diff --git a/tests/unit/test_llm_reliability.py b/tests/unit/test_llm_reliability.py deleted file mode 100644 index 11338c3af..000000000 --- a/tests/unit/test_llm_reliability.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Tests for the generic LLM failure taxonomy and circuit breaker.""" - -from artemis.llm.reliability import ( - CircuitBreaker, - FailureCategory, - classify_failure, - retry_policy_for, -) - - -def test_classifier_categories_and_recovery_decisions(): - rate = classify_failure(RuntimeError("429 rate limit exceeded")) - assert rate.category is FailureCategory.RATE_LIMIT - assert rate.retryable and rate.should_fallback - - outage = classify_failure(RuntimeError("503 service unavailable")) - assert outage.category is FailureCategory.PROVIDER_UNAVAILABLE - assert outage.retryable - - timeout = classify_failure(TimeoutError("request timed out")) - assert timeout.category is FailureCategory.TIMEOUT - assert timeout.retryable - - conn = classify_failure(ConnectionError("connection reset by peer")) - assert conn.category is FailureCategory.CONNECTION - assert conn.retryable - - auth = classify_failure(RuntimeError("401 unauthorized: invalid api key")) - assert auth.category is FailureCategory.AUTHENTICATION - assert not auth.retryable - assert auth.should_fallback # A fallback endpoint may hold a working key. - - class CodedError(Exception): - code = 400 - - bad = classify_failure(CodedError("invalid request payload")) - assert bad.category is FailureCategory.BAD_REQUEST - assert not bad.retryable - assert not bad.should_fallback # Falling back would only hide the bug. - - unknown = classify_failure(RuntimeError("something odd")) - assert unknown.category is FailureCategory.UNKNOWN - assert unknown.retryable - - -def test_non_retryable_categories_get_single_attempt(): - assert retry_policy_for(FailureCategory.AUTHENTICATION).max_attempts == 1 - assert retry_policy_for(FailureCategory.BAD_REQUEST).max_attempts == 1 - assert retry_policy_for(FailureCategory.RATE_LIMIT).max_attempts > 1 - - -def test_breaker_only_trips_on_transient_categories(): - breaker = CircuitBreaker(threshold=2, cooldown_seconds=10.0) - auth = classify_failure(RuntimeError("401 unauthorized")) - for _ in range(5): - breaker.record_failure("m", auth, now=1.0) - assert breaker.allow("m", now=1.5) - - outage = classify_failure(RuntimeError("503 unavailable")) - breaker.record_failure("m", outage, now=2.0) - breaker.record_failure("m", outage, now=3.0) - assert not breaker.allow("m", now=4.0) - assert breaker.open_remaining("m", now=4.0) > 0 - # Cooldown elapsed: half-open admits exactly one trial. - assert breaker.allow("m", now=13.5) - assert not breaker.allow("m", now=13.6) - breaker.record_success("m") - assert breaker.allow("m", now=13.7) - - -def test_retry_policy_accepts_extension_category_values(): - from enum import StrEnum - - class ExtCategory(StrEnum): - RATE_LIMIT = "rate_limit" - MEDIA_PROCESSING = "media_processing" - - assert retry_policy_for(ExtCategory.RATE_LIMIT) is retry_policy_for(FailureCategory.RATE_LIMIT) - assert retry_policy_for("provider_unavailable") is retry_policy_for( - FailureCategory.PROVIDER_UNAVAILABLE - ) - # Extension-only categories fall back to the UNKNOWN policy; callers gate - # on failure.retryable before consulting attempt counts. - assert retry_policy_for(ExtCategory.MEDIA_PROCESSING) is retry_policy_for( - FailureCategory.UNKNOWN - ) - - -def test_classifier_recognizes_genai_deadline_exceeded_shapes(): - # google-genai SDK timeouts can surface without a status code attached. - for message in ("DEADLINE_EXCEEDED", "deadline exceeded while awaiting response"): - failure = classify_failure(RuntimeError(message)) - assert failure.category is FailureCategory.TIMEOUT - assert failure.retryable diff --git a/tests/unit/test_llm_structured.py b/tests/unit/test_llm_structured.py deleted file mode 100644 index 54c85e75f..000000000 --- a/tests/unit/test_llm_structured.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Tests for the unified structured-output parsing pipeline.""" - -from pydantic import BaseModel - -from artemis.llm.structured import ( - ParseFailure, - content_to_text, - extract_json_candidate, - parse_structured, - repair_json, -) - - -def test_parses_fenced_json_block(): - text = 'Here is my plan.\n```json\n{"name": "click", "args": {"x": 1}}\n```\nDone.' - result = parse_structured(text) - assert result == {"name": "click", "args": {"x": 1}} - - -def test_parses_bare_json_embedded_in_prose(): - text = 'Sure! {"is_present": true, "confidence": 0.9} — hope that helps.' - result = parse_structured(text) - assert result == {"is_present": True, "confidence": 0.9} - - -def test_balanced_extraction_ignores_braces_inside_strings(): - text = '{"msg": "curly } inside", "n": [1, 2]}' - assert extract_json_candidate(text) == text - - -def test_repairs_trailing_commas_and_comments(): - noisy = '{\n // a comment\n "a": 1, /* block */\n "b": [1, 2,],\n}' - assert parse_structured(noisy) == {"a": 1, "b": [1, 2]} - # Repair must not touch string contents. - assert repair_json('{"s": "a, } // not a comment"}') == '{"s": "a, } // not a comment"}' - - -def test_strips_thinking_tags_before_extraction(): - text = "{not json}\n```json\n[1, 2, 3]\n```" - assert parse_structured(text) == [1, 2, 3] - - -def test_failure_is_typed_never_raw_text(): - result = parse_structured("no structured payload here at all") - assert isinstance(result, ParseFailure) - assert "no JSON" in result.error - - garbage = parse_structured("```json\n{definitely: not: json}\n```") - assert isinstance(garbage, ParseFailure) - - -def test_schema_validation(): - class Verdict(BaseModel): - is_present: bool - confidence: float - - ok = parse_structured('{"is_present": false, "confidence": 0.4}', schema=Verdict) - assert isinstance(ok, Verdict) and ok.confidence == 0.4 - - bad = parse_structured('{"is_present": "definitely"}', schema=Verdict) - assert isinstance(bad, ParseFailure) - assert "schema validation failed" in bad.error - - -def test_content_to_text_flattens_blocks_and_skips_thinking(): - blocks = [ - {"type": "thinking", "thinking": "hidden"}, - {"type": "text", "text": "hello "}, - "raw", - {"type": "text", "text": "world"}, - ] - assert content_to_text(blocks) == "hello rawworld" - assert content_to_text("plain") == "plain" diff --git a/tests/unit/test_main_entrypoint.py b/tests/unit/test_main_entrypoint.py deleted file mode 100644 index 190a41adf..000000000 --- a/tests/unit/test_main_entrypoint.py +++ /dev/null @@ -1,90 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Tests for argv handling in ``python -m artemis.main``. - -Only a free-text goal is rewritten into ``run ``; registered commands -and root options are passed through unchanged. -""" - -import pytest -import typer - -from artemis import main as legacy_main -from artemis.interfaces.cli.main import app - - -@pytest.mark.parametrize( - "token", - [ - "status", - "ui", - "stop", - "restart", - "helper", - "run", - "init", - "doctor", - "batch", - "mcp", - "server", - "trace", - "--version", - "-v", - "--help", - "-h", - ], -) -def test_known_commands_and_flags_are_not_rewritten(token): - argv = ["artemis", token, "extra"] - assert legacy_main.normalize_argv(argv) == argv - - -@pytest.mark.parametrize( - "argv", - [ - ["artemis", "Open Settings and enable dark mode"], - ["artemis", "status of my battery"], - ["artemis", "--device", "emulator-5554", "open settings"], - ], -) -def test_free_text_goal_is_rewritten_to_run(argv): - assert legacy_main.normalize_argv(argv) == [argv[0], "run", *argv[1:]] - - -def test_no_args_is_left_alone(): - assert legacy_main.normalize_argv(["artemis"]) == ["artemis"] - - -def test_every_registered_command_is_known(): - """Every command registered on the Typer app is a known root token.""" - registered = set(typer.main.get_command(app).commands) - assert registered - assert registered <= legacy_main._root_tokens() - - -def test_cli_status_dispatches_status_not_run(monkeypatch): - calls = [] - - def fake_app(): - calls.append(list(legacy_main.sys.argv)) - - monkeypatch.setattr(legacy_main, "app", fake_app) - monkeypatch.setattr(legacy_main.sys, "argv", ["artemis", "status"]) - legacy_main.cli() - assert calls == [["artemis", "status"]] - - monkeypatch.setattr(legacy_main.sys, "argv", ["artemis", "open settings"]) - legacy_main.cli() - assert calls[-1] == ["artemis", "run", "open settings"] diff --git a/tests/unit/test_memory_config.py b/tests/unit/test_memory_config.py deleted file mode 100644 index eb6e3579d..000000000 --- a/tests/unit/test_memory_config.py +++ /dev/null @@ -1,217 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for the agent.memory configuration block (M1/M2).""" - -import pytest - -from artemis.config import AgentGlobalConfig - - -def test_memory_defaults(): - cfg = AgentGlobalConfig.model_validate({}) - # 2 per the 2026-09-01 on-device baseline (serial queue at 1 pushed - # summary-ready P90 past the scrub-edge grace window). - assert cfg.memory.runtime.max_concurrency == 2 - assert cfg.memory.runtime.retry_limit == 3 - assert cfg.memory.runtime.flush_timeout_s == 30.0 - # Transcript history is enabled by default; false selects the legacy path. - assert cfg.memory.transcript.enabled is True - assert cfg.memory.transcript.image_scrub_depth == 3 - assert cfg.memory.transcript.pending_grace_steps == 3 - # Text cleanup runs independently of the screenshot depth. - assert cfg.memory.transcript.xml_scrub_depth == 1 - # Ready chunks replace their turns only once the - # operator's measured context reaches budget*start_ratio. - assert cfg.memory.chunking.min_steps == 3 - assert cfg.memory.transcript.image_scrub_depth_relaxed == 6 - assert cfg.memory.transcript.start_ratio == 0.35 - assert cfg.memory.transcript.soft_ratio == 0.7 - assert cfg.memory.transcript.hard_ratio == 0.9 - - -def test_transcript_ratio_ladder_is_validated(): - """start <= soft <= hard, all within [0, 1]: the defaults pass, a - crossed pair is rejected with a clear message.""" - import pydantic - - from artemis.config.agent import MemoryTranscriptConfig - - MemoryTranscriptConfig() - MemoryTranscriptConfig(start_ratio=0.0, soft_ratio=0.5, hard_ratio=0.5) - - with pytest.raises(pydantic.ValidationError, match="start_ratio <= soft_ratio"): - MemoryTranscriptConfig(start_ratio=0.8, soft_ratio=0.7) - with pytest.raises(pydantic.ValidationError, match="soft_ratio <= hard_ratio"): - MemoryTranscriptConfig(soft_ratio=0.95, hard_ratio=0.9) - with pytest.raises(pydantic.ValidationError): - AgentGlobalConfig.model_validate( - {"memory": {"transcript": {"start_ratio": 0.9, "soft_ratio": 0.7}}} - ) - - -def test_memory_transcript_flag_explicit_disable_is_rollback_switch(): - cfg = AgentGlobalConfig.model_validate({"memory": {"transcript": {"enabled": False}}}) - assert cfg.memory.transcript.enabled is False - # Sibling scrub defaults are unaffected by flipping the flag. - assert cfg.memory.transcript.image_scrub_depth == 3 - - -def test_memory_explicit_values(): - cfg = AgentGlobalConfig.model_validate( - { - "memory": { - "runtime": {"max_concurrency": 4, "retry_limit": 5, "flush_timeout_s": 10}, - "transcript": { - "image_scrub_depth": 5, - "pending_grace_steps": 1, - "xml_scrub_depth": 2, - }, - } - } - ) - assert cfg.memory.runtime.max_concurrency == 4 - assert cfg.memory.runtime.retry_limit == 5 - assert cfg.memory.runtime.flush_timeout_s == 10.0 - assert cfg.memory.transcript.image_scrub_depth == 5 - assert cfg.memory.transcript.pending_grace_steps == 1 - # Legacy key: still accepted (back-compat), value retained as given. - assert cfg.memory.transcript.xml_scrub_depth == 2 - - -def test_xml_scrub_depth_positions_the_text_edge(): - """The text edge is separate from the screenshot edge: with K=3 and - xml_scrub_depth=1 the observation at depth 2 has already lost its UI - list while it still carries its screenshot.""" - from langchain_core.messages import HumanMessage - - from artemis.memory.transcript import PRO_UI_LIST_MARKER, TranscriptLedger - - cfg = AgentGlobalConfig.model_validate({"memory": {"transcript": {"xml_scrub_depth": 1}}}) - ledger = TranscriptLedger( - image_scrub_depth=3, xml_scrub_depth=cfg.memory.transcript.xml_scrub_depth - ) - - def obs(i): - return HumanMessage( - content=[ - {"type": "text", "text": f"obs {i}"}, - {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{i}"}}, - {"type": "text", "text": f"{PRO_UI_LIST_MARKER}\n[{i}] item"}, - ] - ) - - ledger.stage_turn([obs(1)]) - ledger.commit_staged(step_key="s1") - ledger.render([obs(2)]) - committed = ledger.active_messages[0] - assert PRO_UI_LIST_MARKER not in str(committed.content) - assert any(isinstance(b, dict) and b.get("type") == "image_url" for b in committed.content) - - -def test_legacy_step_summarizer_retry_limit_seeds_memory_runtime(): - """An explicit legacy flash.step_summarizer.retry_limit keeps working.""" - cfg = AgentGlobalConfig.model_validate({"flash": {"step_summarizer": {"retry_limit": 7}}}) - assert cfg.flash.step_summarizer.retry_limit == 7 - assert cfg.memory.runtime.retry_limit == 7 - - -def test_explicit_memory_runtime_wins_over_legacy_alias(): - cfg = AgentGlobalConfig.model_validate( - { - "flash": {"step_summarizer": {"retry_limit": 7}}, - "memory": {"runtime": {"retry_limit": 2}}, - } - ) - assert cfg.memory.runtime.retry_limit == 2 - # The legacy key itself is untouched for back-compat readers. - assert cfg.flash.step_summarizer.retry_limit == 7 - - -def test_legacy_default_does_not_override_memory_default(): - """A step_summarizer block without retry_limit must not seed anything.""" - cfg = AgentGlobalConfig.model_validate({"flash": {"step_summarizer": {"enabled": True}}}) - assert cfg.memory.runtime.retry_limit == 3 - - -def test_recall_and_similarity_defaults(): - """M4 defaults: recall block on, bounded; similarity hint on. The distance - threshold was calibrated to 5 in M5 (2026-09-01 on-device dHash data: - same screen <=4, different screens >=7, valley at 5-6).""" - cfg = AgentGlobalConfig() - assert cfg.memory.recall.enabled is True - assert cfg.memory.recall.max_results == 5 - assert cfg.memory.recall.max_text_tokens == 2000 - assert cfg.memory.recall.screen_scan_steps == 150 - assert not hasattr(cfg.memory.recall, "max_image_steps") - assert cfg.memory.replay.max_steps == 5 - assert cfg.memory.replay.max_tokens == 12000 - assert cfg.memory.transcript.similarity_hint is True - assert cfg.memory.transcript.similarity_max_distance == 5 - assert cfg.memory.policies == {} - - -def test_chunking_era_cap_follows_max_chunks_by_default(): - """max_eras is an independent key; None (default) follows max_chunks.""" - cfg = AgentGlobalConfig() - assert cfg.memory.chunking.max_chunks == 8 - assert cfg.memory.chunking.max_eras is None - - overridden = AgentGlobalConfig.model_validate( - {"memory": {"chunking": {"max_chunks": 12, "max_eras": 4}}} - ) - assert overridden.memory.chunking.max_chunks == 12 - assert overridden.memory.chunking.max_eras == 4 - - -def test_recall_and_policies_overridable(): - cfg = AgentGlobalConfig.model_validate( - { - "memory": { - "recall": {"enabled": False, "max_results": 3, "screen_scan_steps": 20}, - "replay": {"max_steps": 2, "max_tokens": 4000}, - "transcript": {"similarity_hint": False}, - "policies": {"planner": {"last_n_detailed": 3}}, - } - } - ) - assert cfg.memory.recall.enabled is False - assert cfg.memory.recall.max_results == 3 - assert cfg.memory.recall.screen_scan_steps == 20 - assert cfg.memory.replay.max_steps == 2 - assert cfg.memory.replay.max_tokens == 4000 - assert cfg.memory.transcript.similarity_hint is False - assert cfg.memory.policies == {"planner": {"last_n_detailed": 3}} - - -def test_memory_chunking_min_steps_must_not_exceed_max_steps(): - import pytest - from pydantic import ValidationError - - AgentGlobalConfig.model_validate({"memory": {"chunking": {"min_steps": 4, "max_steps": 4}}}) - with pytest.raises(ValidationError): - AgentGlobalConfig.model_validate({"memory": {"chunking": {"min_steps": 5, "max_steps": 4}}}) - - -def test_memory_transcript_relaxed_image_depth_must_not_be_shallower(): - import pytest - from pydantic import ValidationError - - AgentGlobalConfig.model_validate( - {"memory": {"transcript": {"image_scrub_depth": 3, "image_scrub_depth_relaxed": 3}}} - ) - with pytest.raises(ValidationError): - AgentGlobalConfig.model_validate( - {"memory": {"transcript": {"image_scrub_depth": 4, "image_scrub_depth_relaxed": 3}}} - ) diff --git a/tests/unit/test_packaging_resources.py b/tests/unit/test_packaging_resources.py index 2dd4b74d0..a2ad7c337 100644 --- a/tests/unit/test_packaging_resources.py +++ b/tests/unit/test_packaging_resources.py @@ -12,45 +12,18 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Contract tests for release metadata and bundled runtime resources.""" +"""Contract tests for release metadata. + +The bundled Vue showcase and the ``artemis.jsonc`` LLM agent config (and the +legacy ``setup.py`` that packaged them) were removed in the DSH plugin +contraction, so the release-resource completeness tests that pinned them are +gone too. What remains is the package version metadata. +""" from importlib.metadata import version import artemis -from artemis import resources def test_public_version_comes_from_distribution_metadata(): assert artemis.__version__ == version("artemis") - - -def test_source_tree_contains_complete_release_resources(): - config_path = resources.get_bundled_config_path("artemis.jsonc") - showcase_path = resources.get_bundled_showcase_dist() - - assert config_path is not None and config_path.is_file() - assert showcase_path is not None and (showcase_path / "index.html").is_file() - - -def test_bundled_resource_accessors_require_complete_assets(tmp_path, monkeypatch): - resource_root = tmp_path / "resources" - config_dir = resource_root / "config" - showcase_dir = resource_root / "showcase_ui" - config_dir.mkdir(parents=True) - showcase_dir.mkdir() - (config_dir / "artemis.jsonc").write_text("{}", encoding="utf-8") - (showcase_dir / "index.html").write_text("", encoding="utf-8") - - monkeypatch.setattr(resources, "files", lambda _package: resource_root) - - assert resources.get_bundled_config_path("artemis.jsonc") == (config_dir / "artemis.jsonc") - assert resources.get_bundled_config_path("missing.jsonc") is None - assert resources.get_bundled_showcase_dist() == showcase_dir - - -def test_incomplete_showcase_resource_is_rejected(tmp_path, monkeypatch): - resource_root = tmp_path / "resources" - (resource_root / "showcase_ui").mkdir(parents=True) - monkeypatch.setattr(resources, "files", lambda _package: resource_root) - - assert resources.get_bundled_showcase_dist() is None diff --git a/tests/unit/test_planner.py b/tests/unit/test_planner.py deleted file mode 100644 index 7abd8f40b..000000000 --- a/tests/unit/test_planner.py +++ /dev/null @@ -1,80 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -from artemis.agents.planner.planner import run_async_planner_validation - - -from pathlib import Path - - -class DummyDataEngine: - def get_agent_friendly_steps(self): - return [] - - -class DummyCtx: - data_engine = DummyDataEngine() - project_dir = str(Path(__file__).resolve().parents[2]) - - -import pytest -from unittest.mock import AsyncMock, MagicMock, patch -from artemis.agents.planner.planner import ValidationResult - - -@pytest.mark.asyncio -async def test_planner_validation(): - content_before = "- [/] Click the button\n- [ ] Complete task" - content_after = ( - "- [/] Click the button\n- [ ] Complete task\n- [ ] New subgoal appended at bottom" - ) - mock_llm = MagicMock() - mock_structured = MagicMock() - mock_structured.ainvoke = AsyncMock( - return_value=ValidationResult(is_approved=True, feedback="Valid plan modification") - ) - mock_llm.with_structured_output.return_value = mock_structured - - with patch("artemis.agents.planner.planner.get_llm", return_value=mock_llm) as get_llm: - res = await run_async_planner_validation( - ctx=DummyCtx(), - initial_goal="Do the task", - content_before=content_before, - content_after=content_after, - operator_raw_thinking="I need to add a new subgoal", - operator_native_thinking="Thinking...", - ) - assert res is not None - assert res.get("status") == "success" - # The review runs on the lightweight judge node, never the Planner model. - assert get_llm.call_args.kwargs.get("name") == "planner_validation" - - -def test_planner_validation_node_defaults_to_lightweight_judge(): - """Unconfigured planner_validation resolves to the same flash-lite default - as the pixel safety net (cheap, temperature 0).""" - from artemis.config import get_default_llm_config - - llm_cfg = get_default_llm_config() - node = llm_cfg.get_agent("planner_validation") - safety_net = llm_cfg.get_agent("validator_pixel_safety_net") - assert node.model == safety_net.model - assert node.provider == safety_net.provider - assert node.temperature == 0.0 - assert "lite" in node.model - - -if __name__ == "__main__": - asyncio.run(test_planner_validation()) diff --git a/tests/unit/test_sdk.py b/tests/unit/test_sdk.py deleted file mode 100644 index 10a846299..000000000 --- a/tests/unit/test_sdk.py +++ /dev/null @@ -1,137 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 - -"""Compatibility tests for the single, remote-only ArtemisClient implementation.""" - -from __future__ import annotations - -from collections import defaultdict -from collections.abc import Mapping -from typing import Any - -import pytest - -import artemis -from artemis import ArtemisClient, ConcurrencyMode, Task, TaskResult -from artemis_client import ArtemisClient as ThinArtemisClient -from artemis_client import TaskResult as ThinTaskResult -from artemis_client.errors import NotFoundError - - -class FakeTransport: - def __init__(self) -> None: - self.responses: dict[tuple[str, str], list[Any]] = defaultdict(list) - self.calls: list[tuple[str, str, Mapping[str, Any] | None]] = [] - - def add(self, method: str, path: str, *responses: Any) -> None: - self.responses[(method, path)].extend(responses) - - def request( - self, - method: str, - path: str, - *, - json_body: Mapping[str, Any] | None = None, - ) -> Any: - self.calls.append((method, path, json_body)) - queued = self.responses[(method, path)] - if not queued: - raise AssertionError(f"Unexpected request: {method} {path}") - response = queued.pop(0) - if isinstance(response, BaseException): - raise response - return response - - -def test_full_runtime_reexports_thin_client_types(): - assert ArtemisClient is ThinArtemisClient - assert TaskResult is ThinTaskResult - assert artemis.ArtemisClient is ThinArtemisClient - - -def test_legacy_constructor_settings_are_remote_defaults(): - client = ArtemisClient( - device_serial="emulator-5554", - default_profile="pro", - concurrency_mode=ConcurrencyMode.PER_DEVICE, - transport=FakeTransport(), - ) - - assert client.base_url == "http://127.0.0.1:8000" - assert client.device_id == "emulator-5554" - assert client.default_profile == "pro" - assert client.concurrency_mode == "per_device" - assert client.set_device("pixel-10") is client - assert client.device_serial == "pixel-10" - - -@pytest.mark.asyncio -async def test_run_uses_remote_api_and_returns_thin_result(): - task_id = "00000000-0000-4000-8000-000000000001" - transport = FakeTransport() - transport.add( - "POST", - "/api/run", - {"status": "started", "tasks": [{"session_id": task_id, "status": "pending"}]}, - ) - transport.add( - "GET", - f"/api/sessions/{task_id}", - NotFoundError(404, "not ready"), - {"session_id": task_id, "status": "completed", "current_turn": 3}, - ) - transport.add( - "GET", - "/api/status", - {"status": "running", "queue": [{"session_id": task_id, "status": "pending"}]}, - ) - client = ArtemisClient( - device_serial="pixel-10", - default_profile="flash", - poll_interval=0.001, - transport=transport, - ) - - result = await client.run("Open Settings", task_id=task_id, timeout=1) - - assert isinstance(result, ThinTaskResult) - assert result.succeeded - request_body = transport.calls[0][2] - assert request_body is not None - assert request_body["device_serial"] == "pixel-10" - assert request_body["profile"] == "flash" - - -@pytest.mark.asyncio -async def test_legacy_task_object_is_forwarded_to_remote_api(): - task_id = "00000000-0000-4000-8000-000000000002" - transport = FakeTransport() - transport.add( - "POST", - "/api/run", - {"status": "started", "tasks": [{"session_id": task_id, "status": "pending"}]}, - ) - transport.add( - "GET", - f"/api/sessions/{task_id}", - {"session_id": task_id, "status": "success"}, - ) - client = ArtemisClient(transport=transport) - task = Task( - goal="Verify search functionality", - profile="flash", - device_serial="pixel-10", - locked_package="com.android.settings", - ) - - result = await client.run_task(task, task_id=task_id, timeout=1) - - assert result.succeeded - request_body = transport.calls[0][2] - assert request_body is not None - assert request_body["locked_app_package"] == "com.android.settings" diff --git a/tests/unit/test_telemetry.py b/tests/unit/test_telemetry.py deleted file mode 100644 index 79cd004c6..000000000 --- a/tests/unit/test_telemetry.py +++ /dev/null @@ -1,55 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for Telemetry & Span Lifecycle Tracing.""" - -import pytest -from pathlib import Path -from artemis.telemetry.models import SpanType -from artemis.telemetry.tracer import TelemetryTracer - - -@pytest.mark.asyncio -async def test_telemetry_span_lifecycle(tmp_path: Path): - """Verify TelemetryTracer properly records and persists nested spans.""" - tracer = TelemetryTracer(trace_id="test-session-001", traces_dir=tmp_path) - - async with tracer.start_span("parent_agent_step", span_type=SpanType.AGENT) as parent_span: - assert parent_span.status == "running" - assert parent_span.name == "parent_agent_step" - - async with tracer.start_span( - "child_device_action", span_type=SpanType.DEVICE_ACTION, payload={"action": "click"} - ) as child_span: - assert child_span.parent_id == parent_span.span_id - assert child_span.payload["action"] == "click" - - # Spans should be persisted in JSONL - assert len(tracer.spans) == 2 - child_recorded = tracer.spans[0] - parent_recorded = tracer.spans[1] - - assert child_recorded.name == "child_device_action" - assert child_recorded.status == "success" - assert child_recorded.duration_ms is not None - - assert parent_recorded.name == "parent_agent_step" - assert parent_recorded.status == "success" - - # File existence - log_file = tmp_path / "telemetry_spans.jsonl" - assert log_file.exists() - content = log_file.read_text(encoding="utf-8") - assert "child_device_action" in content - assert "parent_agent_step" in content diff --git a/tests/unit/test_token_meter.py b/tests/unit/test_token_meter.py deleted file mode 100644 index 55643c34b..000000000 --- a/tests/unit/test_token_meter.py +++ /dev/null @@ -1,194 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Tests for the M0 session token metering pipeline (pure recording).""" - -from unittest.mock import Mock -from uuid import uuid4 - -from langchain_core.messages import AIMessage - -from artemis.services.token_meter import ( - SessionTokenMeter, - extract_usage, - get_meter, - record_llm_usage, -) - - -def _msg_with_usage(prompt=1000, completion=50, cached=0): - usage = { - "input_tokens": prompt, - "output_tokens": completion, - "total_tokens": prompt + completion, - } - if cached: - usage["input_token_details"] = {"cache_read": cached} - return AIMessage(content="ok", usage_metadata=usage) - - -def test_extract_usage_from_usage_metadata(): - usage = extract_usage(_msg_with_usage(prompt=1200, completion=80, cached=900)) - assert usage == { - "prompt_tokens": 1200, - "completion_tokens": 80, - "total_tokens": 1280, - "cached_tokens": 900, - } - - -def test_extract_usage_from_response_metadata_fallback(): - msg = AIMessage(content="ok") - msg.response_metadata = { - "usage_metadata": { - "prompt_token_count": 500, - "candidates_token_count": 25, - "total_token_count": 525, - "cached_content_token_count": 400, - } - } - usage = extract_usage(msg) - assert usage == { - "prompt_tokens": 500, - "completion_tokens": 25, - "total_tokens": 525, - "cached_tokens": 400, - } - - -def test_extract_usage_absent_returns_none(): - assert extract_usage(AIMessage(content="no usage")) is None - assert extract_usage(object()) is None - - -def test_meter_accumulates_session_totals(): - meter = SessionTokenMeter("s1") - meter.record({"prompt_tokens": 100, "completion_tokens": 10, "cached_tokens": 0}) - snapshot = meter.record({"prompt_tokens": 300, "completion_tokens": 20, "cached_tokens": 250}) - assert snapshot == { - "session_llm_calls": 2, - "session_prompt_tokens": 400, - "session_completion_tokens": 30, - "session_cached_tokens": 250, - "session_cache_hit_calls": 1, - "session_cached_ratio": 0.625, - } - assert meter.last_prompt_tokens == 300 - - -def test_record_llm_usage_records_trace_with_context_base(): - engine = Mock() - engine.current_session_id = uuid4() - engine.current_step_id = uuid4() - - payload = record_llm_usage( - engine, _msg_with_usage(prompt=2000, completion=100, cached=1500), source="google:test" - ) - - assert payload is not None - assert payload["context_base_tokens"] == 2000 - assert payload["cached_tokens"] == 1500 - assert payload["source"] == "google:test" - - engine.record_trace.assert_called_once() - kwargs = engine.record_trace.call_args.kwargs - assert kwargs["type"] == "llm_call" - assert kwargs["name"] == "llm_usage" - assert kwargs["step_id"] == engine.current_step_id - assert kwargs["payload"]["prompt_tokens"] == 2000 - - -def test_record_llm_usage_accumulates_across_calls_same_session(): - engine = Mock() - engine.current_session_id = uuid4() - engine.current_step_id = None - - record_llm_usage(engine, _msg_with_usage(prompt=100, completion=5)) - payload = record_llm_usage(engine, _msg_with_usage(prompt=250, completion=10, cached=90)) - - assert payload["session_llm_calls"] == 2 - assert payload["session_prompt_tokens"] == 350 - assert payload["session_cached_tokens"] == 90 - assert payload["session_cache_hit_calls"] == 1 - - meter = get_meter(engine.current_session_id) - assert meter.last_prompt_tokens == 250 - - -def test_record_llm_usage_never_raises_and_skips_gracefully(): - # No engine - assert record_llm_usage(None, _msg_with_usage()) is None - - # Engine without a session - engine = Mock() - engine.current_session_id = None - assert record_llm_usage(engine, _msg_with_usage()) is None - engine.record_trace.assert_not_called() - - # Response without usage - engine2 = Mock() - engine2.current_session_id = uuid4() - assert record_llm_usage(engine2, AIMessage(content="nope")) is None - engine2.record_trace.assert_not_called() - - # record_trace blowing up must not propagate - engine3 = Mock() - engine3.current_session_id = uuid4() - engine3.record_trace.side_effect = RuntimeError("boom") - assert record_llm_usage(engine3, _msg_with_usage()) is None - - -def test_record_llm_usage_lens_calls_do_not_touch_last_prompt_tokens(): - """Background lens calls accumulate totals but never overwrite the live - context base (last_prompt_tokens) consumed by the compaction thresholds.""" - engine = Mock() - engine.current_session_id = uuid4() - engine.current_step_id = None - - record_llm_usage(engine, _msg_with_usage(prompt=15000)) - meter = get_meter(engine.current_session_id) - assert meter.last_prompt_tokens == 15000 - - payload = record_llm_usage( - engine, - _msg_with_usage(prompt=300, completion=40), - source="lens:visual_transition:test-model", - update_last_prompt=False, - ) - assert payload["source"] == "lens:visual_transition:test-model" - assert meter.last_prompt_tokens == 15000 # untouched by the lens call - assert meter.llm_calls == 2 - assert meter.prompt_tokens == 15300 - - -def test_record_llm_usage_tags_the_traced_node(): - """Each usage trace names the traced node that issued the call so the - session usage endpoint can pick the executor's (Operator/FlashRunner) - prompt size out as the live context.""" - from artemis.data_engine.context_vars import CURRENT_NODE_NAME - - engine = Mock() - engine.current_session_id = uuid4() - engine.current_step_id = None - - token = CURRENT_NODE_NAME.set("operator") - try: - payload = record_llm_usage(engine, _msg_with_usage(prompt=42000, completion=90)) - finally: - CURRENT_NODE_NAME.reset(token) - assert payload["node"] == "operator" - assert payload["context_base_tokens"] == 42000 - - untagged = record_llm_usage(engine, _msg_with_usage(prompt=10)) - assert "node" not in untagged diff --git a/tests/unit/test_ui_command.py b/tests/unit/test_ui_command.py deleted file mode 100644 index a00b334a5..000000000 --- a/tests/unit/test_ui_command.py +++ /dev/null @@ -1,183 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 - -import io -import os -import subprocess -import sys - -import pytest -from rich.console import Console - -from artemis.interfaces.cli.commands import ui -from artemis.interfaces.cli.commands.ui import ( - _npm_install_required, - _resolve_npm_executable, - _run_build_step, - _showcase_build_required, - ensure_showcase_built, -) - - -def test_resolve_npm_executable_prefers_windows_command_shim(monkeypatch): - paths = { - "npm.cmd": r"C:\Program Files\nodejs\npm.cmd", - "npm.exe": None, - "npm": r"C:\Program Files\nodejs\npm.cmd", - } - monkeypatch.setattr(ui.shutil, "which", paths.get) - - assert _resolve_npm_executable("win32") == paths["npm.cmd"] - - -def test_resolve_npm_executable_uses_npm_on_posix(monkeypatch): - monkeypatch.setattr( - ui.shutil, - "which", - lambda command: "/usr/local/bin/npm" if command == "npm" else None, - ) - - assert _resolve_npm_executable("linux") == "/usr/local/bin/npm" - - -def test_showcase_build_required_when_source_is_newer(tmp_path): - showcase_dir = tmp_path / "showcase_ui" - source_file = showcase_dir / "src" / "app" / "agent.service.ts" - built_index = showcase_dir / "dist" / "frontend" / "browser" / "index.html" - source_file.parent.mkdir(parents=True) - built_index.parent.mkdir(parents=True) - source_file.write_text("source", encoding="utf-8") - built_index.write_text("build", encoding="utf-8") - - os.utime(built_index, (100, 100)) - os.utime(source_file, (200, 200)) - - assert _showcase_build_required(showcase_dir) is True - - -def test_showcase_build_not_required_when_build_is_current(tmp_path): - showcase_dir = tmp_path / "showcase_ui" - source_file = showcase_dir / "src" / "app" / "agent.service.ts" - built_index = showcase_dir / "dist" / "frontend" / "browser" / "index.html" - source_file.parent.mkdir(parents=True) - built_index.parent.mkdir(parents=True) - source_file.write_text("source", encoding="utf-8") - built_index.write_text("build", encoding="utf-8") - - os.utime(source_file, (100, 100)) - os.utime(built_index, (200, 200)) - - assert _showcase_build_required(showcase_dir) is False - - -def _showcase_with_manifests(tmp_path, *, stamp_mtime=None): - showcase_dir = tmp_path / "showcase_ui" - showcase_dir.mkdir() - for name in ("package.json", "package-lock.json"): - path = showcase_dir / name - path.write_text("{}", encoding="utf-8") - os.utime(path, (100, 100)) - if stamp_mtime is not None: - stamp = showcase_dir / "node_modules" / ".package-lock.json" - stamp.parent.mkdir() - stamp.write_text("{}", encoding="utf-8") - os.utime(stamp, (stamp_mtime, stamp_mtime)) - return showcase_dir - - -def test_npm_install_required_when_node_modules_missing(tmp_path): - assert _npm_install_required(_showcase_with_manifests(tmp_path)) is True - - -def test_npm_install_not_required_when_lock_stamp_is_current(tmp_path): - assert _npm_install_required(_showcase_with_manifests(tmp_path, stamp_mtime=200)) is False - - -def test_npm_install_required_when_package_lock_is_newer(tmp_path): - showcase_dir = _showcase_with_manifests(tmp_path, stamp_mtime=200) - os.utime(showcase_dir / "package-lock.json", (300, 300)) - - assert _npm_install_required(showcase_dir) is True - - -def _recording_console(): - return Console(file=io.StringIO(), force_terminal=False, width=120) - - -def test_ensure_showcase_built_skips_install_when_deps_current(monkeypatch): - steps = [] - monkeypatch.setattr(ui, "_showcase_build_required", lambda showcase_dir: True) - monkeypatch.setattr(ui, "_npm_install_required", lambda showcase_dir: False) - monkeypatch.setattr(ui, "_resolve_npm_executable", lambda: "npm") - monkeypatch.setattr( - ui, "_run_build_step", lambda console, label, cmd, cwd: steps.append((label, cmd)) - ) - console = _recording_console() - - ensure_showcase_built(console) - - assert [label for label, _ in steps] == ["② ng build"] - assert steps[0][1] == ["npm", "run", "build"] - assert "Showcase UI built in" in console.file.getvalue() - - -def test_ensure_showcase_built_runs_install_first_when_deps_changed(monkeypatch): - steps = [] - monkeypatch.setattr(ui, "_showcase_build_required", lambda showcase_dir: True) - monkeypatch.setattr(ui, "_npm_install_required", lambda showcase_dir: True) - monkeypatch.setattr(ui, "_resolve_npm_executable", lambda: "npm") - monkeypatch.setattr( - ui, "_run_build_step", lambda console, label, cmd, cwd: steps.append((label, cmd)) - ) - - ensure_showcase_built(_recording_console()) - - assert [label for label, _ in steps] == ["① npm install (dependencies changed)", "② ng build"] - assert steps[0][1] == ["npm", "install", "--no-audit", "--no-fund", "--loglevel=warn"] - - -def test_ensure_showcase_built_reports_failure_with_manual_command(monkeypatch): - monkeypatch.setattr(ui, "_showcase_build_required", lambda showcase_dir: True) - monkeypatch.setattr(ui, "_npm_install_required", lambda showcase_dir: False) - monkeypatch.setattr(ui, "_resolve_npm_executable", lambda: "npm") - - def failing_step(console, label, cmd, cwd): - raise subprocess.CalledProcessError(1, cmd) - - monkeypatch.setattr(ui, "_run_build_step", failing_step) - console = _recording_console() - - ensure_showcase_built(console) # must not raise: the server still starts - - output = console.file.getvalue() - assert "Failed to auto-build Showcase UI" in output - assert "npm install && npm run build" in output - - -def test_run_build_step_streams_child_output_and_raises_on_failure(tmp_path): - console = _recording_console() - cmd = [sys.executable, "-c", "print('hello from child'); import sys; sys.exit(3)"] - - with pytest.raises(subprocess.CalledProcessError) as excinfo: - _run_build_step(console, "② ng build", cmd, tmp_path) - - assert excinfo.value.returncode == 3 - output = console.file.getvalue() - assert "② ng build" in output - assert "hello from child" in output - - -def test_run_build_step_strips_ansi_and_succeeds(tmp_path): - console = _recording_console() - cmd = [sys.executable, "-c", "print('\\x1b[32mdone\\x1b[0m')"] - - _run_build_step(console, "step", cmd, tmp_path) - - output = console.file.getvalue() - assert "done" in output - assert "\x1b[32m" not in output diff --git a/tests/unit/test_unified_controller_video.py b/tests/unit/test_unified_controller_video.py index e87330dd4..14d78115e 100644 --- a/tests/unit/test_unified_controller_video.py +++ b/tests/unit/test_unified_controller_video.py @@ -29,7 +29,6 @@ from artemis.drivers.mock.mock_driver import MockDeviceDriver from artemis.utils.video import ( build_scrcpy_record_command, - extract_frames_at_timestamps, get_ffmpeg_path, normalize_recording_to_mp4, plan_timeline_pieces, @@ -58,19 +57,6 @@ async def test_audio_extraction_rejects_silent_video_without_running_ffmpeg(tmp_ assert spawn.await_count == 1 -def test_targeted_frame_extraction_uses_requested_timestamps(): - fixture = Path(__file__).parents[1] / "tools" / "inputs" / "recording.mp4" - frames = extract_frames_at_timestamps( - fixture, - [0.0, 0.2, 0.4, 0.2, -1.0], - max_frames=3, - max_dimension=320, - ) - - assert [timestamp for timestamp, _ in frames] == [0.0, 0.2, 0.4] - assert all(data.startswith(b"\xff\xd8") for _, data in frames) - - def test_scrcpy_recording_locks_each_segment_orientation(tmp_path): output_path = tmp_path / "recording.mkv" @@ -449,41 +435,6 @@ async def test_segment_cache_is_scoped_to_recording_generation(mock_ctx, tmp_pat remove_active_session("emulator-5554") -def test_data_engine_video_lifecycle(tmp_path): - from artemis.data_engine.engine import DataEngine - from artemis.context import ArtemisContext - - mock_c = MagicMock(spec=ArtemisContext) - mock_c.execution_setup = MagicMock(traces_path=str(tmp_path / "traces")) - mock_c.device = None - engine = DataEngine(mock_c) - session_id = engine.start_session(goal="Test Video Engine") - - vid = uuid4() - vpath = tmp_path / "recording.mp4" - vpath.write_bytes(b"video bytes") - - # Start video recording - engine.record_video_start(vid, "device-1", vpath) - rec = engine.storage.get_video_recording(vid) - assert rec is not None - assert rec.device_id == "device-1" - - # Stop video recording - engine.record_video_stop(vid, "device-1", vpath, time.time() - 10.0, time.time()) - sess = engine.storage.get_session(session_id) - assert sess.video_filepath == str(vpath) - - # Move video path on finalize - new_vpath = tmp_path / "archived" / "recording.mp4" - new_vpath.parent.mkdir(parents=True, exist_ok=True) - new_vpath.write_bytes(b"video bytes") - engine.update_video_path(new_vpath) - - sess_updated = engine.storage.get_session(session_id) - assert sess_updated.video_filepath == str(new_vpath) - - @pytest.mark.asyncio async def test_unified_controller_crash_recovery_and_multi_segment(mock_ctx, tmp_path): """Verify that if recording is interrupted, segments are auto-recovered and concatenated seamlessly.""" diff --git a/tests/unit/test_update.py b/tests/unit/test_update.py deleted file mode 100644 index 23b96d4fc..000000000 --- a/tests/unit/test_update.py +++ /dev/null @@ -1,36 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import os -from artemis.utils.notes import update_note_content - -base_dir = "/tmp/artemis_test" -os.makedirs(f"{base_dir}/notes", exist_ok=True) -task_plan = """- [x] Initial task -- [/] Active task -- [ ] Future task""" -with open(f"{base_dir}/notes/task_plan.md", "w") as f: - f.write(task_plan) - -try: - res = update_note_content( - base_dir, - "task_plan", - "- [/] Active task", - "- [/] Active task\n - [ ] New subgoal", - ) - print("Update successful!") - print(open(f"{base_dir}/notes/task_plan.md").read()) -except Exception as e: - print("Error:", e) diff --git a/tests/unit/tools/history/test_declarations.py b/tests/unit/tools/history/test_declarations.py deleted file mode 100644 index 2844031e7..000000000 --- a/tests/unit/tools/history/test_declarations.py +++ /dev/null @@ -1,99 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""One args schema, two contracts: the Flash ``ToolDeclaration`` and the -LangChain tool of every history tool are derived from the same Pydantic model.""" - -from types import SimpleNamespace - -import pytest - -from artemis.core.tool_declaration import ToolDeclaration -from artemis.tools.history import ( - GET_STEP_SCREENSHOT_TOOL, - HISTORY_TOOL_DECLARATIONS, - HISTORY_TOOL_NAMES, - HISTORY_TOOL_WRAPPERS, - HISTORY_TOOLS, - REPLAY_STEPS_TOOL, - SEARCH_HISTORY_TOOL, - get_history_tools, - history_tool_by_name, - history_tool_declarations, -) - - -@pytest.mark.parametrize("tool", HISTORY_TOOLS, ids=lambda t: t.name) -def test_tool_declaration_mirrors_the_args_schema(tool): - declaration = tool.to_tool_declaration() - assert isinstance(declaration, ToolDeclaration) - assert declaration.name == tool.name - assert declaration.description == tool.description - - properties = declaration.parameters["properties"] - assert list(properties) == list(tool.args_schema.model_fields) - for field_name, info in tool.args_schema.model_fields.items(): - assert properties[field_name]["description"] == info.description - assert declaration.parameters["required"] == [ - name for name, info in tool.args_schema.model_fields.items() if info.is_required() - ] - - # The LangChain export binds the identical schema. - lc_tool = tool.to_langchain_tool(SimpleNamespace(data_engine=None)) - assert lc_tool.name == tool.name - assert lc_tool.args_schema is tool.args_schema - - # And the GenAI export agrees on names and required fields. - genai = tool.to_genai_declaration() - assert set(genai.parameters.properties) == set(properties) - assert list(genai.parameters.required or []) == declaration.parameters["required"] - - -def test_declared_types_and_enums(): - search = SEARCH_HISTORY_TOOL.parameters["properties"] - assert search["query"]["type"] == "string" - assert search["step_range"] == { - "type": "array", - "items": {"type": "integer"}, - "description": search["step_range"]["description"], - } - assert search["max_results"]["type"] == "integer" - assert search["max_results"]["default"] == 5 - assert SEARCH_HISTORY_TOOL.parameters["required"] == [] - - replay = REPLAY_STEPS_TOOL.parameters["properties"] - assert replay["start_step"]["type"] == "integer" - assert replay["end_step"]["type"] == "integer" - assert REPLAY_STEPS_TOOL.parameters["required"] == ["start_step"] - - shot = GET_STEP_SCREENSHOT_TOOL.parameters["properties"] - assert shot["step_number"]["type"] == "integer" - assert shot["which"]["type"] == "string" - assert shot["which"]["enum"] == ["pre", "post", "overlay"] - assert shot["which"]["default"] == "pre" - assert GET_STEP_SCREENSHOT_TOOL.parameters["required"] == ["step_number"] - - -def test_exports_are_consistent(): - assert HISTORY_TOOL_NAMES == {"search_history", "replay_steps", "get_step_screenshot"} - assert [d.name for d in HISTORY_TOOL_DECLARATIONS] == [t.name for t in HISTORY_TOOLS] - assert len(HISTORY_TOOL_WRAPPERS) == 3 - assert history_tool_by_name("default_api:replay_steps").name == "replay_steps" - assert history_tool_by_name("nope") is None - - ctx = SimpleNamespace(data_engine=object()) - expected = [t.name for t in HISTORY_TOOLS] - assert [t.name for t in get_history_tools(ctx)] == expected - assert [d.name for d in history_tool_declarations(ctx)] == expected - assert history_tool_declarations(SimpleNamespace(data_engine=None)) == [] diff --git a/tests/unit/tools/history/test_replay.py b/tests/unit/tools/history/test_replay.py deleted file mode 100644 index 399275e02..000000000 --- a/tests/unit/tools/history/test_replay.py +++ /dev/null @@ -1,181 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""replay_steps: range replay, per-call step cap, whole-step token budget, -``[Screen]`` status lines, and the no-history degradation.""" - -from types import SimpleNamespace -from unittest.mock import MagicMock - -import pytest - -from artemis.core.tool_failure import is_tool_failure -from artemis.tools.history import ReplayStepsTool, replay_steps, replay_steps_text - - -SESSION_START = 1000.0 - - -def _friendly_step(number, tool_result="The toggle is ON", **overrides): - step = { - "step_id": f"step_{number}", - "step_number": number, - "timestamp": SESSION_START + number * 3, - "relative_time": f"{number * 3}.0s", - "summary": f"Step {number} summary.", - "action_taken": {"action": "click", "target": [0.5, 0.5], "target_text": "Save"}, - "last_execution_result": {"status": "success"}, - "interleaved_events": [ - {"type": "thought", "content": f"Thinking at step {number}."}, - { - "type": "tool_call", - "name": "ask_explorer", - "args": {"question": "toggle?"}, - "result": tool_result, - }, - ], - } - step.update(overrides) - return step - - -def _reader(steps, session_start=SESSION_START): - reader = MagicMock() - reader.session_start_time = session_start - reader.get_agent_friendly_steps_in_range.side_effect = lambda s, e: [ - st for st in steps if s <= st["step_number"] <= e - ] - return reader - - -def test_replay_range_renders_every_step_in_full(): - reader = _reader([_friendly_step(3), _friendly_step(4, tool_result="The toggle is OFF " * 100)]) - out = replay_steps_text(reader, 3, 4) - - reader.get_agent_friendly_steps_in_range.assert_called_once_with(3, 4) - # Step headers carry the session clock every agent prompt uses. - assert "- **Step 3 (T+00:09)**" in out - assert "- **Step 4 (T+00:12)**" in out - assert "Start:" not in out - assert "[Screen]: Step 3 summary." in out - assert "Thinking at step 4." in out - assert "`ask_explorer(" in out - # Loose clamp: a ~1.8k-char tool result is replayed in full. - assert "The toggle is OFF " * 100 in out - assert "[Action]: Tapped 'Save' at [0.5, 0.5]" in out - assert "[Reasoning & tool calls]:" in out - assert "Operator" not in out - - -def test_replay_header_falls_back_without_a_session_clock(): - """No session start (or a mocked, non-numeric one) / no step timestamp: - the header keeps the stored relative time instead of ``T+??:??``.""" - out = replay_steps_text(_reader([_friendly_step(2)], session_start=None), 2) - assert "- **Step 2 (Start: 6.0s)**" in out - - reader = MagicMock() # session_start_time is a MagicMock, not a number - reader.get_agent_friendly_steps_in_range.return_value = [_friendly_step(2)] - assert "- **Step 2 (Start: 6.0s)**" in replay_steps_text(reader, 2) - - no_timestamp = _friendly_step(2) - del no_timestamp["timestamp"] - assert "- **Step 2 (Start: 6.0s)**" in replay_steps_text(_reader([no_timestamp]), 2) - - -def test_replay_single_step_reversed_bounds_and_missing_step(): - reader = _reader([_friendly_step(2)]) - assert "- **Step 2 (T+00:06)**" in replay_steps_text(reader, 2) - reader.get_agent_friendly_steps_in_range.assert_called_once_with(2, 2) - - reader = _reader([_friendly_step(2), _friendly_step(3)]) - out = replay_steps_text(reader, 3, 2) - reader.get_agent_friendly_steps_in_range.assert_called_once_with(2, 3) - assert out.index("**Step 2") < out.index("**Step 3") - - assert replay_steps_text(_reader([]), 9) == "Error: step 9 not found." - assert replay_steps_text(_reader([]), 9, 12) == "Error: no recorded steps in range 9–12." - assert "must be integers" in replay_steps_text(_reader([]), "x") - - # Every refusal is a structural failure, not just "Error"-looking text. - assert is_tool_failure(replay_steps_text(_reader([]), 9)) - assert is_tool_failure(replay_steps_text(_reader([]), 9, 12)) - assert is_tool_failure(replay_steps_text(_reader([]), "x")) - assert not is_tool_failure(replay_steps_text(_reader([_friendly_step(2)]), 2)) - - -def test_replay_caps_the_range_per_call(): - reader = _reader([_friendly_step(n) for n in range(1, 30)]) - out = replay_steps_text(reader, 1, 20, max_steps=5) - reader.get_agent_friendly_steps_in_range.assert_called_once_with(1, 5) - assert "**Step 5" in out - assert "**Step 6" not in out - assert "Call again from Step 6" in out - - -def test_replay_token_budget_drops_whole_trailing_steps(): - big = "x" * 4000 # ~1k tokens per step - reader = _reader([_friendly_step(n, tool_result=big) for n in range(1, 6)]) - out = replay_steps_text(reader, 1, 5, max_steps=5, max_tokens=2500) - - # Two steps fit; the rest are dropped as whole steps, never cut mid-step. - assert "**Step 1" in out and "**Step 2" in out - assert "**Step 3" not in out - assert out.count(big) == 2 - assert "Token budget reached: Steps 1–2 replayed; 3 more step(s)" in out - assert "Call again from Step 3" in out - - -def test_replay_always_shows_at_least_the_first_step(): - reader = _reader([_friendly_step(1, tool_result="y" * 4000), _friendly_step(2)]) - out = replay_steps_text(reader, 1, 2, max_tokens=10) - assert "**Step 1" in out - assert "y" * 4000 in out - assert "**Step 2" not in out - - -def test_replay_screen_line_states(): - with_text = replay_steps_text(_reader([_friendly_step(1)]), 1) - assert "[Screen]: Step 1 summary." in with_text - assert "[Summary]" not in with_text - - pending = replay_steps_text( - _reader([_friendly_step(1, summary=None, extra_metadata={"summary_status": "pending"})]), - 1, - ) - assert "[Screen]: (screen description pending)" in pending - - failed = replay_steps_text( - _reader([_friendly_step(1, summary="", extra_metadata={"summary_status": "failed"})]), - 1, - ) - assert "[Screen]: (screen description unavailable)" in failed - - no_status = replay_steps_text(_reader([_friendly_step(1, summary=None)]), 1) - assert "[Screen]" not in no_status - - -def test_replay_without_history(): - assert "no execution history" in replay_steps_text(None, 1) - - -@pytest.mark.asyncio -async def test_tool_execute_reads_the_context_engine_and_degrades_without_one(): - reader = _reader([_friendly_step(2)]) - out = await ReplayStepsTool().execute(ctx=SimpleNamespace(data_engine=reader), start_step=2) - assert "- **Step 2 (T+00:06)**" in out - - out = await replay_steps.execute(ctx=SimpleNamespace(data_engine=None), start_step=1) - assert "no execution history" in out - assert replay_steps.is_available(SimpleNamespace(data_engine=None)) is False - assert replay_steps.is_available(SimpleNamespace(data_engine=reader)) is True diff --git a/tests/unit/tools/history/test_result_messages.py b/tests/unit/tools/history/test_result_messages.py deleted file mode 100644 index 59829339d..000000000 --- a/tests/unit/tools/history/test_result_messages.py +++ /dev/null @@ -1,120 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""tool_result_messages: one helper decides how a tool result — with or -without images — enters the conversation, per LLM provider.""" - -from enum import StrEnum -from types import SimpleNamespace - -import pytest -from langchain_core.messages import HumanMessage, ToolMessage - -from artemis.tools.tool_wrapper import ( - resolve_image_carrier, - split_multimodal_result, - tool_result_messages, -) - -IMAGE = {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,AAAA"}} -SHOT = [{"type": "text", "text": "Screenshot of step 7 (pre-action) is attached."}, IMAGE] - - -class _Provider(StrEnum): - GOOGLE = "google" - VERTEX_AI = "vertexai" - - -def _llm(provider): - return SimpleNamespace(endpoint=SimpleNamespace(provider=provider)) - - -@pytest.mark.parametrize( - ("provider", "carrier"), - [ - ("google", "tool"), - ("anthropic", "tool"), - ("vertexai", "human"), - ("openai", "human"), - ("openrouter", "human"), - ("xai", "human"), - ("ollama", "human"), - (None, "human"), - ], -) -def test_resolve_image_carrier_by_provider_name(provider, carrier): - assert resolve_image_carrier(provider) == carrier - assert resolve_image_carrier(_llm(provider)) == carrier - - -def test_resolve_image_carrier_accepts_enums_and_endpoint_less_llms(): - assert resolve_image_carrier(_Provider.GOOGLE) == "tool" - assert resolve_image_carrier(_llm(_Provider.VERTEX_AI)) == "human" - assert resolve_image_carrier(SimpleNamespace()) == "human" - - -def test_split_multimodal_result(): - assert split_multimodal_result("plain") == ("plain", []) - assert split_multimodal_result(None) == ("", []) - assert split_multimodal_result(ToolMessage(content="x", tool_call_id="t")) == ("x", []) - text, images = split_multimodal_result(SHOT) - assert text == "Screenshot of step 7 (pre-action) is attached." - assert images == [IMAGE] - - -def test_text_only_results_stay_a_single_tool_message(): - msgs = tool_result_messages("tc-1", "Step 3 replay", name="replay_steps", status="error") - assert len(msgs) == 1 - assert isinstance(msgs[0], ToolMessage) - assert msgs[0].content == "Step 3 replay" - assert msgs[0].tool_call_id == "tc-1" - assert msgs[0].name == "replay_steps" - assert msgs[0].status == "error" - - # Text-block lists pass through unchanged; other objects are stringified. - blocks = [{"type": "text", "text": "a"}, {"type": "text", "text": "b"}] - assert tool_result_messages("tc", blocks)[0].content == blocks - assert tool_result_messages("tc", {"k": 1})[0].content == "{'k': 1}" - - -def test_tool_carrier_puts_the_image_inside_the_tool_message(): - msgs = tool_result_messages("tc-2", SHOT, name="get_step_screenshot", llm=_llm("google")) - assert len(msgs) == 1 - assert isinstance(msgs[0], ToolMessage) - assert msgs[0].content == SHOT - assert msgs[0].name == "get_step_screenshot" - - -def test_human_carrier_splits_text_and_image_with_a_caption(): - msgs = tool_result_messages( - "tc-3", SHOT, name="get_step_screenshot", status="success", llm=_llm("openai") - ) - assert len(msgs) == 2 - tool_msg, human_msg = msgs - assert isinstance(tool_msg, ToolMessage) - assert tool_msg.content == "Screenshot of step 7 (pre-action) is attached." - assert tool_msg.tool_call_id == "tc-3" - assert isinstance(human_msg, HumanMessage) - assert human_msg.content[0] == { - "type": "text", - "text": "[Screenshot returned by get_step_screenshot for step 7]", - } - assert human_msg.content[1] == IMAGE - - -def test_explicit_carrier_wins_over_the_llm(): - msgs = tool_result_messages("tc", SHOT, image_carrier="human", llm=_llm("google")) - assert len(msgs) == 2 - msgs = tool_result_messages("tc", SHOT, image_carrier="tool", llm=_llm("openai")) - assert len(msgs) == 1 diff --git a/tests/unit/tools/history/test_screenshot.py b/tests/unit/tools/history/test_screenshot.py deleted file mode 100644 index 244f59f91..000000000 --- a/tests/unit/tools/history/test_screenshot.py +++ /dev/null @@ -1,160 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""get_step_screenshot: pre / post / overlay, missing files, invalid variants.""" - -import base64 -from types import SimpleNamespace -from unittest.mock import MagicMock - -import pytest -from PIL import Image - -from artemis.core.tool_failure import is_tool_failure -from artemis.tools.history import get_step_screenshot, load_step_screenshot -from artemis.tools.tool_wrapper import tool_result_messages - - -def _jpeg_file(tmp_path, name="pre.jpg", color="white"): - path = tmp_path / name - Image.new("RGB", (200, 400), color).save(path, format="JPEG") - return path - - -def _reader(pre=None, post=None, action=None, record=True): - reader = MagicMock() - reader.get_step_image_path.side_effect = lambda n, which="pre": pre if which == "pre" else post - reader.get_step_record.return_value = SimpleNamespace(action_taken=action) if record else None - return reader - - -def test_pre_and_post_screenshots_are_attached_as_content_blocks(tmp_path): - pre = _jpeg_file(tmp_path, "pre.jpg", "white") - post = _jpeg_file(tmp_path, "post.jpg", "black") - reader = _reader(pre=pre, post=post) - - shot = load_step_screenshot(reader, 2, "pre") - reader.get_step_image_path.assert_called_with(2, "pre") - assert shot.image_bytes == pre.read_bytes() - assert shot.description == "Screenshot of step 2 (pre-action) is attached." - blocks = shot.to_content_blocks() - assert blocks[0] == {"type": "text", "text": shot.description} - assert blocks[1]["type"] == "image_url" - url = blocks[1]["image_url"]["url"] - assert url.startswith("data:image/jpeg;base64,") - assert base64.b64decode(url.split(",", 1)[1]) == pre.read_bytes() - - shot = load_step_screenshot(reader, 2, "post") - reader.get_step_image_path.assert_called_with(2, "post") - assert shot.image_bytes == post.read_bytes() - assert "post-action" in shot.description - - -def test_overlay_draws_the_recorded_raw_action(tmp_path): - pre = _jpeg_file(tmp_path) - reader = _reader(pre=pre, action={"action": "click", "target": [100, 200]}) - - shot = load_step_screenshot(reader, 2, "overlay") - reader.get_step_image_path.assert_called_once_with(2, "pre") - assert shot.overlay_drawn is True - assert "action drawn on it" in shot.description - assert shot.image_bytes != pre.read_bytes() - - -def test_overlay_falls_back_to_plain_pre_without_action(tmp_path): - pre = _jpeg_file(tmp_path) - shot = load_step_screenshot(_reader(pre=pre, action=None), 2, "overlay") - assert shot.overlay_drawn is False - assert shot.image_bytes == pre.read_bytes() - assert "plain screenshot" in shot.description - - -def test_missing_file_is_an_answer_but_missing_step_is_a_failure(): - shot = load_step_screenshot(_reader(pre=None, post=None), 3, "post") - assert shot.image_bytes is None - assert shot.is_error is False - out = shot.to_content_blocks() - assert out == "No post-action screenshot recorded for step 3." - assert not is_tool_failure(out) - - shot = load_step_screenshot(_reader(pre=None, record=False), 9, "pre") - assert shot.is_error is True - out = shot.to_content_blocks() - assert out == "Error: step 9 not found." - assert is_tool_failure(out) - - -def test_invalid_variant_and_step_number_are_rejected(): - shot = load_step_screenshot(_reader(), 1, "bogus") - assert shot.image_bytes is None - assert shot.is_error is True - assert "'pre', 'post' or 'overlay'" in shot.description - assert is_tool_failure(shot.to_content_blocks()) - - shot = load_step_screenshot(_reader(), "one") - assert "must be an integer" in shot.description and shot.is_error is True - shot = load_step_screenshot(None, 1) - assert "no execution history" in shot.description and shot.is_error is True - - -def test_unreadable_file_and_reader_errors_are_failures(tmp_path): - shot = load_step_screenshot(_reader(pre=tmp_path / "missing.jpg"), 2, "pre") - assert shot.is_error is True - assert "Error reading screenshot" in shot.description - - reader = _reader() - reader.get_step_image_path.side_effect = ValueError("corrupt row") - shot = load_step_screenshot(reader, 2, "pre") - assert shot.is_error is True - assert "Error looking up step 2: corrupt row" in shot.description - - -@pytest.mark.asyncio -async def test_error_answers_reach_the_model_with_status_error(): - """The executor loops derive the ToolMessage status from ``is_tool_failure``: - an unknown step arrives as status=error, a recorded-but-absent screenshot - as a normal answer.""" - ctx = SimpleNamespace(data_engine=_reader(pre=None, record=False)) - out = await get_step_screenshot.execute(ctx=ctx, step_number=999, which="pre") - assert out == "Error: step 999 not found." - assert is_tool_failure(out) - status = "error" if is_tool_failure(out) else "success" - (msg,) = tool_result_messages("tc-1", out, name="get_step_screenshot", status=status) - assert msg.status == "error" - assert msg.content == "Error: step 999 not found." - - ctx = SimpleNamespace(data_engine=_reader(pre=None, post=None)) - out = await get_step_screenshot.execute(ctx=ctx, step_number=3, which="post") - assert not is_tool_failure(out) - (msg,) = tool_result_messages( - "tc-2", - out, - name="get_step_screenshot", - status="error" if is_tool_failure(out) else "success", - ) - assert msg.status == "success" - - -@pytest.mark.asyncio -async def test_tool_execute_returns_blocks_or_text(tmp_path): - pre = _jpeg_file(tmp_path) - ctx = SimpleNamespace(data_engine=_reader(pre=pre)) - out = await get_step_screenshot.execute(ctx=ctx, step_number=1, which="pre") - assert isinstance(out, list) and out[1]["type"] == "image_url" - - out = await get_step_screenshot.execute(ctx=ctx, step_number=1, which="post") - assert out == "No post-action screenshot recorded for step 1." - - out = await get_step_screenshot.execute(ctx=SimpleNamespace(data_engine=None), step_number=1) - assert "no execution history" in out diff --git a/tests/unit/tools/history/test_search.py b/tests/unit/tools/history/test_search.py deleted file mode 100644 index e899bc124..000000000 --- a/tests/unit/tools/history/test_search.py +++ /dev/null @@ -1,468 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""search_history boundaries and search correctness. - -Hard boundaries under test: bounded result count, bounded response tokens, -every result carrying a step number. Plus: keyword/step-range/notes/chunk -filter correctness, the step-range ledger re-entry (full-width -``build_action_ledger`` rows), and the screen-text surface: the step's own -pre AND post screenshots plus screenshots its tool results embedded. -""" - -import hashlib -import io -import re -from types import SimpleNamespace -from unittest.mock import MagicMock - -import pytest -from PIL import Image - -from artemis.context import ArtemisContext -from artemis.core.tool_failure import is_tool_failure -from artemis.data_engine.engine import DataEngine -from artemis.tools.history import ( - SearchHistoryTool, - search_history, - search_history_available, - search_history_text, -) -from artemis.tools.history.search import result_search_text -from artemis.utils.notes import save_note_content - - -def _jpeg(color: str) -> bytes: - buf = io.BytesIO() - Image.new("RGB", (64, 128), color).save(buf, format="JPEG") - return buf.getvalue() - - -def _make_engine(tmp_path): - mock_ctx = MagicMock(spec=ArtemisContext) - mock_execution_setup = MagicMock() - mock_execution_setup.traces_path = str(tmp_path) - mock_ctx.execution_setup = mock_execution_setup - mock_ctx.device = None - engine = DataEngine(mock_ctx) - engine.start_session("search test session") - return engine - - -def _flush(engine): - for t in list(engine._pending_threads): - t.join() - - -def _cfg(**overrides): - values = {"enabled": True, "max_results": 5, "max_text_tokens": 2000, "screen_scan_steps": 150} - values.update(overrides) - return SimpleNamespace(**values) - - -@pytest.fixture -def engine(tmp_path): - engine = _make_engine(tmp_path) - engine.record_step( - pre_screenshot_bytes=_jpeg("red"), - summary="Opened the login page.", - action_taken={"action": "click", "target_text": "Login entry"}, - last_execution_result={"status": "success"}, - ) - engine.record_step( - pre_screenshot_bytes=_jpeg("green"), - post_screenshot_bytes=_jpeg("blue"), - summary="Typed the verification code 4711 into the input.", - action_taken={"action": "input_text", "text": "4711"}, - last_execution_result={"status": "success"}, - operator_raw_thinking="The code arrived via SMS; entering 4711 now.", - ) - engine.record_step( - summary="A promo popup appeared and was dismissed.", - action_taken={"action": "click", "target_text": "Close popup"}, - last_execution_result={"status": "failed", "error": "element vanished"}, - ) - _flush(engine) - return engine - - -def _result_blocks(text: str) -> list[str]: - return [b for b in text.split("\n") if b.startswith("[")] - - -def test_keyword_search_finds_step_and_carries_step_number(engine): - out = search_history_text(engine, query="verification code", recall_config=_cfg()) - assert isinstance(out, str) - assert "Step 2" in out - assert "4711" in out - # Every result header names a step / step range / step anchor. - for header in _result_blocks(out): - assert re.search(r"Step[s]? \d", header), header - - -def test_step_range_filters_matches(engine): - out = search_history_text(engine, query="popup", step_range=[1, 2], recall_config=_cfg()) - # Step 3 (the popup step) is outside the range: only the range ledger and - # a no-match line may appear. - assert "A promo popup appeared" not in out - assert "No matches" in out - - -def test_step_range_returns_full_width_ledger_rows(engine): - """The compressed-history marker line's re-entry point: a step range - returns that range's full per-step action ledger.""" - out = search_history_text(engine, query="", step_range=[3, 1], recall_config=_cfg()) - assert "Action ledger for Steps 1–3" in out - # Full-width build_action_ledger rows: step number + T+mm:ss offset + - # semantic action + result phrase. - assert re.search(r"- Step 1 \(T\+\d{2}:\d{2}\): .*Login entry", out) - assert re.search(r"- Step 2 \(T\+\d{2}:\d{2}\): ", out) - assert re.search(r"- Step 3 \(T\+\d{2}:\d{2}\): ", out) - - -def test_result_count_is_clamped_by_config(engine): - for i in range(8): - engine.record_step( - summary=f"Scrolled the anchorterm feed page {i}.", - action_taken={"action": "swipe"}, - last_execution_result={"status": "success"}, - ) - _flush(engine) - out = search_history_text( - engine, query="anchorterm", max_results=50, recall_config=_cfg(max_results=3) - ) - assert out.count("[Step ") == 3 - assert "more not shown" in out - - -def test_response_is_truncated_at_token_budget(engine): - for i in range(6): - engine.record_step( - summary=f"needleword page {i} " + "filler content " * 60, - action_taken={"action": "click"}, - last_execution_result={"status": "success"}, - ) - _flush(engine) - out = search_history_text( - engine, query="needleword", max_results=6, recall_config=_cfg(max_text_tokens=150) - ) - assert len(out) <= 150 * 4 + 200 # budget + truncation notice - assert "truncated at the search token budget" in out - - -def test_notes_are_searchable_and_anchored_to_a_step(engine): - save_note_content(engine.base_dir, "login_flow", "The OTP entry lives behind the SMS tab.") - out = search_history_text(engine, query="OTP entry", recall_config=_cfg()) - assert "[Note 'login_flow'" in out - assert re.search(r"as of Step \d|last written at Step \d", out) - - -def test_chunk_rows_are_searchable(engine): - engine.record_history_chunk( - start_step_number=1, - end_step_number=2, - version=1, - status="ready", - band2=" - Steps 1–2: walked the chunkneedle flow", - band3="- Step 1 (T+00:01): click -> executed", - rendered_text=( - "[Chunk 1 | Steps 1–2]\n② Compressed step summary\n" - " - Steps 1–2: walked the chunkneedle flow\n" - "③ Step action ledger\n- Step 1 (T+00:01): click -> executed" - ), - ) - _flush(engine) - out = search_history_text(engine, query="chunkneedle", recall_config=_cfg()) - assert "[History chunk | Steps 1–2" in out - - -def test_foreground_app_stamp_joins_search_surface(tmp_path): - """A package-name query hits the record_step foreground_app stamp.""" - engine = _make_engine(tmp_path) - engine.record_step( - summary="Opened the alarms list.", - action_taken={"action": "click", "target_text": "Alarm"}, - ui_tree=[{"package": "com.google.android.deskclock", "bounds": "[0,0][9,9]"}], - ) - _flush(engine) - - out = search_history_text(engine, query="deskclock", recall_config=_cfg()) - assert "Step 1" in out - assert "No matches" not in out - - -def test_tool_calls_of_a_step_are_searchable(engine): - engine.record_step( - summary="Asked the explorer.", - action_taken={"action": "click", "target_text": "Save"}, - last_execution_result={"status": "success"}, - ) - engine.record_trace( - type="tool", - name="ask_explorer", - payload={"args": {"question": "toolneedle?"}, "result": "The resultneedle is ON."}, - step_id=engine.last_recorded_step_id, - ) - _flush(engine) - assert "[Step 4 " in search_history_text(engine, query="toolneedle", recall_config=_cfg()) - assert "[Step 4 " in search_history_text(engine, query="resultneedle", recall_config=_cfg()) - - -# --- Screen text: pre + post screenshots and referenced screenshots ---------------------- - - -def test_post_screenshot_text_joins_the_search_surface(engine): - """OCR text that only exists on the step's post-action screenshot is found.""" - record = engine.get_step_record(2) - engine.storage.update_image_data( - record.post_image_name, - ocr_result=[{"text": "Welcome back postneedle", "bounds": [0, 0, 10, 10]}], - ui_tree=None, - ) - out = search_history_text(engine, query="postneedle", recall_config=_cfg()) - assert "[Step 2 " in out - - -def test_screen_scan_cap_limits_the_screenshot_sweep(engine): - record = engine.get_step_record(1) - engine.storage.update_image_data( - record.pre_image_name, - ocr_result=[{"text": "capneedle", "bounds": [0, 0, 10, 10]}], - ui_tree=None, - ) - # Only the most recent step is scanned: Step 1's OCR is out of reach ... - out = search_history_text(engine, query="capneedle", recall_config=_cfg(screen_scan_steps=1)) - assert "No matches" in out - # ... unless the range narrows the sweep onto it. - out = search_history_text( - engine, query="capneedle", step_range=[1, 1], recall_config=_cfg(screen_scan_steps=1) - ) - assert "[Step 1 " in out - - -def test_embedded_screenshot_description_joins_the_search_surface(tmp_path): - """A step whose tool result embedded an earlier screenshot is found by - that screenshot's OCR text: the image is described, not stripped.""" - engine = _make_engine(tmp_path) - pre_bytes = _jpeg("red") - engine.record_step( - pre_screenshot_bytes=pre_bytes, - summary="Opened the alarms list.", - action_taken={"action": "click", "target_text": "Alarm"}, - ocr_result=[{"text": "Alarm 07:30 ocrneedle", "bounds": [0, 0, 10, 10]}], - ) - engine.record_step( - summary="Looked back at the alarm screen.", - action_taken={"action": "click", "target_text": "Save"}, - ) - engine.record_trace( - type="tool", - name="get_step_screenshot", - payload={ - "args": {"step_number": 1, "which": "pre"}, - "result": [ - {"type": "text", "text": "Screenshot of step 1 (pre-action) is attached."}, - { - "type": "image_url", - "image_url": { - "url": f"" - }, - }, - ], - }, - step_id=engine.last_recorded_step_id, - ) - _flush(engine) - - out = search_history_text(engine, query="ocrneedle", recall_config=_cfg()) - assert "[Step 1 " in out - assert "[Step 2 " in out - assert "base64" not in out - - -def test_result_search_text_strips_image_blocks(): - blocks = [ - {"type": "text", "text": "hello needle"}, - {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,QUJDRA=="}}, - ] - text = result_search_text(blocks) - assert "hello needle" in text - assert "base64" not in text and "QUJDRA" not in text - assert result_search_text({"answer": "yes"}) == '{"answer": "yes"}' - assert result_search_text(None) == "" - - -# --- Tool surface --------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_execute_requires_query_or_range(engine): - tool = SearchHistoryTool() - out = await tool.execute(ctx=SimpleNamespace(data_engine=engine), query="", step_range=None) - assert "needs a query and/or a step_range" in out - # A usage error is a request the tool did not serve: reported structurally. - assert is_tool_failure(out) - - -@pytest.mark.asyncio -async def test_execute_reports_history_load_error_structurally(): - reader = MagicMock() - reader.get_agent_friendly_steps.side_effect = RuntimeError("db locked") - out = await search_history.execute(ctx=SimpleNamespace(data_engine=reader), query="login") - assert "search_history failed to load history: db locked" in out - assert is_tool_failure(out) - - -@pytest.mark.asyncio -async def test_execute_searches_and_never_returns_images(engine): - out = await search_history.execute( - ctx=SimpleNamespace(data_engine=engine), query="verification code" - ) - assert isinstance(out, str) - assert "Step 2" in out - - -@pytest.mark.asyncio -async def test_execute_without_engine_degrades(): - out = await search_history.execute(ctx=SimpleNamespace(data_engine=None), query="x") - assert "no active execution history" in out - - -def test_availability_requires_engine_and_config(): - assert search_history.is_available(None) is False - assert search_history.is_available(SimpleNamespace(data_engine=None)) is False - assert search_history.is_available(SimpleNamespace(data_engine=object())) is True - assert search_history_available(SimpleNamespace(data_engine=object())) is True - - -def test_excerpts_never_show_raw_json(engine): - """The raw action / result JSON only scores; the ``Match:`` excerpt is the - ledger action phrase plus the result's status/error words.""" - engine.record_step( - summary="Started playback.", - action_taken={ - "action": "click", - "coordinates": [500, 520], - "coordinate_space": "normalized", - "target_description": "Wi-Fi jsonneedle row", - "args": {"target": [500, 520], "target_description": "Wi-Fi jsonneedle row"}, - }, - last_execution_result={"status": "failed", "error": "Error: tap rejected"}, - ) - _flush(engine) - - out = search_history_text(engine, query="jsonneedle", recall_config=_cfg()) - match = next(line for line in out.splitlines() if line.strip().startswith("Match:")) - assert "Tapped 'Wi-Fi jsonneedle row' (self-described) at [500, 520]" in match - assert "status failed" in match - assert "Error: tap rejected" in match - for token in ('{"action"', '"coordinates"', '"coordinate_space"', '"args"', "{", "}"): - assert token not in match, match - - -def test_raw_only_fields_still_score(engine): - """A hit that only exists in the raw record (a resource id / package name - under ``args``) is still found; its excerpt is the readable action line.""" - engine.record_step( - summary="Opened the drawer.", - action_taken={ - "action": "click", - "coordinates": [10, 20], - "coordinate_space": "normalized", - "args": {"target": [10, 20], "resource_id": "com.example:id/rawonlyneedle"}, - }, - last_execution_result={"status": "dispatched"}, - ) - _flush(engine) - out = search_history_text(engine, query="rawonlyneedle", recall_config=_cfg()) - assert "[Step 4 " in out - assert "Match: Tapped element at [10, 20] | status dispatched" in out - assert '"resource_id"' not in out - - -def test_summary_hit_is_not_repeated_in_the_match_line(engine): - out = search_history_text(engine, query="promo popup", recall_config=_cfg()) - block = out[out.index("[Step 3 ") :] - assert "Screen: A promo popup appeared and was dismissed." in block - # The summary heads the result once; the Match line (if any) shows the - # other readable fields, never the summary again. - assert block.count("A promo popup appeared") == 1 - - -def test_screen_hits_render_readable_values_not_ui_tree_json(tmp_path): - engine = _make_engine(tmp_path) - engine.record_step( - pre_screenshot_bytes=_jpeg("red"), - summary="On the network settings screen.", - action_taken={"action": "click", "target_text": "Network"}, - ui_tree=[ - { - "text": "", - "content-desc": "Navigate up", - "resource-id": "com.android.settings:id/toolbar_up", - "bounds": "[0,0][100,100]", - "class": "android.widget.ImageButton", - "clickable": "true", - "package": "com.android.settings", - }, - { - "text": "Wi-Fi treeneedle", - "content-desc": "", - "resource-id": "android:id/title", - "bounds": "[40,560][600,640]", - "class": "android.widget.TextView", - "clickable": "false", - "children": [{"text": "Connected", "bounds": "[40,640][600,700]"}], - }, - ], - ocr_result=[{"text": "Bluetooth ocrword", "bounds": [0, 0, 10, 10]}], - ) - _flush(engine) - - out = search_history_text(engine, query="treeneedle", recall_config=_cfg()) - match = next(line for line in out.splitlines() if line.strip().startswith("Match:")) - assert "Wi-Fi treeneedle" in match - assert "Navigate up | com.android.settings:id/toolbar_up | Wi-Fi treeneedle" in match - assert "android:id/title | Connected | Bluetooth ocrword" in match - for token in ('"bounds"', '"class"', '"clickable"', "{", "}"): - assert token not in match, match - # Bounds / class names still score (they stay in the raw haystack). - assert "[Step 1 " in search_history_text(engine, query="ImageButton", recall_config=_cfg()) - - -def test_action_matches_keep_target_provenance(engine): - """The action haystack is the shared ledger rendering, so an excerpt shows a - self-described coordinate target with its marker and an observed index - target without one.""" - from artemis.utils.task_tree import SELF_DESCRIBED_MARKER - - engine.record_step( - summary="Started playback.", - action_taken={ - "action": "click", - "coordinates": [500, 600], - "target_description": "play button", - }, - last_execution_result={"status": "dispatched"}, - ) - _flush(engine) - - described = search_history_text(engine, query="play button", recall_config=_cfg()) - assert "[Step 4 " in described - assert f"'play button' {SELF_DESCRIBED_MARKER}" in described - - observed = search_history_text(engine, query="Login entry", recall_config=_cfg()) - assert "[Step 1 " in observed - assert "'Login entry'" in observed - assert SELF_DESCRIBED_MARKER not in observed diff --git a/tests/unit/tools/history/test_tool_failures.py b/tests/unit/tools/history/test_tool_failures.py deleted file mode 100644 index 6dff428b0..000000000 --- a/tests/unit/tools/history/test_tool_failures.py +++ /dev/null @@ -1,86 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Every way a history tool fails to do what was asked is reported -structurally (a ``ToolFailure``), never as a plain string the caller would -have to sniff: no history, a usage error, or an exception from the reader.""" - -from types import SimpleNamespace -from unittest.mock import MagicMock, patch - -import pytest - -from artemis.core.tool_failure import is_tool_failure -from artemis.tools.history import ( - GetStepScreenshotTool, - ReplayStepsTool, - SearchHistoryTool, -) - - -def _ctx(): - return SimpleNamespace(data_engine=MagicMock()) - - -@pytest.mark.asyncio -async def test_search_history_reader_exception_is_a_tool_failure(): - with patch( - "artemis.tools.history.search_history_text", side_effect=RuntimeError("index corrupt") - ): - out = await SearchHistoryTool().execute(ctx=_ctx(), query="login") - assert is_tool_failure(out) - assert out == "search_history failed: index corrupt" - - -@pytest.mark.asyncio -async def test_search_history_usage_error_is_a_tool_failure(): - """Asking for neither a query nor a range is a request the tool did not - serve: the guidance is the failure text the model reads.""" - out = await SearchHistoryTool().execute(ctx=_ctx(), query="", step_range=None) - assert is_tool_failure(out) - assert "needs a query and/or a step_range" in out - - -@pytest.mark.asyncio -async def test_replay_steps_reader_exception_is_a_tool_failure(): - with patch("artemis.tools.history.replay_steps_text", side_effect=OSError("db gone")): - out = await ReplayStepsTool().execute(ctx=_ctx(), start_step=3) - assert is_tool_failure(out) - assert out == "replay_steps failed: db gone" - - -@pytest.mark.asyncio -async def test_get_step_screenshot_exception_is_a_tool_failure(): - with patch( - "artemis.tools.history.load_step_screenshot", - side_effect=FileNotFoundError("no screenshot for step 7"), - ): - out = await GetStepScreenshotTool().execute(ctx=_ctx(), step_number=7, which="post") - assert is_tool_failure(out) - assert out == "get_step_screenshot failed: no screenshot for step 7" - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("tool", "kwargs"), - [ - (SearchHistoryTool(), {"query": "x"}), - (ReplayStepsTool(), {"start_step": 1}), - (GetStepScreenshotTool(), {"step_number": 1}), - ], - ids=lambda v: getattr(v, "name", None), -) -async def test_missing_history_is_a_tool_failure(tool, kwargs): - assert is_tool_failure(await tool.execute(ctx=None, **kwargs)) - assert is_tool_failure(await tool.execute(ctx=SimpleNamespace(data_engine=None), **kwargs)) diff --git a/tests/unit/tools/test_append_note_pure_tool.py b/tests/unit/tools/test_append_note_pure_tool.py deleted file mode 100644 index f29db070d..000000000 --- a/tests/unit/tools/test_append_note_pure_tool.py +++ /dev/null @@ -1,127 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from pathlib import Path -from unittest.mock import MagicMock, patch - -from artemis.context import ArtemisContext -from artemis.tools.base import ArtemisTool -from artemis.tools.scratchpad import ( - AppendNoteArgs, - AppendNotePure, - AppendNotePureTool, - append_note_pure, - get_append_note_tool_pure, -) -import pytest - - -@pytest.fixture -def mock_ctx(tmp_path): - ctx = MagicMock(spec=ArtemisContext) - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = str(tmp_path) - return ctx - - -def test_append_note_pure_tool_subclass(): - """Verify AppendNotePureTool is a subclass of ArtemisTool.""" - assert issubclass(AppendNotePureTool, ArtemisTool) - assert issubclass(AppendNotePure, ArtemisTool) - assert isinstance(append_note_pure, ArtemisTool) - assert isinstance(append_note_pure, AppendNotePureTool) - - assert append_note_pure.name == "append_note_pure" - assert append_note_pure.category == "memory" - assert append_note_pure.args_schema == AppendNoteArgs - - # GenAI FunctionDeclaration export - declaration = append_note_pure.to_genai_declaration() - assert declaration.name == "append_note_pure" - assert "key" in declaration.parameters.properties - assert "content" in declaration.parameters.properties - - -@pytest.mark.asyncio -async def test_append_note_pure_direct_execution_success(mock_ctx, tmp_path): - """Verify direct execution of AppendNotePureTool.execute appends to note file.""" - # First append creates note - result1 = await append_note_pure.execute( - ctx=mock_ctx, - key="pure_append", - content="First line", - ) - assert result1 == "Appended to note 'pure_append'." - - note_path = Path(tmp_path) / "notes" / "pure_append.md" - assert note_path.exists() - assert note_path.read_text(encoding="utf-8") == "First line\n" - - # Second append appends line - result2 = await append_note_pure.execute( - ctx=mock_ctx, - key="pure_append", - content="Second line", - ) - assert result2 == "Appended to note 'pure_append'." - assert note_path.read_text(encoding="utf-8") == "First line\nSecond line\n" - - -@pytest.mark.asyncio -async def test_append_note_pure_callable_execution(mock_ctx, tmp_path): - """Verify invoking append_note_pure directly as a callable.""" - result = await append_note_pure( - ctx=mock_ctx, - key="callable_pure_append", - content="Pure callable append", - ) - assert result == "Appended to note 'callable_pure_append'." - - note_path = Path(tmp_path) / "notes" / "callable_pure_append.md" - assert note_path.exists() - assert note_path.read_text(encoding="utf-8") == "Pure callable append\n" - - -@pytest.mark.asyncio -async def test_append_note_pure_execution_failure(mock_ctx): - """Verify error handling when append_note_content fails.""" - with patch( - "artemis.tools.scratchpad.append_note_content", - side_effect=PermissionError("Permission denied"), - ): - result = await append_note_pure.execute( - ctx=mock_ctx, - key="fail_append", - content="Some content", - ) - assert "Failed to append note fail_append.md: Permission denied" in result - - -@pytest.mark.asyncio -async def test_get_append_note_tool_pure_langchain_ainvoke(mock_ctx, tmp_path): - """Verify get_append_note_tool_pure exports a LangChain tool named 'append_note'.""" - pure_tool = get_append_note_tool_pure(mock_ctx) - assert pure_tool.name == "append_note" - - result = await pure_tool.ainvoke( - { - "key": "lc_pure_append", - "content": "Saved via pure append tool", - } - ) - assert result == "Appended to note 'lc_pure_append'." - - note_path = Path(tmp_path) / "notes" / "lc_pure_append.md" - assert note_path.exists() - assert note_path.read_text(encoding="utf-8") == "Saved via pure append tool\n" diff --git a/tests/unit/tools/test_append_note_tool.py b/tests/unit/tools/test_append_note_tool.py deleted file mode 100644 index 775a251dd..000000000 --- a/tests/unit/tools/test_append_note_tool.py +++ /dev/null @@ -1,177 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from pathlib import Path -from unittest.mock import MagicMock, patch - -from artemis.context import ArtemisContext -from artemis.graph.state import State -from artemis.tools.base import ArtemisTool -from artemis.tools.scratchpad import ( - AppendNote, - AppendNoteArgs, - AppendNoteTool, - get_append_note_tool, - get_append_note_tool_pure, - append_note, - append_note_wrapper, -) -from langchain_core.messages import ToolMessage -import pytest - - -@pytest.fixture -def mock_ctx(tmp_path): - ctx = MagicMock(spec=ArtemisContext) - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = str(tmp_path) - return ctx - - -def test_append_note_tool_subclass(): - """Verify AppendNoteTool is a subclass of ArtemisTool.""" - assert issubclass(AppendNoteTool, ArtemisTool) - assert issubclass(AppendNote, ArtemisTool) - assert isinstance(append_note, ArtemisTool) - assert isinstance(append_note, AppendNoteTool) - - assert append_note.name == "append_note" - assert append_note.category == "memory" - assert append_note.args_schema == AppendNoteArgs - - # GenAI FunctionDeclaration export - declaration = append_note.to_genai_declaration() - assert declaration.name == "append_note" - assert "key" in declaration.parameters.properties - assert "content" in declaration.parameters.properties - - # Wrapper check - assert append_note_wrapper is not None - assert append_note_wrapper.tool_fn_getter == get_append_note_tool - - -@pytest.mark.asyncio -async def test_append_note_direct_execution_success(mock_ctx, tmp_path): - """Verify direct execution of AppendNoteTool.execute appends to note file.""" - # First append creates the note - result1 = await append_note.execute( - ctx=mock_ctx, - key="test_append", - content="First line", - ) - assert result1 == "Appended to note 'test_append'." - - note_path = Path(tmp_path) / "notes" / "test_append.md" - assert note_path.exists() - assert note_path.read_text(encoding="utf-8") == "First line\n" - - # Second append appends with newline - result2 = await append_note.execute( - ctx=mock_ctx, - key="test_append", - content="Second line", - ) - assert result2 == "Appended to note 'test_append'." - assert note_path.read_text(encoding="utf-8") == "First line\nSecond line\n" - - -@pytest.mark.asyncio -async def test_append_note_callable_execution(mock_ctx, tmp_path): - """Verify invoking append_note directly as a callable.""" - result = await append_note( - ctx=mock_ctx, - key="callable_append", - content="Callable append test", - ) - assert result == "Appended to note 'callable_append'." - - note_path = Path(tmp_path) / "notes" / "callable_append.md" - assert note_path.exists() - assert note_path.read_text(encoding="utf-8") == "Callable append test\n" - - -@pytest.mark.asyncio -async def test_append_note_with_state_tool_message(mock_ctx, tmp_path): - """Verify AppendNoteTool.execute with state returns a ToolMessage directly.""" - mock_state = MagicMock(spec=State) - - result = await append_note.execute( - ctx=mock_ctx, - key="state_append_note", - content="Appended with state", - state=mock_state, - tool_call_id="call_append_999", - ) - - assert isinstance(result, ToolMessage) - assert result.tool_call_id == "call_append_999" - assert result.content == "Appended to note 'state_append_note'." - assert result.status == "success" - - note_path = Path(tmp_path) / "notes" / "state_append_note.md" - assert note_path.exists() - assert note_path.read_text(encoding="utf-8") == "Appended with state\n" - - -@pytest.mark.asyncio -async def test_append_note_execution_failure(mock_ctx): - """Verify error handling when appending note fails.""" - with patch( - "artemis.tools.scratchpad.append_note_content", - side_effect=PermissionError("Permission denied"), - ): - result = await append_note.execute( - ctx=mock_ctx, - key="fail_append_note", - content="Fail content", - ) - assert "Failed to append note fail_append_note.md: Permission denied" in result - - -@pytest.mark.asyncio -async def test_get_append_note_tool_langchain_ainvoke(mock_ctx, tmp_path): - """Verify get_append_note_tool exports a LangChain tool that works with ainvoke.""" - lc_tool = get_append_note_tool(mock_ctx) - assert lc_tool.name == "append_note" - - result = await lc_tool.ainvoke( - { - "key": "lc_append_note", - "content": "Appended via LangChain BaseTool", - } - ) - assert result == "Appended to note 'lc_append_note'." - - note_path = Path(tmp_path) / "notes" / "lc_append_note.md" - assert note_path.exists() - assert note_path.read_text(encoding="utf-8") == "Appended via LangChain BaseTool\n" - - -@pytest.mark.asyncio -async def test_get_append_note_tool_pure(mock_ctx, tmp_path): - """Verify get_append_note_tool_pure returns a pure LangChain tool.""" - pure_tool = get_append_note_tool_pure(mock_ctx) - assert pure_tool.name == "append_note" - - result = await pure_tool.ainvoke( - { - "key": "pure_append_note", - "content": "Appended via pure tool", - } - ) - assert result == "Appended to note 'pure_append_note'." - - note_path = Path(tmp_path) / "notes" / "pure_append_note.md" - assert note_path.exists() - assert note_path.read_text(encoding="utf-8") == "Appended via pure tool\n" diff --git a/tests/unit/tools/test_command_tool.py b/tests/unit/tools/test_command_tool.py deleted file mode 100644 index 99eafe49b..000000000 --- a/tests/unit/tools/test_command_tool.py +++ /dev/null @@ -1,779 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -from unittest.mock import AsyncMock, MagicMock, patch -from artemis.context import ArtemisContext -from artemis.tools.command_tool import ( - get_adb_task_registry, - get_manage_task_tool, - get_run_adb_command_tool, - get_run_short_adb_command_tool, -) -import pytest - - -class MockProcess: - def __init__( - self, - output_bytes: bytes, - exit_code: int = 0, - pid: int = 12345, - delay: float = 0, - ): - self.output_bytes = output_bytes - self.exit_code = exit_code - self.pid = pid - self.returncode = None - self._read_offset = 0 - self.delay = delay - - self.stdin = MagicMock() - self.stdin.write = MagicMock() - self.stdin.drain = AsyncMock() - self.stdin.close = MagicMock() - - self.stdout = MagicMock() - self.stdout.readline = AsyncMock(side_effect=self._readline) - - async def _readline(self) -> bytes: - if self.delay > 0: - await asyncio.sleep(self.delay) - self.delay = 0 # Only delay the first read - - if self._read_offset >= len(self.output_bytes): - self.returncode = self.exit_code - return b"" - - idx = self.output_bytes.find(b"\n", self._read_offset) - if idx == -1: - line = self.output_bytes[self._read_offset :] - self._read_offset = len(self.output_bytes) - else: - line = self.output_bytes[self._read_offset : idx + 1] - self._read_offset = idx + 1 - return line - - async def wait(self) -> int: - self.returncode = self.exit_code - return self.exit_code - - def terminate(self): - self.returncode = -15 - self.exit_code = -15 - - async def communicate(self) -> tuple[bytes, bytes]: - lines = [] - while True: - line = await self._readline() - if not line: - break - lines.append(line) - self.returncode = self.exit_code - return b"".join(lines), b"" - - -@pytest.fixture -def mock_ctx(): - ctx = MagicMock(spec=ArtemisContext) - ctx.data_engine = None - ctx.device = MagicMock() - ctx.device.device_id = "test_device_1234" - return ctx - - -@pytest.mark.asyncio -@patch("asyncio.create_subprocess_exec") -async def test_run_command_sync(mock_exec, mock_ctx): - # Mock subprocess output - mock_process = MockProcess( - output_bytes=b"hello world\n===EXIT_CODE===0\n===ENV_START===\n", - exit_code=0, - ) - mock_exec.return_value = mock_process - - run_command = get_run_adb_command_tool(mock_ctx) - - result = await run_command.ainvoke( - { - "CommandLine": "echo 'hello world'", - "Cwd": "/data/local/tmp", - "RunPersistent": False, - "WaitMsBeforeAsync": 500, - } - ) - - assert "completed with exit code 0" in result - assert "hello world" in result - mock_exec.assert_called_once() - # Check that adb was called with device ID and "shell" - args, kwargs = mock_exec.call_args - assert args[0].lower().replace(".exe", "").endswith("adb") - assert args[1] == "-s" - assert args[2] == "test_device_1234" - assert args[3] == "shell" - - -@pytest.mark.asyncio -@patch("asyncio.create_subprocess_exec") -async def test_run_command_persistent_env(mock_exec, mock_ctx): - run_command = get_run_adb_command_tool(mock_ctx) - terminal_id = "test_term_android" - - # 1. Set environment variable in persistent terminal - mock_process_1 = MockProcess( - output_bytes=(b"===EXIT_CODE===0\n===ENV_START===\nARTEMIS_TEST_ENV=coffee\n"), - exit_code=0, - ) - mock_exec.return_value = mock_process_1 - - res1 = await run_command.ainvoke( - { - "CommandLine": "export ARTEMIS_TEST_ENV=coffee", - "Cwd": "/data/local/tmp", - "RunPersistent": True, - "RequestedTerminalID": terminal_id, - "WaitMsBeforeAsync": 500, - } - ) - assert "completed with exit code 0" in res1 - envs = get_adb_task_registry(mock_ctx).persistent_envs - assert terminal_id in envs - assert envs[terminal_id].get("ARTEMIS_TEST_ENV") == "coffee" - - # 2. Query environment variable in subsequent command on same terminal - mock_process_2 = MockProcess( - output_bytes=b"coffee\n===EXIT_CODE===0\n===ENV_START===\nARTEMIS_TEST_ENV=coffee\n", - exit_code=0, - ) - mock_exec.return_value = mock_process_2 - - res2 = await run_command.ainvoke( - { - "CommandLine": "echo $ARTEMIS_TEST_ENV", - "Cwd": "/data/local/tmp", - "RunPersistent": True, - "RequestedTerminalID": terminal_id, - "WaitMsBeforeAsync": 500, - } - ) - assert "completed with exit code 0" in res2 - assert "coffee" in res2 - - -@pytest.mark.asyncio -@patch("asyncio.create_subprocess_exec") -async def test_run_command_async_and_status(mock_exec, mock_ctx): - # Mock a long running command by delaying stdout response - mock_process = MockProcess( - output_bytes=(b"background task finished\n===EXIT_CODE===0\n===ENV_START===\n"), - exit_code=0, - delay=1.0, # 1.0s delay will trigger timeout - ) - mock_exec.return_value = mock_process - - run_command = get_run_adb_command_tool(mock_ctx) - manage_task = get_manage_task_tool(mock_ctx) - - registry = get_adb_task_registry(mock_ctx) - registry.background.clear() - - res = await run_command.ainvoke( - { - "CommandLine": "sleep 2 && echo 'background task finished'", - "Cwd": "/data/local/tmp", - "RunPersistent": False, - "WaitMsBeforeAsync": 100, # 100ms wait threshold - } - ) - - assert "sent to the background as a task" in res - assert "TaskId: task_" in res - - lines = res.splitlines() - task_id_line = [line for line in lines if line.startswith("TaskId: ")][0] - task_id = task_id_line.split(": ")[1] - - assert task_id in registry.background - - # Check status (should be running) - status_res = await manage_task.ainvoke( - { - "Action": "status", - "TaskId": task_id, - } - ) - assert "Status: running" in status_res - - # Wait for the background listener task to finish reading delayed output - await asyncio.sleep(1.2) - - # Status should be completed (and task removed from active map) - assert task_id not in registry.background - - -@pytest.mark.asyncio -@patch("asyncio.create_subprocess_exec") -async def test_run_command_kill(mock_exec, mock_ctx): - mock_process = MockProcess(output_bytes=b"", exit_code=0, delay=5.0) - mock_exec.return_value = mock_process - - run_command = get_run_adb_command_tool(mock_ctx) - manage_task = get_manage_task_tool(mock_ctx) - - registry = get_adb_task_registry(mock_ctx) - registry.background.clear() - - res = await run_command.ainvoke( - { - "CommandLine": "sleep 10", - "Cwd": "/data/local/tmp", - "RunPersistent": False, - "WaitMsBeforeAsync": 100, - } - ) - - lines = res.splitlines() - task_id_line = [line for line in lines if line.startswith("TaskId: ")][0] - task_id = task_id_line.split(": ")[1] - - # Kill the task - kill_res = await manage_task.ainvoke( - { - "Action": "kill", - "TaskId": task_id, - } - ) - assert f"Terminated task {task_id}" in kill_res - assert "successfully" not in kill_res - assert task_id not in registry.background - - # Refusals (unknown or already-finished task) are reported structurally. - from artemis.core.tool_failure import is_tool_failure - - again = await manage_task.ainvoke({"Action": "kill", "TaskId": task_id}) - assert is_tool_failure(again) - assert f"Task {task_id} is not active or already finished." in str(again) - - missing = await manage_task.ainvoke({"Action": "status", "TaskId": "task_nope"}) - assert is_tool_failure(missing) - assert "Error: Task task_nope not found." in str(missing) - - no_input = await manage_task.ainvoke({"Action": "send_input", "TaskId": task_id, "Input": "y"}) - assert is_tool_failure(no_input) - assert f"Task {task_id} is not active." in str(no_input) - - -@pytest.mark.asyncio -@patch("asyncio.create_subprocess_exec") -async def test_run_short_command_success(mock_exec, mock_ctx): - mock_process = MockProcess(output_bytes=b"short command success output", exit_code=0) - mock_exec.return_value = mock_process - - run_short_command = get_run_short_adb_command_tool(mock_ctx) - - result = await run_short_command.ainvoke( - { - "CommandLine": "echo 'short command'", - } - ) - - assert "completed with exit code 0" in result - assert "short command success output" in result - mock_exec.assert_called_once() - args, kwargs = mock_exec.call_args - assert args[0].lower().replace(".exe", "").endswith("adb") - assert args[1] == "-s" - assert args[2] == "test_device_1234" - assert args[3] == "shell" - # Verify it cd'd to /data/local/tmp by default - assert "cd /data/local/tmp" in args[4] - - -@pytest.mark.asyncio -@patch("asyncio.create_subprocess_exec") -async def test_run_short_command_timeout(mock_exec, mock_ctx): - mock_process = MockProcess(output_bytes=b"some output", exit_code=0, delay=5.0) - mock_exec.return_value = mock_process - - run_short_command = get_run_short_adb_command_tool(mock_ctx) - - result = await run_short_command.ainvoke( - { - "CommandLine": "sleep 5", - } - ) - - assert "Error: Command timed out after 3 seconds." in result - - -@pytest.mark.asyncio -@patch("asyncio.create_subprocess_exec") -async def test_run_command_sync_long_output(mock_exec, mock_ctx): - # Generate long output (> 50000 chars): the trigger is size, not line count. - long_output = "\n".join(f"Output line {i} {'x' * 90}" for i in range(1, 600)) - mock_process = MockProcess( - output_bytes=long_output.encode() + b"\n===EXIT_CODE===0\n===ENV_START===\n", - exit_code=0, - ) - mock_exec.return_value = mock_process - - run_command = get_run_adb_command_tool(mock_ctx) - result = await run_command.ainvoke( - { - "CommandLine": "generate_long_output", - "Cwd": "/data/local/tmp", - "RunPersistent": False, - "WaitMsBeforeAsync": 500, - } - ) - - assert "ADB command completed with exit code 0." in result - assert "has been truncated" in result - assert "Output line 599" in result - assert "Output line 1 " not in result # since it was truncated to last 200 lines - assert "TaskId: task_sync_" in result - task_id = result.split("TaskId: ")[1].split(".")[0] - assert f"use analyze_task_output with TaskId {task_id}" in result - - -@pytest.mark.asyncio -@patch("asyncio.create_subprocess_exec") -async def test_run_command_many_short_lines_is_shown_inline(mock_exec, mock_ctx): - """1000 short lines (~4 KB) are not 'long': the whole output stays inline.""" - output = "\n".join(str(i) for i in range(1, 1001)) - mock_exec.return_value = MockProcess(output_bytes=output.encode(), exit_code=0) - - run_command = get_run_adb_command_tool(mock_ctx) - result = await run_command.ainvoke({"CommandLine": "seq 1 1000", "WaitMsBeforeAsync": 500}) - - assert "has been truncated" not in result - assert "\n1\n2\n" in result - assert result.endswith("\n1000") - - -@pytest.mark.asyncio -@patch("asyncio.create_subprocess_exec") -async def test_run_command_empty_output_is_labelled(mock_exec, mock_ctx): - mock_exec.return_value = MockProcess(output_bytes=b"", exit_code=0) - - run_command = get_run_adb_command_tool(mock_ctx) - result = await run_command.ainvoke({"CommandLine": "true", "WaitMsBeforeAsync": 500}) - - assert result == "ADB command completed with exit code 0.\nOutput:\n(empty output)" - - -@pytest.mark.asyncio -@patch("artemis.agents.log_analyzer.output_analyzer.get_llm") -async def test_analyze_task_output_tool(mock_get_llm, mock_ctx): - # Set up mock LLM - mock_llm = MagicMock() - mock_response = MagicMock() - mock_response.content = "This is the analysis result: everything looks good." - mock_response.tool_calls = [] - mock_llm.bind_tools.return_value = mock_llm - mock_llm.ainvoke = AsyncMock(return_value=mock_response) - mock_get_llm.return_value = mock_llm - - # Set up finished tasks logs cache manually - from artemis.tools.command_tool import ( - _FINISHED_TASKS_LOGS, - _register_finished_task, - get_analyze_task_output_tool, - ) - - task_id = "test_task_long_output" - _register_finished_task( - task_id=task_id, - command="cat long_log.txt", - cwd="/data/local/tmp", - terminal_id=None, - status="completed", - exit_code=0, - output="line1\nline2\n" + "\n".join(f"line{i}" for i in range(3, 50)), - ) - - analyze_tool = get_analyze_task_output_tool(mock_ctx) - result = await analyze_tool.ainvoke({"TaskId": task_id, "Query": "Summarize the log"}) - - assert "Analysis result for Task test_task_long_output:" in result - assert "This is the analysis result: everything looks good." in result - mock_get_llm.assert_called_once_with(ctx=mock_ctx, name="output_analyzer") - - # Clean up registry - if task_id in _FINISHED_TASKS_LOGS: - del _FINISHED_TASKS_LOGS[task_id] - - -def test_run_adb_command_tool_subclass(): - """Verify RunAdbCommandTool is a subclass of ArtemisTool.""" - from artemis.tools.base import ArtemisTool - from artemis.tools.command_tool import ( - RunAdbCommand, - RunAdbCommandArgs, - RunAdbCommandTool, - run_adb_command, - ) - - assert issubclass(RunAdbCommandTool, ArtemisTool) - assert issubclass(RunAdbCommand, ArtemisTool) - assert isinstance(run_adb_command, ArtemisTool) - assert isinstance(run_adb_command, RunAdbCommandTool) - - assert run_adb_command.name == "run_adb_command" - assert run_adb_command.category == "system" - assert run_adb_command.args_schema == RunAdbCommandArgs - - # GenAI FunctionDeclaration export - declaration = run_adb_command.to_genai_declaration() - assert declaration.name == "run_adb_command" - assert "CommandLine" in declaration.parameters.properties - - -@pytest.mark.asyncio -async def test_run_adb_command_tool_mock_driver_execute(): - """Verify RunAdbCommandTool.execute dispatches to MockDeviceDriver when ctx is None.""" - from artemis.drivers.mock.mock_driver import MockDeviceDriver - from artemis.tools.command_tool import RunAdbCommandTool - - driver = MockDeviceDriver(width=1080, height=2400) - tool_inst = RunAdbCommandTool() - - result = await tool_inst.execute( - driver=driver, - ctx=None, - CommandLine="pm list packages", - ) - assert "mock_output: pm list packages" in result - assert "pm list packages" in driver.action_history[-1]["command"] - - -def test_manage_task_tool_subclass(): - """Verify ManageTaskTool is a subclass of ArtemisTool.""" - from artemis.tools.base import ArtemisTool - from artemis.tools.command_tool import ( - ManageTask, - ManageTaskArgs, - ManageTaskTool, - manage_task, - ) - - assert issubclass(ManageTaskTool, ArtemisTool) - assert issubclass(ManageTask, ArtemisTool) - assert isinstance(manage_task, ArtemisTool) - assert isinstance(manage_task, ManageTaskTool) - - assert manage_task.name == "manage_task" - assert manage_task.category == "system" - assert manage_task.args_schema == ManageTaskArgs - - # GenAI FunctionDeclaration export - declaration = manage_task.to_genai_declaration() - assert declaration.name == "manage_task" - assert "Action" in declaration.parameters.properties - - -@pytest.mark.asyncio -async def test_manage_task_tool_direct_execute(mock_ctx): - """Verify direct ManageTaskTool.execute execution.""" - from artemis.tools.command_tool import ( - BackgroundTask, - manage_task, - ) - - registry = get_adb_task_registry(mock_ctx) - registry.background.clear() - list_res = await manage_task.execute(ctx=mock_ctx, Action="list") - assert "No active background tasks" in list_res - - # Create dummy task - dummy_proc = MagicMock() - bg_task = BackgroundTask( - task_id="dummy_task_99", - command="sleep 100", - process=dummy_proc, - terminal_id="term_1", - cwd="/data/local/tmp", - ) - registry.background["dummy_task_99"] = bg_task - - list_res2 = await manage_task.execute(ctx=mock_ctx, Action="list") - assert "dummy_task_99" in list_res2 - assert "sleep 100" in list_res2 - - # Status check - status_res = await manage_task.execute(ctx=mock_ctx, Action="status", TaskId="dummy_task_99") - assert "Status: running" in status_res - assert "dummy_task_99" in status_res - - registry = get_adb_task_registry(mock_ctx) - registry.background.clear() - - -def test_analyze_task_output_tool_subclass(): - """Verify AnalyzeTaskOutputTool is a subclass of ArtemisTool.""" - from artemis.tools.base import ArtemisTool - from artemis.tools.command_tool import ( - AnalyzeTaskOutput, - AnalyzeTaskOutputArgs, - AnalyzeTaskOutputTool, - analyze_task_output, - ) - - assert issubclass(AnalyzeTaskOutputTool, ArtemisTool) - assert issubclass(AnalyzeTaskOutput, ArtemisTool) - assert isinstance(analyze_task_output, ArtemisTool) - assert isinstance(analyze_task_output, AnalyzeTaskOutputTool) - - assert analyze_task_output.name == "analyze_task_output" - assert analyze_task_output.category == "system" - assert analyze_task_output.args_schema == AnalyzeTaskOutputArgs - - # GenAI FunctionDeclaration export - declaration = analyze_task_output.to_genai_declaration() - assert declaration.name == "analyze_task_output" - assert "TaskId" in declaration.parameters.properties - assert "Query" in declaration.parameters.properties - - -@pytest.mark.asyncio -@patch("artemis.agents.log_analyzer.output_analyzer.get_llm") -async def test_analyze_task_output_tool_direct_execute(mock_get_llm, mock_ctx): - """Verify direct AnalyzeTaskOutputTool.execute execution.""" - from artemis.tools.command_tool import ( - _FINISHED_TASKS_LOGS, - _register_finished_task, - analyze_task_output, - ) - - mock_llm = MagicMock() - mock_response = MagicMock() - mock_response.content = "Summary of task output." - mock_response.tool_calls = [] - mock_llm.bind_tools.return_value = mock_llm - mock_llm.ainvoke = AsyncMock(return_value=mock_response) - mock_get_llm.return_value = mock_llm - - task_id = "test_direct_analyze" - _register_finished_task( - task_id=task_id, - command="cat file.txt", - cwd="/data/local/tmp", - terminal_id=None, - status="completed", - exit_code=0, - output="Sample log content", - ) - - result = await analyze_task_output.execute( - ctx=mock_ctx, TaskId=task_id, Query="What is the content?" - ) - assert "Analysis result for Task test_direct_analyze:" in result - assert "Summary of task output." in result - - if task_id in _FINISHED_TASKS_LOGS: - del _FINISHED_TASKS_LOGS[task_id] - - -def test_run_short_adb_command_tool_subclass(): - """Verify RunShortAdbCommandTool is a subclass of ArtemisTool.""" - from artemis.tools.base import ArtemisTool - from artemis.tools.command_tool import ( - RunShortAdbCommand, - RunShortAdbCommandArgs, - RunShortAdbCommandTool, - run_short_adb_command, - ) - - assert issubclass(RunShortAdbCommandTool, ArtemisTool) - assert issubclass(RunShortAdbCommand, ArtemisTool) - assert isinstance(run_short_adb_command, ArtemisTool) - assert isinstance(run_short_adb_command, RunShortAdbCommandTool) - - assert run_short_adb_command.name == "run_short_adb_command" - assert run_short_adb_command.category == "system" - assert run_short_adb_command.args_schema == RunShortAdbCommandArgs - - # GenAI FunctionDeclaration export - declaration = run_short_adb_command.to_genai_declaration() - assert declaration.name == "run_short_adb_command" - assert "CommandLine" in declaration.parameters.properties - - -@pytest.mark.asyncio -async def test_run_short_adb_command_tool_mock_driver_execute(): - """Verify RunShortAdbCommandTool.execute dispatches to MockDeviceDriver when ctx is None.""" - from artemis.drivers.mock.mock_driver import MockDeviceDriver - from artemis.tools.command_tool import RunShortAdbCommandTool - - driver = MockDeviceDriver(width=1080, height=2400) - tool_inst = RunShortAdbCommandTool() - - result = await tool_inst.execute( - driver=driver, - ctx=None, - CommandLine="getprop ro.build.version.release", - ) - assert "mock_output: getprop ro.build.version.release" in result - assert "getprop ro.build.version.release" in driver.action_history[-1]["command"] - - -# --------------------------------------------------------------------------- -# Registry, exit codes, stdin control, cloud timeout, shutdown -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -@patch("asyncio.create_subprocess_exec") -async def test_persistent_reports_script_exit_code(mock_exec, mock_ctx): - """A failing command in a persistent terminal must not report exit code 0.""" - mock_exec.return_value = MockProcess( - output_bytes=b"boom\n===EXIT_CODE===3\n===ENV_START===\nPATH=/bin\nFOO=bar baz\n", - exit_code=0, # the trailing `env` succeeds, the command did not - ) - run_command = get_run_adb_command_tool(mock_ctx) - res = await run_command.ainvoke( - { - "CommandLine": "false", - "RunPersistent": True, - "RequestedTerminalID": "term_exit", - "WaitMsBeforeAsync": 500, - } - ) - assert "completed with exit code 3" in res - assert "===EXIT_CODE===" not in res - envs = get_adb_task_registry(mock_ctx).persistent_envs["term_exit"] - assert envs == {"FOO": "bar baz"} # platform vars like PATH are dropped - - -def test_build_phone_script_quotes_env_and_cwd(): - import shlex - - from artemis.tools.command_tool import _build_phone_script - - script = _build_phone_script("echo hi", "/sdcard/my dir", {"FOO": "it's"}, True) - assert script.startswith("cd '/sdcard/my dir'\n") - expected_export = "export FOO=" + shlex.quote("it's") + "\n" - assert expected_export in script - assert "===EXIT_CODE===$_artemis_ec" in script - assert script.rstrip().endswith("env") - - -@pytest.mark.asyncio -@patch("asyncio.create_subprocess_exec") -async def test_stdin_closed_unless_interactive(mock_exec, mock_ctx): - mock_exec.return_value = MockProcess(output_bytes=b"ok\n", exit_code=0) - run_command = get_run_adb_command_tool(mock_ctx) - - await run_command.ainvoke({"CommandLine": "cat", "WaitMsBeforeAsync": 500}) - assert mock_exec.call_args.kwargs["stdin"] == asyncio.subprocess.DEVNULL - - mock_exec.return_value = MockProcess(output_bytes=b"ok\n", exit_code=0) - await run_command.ainvoke({"CommandLine": "cat", "Interactive": True, "WaitMsBeforeAsync": 500}) - assert mock_exec.call_args.kwargs["stdin"] == asyncio.subprocess.PIPE - - -@pytest.mark.asyncio -@patch("asyncio.create_subprocess_exec") -async def test_send_input_rejected_for_non_interactive_task(mock_exec, mock_ctx): - mock_exec.return_value = MockProcess(output_bytes=b"", exit_code=0, delay=5.0) - run_command = get_run_adb_command_tool(mock_ctx) - manage_task = get_manage_task_tool(mock_ctx) - registry = get_adb_task_registry(mock_ctx) - - res = await run_command.ainvoke({"CommandLine": "sleep 10", "WaitMsBeforeAsync": 100}) - task_id = [ln for ln in res.splitlines() if ln.startswith("TaskId: ")][0].split(": ")[1] - assert "stdin is closed" in res - - reply = await manage_task.ainvoke({"Action": "send_input", "TaskId": task_id, "Input": "y\n"}) - assert "Interactive=true" in reply - - await manage_task.ainvoke({"Action": "kill", "TaskId": task_id}) - assert task_id not in registry.background - - -@pytest.mark.asyncio -@patch("asyncio.create_subprocess_exec") -async def test_shutdown_kills_background_tasks_and_records_them(mock_exec, mock_ctx): - from artemis.tools.command_tool import shutdown_adb_background_tasks - - mock_exec.return_value = MockProcess(output_bytes=b"", exit_code=0, delay=5.0) - run_command = get_run_adb_command_tool(mock_ctx) - registry = get_adb_task_registry(mock_ctx) - - res = await run_command.ainvoke({"CommandLine": "logcat", "WaitMsBeforeAsync": 100}) - task_id = [ln for ln in res.splitlines() if ln.startswith("TaskId: ")][0].split(": ")[1] - assert task_id in registry.background - - killed = await shutdown_adb_background_tasks(mock_ctx) - assert killed == 1 - assert registry.background == {} - assert registry.finished[task_id]["status"] == "killed" - # A second shutdown is a no-op. - assert await shutdown_adb_background_tasks(mock_ctx) == 0 - # A context that never used ADB tools has no registry and is a no-op too. - fresh = MagicMock(spec=ArtemisContext) - assert await shutdown_adb_background_tasks(fresh) == 0 - - -def test_registry_is_per_context_and_notifications_do_not_leak(): - from artemis.tools.command_tool import _register_finished_task - - ctx_a = MagicMock(spec=ArtemisContext) - ctx_b = MagicMock(spec=ArtemisContext) - reg_a = get_adb_task_registry(ctx_a) - reg_b = get_adb_task_registry(ctx_b) - assert reg_a is not reg_b - assert get_adb_task_registry(ctx_a) is reg_a - - _register_finished_task("t_a", "logcat -d", None, None, "completed", 0, "out", ctx=ctx_a) - assert [t["task_id"] for t in reg_a.pop_unnotified_finished()] == ["t_a"] - assert reg_b.pop_unnotified_finished() == [] - # Consumed once: the next Operator turn on ctx_a does not see it again. - assert reg_a.pop_unnotified_finished() == [] - - -@pytest.mark.asyncio -@patch("asyncio.create_subprocess_exec") -async def test_sync_long_output_is_not_reannounced(mock_exec, mock_ctx): - long_output = "\n".join(f"line {i} {'x' * 90}" for i in range(600)) - mock_exec.return_value = MockProcess(output_bytes=long_output.encode(), exit_code=0) - run_command = get_run_adb_command_tool(mock_ctx) - registry = get_adb_task_registry(mock_ctx) - registry.finished.clear() - - res = await run_command.ainvoke({"CommandLine": "dumpsys", "WaitMsBeforeAsync": 500}) - assert "has been truncated" in res - assert registry.pop_unnotified_finished() == [] - # ... but analyze_task_output can still reach the full text. - task_id = res.split("TaskId: ")[1].split(".")[0] - assert "line 0" in registry.get_task_info(task_id)["output"] - - -@pytest.mark.asyncio -async def test_cloud_mode_command_has_hard_timeout(mock_ctx, monkeypatch): - import time - - monkeypatch.setenv("ARTEMIS_CLOUD_MODE", "1") - device = MagicMock() - device.shell = MagicMock(side_effect=lambda _script: time.sleep(1.0) or "late") - with patch("artemis.tools.command_tool.get_adb_device", return_value=device): - run_command = get_run_adb_command_tool(mock_ctx) - res = await run_command.ainvoke({"CommandLine": "logcat", "WaitMsBeforeAsync": 100}) - assert "did not finish within" in res - - device.shell = MagicMock(return_value="fast\n") - with patch("artemis.tools.command_tool.get_adb_device", return_value=device): - res = await run_command.ainvoke({"CommandLine": "echo fast", "WaitMsBeforeAsync": 500}) - assert "fast" in res diff --git a/tests/unit/tools/test_committee_tool.py b/tests/unit/tools/test_committee_tool.py deleted file mode 100644 index e059f803f..000000000 --- a/tests/unit/tools/test_committee_tool.py +++ /dev/null @@ -1,353 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from pathlib import Path -import shutil -import tempfile -import unittest -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from artemis.context import ArtemisContext -from artemis.core.tool_failure import ToolFailure -from artemis.tools.committee_tool import get_ask_committee_tool - - -class TestCommitteeTool(unittest.IsolatedAsyncioTestCase): - async def test_ask_committee_tool_with_directive(self): - # Create a temp directory for testing - temp_dir = tempfile.mkdtemp() - - try: - notes_dir = Path(temp_dir) / "notes" - notes_dir.mkdir(parents=True, exist_ok=True) - - # Create a mock screenshot file - mock_screenshot_path = Path(temp_dir) / "mock_screenshot.jpg" - mock_screenshot_path.write_bytes(b"fake image bytes") - - # Create a mock failed plans history file - mock_failed_plans_history_path = notes_dir / "failed_plans_history.md" - mock_failed_plans_history_path.write_text( - "## Failed Plan Attempt #1\n### Plan:\n- [ ] Step 1\n###" - " Failure Reason:\nTimeout waiting for element", - encoding="utf-8", - ) - # Mock context and state - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.device = MagicMock() - mock_ctx.device.device_width = 1080 - mock_ctx.device.device_height = 2400 - mock_ctx.data_engine = MagicMock() - mock_ctx.data_engine.base_dir = temp_dir - mock_ctx.data_engine.get_agent_friendly_steps.return_value = [ - {"pre_image_name": "test_image", "step_number": 1} - ] - - mock_state = MagicMock() - mock_state.initial_goal = "Test Goal" - mock_state.messages = [] - mock_state.latest_ui_hierarchy = None - mock_state.latest_screenshot = str(mock_screenshot_path) - mock_state.focused_app_info = None - mock_state.device_date = None - mock_state.structured_decisions = None - mock_state.complete_subgoals_by_ids = [] - - # Mock LLM responses for the members - mock_llm_pl = MagicMock() - mock_llm_pl.bind_tools.return_value = mock_llm_pl - - m1 = MagicMock(content="Op: I think we should tap.") - m1.tool_calls = [] - m2 = MagicMock(content="Op: I agree with Exp.") - m2.tool_calls = [] - m3 = MagicMock(content="Op: Final conclusion: Tap at [50, 50]") - m3.tool_calls = [] - - mock_llm_pl.ainvoke = AsyncMock(side_effect=[m1, m2, m3]) - - mock_llm_diag = MagicMock() - mock_llm_diag.bind_tools.return_value = mock_llm_diag - m_diag = MagicMock(content="Diag: I see an error.") - m_diag.tool_calls = [] - mock_llm_diag.ainvoke = AsyncMock(return_value=m_diag) - - # Mock History Analyzer to call a tool first - mock_llm_hist = MagicMock() - mock_llm_hist.bind_tools.return_value = mock_llm_hist - - mock_response_tool_call = MagicMock() - mock_response_tool_call.tool_calls = [ - { - "name": "replay_steps", - "args": {"start_step": 1, "end_step": 1}, - "id": "call_1", - } - ] - mock_response_tool_call.content = "" - - mock_response_final = MagicMock() - mock_response_final.tool_calls = [] - mock_response_final.content = "Hist: I found step 1 details." - - m_hist_agree = MagicMock(content="Hist: Still agree.") - m_hist_agree.tool_calls = [] - - mock_llm_hist.ainvoke = AsyncMock( - side_effect=[ - mock_response_tool_call, - mock_response_final, - # For second round - m_hist_agree, - ] - ) - - def mock_get_llm(ctx, name, temperature=None): - if name == "planner_avatar": - return mock_llm_pl - elif name == "diagnoser_expert": - return mock_llm_diag - elif name == "history_analyzer_expert": - return mock_llm_hist - return MagicMock() - - # Patch get_llm and the actual tool functions to avoid real calls - with ( - patch( - "artemis.tools.committee_tool.get_llm", - side_effect=mock_get_llm, - ), - patch( - "artemis.tools.committee_tool.trace_langchain_tool", - side_effect=lambda t, ctx: t, - ), - ): - tool = get_ask_committee_tool(mock_ctx) - - # Invoke tool with avatar_directive - from artemis.data_engine.trace import CURRENT_TRACE_ID - - token = CURRENT_TRACE_ID.set("test_trace_id") - try: - result = await tool.ainvoke( - { - "avatar_directive": "Advocate for paying", - "state": mock_state, - } - ) - finally: - CURRENT_TRACE_ID.reset(token) - - # Verify result - self.assertIn("Final conclusion: Tap at [50, 50]", result) - - # Verify file was created and contains content - notes_dir = Path(temp_dir) / "notes" - blackboard_files = list(notes_dir.glob("blackboard_*.md")) - self.assertTrue(len(blackboard_files) > 0, "No blackboard file created") - blackboard_path = blackboard_files[0] - content = blackboard_path.read_text(encoding="utf-8") - self.assertIn("Advocate for paying", content) # Check directive is in blackboard - self.assertIn("Hist: I found step 1 details.", content) - self.assertIn( - "Timeout waiting for element", content - ) # Check failed history is in blackboard - self.assertIn("Test Goal", content) # Check initial goal is in blackboard - - finally: - # Clean up temp directory - shutil.rmtree(temp_dir) - - def test_ask_committee_tool_subclass(self): - """Verify AskCommitteeTool is a subclass of ArtemisTool.""" - from artemis.tools.base import ArtemisTool - from artemis.tools.committee_tool import ( - AskCommittee, - AskCommitteeArgs, - AskCommitteeTool, - AskCommitteeToolAlias, - ask_committee, - ask_committee_wrapper, - get_ask_committee_tool, - ) - - self.assertTrue(issubclass(AskCommitteeTool, ArtemisTool)) - self.assertTrue(issubclass(AskCommittee, ArtemisTool)) - self.assertTrue(issubclass(AskCommitteeToolAlias, ArtemisTool)) - self.assertIsInstance(ask_committee, ArtemisTool) - self.assertIsInstance(ask_committee, AskCommitteeTool) - - self.assertEqual(ask_committee.name, "ask_committee") - self.assertEqual(ask_committee.category, "custom") - self.assertEqual(ask_committee.args_schema, AskCommitteeArgs) - - # GenAI FunctionDeclaration export - declaration = ask_committee.to_genai_declaration() - self.assertEqual(declaration.name, "ask_committee") - self.assertIn("avatar_directive", declaration.parameters.properties) - - # Wrapper check - self.assertIsNotNone(ask_committee_wrapper) - self.assertEqual(ask_committee_wrapper.tool_fn_getter, get_ask_committee_tool) - - async def test_ask_committee_no_ctx(self): - """Verify executing without ctx returns an error message.""" - from artemis.tools.committee_tool import ask_committee - - result = await ask_committee.execute(avatar_directive="Test") - self.assertEqual(result, "Error: ArtemisContext is required for ask_committee.") - - -if __name__ == "__main__": - unittest.main() - - -# --- member tool results: status is structural, never sniffed from the words ---------- - - -def _text_tool(name: str, result): - from langchain_core.tools import StructuredTool - - async def _run(start_step: int, end_step: int | None = None) -> str: - return result - - return StructuredTool.from_function(coroutine=_run, name=name, description=name) - - -async def _run_committee_with_history_tool(tmp_path, history_tool): - """Drives one debate round whose History Analyzer calls ``history_tool`` - once; returns the messages of that member's second turn.""" - from langchain_core.messages import AIMessage - - (tmp_path / "notes").mkdir() - screenshot = tmp_path / "shot.jpg" - screenshot.write_bytes(b"fake image bytes") - - ctx = MagicMock(spec=ArtemisContext) - ctx.device = MagicMock() - ctx.device.device_width = 1080 - ctx.device.device_height = 2400 - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = str(tmp_path) - ctx.data_engine.get_agent_friendly_steps.return_value = [ - {"pre_image_name": "test_image", "step_number": 1} - ] - ctx.execution_setup = MagicMock() - ctx.execution_setup.committee_debate_rounds = 1 - - state = MagicMock() - state.initial_goal = "Test Goal" - state.messages = [] - state.latest_ui_hierarchy = None - state.latest_screenshot = str(screenshot) - state.focused_app_info = None - state.device_date = None - state.structured_decisions = None - state.complete_subgoals_by_ids = [] - - def _text_turn(text): - return AIMessage(content=text, tool_calls=[]) - - llm_pl = MagicMock() - llm_pl.bind_tools.return_value = llm_pl - llm_pl.ainvoke = AsyncMock(side_effect=[_text_turn("Op: tap."), _text_turn("Final.")]) - - llm_diag = MagicMock() - llm_diag.bind_tools.return_value = llm_diag - llm_diag.ainvoke = AsyncMock(return_value=_text_turn("Diag: fine.")) - - llm_hist = MagicMock() - llm_hist.bind_tools.return_value = llm_hist - llm_hist.ainvoke = AsyncMock( - side_effect=[ - AIMessage( - content="", - tool_calls=[ - {"name": "replay_steps", "args": {"start_step": 1, "end_step": 1}, "id": "c1"} - ], - ), - _text_turn("Hist: step 1 seen."), - ] - ) - members = { - "planner_avatar": llm_pl, - "diagnoser_expert": llm_diag, - "history_analyzer_expert": llm_hist, - } - - with ( - patch( - "artemis.tools.committee_tool.get_llm", - side_effect=lambda ctx, name, temperature=None: members[name], - ), - patch("artemis.tools.committee_tool.get_history_tools", return_value=[history_tool]), - patch("artemis.tools.committee_tool.trace_langchain_tool", side_effect=lambda t, ctx: t), - ): - tool = get_ask_committee_tool(ctx) - from artemis.data_engine.trace import CURRENT_TRACE_ID - - token = CURRENT_TRACE_ID.set("test_trace_id") - try: - outcome = await tool.ainvoke({"avatar_directive": "Advocate", "state": state}) - finally: - CURRENT_TRACE_ID.reset(token) - - assert "Final." in outcome - assert llm_hist.ainvoke.await_count == 2 - return llm_hist.ainvoke.call_args_list[1].args[0] - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "result, expected_status", - [ - pytest.param(ToolFailure("Error: step 1 not found."), "error", id="tool_failure"), - pytest.param("Error 404 was typed into the search box", "success", id="plain_error_text"), - ], -) -async def test_committee_member_tool_message_status_is_structural( - tmp_path, result, expected_status -): - from langchain_core.messages import ToolMessage - - messages = await _run_committee_with_history_tool(tmp_path, _text_tool("replay_steps", result)) - - tool_msgs = [m for m in messages if isinstance(m, ToolMessage)] - assert len(tool_msgs) == 1 - assert tool_msgs[0].tool_call_id == "c1" - assert tool_msgs[0].status == expected_status - assert tool_msgs[0].content == str(result) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("tool_status", ["error", "success"]) -async def test_committee_member_keeps_tool_message_status(tmp_path, tool_status): - """A tool answering with a ``ToolMessage`` (video analyzer, note tools) - carries failure only in ``status``: its content is plain str. The member - loop must read the status off the raw message, not off the unwrapped - content, or every such failure is re-labelled a success.""" - from langchain_core.messages import ToolMessage - - returned = ToolMessage(tool_call_id="c1", content="boom", status=tool_status) - messages = await _run_committee_with_history_tool( - tmp_path, _text_tool("replay_steps", returned) - ) - - tool_msgs = [m for m in messages if isinstance(m, ToolMessage)] - assert len(tool_msgs) == 1 - assert tool_msgs[0].tool_call_id == "c1" - assert tool_msgs[0].status == tool_status - assert tool_msgs[0].content == "boom" diff --git a/tests/unit/tools/test_explorer_tool.py b/tests/unit/tools/test_explorer_tool.py deleted file mode 100644 index 5e363b3fa..000000000 --- a/tests/unit/tools/test_explorer_tool.py +++ /dev/null @@ -1,678 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""``ask_explorer``: tier-agnostic contract and the locate / register / render pipeline.""" - -import json -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from artemis.agents.explorer.constants import ( - ASK_EXPLORER_CONTEXT_FEEDBACK_DESCRIPTION, - ASK_EXPLORER_DESCRIPTION, - ASK_EXPLORER_QUERY_DESCRIPTION, -) -from artemis.context import ArtemisContext -from artemis.graph.state import State -from artemis.tools.base import ArtemisTool -from artemis.tools.explorer_tool import ( - AskExplorerArgs, - AskExplorerTool, - ExplorerCandidate, - ExplorerOutcome, - RegisteredCandidate, - _run_explorer_logic, - ask_explorer, - ask_explorer_text, - ask_explorer_wrapper, - get_ask_explorer_tool, - locate, - register_candidates, - render_operator_blocks, - render_text, -) - -# --------------------------------------------------------------------------- # -# Fixtures -# --------------------------------------------------------------------------- # - - -@pytest.fixture(autouse=True) -def _no_env_tier(monkeypatch): - """The environment override must not leak into tier-resolution assertions.""" - monkeypatch.delenv("ARTEMIS_EXPLORER_VERSION", raising=False) - - -def _ctx(width: int | None = 1080, height: int | None = 2400, base_dir: Path | None = None): - ctx = MagicMock(spec=ArtemisContext) - ctx.agent_config = None - ctx.llm_config = None - ctx.execution_setup = None - if base_dir is None: - ctx.data_engine = None - else: - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = str(base_dir) - if width is None: - ctx.device = None - else: - ctx.device = MagicMock() - ctx.device.device_width = width - ctx.device.device_height = height - return ctx - - -def _state(screenshot: str | None = "/tmp/test.jpg", raw: dict | None = None): - state = MagicMock(spec=State) - state.latest_screenshot = screenshot - state.indexed_points = [] - state.indexed_elements = [] - state.operator_raw_data = raw - return state - - -def _explorer_returning(raw): - instance = MagicMock() - instance.run = AsyncMock(return_value=raw) - return patch("artemis.tools.explorer_tool.Explorer", return_value=instance), instance - - -# --------------------------------------------------------------------------- # -# Contract -# --------------------------------------------------------------------------- # - - -def test_tool_contract_is_tier_agnostic(): - assert issubclass(AskExplorerTool, ArtemisTool) - assert isinstance(ask_explorer, AskExplorerTool) - assert ask_explorer.name == "ask_explorer" - assert ask_explorer.category == "explorer" - assert ask_explorer.args_schema is AskExplorerArgs - - fields = AskExplorerArgs.model_fields - assert set(fields) == {"query", "context_feedback"} - assert fields["query"].is_required() - assert fields["query"].description == ASK_EXPLORER_QUERY_DESCRIPTION - assert fields["context_feedback"].default == "" - assert fields["context_feedback"].description == ASK_EXPLORER_CONTEXT_FEEDBACK_DESCRIPTION - - declaration = ask_explorer.to_genai_declaration() - assert declaration.name == "ask_explorer" - assert set(declaration.parameters.properties) == {"query", "context_feedback"} - assert declaration.parameters.required == ["query"] - - -@pytest.mark.parametrize("version", [None, "flash", "pro", "ultra"]) -def test_description_does_not_depend_on_the_tier(version): - tool = AskExplorerTool(version=version) - assert tool.description == ASK_EXPLORER_DESCRIPTION - assert tool.version == version - - -def test_wrapper_export(): - assert ask_explorer_wrapper.tool_fn_getter is get_ask_explorer_tool - assert ask_explorer_wrapper.on_success_fn("x") == "x" - assert "boom" in ask_explorer_wrapper.on_failure_fn("boom") - - -# --------------------------------------------------------------------------- # -# ExplorerOutcome.from_raw -# --------------------------------------------------------------------------- # - - -def test_from_raw_parses_candidates_and_message(): - raw = json.dumps( - { - "candidates": [ - {"label": "S1", "coords": [500, 500], "description": "First Button"}, - {"label": "S2", "coords": [250, 750], "description": "Second Button"}, - ], - "fallback_message": "Please choose carefully.", - } - ) - outcome = ExplorerOutcome.from_raw(raw) - assert outcome.found and not outcome.error - assert outcome.candidates == [ - ExplorerCandidate(label="S1", coords=(500, 500), description="First Button"), - ExplorerCandidate(label="S2", coords=(250, 750), description="Second Button"), - ] - assert outcome.message == "Please choose carefully." - assert outcome.raw == raw - - -def test_from_raw_accepts_a_dict_and_fills_defaults(): - outcome = ExplorerOutcome.from_raw( - {"candidates": [{"coords": ["10", "20"]}]}, default_label="gear icon" - ) - assert len(outcome.candidates) == 1 - cand = outcome.candidates[0] - assert cand.label == "gear icon" - assert cand.description == "gear icon" - assert cand.coords == (10, 20) - assert outcome.message == "" - - -def test_from_raw_drops_candidates_with_invalid_coords(): - outcome = ExplorerOutcome.from_raw( - { - "candidates": [ - {"label": "bad1", "coords": [1500, 10]}, - {"label": "bad2", "coords": [10]}, - {"label": "bad3", "coords": "10,10"}, - {"label": "bad4"}, - "not a dict", - {"label": "ok", "coords": [10, 10]}, - ] - } - ) - assert [c.label for c in outcome.candidates] == ["ok"] - assert not outcome.error - - -def test_from_raw_unreadable_text_is_an_error_outcome(): - outcome = ExplorerOutcome.from_raw("I could not parse the screen {") - assert outcome.error and not outcome.found - assert "unreadable answer" in outcome.message - assert outcome.raw == "I could not parse the screen {" - - -def test_from_raw_non_object_json_is_an_error_outcome(): - outcome = ExplorerOutcome.from_raw("[1, 2, 3]") - assert outcome.error - assert "unexpected answer" in outcome.message - - -def test_from_raw_empty_candidates_is_a_clean_not_found(): - outcome = ExplorerOutcome.from_raw({"candidates": [], "fallback_message": "Covered."}) - assert not outcome.found and not outcome.error - assert outcome.message == "Covered." - - -# --------------------------------------------------------------------------- # -# register_candidates -# --------------------------------------------------------------------------- # - - -def test_register_candidates_appends_after_existing_elements_using_observation_size(): - ctx = _ctx() - state = _state(raw={"width": 1000, "height": 2000}) - state.indexed_points = [[100, 200]] - state.indexed_elements = [{"index": 1, "center": [100, 200], "text": "Pre-existing"}] - outcome = ExplorerOutcome( - candidates=[ - ExplorerCandidate(label="S1", coords=(500, 500), description="First Button"), - ExplorerCandidate(label="S2", coords=(250, 750), description="Second Button"), - ] - ) - - registered = register_candidates(ctx, state, outcome) - - assert registered == [ - RegisteredCandidate( - index=2, pixel=(500, 1000), coords=(500, 500), description="First Button" - ), - RegisteredCandidate( - index=3, pixel=(250, 1500), coords=(250, 750), description="Second Button" - ), - ] - assert state.indexed_points == [[100, 200], [500, 1000], [250, 1500]] - assert len(state.indexed_elements) == 3 - assert state.indexed_elements[1] == { - "index": 2, - "center": [500, 1000], - "text": "First Button", - "bounds": None, - "class": "ExplorerCandidate", - "resource_id": None, - "is_ocr": False, - } - assert state.indexed_elements[2]["index"] == 3 - assert state.indexed_elements[2]["center"] == [250, 1500] - - -def test_register_candidates_falls_back_to_the_device_size(): - ctx = _ctx(1080, 2400) - state = _state(raw=None) - outcome = ExplorerOutcome(candidates=[ExplorerCandidate("S1", (500, 500), "Btn")]) - registered = register_candidates(ctx, state, outcome) - assert registered[0].pixel == (540, 1200) - assert registered[0].index == 1 - - -def test_register_candidates_creates_the_lists_when_the_state_has_none(): - ctx = _ctx() - state = MagicMock(spec=State) - state.indexed_points = None - state.indexed_elements = None - state.operator_raw_data = None - outcome = ExplorerOutcome(candidates=[ExplorerCandidate("S1", (0, 0), "Origin")]) - registered = register_candidates(ctx, state, outcome) - assert registered[0].index == 1 - assert state.indexed_points == [[0, 0]] - assert state.indexed_elements[0]["text"] == "Origin" - - -def test_register_candidates_is_a_no_op_without_candidates(): - ctx = _ctx() - state = _state() - assert register_candidates(ctx, state, ExplorerOutcome(message="nope")) == [] - assert register_candidates(ctx, state, ExplorerOutcome.failure("boom")) == [] - assert state.indexed_points == [] - assert state.indexed_elements == [] - - -# --------------------------------------------------------------------------- # -# render_text / render_operator_blocks -# --------------------------------------------------------------------------- # - - -def test_render_text_found_lists_indices_and_coordinates(): - outcome = ExplorerOutcome( - candidates=[ExplorerCandidate("S1", (500, 500), "First Button")], - message="Please choose carefully.", - ) - registered = [RegisteredCandidate(2, (540, 1200), (500, 500), "First Button")] - text = render_text("Find buttons", outcome, registered) - assert "Explorer located 1 candidate(s) for 'Find buttons'" in text - assert "- [2] 'First Button' at normalized [500, 500]" in text - assert "Explorer notes: Please choose carefully." in text - - -def test_render_text_targeting_guidance_follows_the_callers_click_dialect(): - """Flash / Validator / MCP ``click`` takes coordinates only; the Pro Operator - takes a bare integer index. Neither dialect accepts ``target=[index]``.""" - outcome = ExplorerOutcome(candidates=[ExplorerCandidate("S1", (500, 500), "First Button")]) - registered = [RegisteredCandidate(2, (540, 1200), (500, 500), "First Button")] - - coordinate_text = render_text("Find buttons", outcome, registered, index_targets=False) - assert ( - "by its normalized coordinate (target=[x, y]) with a target_description" in coordinate_text - ) - assert "bare integer" not in coordinate_text - # The keyword defaults to the coordinate-only dialect (text entry points). - assert render_text("Find buttons", outcome, registered) == coordinate_text - - index_text = render_text("Find buttons", outcome, registered, index_targets=True) - assert "by its index (target=3, a bare integer) or by its normalized coordinate" in index_text - assert "target=[x, y]" not in index_text - - for text in (coordinate_text, index_text): - assert "target=[index]" not in text - assert "Candidates are ranked by confidence." in text - - -def test_render_text_not_found_explains_and_suggests_rephrasing(): - text = render_text("hidden button", ExplorerOutcome(message="Covered by keyboard."), []) - assert text.startswith("Explorer could not locate 'hidden button'. Covered by keyboard.") - assert "describing the target differently" in text - # Without an explanation the text still reads as a complete sentence. - text = render_text("x", ExplorerOutcome(), []) - assert "It gave no further detail." in text - - -def test_render_text_error_reports_the_failure(): - text = render_text("gear icon", ExplorerOutcome.failure("Explorer failed: quota"), []) - assert text == "Explorer could not run for 'gear icon'. Explorer failed: quota" - - -def test_render_operator_blocks_adds_the_annotated_image(tmp_path): - ctx = _ctx(base_dir=tmp_path) - state = _state(screenshot=str(tmp_path / "shot.jpg")) - outcome = ExplorerOutcome(candidates=[ExplorerCandidate("S1", (500, 500), "First Button")]) - registered = [RegisteredCandidate(2, (540, 1200), (500, 500), "First Button")] - - def fake_draw_dots(screenshot_path, points, labels, output_path, **kwargs): - Path(output_path).write_bytes(b"fake_annotated_image_bytes") - - with patch("artemis.tools.explorer_tool.draw_dots", side_effect=fake_draw_dots) as draw: - result = render_operator_blocks(ctx, state, "Find buttons", outcome, registered) - - draw.assert_called_once() - args, kwargs = draw.call_args - assert args[0] == str(tmp_path / "shot.jpg") - assert args[1] == [[540, 1200]] - assert args[2] == ["2"] - assert Path(args[3]).parent == tmp_path / "images" / "explorer_tool" - assert kwargs["color"] == "magenta" - - assert isinstance(result, list) and len(result) == 2 - assert result[0]["type"] == "text" - assert "Explorer located 1 candidate(s)" in result[0]["text"] - # The Operator's click takes a bare integer index. - assert "target=3, a bare integer" in result[0]["text"] - assert "target=[x, y]" not in result[0]["text"] - assert result[1]["type"] == "image_url" - assert result[1]["image_url"]["url"].startswith("data:image/jpeg;base64,") - - -def test_render_operator_blocks_numbers_annotations_sequentially(tmp_path): - ctx = _ctx(base_dir=tmp_path) - state = _state(screenshot=str(tmp_path / "shot.jpg")) - outcome = ExplorerOutcome(candidates=[ExplorerCandidate("S1", (500, 500), "B")]) - registered = [RegisteredCandidate(1, (540, 1200), (500, 500), "B")] - seen: list[str] = [] - - def fake_draw_dots(screenshot_path, points, labels, output_path, **kwargs): - seen.append(Path(output_path).name) - Path(output_path).write_bytes(b"img") - - with patch("artemis.tools.explorer_tool.draw_dots", side_effect=fake_draw_dots): - render_operator_blocks(ctx, state, "B", outcome, registered) - render_operator_blocks(ctx, state, "B", outcome, registered) - assert seen == ["explorer_output_1.jpg", "explorer_output_2.jpg"] - - -def test_render_operator_blocks_degrades_to_text_when_drawing_fails(tmp_path): - ctx = _ctx(base_dir=tmp_path) - state = _state(screenshot=str(tmp_path / "shot.jpg")) - outcome = ExplorerOutcome(candidates=[ExplorerCandidate("S1", (500, 500), "First Button")]) - registered = [RegisteredCandidate(2, (540, 1200), (500, 500), "First Button")] - - with patch("artemis.tools.explorer_tool.draw_dots", side_effect=OSError("no PIL")): - result = render_operator_blocks(ctx, state, "Find buttons", outcome, registered) - - assert isinstance(result, str) - assert "Explorer located 1 candidate(s)" in result - - -def test_render_operator_blocks_is_plain_text_without_candidates(tmp_path): - ctx = _ctx(base_dir=tmp_path) - state = _state(screenshot=str(tmp_path / "shot.jpg")) - with patch("artemis.tools.explorer_tool.draw_dots") as draw: - result = render_operator_blocks(ctx, state, "x", ExplorerOutcome(message="Nope."), []) - draw.assert_not_called() - assert isinstance(result, str) - assert "Explorer could not locate 'x'. Nope." in result - - -# --------------------------------------------------------------------------- # -# locate -# --------------------------------------------------------------------------- # - - -@pytest.mark.asyncio -async def test_locate_runs_the_resolved_tier_and_parses_the_answer(): - ctx = _ctx() - state = _state() - raw = json.dumps({"candidates": [{"label": "S1", "coords": [500, 500]}]}) - explorer_patch, instance = _explorer_returning(raw) - with ( - explorer_patch, - patch( - "artemis.tools.explorer_tool.resolve_explorer_version", return_value="ultra" - ) as resolve, - ): - outcome = await locate(ctx, state, "gear icon", "was wrong", agent_name="validator") - - resolve.assert_called_once_with(ctx, explicit_version=None, agent_or_profile_name="validator") - instance.run.assert_awaited_once_with( - "gear icon", "was wrong", "/tmp/test.jpg", state, version="ultra" - ) - assert outcome.found and not outcome.error - assert outcome.candidates[0].coords == (500, 500) - - -@pytest.mark.asyncio -async def test_locate_forwards_a_programmatic_version_pin(): - ctx = _ctx() - state = _state() - explorer_patch, instance = _explorer_returning(json.dumps({"candidates": []})) - with ( - explorer_patch, - patch( - "artemis.tools.explorer_tool.resolve_explorer_version", return_value="pro" - ) as resolve, - ): - await locate(ctx, state, "q", version="pro", agent_name="flash") - resolve.assert_called_once_with(ctx, explicit_version="pro", agent_or_profile_name="flash") - assert instance.run.await_args.kwargs["version"] == "pro" - - -@pytest.mark.asyncio -async def test_locate_unknown_resolved_tier_falls_back_to_the_default_tier(): - ctx = _ctx() - state = _state() - explorer_patch, instance = _explorer_returning(json.dumps({"candidates": []})) - with ( - explorer_patch, - patch("artemis.tools.explorer_tool.resolve_explorer_version", return_value="turbo"), - ): - await locate(ctx, state, "q") - assert instance.run.await_args.kwargs["version"] == "flash" - - -@pytest.mark.asyncio -async def test_locate_without_a_screenshot_is_an_error_outcome(): - ctx = _ctx() - state = _state(screenshot=None) - with patch("artemis.tools.explorer_tool.Explorer") as explorer_cls: - outcome = await locate(ctx, state, "gear icon") - explorer_cls.assert_not_called() - assert outcome.error - assert "No screenshot" in outcome.message - - -@pytest.mark.asyncio -async def test_locate_contains_explorer_exceptions_as_error_outcomes(): - ctx = _ctx() - state = _state() - instance = MagicMock() - instance.run = AsyncMock(side_effect=RuntimeError("quota exhausted")) - with patch("artemis.tools.explorer_tool.Explorer", return_value=instance): - outcome = await locate(ctx, state, "gear icon") - assert outcome.error and not outcome.found - assert outcome.message == "Explorer failed: quota exhausted" - assert "could not run for 'gear icon'" in render_text("gear icon", outcome, []) - - -# --------------------------------------------------------------------------- # -# Entry points -# --------------------------------------------------------------------------- # - - -@pytest.mark.asyncio -async def test_ask_explorer_text_end_to_end(): - ctx = _ctx() - state = _state() - state.indexed_points = [[100, 200]] - state.indexed_elements = [{"index": 1, "center": [100, 200], "text": "Pre-existing"}] - raw = json.dumps( - { - "candidates": [ - {"label": "S1", "coords": [500, 500], "description": "First Button"}, - {"label": "S2", "coords": [250, 750], "description": "Second Button"}, - ], - "fallback_message": "Please choose carefully.", - } - ) - explorer_patch, instance = _explorer_returning(raw) - with explorer_patch: - text = await ask_explorer_text(ctx, state, "Find buttons", "Attempt 1", agent_name="flash") - - instance.run.assert_awaited_once() - assert instance.run.await_args.args[:2] == ("Find buttons", "Attempt 1") - assert state.indexed_points == [[100, 200], [540, 1200], [270, 1800]] - assert state.indexed_elements[1]["text"] == "First Button" - assert state.indexed_elements[2]["text"] == "Second Button" - assert "Explorer located 2 candidate(s) for 'Find buttons'" in text - assert "- [2] 'First Button' at normalized [500, 500]" in text - assert "- [3] 'Second Button' at normalized [250, 750]" in text - # The text entry serves Flash / Validator / MCP, whose click takes coordinates only. - assert "target=[x, y]" in text - assert "bare integer" not in text - assert "Explorer notes: Please choose carefully." in text - - -@pytest.mark.asyncio -async def test_ask_explorer_text_not_found_leaves_the_state_untouched(): - ctx = _ctx() - state = _state() - explorer_patch, _ = _explorer_returning( - json.dumps({"candidates": [], "fallback_message": "Covered by a keyboard."}) - ) - with explorer_patch: - text = await ask_explorer_text(ctx, state, "hidden button") - assert state.indexed_points == [] - assert state.indexed_elements == [] - assert "Explorer could not locate 'hidden button'. Covered by a keyboard." in text - - -@pytest.mark.asyncio -async def test_langchain_tool_uses_the_operator_presentation(tmp_path): - ctx = _ctx(base_dir=tmp_path) - state = _state(screenshot=str(tmp_path / "shot.jpg")) - raw = json.dumps({"candidates": [{"label": "S1", "coords": [500, 500], "description": "B"}]}) - explorer_patch, _ = _explorer_returning(raw) - - def fake_draw_dots(screenshot_path, points, labels, output_path, **kwargs): - Path(output_path).write_bytes(b"img") - - with explorer_patch, patch("artemis.tools.explorer_tool.draw_dots", side_effect=fake_draw_dots): - tool = get_ask_explorer_tool(ctx) - result = await tool.ainvoke( - {"query": "Find buttons", "context_feedback": "", "state": state} - ) - - assert tool.name == "ask_explorer" - assert isinstance(result, list) and result[1]["type"] == "image_url" - assert "- [1] 'B' at normalized [500, 500]" in result[0]["text"] - - -@pytest.mark.asyncio -async def test_direct_execute_accepts_a_version_pin_and_legacy_arg_names(): - ctx = _ctx(width=None) - state = _state() - explorer_patch, instance = _explorer_returning( - json.dumps({"candidates": [], "fallback_message": "Direct execution not found."}) - ) - with explorer_patch: - result = await ask_explorer.execute( - ctx=ctx, state=state, Query="Find icon", ContextFeedback="cf", version="pro" - ) - assert instance.run.await_args.args[:2] == ("Find icon", "cf") - assert instance.run.await_args.kwargs["version"] == "pro" - assert "Direct execution not found." in result - - -@pytest.mark.asyncio -async def test_run_explorer_logic_replay_shim_returns_operator_blocks(tmp_path): - ctx = _ctx(base_dir=tmp_path) - state = _state(screenshot=str(tmp_path / "shot.jpg")) - raw = json.dumps({"candidates": [{"label": "S1", "coords": [500, 500], "description": "B"}]}) - explorer_patch, instance = _explorer_returning(raw) - - def fake_draw_dots(screenshot_path, points, labels, output_path, **kwargs): - Path(output_path).write_bytes(b"img") - - with explorer_patch, patch("artemis.tools.explorer_tool.draw_dots", side_effect=fake_draw_dots): - result = await _run_explorer_logic(ctx, state, "B", "", version="ultra") - - assert instance.run.await_args.kwargs["version"] == "ultra" - assert isinstance(result, list) and len(result) == 2 - assert result[0]["type"] == "text" and result[1]["type"] == "image_url" - assert state.indexed_points == [[540, 1200]] - - -# --------------------------------------------------------------------------- # -# Bounds and de-duplication against the existing index -# --------------------------------------------------------------------------- # - - -def test_from_raw_parses_bounds_and_source_and_drops_invalid_bounds(): - raw = json.dumps( - { - "candidates": [ - { - "label": "X1", - "coords": [500, 500], - "bounds": [400, 450, 600, 550], - "source": "xml", - }, - {"label": "D1", "coords": [100, 100], "bounds": [600, 550, 400, 450]}, - {"label": "D2", "coords": [100, 200], "bounds": [0, 0, 1200, 10]}, - ] - } - ) - outcome = ExplorerOutcome.from_raw(raw) - assert outcome.candidates[0].bounds == (400, 450, 600, 550) - assert outcome.candidates[0].source == "xml" - assert outcome.candidates[1].bounds is None - assert outcome.candidates[2].bounds is None - assert outcome.candidates[1].source == "" - - -def test_register_candidates_reuses_an_indexed_element_that_contains_the_point(): - ctx = _ctx() - state = _state(raw={"width": 1000, "height": 2000}) - state.indexed_points = [[300, 400], [800, 1600]] - state.indexed_elements = [ - {"index": 1, "center": [300, 400], "text": "Gmail", "bounds": [200, 300, 400, 500]}, - {"index": 2, "center": [800, 1600], "text": "Send", "bounds": [700, 1500, 900, 1700]}, - ] - outcome = ExplorerOutcome(candidates=[ExplorerCandidate("D1", (310, 210), "Gmail app icon")]) - registered = register_candidates(ctx, state, outcome) - assert registered == [ - RegisteredCandidate( - index=1, - pixel=(310, 420), - coords=(310, 210), - description="Gmail app icon", - bounds=(200, 300, 400, 500), - reused=True, - existing_text="Gmail", - ) - ] - assert len(state.indexed_points) == 2 # nothing appended - # The reused line names the element the way the agent's own list shows it. - text = render_text("Gmail app icon", outcome, registered) - assert ( - "- [1] 'Gmail app icon' at normalized [310, 210] (matches your indexed element [1] 'Gmail')" - ) in text - assert "already in your indexed list" not in text - - -def test_register_candidates_reuses_a_bare_center_within_the_dedup_radius_only(): - ctx = _ctx() - state = _state(raw={"width": 1000, "height": 2000}) - state.indexed_points = [[500, 1000]] - state.indexed_elements = [ - {"index": 1, "center": [500, 1000], "text": "Earlier candidate", "bounds": None} - ] - near = ExplorerCandidate("D1", (510, 505), "Same spot") # 10 x 10 px away - far = ExplorerCandidate("D2", (560, 500), "Sixty px right") - registered = register_candidates(ctx, state, ExplorerOutcome(candidates=[near, far])) - assert [r.reused for r in registered] == [True, False] - assert registered[0].index == 1 - assert registered[0].existing_text == "Earlier candidate" - assert registered[1].index == 2 - assert registered[1].existing_text == "" - assert state.indexed_points == [[500, 1000], [560, 1000]] - - -def test_register_candidates_stores_pixel_bounds_and_ocr_flag(): - ctx = _ctx() - state = _state(raw={"width": 1000, "height": 2000}) - outcome = ExplorerOutcome( - candidates=[ - ExplorerCandidate("O3", (500, 500), "Search", bounds=(400, 450, 600, 550), source="ocr") - ] - ) - registered = register_candidates(ctx, state, outcome) - assert registered[0].bounds == (400, 900, 600, 1100) - assert registered[0].reused is False - element = state.indexed_elements[0] - assert element["bounds"] == [400, 900, 600, 1100] - assert element["is_ocr"] is True diff --git a/tests/unit/tools/test_image_processor_tool.py b/tests/unit/tools/test_image_processor_tool.py deleted file mode 100644 index 8bc200f41..000000000 --- a/tests/unit/tools/test_image_processor_tool.py +++ /dev/null @@ -1,94 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import AsyncMock, MagicMock, patch - -from artemis.context import ArtemisContext -from artemis.graph.state import State -from artemis.tools.image_processor_tool import get_ask_image_processor_tool -import pytest - - -@pytest.mark.asyncio -async def test_ask_vision_coder_success(): - # Mock Context and State - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = None - - mock_state = MagicMock(spec=State) - mock_state.latest_screenshot = "/tmp/test_screen.jpg" - - # Mock VisionCoder Agent Outcome - vision_coder_outcome = { - "new_image_path": "/tmp/coder_output.jpg", - "summary": "Found red button at pixel coords (100, 200)", - } - - # We need to patch the ImageProcessor class inside the tool - mock_coder_instance = MagicMock() - mock_coder_instance.run = AsyncMock(return_value=vision_coder_outcome) - - with patch( - "artemis.agents.image_processor.image_processor.ImageProcessor", - return_value=mock_coder_instance, - ): - # Retrieve the tool - ask_vision_coder_tool = get_ask_image_processor_tool(mock_ctx) - - # Invoke the tool - result = await ask_vision_coder_tool.ainvoke( - { - "instruction": "Find the red button", - "target_image_id": "img_0", - "state": mock_state, - } - ) - - # Verify the agent was called with correct arguments - mock_coder_instance.run.assert_called_once_with( - "Find the red button", "/tmp/test_screen.jpg" - ) - - # Verify the output matches - assert result == vision_coder_outcome - - -def test_ask_image_processor_tool_subclass(): - """Verify AskImageProcessorTool is an ArtemisTool subclass.""" - from artemis.tools.base import ArtemisTool - from artemis.tools.image_processor_tool import ( - AskImageProcessor, - AskImageProcessorTool, - AskVisionCoderArgs, - AskVisionCoderTool, - ImageProcessorTool, - ask_image_processor, - ) - - assert issubclass(AskImageProcessorTool, ArtemisTool) - assert issubclass(AskImageProcessor, ArtemisTool) - assert issubclass(AskVisionCoderTool, ArtemisTool) - assert issubclass(ImageProcessorTool, ArtemisTool) - assert isinstance(ask_image_processor, ArtemisTool) - assert isinstance(ask_image_processor, AskImageProcessorTool) - - assert ask_image_processor.name == "ask_image_processor" - assert ask_image_processor.category == "custom" - assert ask_image_processor.args_schema == AskVisionCoderArgs - - # GenAI FunctionDeclaration export - declaration = ask_image_processor.to_genai_declaration() - assert declaration.name == "ask_image_processor" - assert "instruction" in declaration.parameters.properties - assert "target_image_id" in declaration.parameters.properties diff --git a/tests/unit/tools/test_launch_app.py b/tests/unit/tools/test_launch_app.py deleted file mode 100644 index cdc6f2846..000000000 --- a/tests/unit/tools/test_launch_app.py +++ /dev/null @@ -1,92 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import MagicMock, patch -from artemis.context import ArtemisContext -from artemis.tools.mobile.launch_app import find_package -from third_party.mobile_use.agents.hopper.hopper import HopperOutput -import pytest - - -@pytest.fixture -def mock_context(): - ctx = MagicMock(spec=ArtemisContext) - ctx.package_cache = {} - return ctx - - -@pytest.mark.asyncio -@patch("third_party.mobile_use.tools.mobile.launch_app.list_packages_async") -@patch("third_party.mobile_use.tools.mobile.launch_app.hopper") -async def test_find_package_cache_hit(mock_hopper, mock_list_packages, mock_context): - mock_list_packages.return_value = "com.example.app1\ncom.example.app2" - - # First call: not in cache, calls hopper - mock_hopper.return_value = HopperOutput( - found=True, output="com.example.app1", reason="Found it" - ) - - pkg1 = await find_package(mock_context, "App 1") - assert pkg1 == "com.example.app1" - assert mock_hopper.call_count == 1 - assert mock_context.package_cache["App 1"] == "com.example.app1" - - # Second call: cache hit, does not call hopper or list_packages again - mock_hopper.reset_mock() - mock_list_packages.reset_mock() - - pkg2 = await find_package(mock_context, "App 1") - assert pkg2 == "com.example.app1" - mock_hopper.assert_not_called() - mock_list_packages.assert_not_called() - - -@pytest.mark.asyncio -@patch("third_party.mobile_use.tools.mobile.launch_app.list_packages_async") -@patch("third_party.mobile_use.tools.mobile.launch_app.hopper") -async def test_find_package_not_found_caches_none(mock_hopper, mock_list_packages, mock_context): - mock_list_packages.return_value = "com.example.app1\ncom.example.app2" - - # Hopper doesn't find it - mock_hopper.return_value = HopperOutput(found=False, output=None, reason="Not found") - - pkg1 = await find_package(mock_context, "App 3") - assert pkg1 is None - assert mock_hopper.call_count == 1 - assert mock_context.package_cache["App 3"] is None - - # Second call: cache hit (None), does not call hopper again - mock_hopper.reset_mock() - pkg2 = await find_package(mock_context, "App 3") - assert pkg2 is None - mock_hopper.assert_not_called() - - -@pytest.mark.asyncio -@patch("third_party.mobile_use.tools.mobile.launch_app.list_packages_async") -@patch("third_party.mobile_use.tools.mobile.launch_app.hopper") -async def test_find_package_passes_use_fallback(mock_hopper, mock_list_packages, mock_context): - mock_list_packages.return_value = "com.example.app1\ncom.example.app2" - mock_hopper.return_value = HopperOutput( - found=True, output="com.example.app1", reason="Found it" - ) - - # Pass use_fallback=False - await find_package(mock_context, "App 1", use_fallback=False) - mock_hopper.assert_called_once_with( - ctx=mock_context, - request=("I'm looking for the package name of the following app: 'App 1'"), - data="com.example.app1\ncom.example.app2", - use_fallback=False, - ) diff --git a/tests/unit/tools/test_launch_app_tool.py b/tests/unit/tools/test_launch_app_tool.py deleted file mode 100644 index 4438e397d..000000000 --- a/tests/unit/tools/test_launch_app_tool.py +++ /dev/null @@ -1,195 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import AsyncMock, MagicMock, patch - -from artemis.context import ArtemisContext -from artemis.drivers.base import BaseDeviceDriver -from artemis.graph.state import State -from artemis.tools.base import ArtemisTool -from artemis.tools.mobile.launch_app import ( - LaunchApp, - LaunchAppArgs, - LaunchAppTool, - get_launch_app_tool, - launch_app, - launch_app_wrapper, -) -from langchain_core.messages import ToolMessage -import pytest - - -@pytest.fixture -def mock_ctx(): - ctx = MagicMock(spec=ArtemisContext) - ctx.package_cache = {} - return ctx - - -@pytest.fixture -def mock_driver(): - driver = MagicMock(spec=BaseDeviceDriver) - driver.launch_app = AsyncMock(return_value=True) - return driver - - -@pytest.fixture -def mock_state(): - return MagicMock(spec=State) - - -def test_launch_app_tool_subclass(): - """Verify LaunchAppTool is a subclass of ArtemisTool.""" - assert issubclass(LaunchAppTool, ArtemisTool) - assert issubclass(LaunchApp, ArtemisTool) - assert isinstance(launch_app, ArtemisTool) - assert isinstance(launch_app, LaunchAppTool) - - assert launch_app.name == "launch_app" - assert launch_app.category == "action" - assert launch_app.args_schema == LaunchAppArgs - - # GenAI FunctionDeclaration export - declaration = launch_app.to_genai_declaration() - assert declaration.name == "launch_app" - assert "app_name" in declaration.parameters.properties - - # Wrapper check - assert launch_app_wrapper is not None - assert launch_app_wrapper.tool_fn_getter == get_launch_app_tool - - -@pytest.mark.asyncio -async def test_launch_app_direct_execution_with_driver(mock_driver): - """Verify direct execution with BaseDeviceDriver.""" - result = await launch_app.execute(driver=mock_driver, app_name="com.android.settings") - mock_driver.launch_app.assert_called_once_with("com.android.settings") - assert result == "Launched app 'com.android.settings'." - - -@pytest.mark.asyncio -async def test_launch_app_direct_execution_with_ctx(mock_ctx): - """Verify direct execution with ArtemisContext.""" - with ( - patch( - "artemis.tools.mobile.launch_app.find_package", - new_callable=AsyncMock, - return_value="com.example.myapp", - ) as mock_find, - patch( - "artemis.tools.mobile.launch_app.launch_app_with_retries", - new_callable=AsyncMock, - return_value=(True, None), - ) as mock_launch, - ): - result = await launch_app.execute(ctx=mock_ctx, app_name="My App") - mock_find.assert_called_once_with(ctx=mock_ctx, app_name="My App") - mock_launch.assert_called_once_with(ctx=mock_ctx, app_package="com.example.myapp") - assert result == "Launched app 'My App' (com.example.myapp); foreground confirmed." - - -@pytest.mark.asyncio -async def test_launch_app_package_not_found(mock_ctx): - """Verify outcome when package is not found.""" - with patch( - "artemis.tools.mobile.launch_app.find_package", - new_callable=AsyncMock, - return_value=None, - ): - result = await launch_app.execute(ctx=mock_ctx, app_name="Nonexistent App") - assert "Failed to launch app 'Nonexistent App': Package not found." in result - - -@pytest.mark.asyncio -async def test_launch_app_execution_failure(mock_ctx): - """Verify outcome when launch_app_with_retries fails.""" - with ( - patch( - "artemis.tools.mobile.launch_app.find_package", - new_callable=AsyncMock, - return_value="com.example.failapp", - ), - patch( - "artemis.tools.mobile.launch_app.launch_app_with_retries", - new_callable=AsyncMock, - return_value=(False, "App crashed on startup"), - ), - ): - result = await launch_app.execute(ctx=mock_ctx, app_name="Crash App") - assert "Failed to launch app 'Crash App': App crashed on startup" in result - - -@pytest.mark.asyncio -async def test_launch_app_with_state_command(mock_ctx, mock_state): - """Verify LaunchAppTool returns ToolMessage when state is provided.""" - with ( - patch( - "artemis.tools.mobile.launch_app.find_package", - new_callable=AsyncMock, - return_value="com.example.stateapp", - ), - patch( - "artemis.tools.mobile.launch_app.launch_app_with_retries", - new_callable=AsyncMock, - return_value=(True, None), - ), - ): - cmd = await launch_app.execute( - ctx=mock_ctx, - app_name="State App", - tool_call_id="call_la_1", - state=mock_state, - ) - assert isinstance(cmd, ToolMessage) - assert cmd.tool_call_id == "call_la_1" - assert cmd.status == "success" - assert ( - cmd.content == "Launched app 'State App' (com.example.stateapp); foreground confirmed." - ) - - -@pytest.mark.asyncio -async def test_launch_app_callable_execution(mock_driver): - """Verify invoking launch_app directly as a callable.""" - result = await launch_app(driver=mock_driver, app_name="com.android.chrome") - assert result == "Launched app 'com.android.chrome'." - - -@pytest.mark.asyncio -async def test_launch_app_missing_name(mock_ctx): - """Verify error handling when app_name is empty.""" - result = await launch_app.execute(ctx=mock_ctx, app_name="") - assert "Error during launch app: app_name parameter is required." in result - - -@pytest.mark.asyncio -async def test_get_launch_app_tool_langchain_ainvoke(mock_ctx): - """Verify get_launch_app_tool exports a LangChain tool that works with ainvoke.""" - la_tool = get_launch_app_tool(mock_ctx) - assert la_tool.name == "launch_app" - - with ( - patch( - "artemis.tools.mobile.launch_app.find_package", - new_callable=AsyncMock, - return_value="com.example.ainvoke", - ), - patch( - "artemis.tools.mobile.launch_app.launch_app_with_retries", - new_callable=AsyncMock, - return_value=(True, None), - ), - ): - result = await la_tool.ainvoke({"app_name": "Invoke App"}) - assert result == "Launched app 'Invoke App' (com.example.ainvoke); foreground confirmed." diff --git a/tests/unit/tools/test_list_notes_pure_tool.py b/tests/unit/tools/test_list_notes_pure_tool.py deleted file mode 100644 index 79d255537..000000000 --- a/tests/unit/tools/test_list_notes_pure_tool.py +++ /dev/null @@ -1,104 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import MagicMock, patch - -from artemis.context import ArtemisContext -from artemis.tools.base import ArtemisTool -from artemis.tools.scratchpad import ( - ListNotesArgs, - ListNotesPure, - ListNotesPureTool, - get_list_notes_tool_pure, - list_notes_pure, -) -from artemis.utils.notes import save_note_content -import pytest - - -@pytest.fixture -def mock_ctx(tmp_path): - ctx = MagicMock(spec=ArtemisContext) - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = str(tmp_path) - return ctx - - -def test_list_notes_pure_tool_subclass(): - """Verify ListNotesPureTool is a subclass of ArtemisTool.""" - assert issubclass(ListNotesPureTool, ArtemisTool) - assert issubclass(ListNotesPure, ArtemisTool) - assert isinstance(list_notes_pure, ArtemisTool) - assert isinstance(list_notes_pure, ListNotesPureTool) - - assert list_notes_pure.name == "list_notes_pure" - assert list_notes_pure.category == "memory" - assert list_notes_pure.args_schema == ListNotesArgs - - # GenAI FunctionDeclaration export - declaration = list_notes_pure.to_genai_declaration() - assert declaration.name == "list_notes_pure" - - -@pytest.mark.asyncio -async def test_list_notes_pure_direct_execution_empty(mock_ctx): - """Verify listing notes when no notes are stored.""" - result = await list_notes_pure.execute(ctx=mock_ctx) - assert "No notes saved yet." in result - - -@pytest.mark.asyncio -async def test_list_notes_pure_direct_execution_success(mock_ctx, tmp_path): - """Verify direct execution of ListNotesPureTool.execute lists stored notes.""" - save_note_content(tmp_path, "note_1", "line 1\nline 2") - save_note_content(tmp_path, "note_2", "single line") - - result = await list_notes_pure.execute(ctx=mock_ctx) - assert "Here are all the notes:" in result - assert "- note_1 (2 lines)" in result - assert "- note_2 (1 lines)" in result - - -@pytest.mark.asyncio -async def test_list_notes_pure_callable_execution(mock_ctx, tmp_path): - """Verify invoking list_notes_pure directly as a callable.""" - save_note_content(tmp_path, "callable_note", "line A\nline B") - - result = await list_notes_pure(ctx=mock_ctx) - assert "Here are all the notes:" in result - assert "- callable_note (2 lines)" in result - - -@pytest.mark.asyncio -async def test_list_notes_pure_execution_failure(mock_ctx): - """Verify error handling when list_notes_pure fails.""" - with patch( - "artemis.tools.scratchpad.list_notes_info", - side_effect=RuntimeError("Disk failure"), - ): - result = await list_notes_pure.execute(ctx=mock_ctx) - assert "Failed to list notes: Disk failure" in result - - -@pytest.mark.asyncio -async def test_get_list_notes_tool_pure_langchain_ainvoke(mock_ctx, tmp_path): - """Verify get_list_notes_tool_pure exports a LangChain tool named 'list_notes'.""" - save_note_content(tmp_path, "pure_export_note", "content") - - pure_tool = get_list_notes_tool_pure(mock_ctx) - assert pure_tool.name == "list_notes" - - result = await pure_tool.ainvoke({}) - assert "Here are all the notes:" in result - assert "- pure_export_note (1 lines)" in result diff --git a/tests/unit/tools/test_list_notes_tool.py b/tests/unit/tools/test_list_notes_tool.py deleted file mode 100644 index 1a8ab6a7b..000000000 --- a/tests/unit/tools/test_list_notes_tool.py +++ /dev/null @@ -1,145 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import MagicMock, patch - -from artemis.context import ArtemisContext -from artemis.graph.state import State -from artemis.tools.base import ArtemisTool -from artemis.tools.scratchpad import ( - ListNotes, - ListNotesArgs, - ListNotesTool, - get_list_notes_tool, - get_list_notes_tool_pure, - list_notes, - list_notes_wrapper, -) -from artemis.utils.notes import save_note_content -from langchain_core.messages import ToolMessage -import pytest - - -@pytest.fixture -def mock_ctx(tmp_path): - ctx = MagicMock(spec=ArtemisContext) - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = str(tmp_path) - return ctx - - -def test_list_notes_tool_subclass(): - """Verify ListNotesTool is a subclass of ArtemisTool.""" - assert issubclass(ListNotesTool, ArtemisTool) - assert issubclass(ListNotes, ArtemisTool) - assert isinstance(list_notes, ArtemisTool) - assert isinstance(list_notes, ListNotesTool) - - assert list_notes.name == "list_notes" - assert list_notes.category == "memory" - assert list_notes.args_schema == ListNotesArgs - - # GenAI FunctionDeclaration export - declaration = list_notes.to_genai_declaration() - assert declaration.name == "list_notes" - - # Wrapper check - assert list_notes_wrapper is not None - assert list_notes_wrapper.tool_fn_getter == get_list_notes_tool - - -@pytest.mark.asyncio -async def test_list_notes_direct_execution_empty(mock_ctx): - """Verify listing notes when no notes are stored.""" - result = await list_notes.execute(ctx=mock_ctx) - assert "No notes saved yet." in result - - -@pytest.mark.asyncio -async def test_list_notes_direct_execution_success(mock_ctx, tmp_path): - """Verify direct execution of ListNotesTool.execute lists stored notes.""" - save_note_content(tmp_path, "note_alpha", "line 1\nline 2") - save_note_content(tmp_path, "note_beta", "single line") - - result = await list_notes.execute(ctx=mock_ctx) - assert "Here are all the notes:" in result - assert "- note_alpha (2 lines)" in result - assert "- note_beta (1 lines)" in result - - -@pytest.mark.asyncio -async def test_list_notes_callable_execution(mock_ctx, tmp_path): - """Verify invoking list_notes directly as a callable.""" - save_note_content(tmp_path, "note_gamma", "line A\nline B\nline C") - - result = await list_notes(ctx=mock_ctx) - assert "Here are all the notes:" in result - assert "- note_gamma (3 lines)" in result - - -@pytest.mark.asyncio -async def test_list_notes_with_state_tool_message(mock_ctx, tmp_path): - """Verify ListNotesTool.execute with state returns a ToolMessage directly.""" - save_note_content(tmp_path, "state_note", "content") - - mock_state = MagicMock(spec=State) - - result = await list_notes.execute( - ctx=mock_ctx, - state=mock_state, - tool_call_id="call_list_999", - ) - - assert isinstance(result, ToolMessage) - assert result.tool_call_id == "call_list_999" - assert "Here are all the notes:" in result.content - assert "- state_note (1 lines)" in result.content - assert result.status == "success" - - -@pytest.mark.asyncio -async def test_list_notes_execution_failure(mock_ctx): - """Verify error handling when list_notes fails.""" - with patch( - "artemis.tools.scratchpad.list_notes_info", - side_effect=RuntimeError("Disk failure"), - ): - result = await list_notes.execute(ctx=mock_ctx) - assert "Failed to list notes: Disk failure" in result - - -@pytest.mark.asyncio -async def test_get_list_notes_tool_langchain_ainvoke(mock_ctx, tmp_path): - """Verify get_list_notes_tool exports a LangChain tool that works with ainvoke.""" - save_note_content(tmp_path, "lc_note", "hello") - - lc_tool = get_list_notes_tool(mock_ctx) - assert lc_tool.name == "list_notes" - - result = await lc_tool.ainvoke({}) - assert "Here are all the notes:" in result - assert "- lc_note (1 lines)" in result - - -@pytest.mark.asyncio -async def test_get_list_notes_tool_pure(mock_ctx, tmp_path): - """Verify get_list_notes_tool_pure returns a pure LangChain tool.""" - save_note_content(tmp_path, "pure_note", "world") - - pure_tool = get_list_notes_tool_pure(mock_ctx) - assert pure_tool.name == "list_notes" - - result = await pure_tool.ainvoke({}) - assert "Here are all the notes:" in result - assert "- pure_note (1 lines)" in result diff --git a/tests/unit/tools/test_log_tool.py b/tests/unit/tools/test_log_tool.py deleted file mode 100644 index 98896d810..000000000 --- a/tests/unit/tools/test_log_tool.py +++ /dev/null @@ -1,110 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import AsyncMock, MagicMock, patch - -from artemis.context import ArtemisContext -from artemis.graph.state import State -from artemis.tools.base import ArtemisTool -from artemis.tools.log_tool import ( - AnalyzeLogs, - AnalyzeLogsArgs, - AnalyzeLogsTool, - LogTool, - analyze_logs, - analyze_logs_wrapper, - get_analyze_logs_tool, -) -import pytest - - -@pytest.mark.asyncio -async def test_analyze_logs_tool_execution(): - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = None - - mock_state = MagicMock(spec=State) - - expected_output = "Found 2 errors in system log: NullPointerException at line 42." - - mock_analyst = MagicMock() - mock_analyst.run = AsyncMock(return_value=expected_output) - - with patch( - "artemis.tools.log_tool.LogAnalyzerNode", - return_value=mock_analyst, - ): - tool = get_analyze_logs_tool(mock_ctx) - - result = await tool.ainvoke( - { - "specific_query": "Check for crash exceptions", - "state": mock_state, - } - ) - - mock_analyst.run.assert_called_once_with("Check for crash exceptions", mock_state) - assert result == expected_output - - -@pytest.mark.asyncio -async def test_analyze_logs_tool_direct_call(): - mock_ctx = MagicMock(spec=ArtemisContext) - mock_state = MagicMock(spec=State) - - mock_analyst = MagicMock() - mock_analyst.run = AsyncMock(return_value="Log scan complete.") - - with patch( - "artemis.tools.log_tool.LogAnalyzerNode", - return_value=mock_analyst, - ): - result = await analyze_logs( - ctx=mock_ctx, - specific_query="Find ANR", - state=mock_state, - ) - mock_analyst.run.assert_called_once_with("Find ANR", mock_state) - assert result == "Log scan complete." - - -@pytest.mark.asyncio -async def test_analyze_logs_tool_no_context(): - result = await analyze_logs( - ctx=None, - specific_query="Find crash", - ) - assert result == "Error: ArtemisContext is required for LogAnalyzer." - - -def test_analyze_logs_tool_subclass(): - """Verify AnalyzeLogsTool is an ArtemisTool subclass.""" - assert issubclass(AnalyzeLogsTool, ArtemisTool) - assert issubclass(AnalyzeLogs, ArtemisTool) - assert issubclass(LogTool, ArtemisTool) - assert isinstance(analyze_logs, ArtemisTool) - assert isinstance(analyze_logs, AnalyzeLogsTool) - - assert analyze_logs.name == "analyze_logs" - assert analyze_logs.category == "custom" - assert analyze_logs.args_schema == AnalyzeLogsArgs - - # GenAI FunctionDeclaration export - declaration = analyze_logs.to_genai_declaration() - assert declaration.name == "analyze_logs" - assert "specific_query" in declaration.parameters.properties - - # Wrapper check - assert analyze_logs_wrapper is not None - assert analyze_logs_wrapper.tool_fn_getter == get_analyze_logs_tool diff --git a/tests/unit/tools/test_log_utils.py b/tests/unit/tools/test_log_utils.py deleted file mode 100644 index eafc35050..000000000 --- a/tests/unit/tools/test_log_utils.py +++ /dev/null @@ -1,116 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from datetime import datetime -from unittest.mock import Mock, patch -from artemis.context import ArtemisContext -from artemis.tools.mobile.log_utils import fetch_and_filter_logs -import pytest - - -@pytest.fixture -def mock_context(): - ctx = Mock(spec=ArtemisContext) - ctx.data_engine = Mock() - ctx.data_engine.session_start_time = 1700000000.0 # Example timestamp - return ctx - - -@pytest.fixture -def mock_device(): - return Mock() - - -@patch("artemis.tools.mobile.log_utils.get_adb_device") -def test_fetch_and_filter_logs_basic(mock_get_adb_device, mock_context, mock_device): - mock_get_adb_device.return_value = mock_device - mock_device.shell.return_value = "line 1\nline 2\n" - - result = fetch_and_filter_logs(mock_context, lines=10) - - mock_device.shell.assert_called_once_with("logcat -v threadtime -t 10") - assert result == "line 1\nline 2\n" - - -@patch("artemis.tools.mobile.log_utils.get_adb_device") -@patch("artemis.tools.mobile.log_utils.resolve_time") -def test_fetch_and_filter_logs_since_time_success( - mock_resolve_time, mock_get_adb_device, mock_context, mock_device -): - mock_get_adb_device.return_value = mock_device - since_dt = datetime(datetime.now().year, 5, 3, 11, 11, 11, 111000) - mock_resolve_time.side_effect = [ - ("05-03 11:11:11.111", since_dt), # for since_time - (None, None), # for until_time - ] - - mock_device.shell.return_value = "05-03 11:11:12.000 line 1\n05-03 11:11:13.000 line 2\n" - - result = fetch_and_filter_logs(mock_context, since_time="05-03 11:11:11.111") - - mock_device.shell.assert_called_once_with('logcat -v threadtime -t "05-03 11:11:11.111"') - assert "line 1" in result - assert "line 2" in result - - -@patch("artemis.tools.mobile.log_utils.get_adb_device") -@patch("artemis.tools.mobile.log_utils.resolve_time") -def test_fetch_and_filter_logs_since_time_fallback( - mock_resolve_time, mock_get_adb_device, mock_context, mock_device -): - mock_get_adb_device.return_value = mock_device - current_year = datetime.now().year - since_dt = datetime(current_year, 5, 4, 12, 0, 0) - mock_resolve_time.side_effect = [ - ("05-04 12:00:00.000", since_dt), # for since_time - (None, None), # for until_time - ] - - # Direct fetch fails - mock_device.shell.side_effect = [ - Exception("logcat failed"), # first call to direct fetch - "05-04 11:59:00.000 line old\n05-04 12:00:01.000 line new\n", # second call (fallback) - ] - - result = fetch_and_filter_logs(mock_context, lines=100, since_time="05-04 12:00:00.000") - - assert mock_device.shell.call_count == 2 - mock_device.shell.assert_any_call('logcat -v threadtime -t "05-04 12:00:00.000"') - mock_device.shell.assert_any_call("logcat -v threadtime -t 100") - - # Should filter out "line old" because it's before since_dt - assert "line old" not in result - assert "line new" in result - - -@patch("artemis.tools.mobile.log_utils.get_adb_device") -@patch("artemis.tools.mobile.log_utils.resolve_time") -def test_fetch_and_filter_logs_until_time( - mock_resolve_time, mock_get_adb_device, mock_context, mock_device -): - mock_get_adb_device.return_value = mock_device - current_year = datetime.now().year - until_dt = datetime(current_year, 5, 4, 12, 0, 0) - mock_resolve_time.side_effect = [ - (None, None), # for since_time - ("05-04 12:00:00.000", until_dt), # for until_time - ] - - mock_device.shell.return_value = "05-04 11:59:00.000 line old\n05-04 12:00:01.000 line new\n" - - result = fetch_and_filter_logs(mock_context, lines=100, until_time="05-04 12:00:00.000") - - mock_device.shell.assert_called_once_with("logcat -v threadtime -t 100") - assert "line old" in result - assert "line new" not in result diff --git a/tests/unit/tools/test_object_detection_tool.py b/tests/unit/tools/test_object_detection_tool.py deleted file mode 100644 index 57b6fa303..000000000 --- a/tests/unit/tools/test_object_detection_tool.py +++ /dev/null @@ -1,188 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for ObjectDetectionTool and OperatorObjectDetectionTool.""" - -import json -from unittest.mock import AsyncMock, MagicMock, patch - -from langchain_core.messages import ToolMessage - -from artemis.context import ArtemisContext -from artemis.graph.state import State -from artemis.tools.base import ArtemisTool -from artemis.tools.object_detection_tool import ( - ObjectDetection, - ObjectDetectionArgs, - ObjectDetectionTool, - ObjectDetectorTool, - OperatorObjectDetection, - OperatorObjectDetectionArgs, - OperatorObjectDetectionTool, - OperatorObjectDetectorTool, - get_object_detector_tool, - get_operator_object_detector_tool, - object_detection, - object_detection_wrapper, - operator_object_detection, - operator_object_detection_wrapper, -) -import pytest - - -def test_object_detection_tool_subclass(): - """Verify ObjectDetectionTool and OperatorObjectDetectionTool subclass ArtemisTool and register.""" - assert issubclass(ObjectDetectionTool, ArtemisTool) - assert issubclass(ObjectDetection, ArtemisTool) - assert issubclass(ObjectDetectorTool, ArtemisTool) - assert issubclass(OperatorObjectDetectionTool, ArtemisTool) - assert issubclass(OperatorObjectDetectionTool, ObjectDetectionTool) - assert issubclass(OperatorObjectDetection, ArtemisTool) - assert issubclass(OperatorObjectDetectorTool, ArtemisTool) - - assert isinstance(object_detection, ArtemisTool) - assert isinstance(object_detection, ObjectDetectionTool) - assert isinstance(operator_object_detection, ArtemisTool) - assert isinstance(operator_object_detection, OperatorObjectDetectionTool) - - # Properties - assert object_detection.name == "object_detection" - assert object_detection.category == "perception" - assert object_detection.args_schema == ObjectDetectionArgs - - assert operator_object_detection.name == "object_detection" - assert operator_object_detection.category == "perception" - assert operator_object_detection.args_schema == OperatorObjectDetectionArgs - - # GenAI FunctionDeclaration export - declaration = object_detection.to_genai_declaration() - assert declaration.name == "object_detection" - assert "image_path" in declaration.parameters.properties - assert "queries" in declaration.parameters.properties - - op_declaration = operator_object_detection.to_genai_declaration() - assert op_declaration.name == "object_detection" - assert "queries" in op_declaration.parameters.properties - - # Wrappers - assert object_detection_wrapper.tool_fn_getter == get_object_detector_tool - assert operator_object_detection_wrapper.tool_fn_getter == get_operator_object_detector_tool - - -@pytest.mark.asyncio -async def test_object_detection_direct_execution_success(): - """Verify direct async call to object_detection with image_path.""" - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = None - - expected_result = { - "detected": [{"label": "button", "point": [100, 200]}], - "failed": [], - } - - with patch( - "artemis.tools.object_detection_tool._run_object_detection", - AsyncMock(return_value=expected_result), - ) as mock_run: - result = await object_detection( - ctx=mock_ctx, - image_path="/path/to/screenshot.jpg", - queries=["button"], - ) - - mock_run.assert_called_once() - assert json.loads(result) == expected_result - - -@pytest.mark.asyncio -async def test_operator_object_detection_direct_execution_with_state(): - """Verify operator_object_detection reads screenshot from state and returns a ToolMessage.""" - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = None - - mock_state = MagicMock(spec=State) - mock_state.latest_screenshot = "/path/to/state_screen.jpg" - - expected_result = { - "detected": [{"label": "search bar", "point": [500, 100]}], - "failed": [], - } - - with patch( - "artemis.tools.object_detection_tool._run_object_detection", - AsyncMock(return_value=expected_result), - ) as mock_run: - result = await operator_object_detection( - ctx=mock_ctx, - state=mock_state, - queries=["search bar"], - ) - - mock_run.assert_called_once() - # Verify it used the latest_screenshot from state - call_args = mock_run.call_args[0] - assert call_args[1] == "/path/to/state_screen.jpg" - - # With state, the tool returns a ToolMessage directly - assert isinstance(result, ToolMessage) - assert result.status == "success" - assert "Object Detection successful." in result.content - json_payload = result.content.split("Results:\n", 1)[1] - assert json.loads(json_payload) == expected_result - - -@pytest.mark.asyncio -async def test_object_detection_no_image_error(): - """Verify error when no image path is given and no state screenshot is available.""" - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = None - - result = await object_detection( - ctx=mock_ctx, - image_path=None, - queries=["icon"], - ) - assert "Object Detection failed" in result - assert "No image path provided" in result - - -@pytest.mark.asyncio -async def test_object_detection_exception_handling(): - """Verify graceful handling when _run_object_detection raises an exception.""" - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = None - - with patch( - "artemis.tools.object_detection_tool._run_object_detection", - AsyncMock(side_effect=RuntimeError("Model inference timeout")), - ): - result = await object_detection( - ctx=mock_ctx, - image_path="/path/to/screen.jpg", - queries=["icon"], - ) - assert "Object Detection failed: Model inference timeout" in result - - -def test_get_langchain_tools(): - """Verify LangChain BaseTool exports.""" - mock_ctx = MagicMock(spec=ArtemisContext) - tool = get_object_detector_tool(mock_ctx) - op_tool = get_operator_object_detector_tool(mock_ctx) - - assert tool.name == "object_detection" - assert tool.args_schema == ObjectDetectionArgs - - assert op_tool.name == "object_detection" - assert op_tool.args_schema == OperatorObjectDetectionArgs diff --git a/tests/unit/tools/test_object_detection_trace.py b/tests/unit/tools/test_object_detection_trace.py deleted file mode 100644 index ec9ac06a5..000000000 --- a/tests/unit/tools/test_object_detection_trace.py +++ /dev/null @@ -1,69 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import AsyncMock, MagicMock, patch - -from artemis.context import ArtemisContext -from artemis.graph.state import State -from artemis.tools.object_detection_tool import get_operator_object_detector_tool -import pytest - - -@pytest.mark.asyncio -async def test_object_detection_trace(): - mock_ctx = MagicMock(spec=ArtemisContext) - mock_ctx.data_engine = MagicMock() - - mock_state = MagicMock(spec=State) - mock_state.latest_screenshot = "/path/to/screenshot.jpg" - - # We need to mock the decorator in object_detection_tool.py if we want to avoid it calling real get_llm - # Or we can just mock get_llm. - # In the tool definition: @trace(type="tool", ctx=ctx) - # This calls record_trace immediately on entry/exit. - - mock_tool = get_operator_object_detector_tool(mock_ctx) - - # Mock the internal function called by the tool - with patch( - "artemis.tools.object_detection_tool._run_object_detection", - AsyncMock(return_value="{}"), - ) as mock_run: - # We need to handle the fact that ainvoke might fail if LangChain internals are not fully mocked. - # But let's try it. - - await mock_tool.ainvoke( - { - "type": "tool_call", - "name": "object_detection", - "id": "call_123", - "args": {"queries": ["test_label"], "state": mock_state}, - } - ) - - # Verify _run_object_detection was called - mock_run.assert_called_once() - - # Verify record_trace was called for the span - assert mock_ctx.data_engine.record_trace.call_count >= 1 - - calls = mock_ctx.data_engine.record_trace.call_args_list - span_called = False - for call in calls: - kwargs = call.kwargs - if kwargs.get("type") == "span" and kwargs.get("name") == "run_object_detection": - span_called = True - break - - assert span_called, "TraceSpan 'run_object_detection' was not called" diff --git a/tests/unit/tools/test_ocr_tool.py b/tests/unit/tools/test_ocr_tool.py deleted file mode 100644 index 720ab263b..000000000 --- a/tests/unit/tools/test_ocr_tool.py +++ /dev/null @@ -1,224 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import base64 -from unittest.mock import AsyncMock, MagicMock, patch - -from artemis.context import ArtemisContext -from artemis.drivers.base import BaseDeviceDriver -from artemis.graph.state import State -from artemis.tools.base import ArtemisTool -from artemis.tools.mobile.ocr import ( - OCRRecognition, - OcrArgs, - OcrRecognition, - OcrRecognitionTool, - OcrTool, - get_ocr_tool, - ocr_recognition, - ocr_recognition_wrapper, -) -from langchain_core.messages import ToolMessage -import pytest - - -@pytest.fixture -def mock_ctx(): - ctx = MagicMock(spec=ArtemisContext) - ctx.device = MagicMock() - ctx.device.device_width = 1080 - ctx.device.device_height = 2400 - return ctx - - -@pytest.fixture -def mock_driver(): - driver = MagicMock(spec=BaseDeviceDriver) - driver.screen_size = (1080, 2400) - screen_data = MagicMock() - screen_data.screenshot_base64 = base64.b64encode(b"dummy_image_data").decode("utf-8") - screen_data.screenshot_bytes = b"dummy_image_data" - driver.get_screen_data = AsyncMock(return_value=screen_data) - return driver - - -@pytest.fixture -def mock_state(tmp_path): - screenshot_file = tmp_path / "test_screenshot.png" - screenshot_file.write_bytes(b"dummy_image_content") - - state = MagicMock(spec=State) - state.latest_screenshot = str(screenshot_file) - return state - - -def test_ocr_tool_subclass(): - """Verify OcrRecognitionTool is a subclass of ArtemisTool.""" - assert issubclass(OcrRecognitionTool, ArtemisTool) - assert issubclass(OcrRecognition, ArtemisTool) - assert issubclass(OCRRecognition, ArtemisTool) - assert issubclass(OcrTool, ArtemisTool) - assert isinstance(ocr_recognition, ArtemisTool) - assert isinstance(ocr_recognition, OcrRecognitionTool) - - assert ocr_recognition.name == "ocr_recognition" - assert ocr_recognition.category == "explorer" - assert ocr_recognition.args_schema == OcrArgs - - # GenAI FunctionDeclaration export - declaration = ocr_recognition.to_genai_declaration() - assert declaration.name == "ocr_recognition" - - # Wrapper check - assert ocr_recognition_wrapper is not None - assert ocr_recognition_wrapper.tool_fn_getter == get_ocr_tool - - -class SimpleState: - def __init__(self, screenshot_path: str): - self.latest_screenshot = screenshot_path - - -@pytest.mark.asyncio -async def test_ocr_direct_execution_with_state(mock_ctx, tmp_path): - """Verify direct execution with state and screenshot file.""" - screenshot_file = tmp_path / "test_screenshot.png" - screenshot_file.write_bytes(b"dummy_image_content") - simple_state = SimpleState(str(screenshot_file)) - - mock_results = [ - { - "text": "Submit", - "position": [ - {"x": 100, "y": 200}, - {"x": 200, "y": 200}, - {"x": 200, "y": 300}, - {"x": 100, "y": 300}, - ], - } - ] - with patch( - "artemis.tools.mobile.ocr.perform_ocr", - new_callable=AsyncMock, - return_value=mock_results, - ): - result = await ocr_recognition.execute(ctx=mock_ctx, state=simple_state) - assert isinstance(result, ToolMessage) - assert "OCR Recognition successful." in result.content - assert "Submit" in result.content - - -@pytest.mark.asyncio -async def test_ocr_direct_execution_with_driver(mock_ctx, mock_driver): - """Verify direct execution with driver.""" - mock_results = [ - { - "text": "Settings", - "position": [ - {"x": 500, "y": 500}, - {"x": 600, "y": 600}, - ], - } - ] - with patch( - "artemis.tools.mobile.ocr.perform_ocr", - new_callable=AsyncMock, - return_value=mock_results, - ): - result = await ocr_recognition.execute(ctx=mock_ctx, driver=mock_driver) - assert "OCR Recognition successful." in result - assert "Settings" in result - - -@pytest.mark.asyncio -async def test_ocr_no_text_detected(mock_ctx, tmp_path): - """Verify outcome when no text is detected.""" - screenshot_file = tmp_path / "test_empty_screenshot.png" - screenshot_file.write_bytes(b"dummy_empty_content") - simple_state = SimpleState(str(screenshot_file)) - - with patch( - "artemis.tools.mobile.ocr.perform_ocr", - new_callable=AsyncMock, - return_value=[], - ): - result = await ocr_recognition.execute(ctx=mock_ctx, state=simple_state) - assert isinstance(result, ToolMessage) - assert "No text detected on the screen." in result.content - - -@pytest.mark.asyncio -async def test_ocr_with_state_command(mock_ctx, mock_state): - """Verify OcrRecognitionTool returns ToolMessage when state is provided.""" - mock_results = [{"text": "Hello", "position": [{"x": 100, "y": 100}]}] - with patch( - "artemis.tools.mobile.ocr.perform_ocr", - new_callable=AsyncMock, - return_value=mock_results, - ): - cmd = await ocr_recognition.execute( - ctx=mock_ctx, - tool_call_id="call_ocr_1", - state=mock_state, - ) - assert isinstance(cmd, ToolMessage) - assert cmd.tool_call_id == "call_ocr_1" - assert cmd.status == "success" - assert "Hello" in cmd.content - - -@pytest.mark.asyncio -async def test_ocr_failure_missing_screenshot(mock_ctx): - """Verify error handling when screenshot path is missing.""" - empty_state = MagicMock(spec=State) - empty_state.latest_screenshot = None - - cmd = await ocr_recognition.execute( - ctx=mock_ctx, - tool_call_id="call_ocr_err", - state=empty_state, - ) - assert isinstance(cmd, ToolMessage) - assert cmd.status == "error" - assert "OCR Recognition failed" in cmd.content - - -@pytest.mark.asyncio -async def test_ocr_callable_execution(mock_ctx, mock_driver): - """Verify invoking ocr_recognition directly as a callable.""" - with patch( - "artemis.tools.mobile.ocr.perform_ocr", - new_callable=AsyncMock, - return_value=[{"text": "Callable", "position": []}], - ): - result = await ocr_recognition(ctx=mock_ctx, driver=mock_driver) - assert "OCR Recognition successful." in result - assert "Callable" in result - - -@pytest.mark.asyncio -async def test_get_ocr_tool_langchain_ainvoke(mock_ctx, mock_driver): - """Verify get_ocr_tool exports a LangChain tool that works with ainvoke.""" - mock_ctx._active_driver = mock_driver - ocr_tool = get_ocr_tool(mock_ctx) - assert ocr_tool.name == "ocr_recognition" - - with patch( - "artemis.tools.mobile.ocr.perform_ocr", - new_callable=AsyncMock, - return_value=[{"text": "LangChain", "position": []}], - ): - result = await ocr_tool.ainvoke({}) - assert "OCR Recognition successful." in result - assert "LangChain" in result diff --git a/tests/unit/tools/test_read_hierarchy_tool.py b/tests/unit/tools/test_read_hierarchy_tool.py deleted file mode 100644 index 03e639e7f..000000000 --- a/tests/unit/tools/test_read_hierarchy_tool.py +++ /dev/null @@ -1,140 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import AsyncMock, MagicMock - -from artemis.context import ArtemisContext -from artemis.drivers.base import BaseDeviceDriver -from artemis.tools.base import ArtemisTool -from artemis.tools.mobile.read_hierarchy import ( - GetUIHierarchy, - GetUiHierarchy, - GetUiHierarchyArgs, - GetUiHierarchyTool, - get_ui_hierarchy, - get_ui_hierarchy_tool, - ui_hierarchy_wrapper, -) -import pytest - - -@pytest.fixture -def mock_ctx(): - ctx = MagicMock(spec=ArtemisContext) - ctx.ui_adb_client = MagicMock() - ctx.ui_adb_client.get_hierarchy.return_value = "" - return ctx - - -@pytest.fixture -def mock_driver(): - driver = MagicMock(spec=BaseDeviceDriver) - screen_data = MagicMock() - screen_data.ui_hierarchy_xml = "" - driver.get_screen_data = AsyncMock(return_value=screen_data) - return driver - - -def test_get_ui_hierarchy_tool_subclass(): - """Verify GetUiHierarchyTool is a subclass of ArtemisTool.""" - assert issubclass(GetUiHierarchyTool, ArtemisTool) - assert issubclass(GetUiHierarchy, ArtemisTool) - assert issubclass(GetUIHierarchy, ArtemisTool) - assert isinstance(get_ui_hierarchy, ArtemisTool) - assert isinstance(get_ui_hierarchy, GetUiHierarchyTool) - - assert get_ui_hierarchy.name == "get_ui_hierarchy" - assert get_ui_hierarchy.category == "diagnostic" - assert get_ui_hierarchy.args_schema == GetUiHierarchyArgs - - # GenAI FunctionDeclaration export - declaration = get_ui_hierarchy.to_genai_declaration() - assert declaration.name == "get_ui_hierarchy" - - # Wrapper check - assert ui_hierarchy_wrapper is not None - assert ui_hierarchy_wrapper.tool_fn_getter == get_ui_hierarchy_tool - - -@pytest.mark.asyncio -async def test_get_ui_hierarchy_direct_execution_with_ctx(mock_ctx): - """Verify direct execution with ArtemisContext.""" - result = await get_ui_hierarchy.execute(ctx=mock_ctx) - mock_ctx.ui_adb_client.get_hierarchy.assert_called_once() - assert "" in result - - -@pytest.mark.asyncio -async def test_get_ui_hierarchy_direct_execution_with_driver_screen_data(mock_driver): - """Verify direct execution with driver's get_screen_data.""" - result = await get_ui_hierarchy.execute(driver=mock_driver) - mock_driver.get_screen_data.assert_called_once() - assert "" in result - - -@pytest.mark.asyncio -async def test_get_ui_hierarchy_direct_execution_with_driver_get_hierarchy(): - """Verify direct execution with driver's get_ui_hierarchy.""" - driver = MagicMock(spec=BaseDeviceDriver) - del driver.get_screen_data - driver.get_ui_hierarchy = AsyncMock( - return_value="" - ) - result = await get_ui_hierarchy.execute(driver=driver) - driver.get_ui_hierarchy.assert_called_once() - assert "" in result - - -@pytest.mark.asyncio -async def test_get_ui_hierarchy_with_state(mock_ctx): - """Verify GetUiHierarchyTool returns string when state is provided.""" - state = MagicMock() - result = await get_ui_hierarchy.execute( - ctx=mock_ctx, - tool_call_id="call_hier_1", - state=state, - ) - assert "" in result - - -@pytest.mark.asyncio -async def test_get_ui_hierarchy_no_client_or_driver(): - """Verify error handling when no client or driver is provided.""" - result = await get_ui_hierarchy.execute() - assert "Error retrieving UI hierarchy: No UI Automator client or driver provided." in result - - -@pytest.mark.asyncio -async def test_get_ui_hierarchy_exception_handling(mock_ctx): - """Verify error handling when ui_adb_client raises an exception.""" - mock_ctx.ui_adb_client.get_hierarchy.side_effect = RuntimeError("ADB server died") - result = await get_ui_hierarchy.execute(ctx=mock_ctx) - assert "Error retrieving UI hierarchy: ADB server died" in result - - -@pytest.mark.asyncio -async def test_get_ui_hierarchy_callable_execution(mock_ctx): - """Verify invoking get_ui_hierarchy directly as a callable.""" - result = await get_ui_hierarchy(ctx=mock_ctx) - assert "" in result - - -@pytest.mark.asyncio -async def test_get_ui_hierarchy_tool_langchain_ainvoke(mock_ctx): - """Verify get_ui_hierarchy_tool exports a LangChain tool that works with ainvoke.""" - tool = get_ui_hierarchy_tool(mock_ctx) - assert tool.name == "get_ui_hierarchy" - - result = await tool.ainvoke({}) - assert "" in result diff --git a/tests/unit/tools/test_read_logs_tool.py b/tests/unit/tools/test_read_logs_tool.py deleted file mode 100644 index b3ac417e3..000000000 --- a/tests/unit/tools/test_read_logs_tool.py +++ /dev/null @@ -1,135 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import MagicMock, patch - -from artemis.context import ArtemisContext -from artemis.tools.base import ArtemisTool -from artemis.tools.mobile.read_logs import ( - ReadLogs, - ReadLogsArgs, - ReadLogsTool, - ReadLogsToolAlias, - get_read_logs_tool, - read_logs, -) -import pytest - - -@pytest.fixture -def mock_ctx(): - ctx = MagicMock(spec=ArtemisContext) - return ctx - - -def test_read_logs_tool_subclass(): - """Verify ReadLogsTool is a subclass of ArtemisTool.""" - assert issubclass(ReadLogsTool, ArtemisTool) - assert issubclass(ReadLogs, ArtemisTool) - assert issubclass(ReadLogsToolAlias, ArtemisTool) - assert isinstance(read_logs, ArtemisTool) - assert isinstance(read_logs, ReadLogsTool) - - assert read_logs.name == "read_logs" - assert read_logs.category == "diagnostic" - assert read_logs.args_schema == ReadLogsArgs - - # GenAI FunctionDeclaration export - declaration = read_logs.to_genai_declaration() - assert declaration.name == "read_logs" - assert "lines" in declaration.parameters.properties - assert "since_time" in declaration.parameters.properties - assert "until_time" in declaration.parameters.properties - - -@pytest.mark.asyncio -async def test_read_logs_direct_execution(mock_ctx): - """Verify direct execution of ReadLogsTool.""" - mock_log_output = "08-12 10:00:00.000 1000 1000 I Test: Hello Logs\n" - with patch( - "artemis.tools.mobile.read_logs.fetch_and_filter_logs", - return_value=mock_log_output, - ) as mock_fetch: - result = await read_logs.execute( - ctx=mock_ctx, - lines=100, - since_time="08-12 09:59:00", - until_time="08-12 10:01:00", - ) - mock_fetch.assert_called_once_with( - ctx=mock_ctx, - lines=100, - since_time="08-12 09:59:00", - until_time="08-12 10:01:00", - ) - assert result == mock_log_output - - -@pytest.mark.asyncio -async def test_read_logs_default_lines(mock_ctx): - """Verify read_logs defaults lines to 200 when None.""" - with patch( - "artemis.tools.mobile.read_logs.fetch_and_filter_logs", - return_value="logs", - ) as mock_fetch: - result = await read_logs.execute(ctx=mock_ctx, lines=None) - mock_fetch.assert_called_once_with( - ctx=mock_ctx, - lines=200, - since_time=None, - until_time=None, - ) - assert result == "logs" - - -@pytest.mark.asyncio -async def test_read_logs_exception_handling(mock_ctx): - """Verify error handling when fetching logs raises an exception.""" - with patch( - "artemis.tools.mobile.read_logs.fetch_and_filter_logs", - side_effect=RuntimeError("Device disconnected"), - ): - result = await read_logs.execute(ctx=mock_ctx) - assert "Failed to read logs: Device disconnected" in result - - -@pytest.mark.asyncio -async def test_read_logs_callable_execution(mock_ctx): - """Verify invoking read_logs directly as a callable.""" - with patch( - "artemis.tools.mobile.read_logs.fetch_and_filter_logs", - return_value="sample log stream", - ): - result = await read_logs(ctx=mock_ctx, lines=50) - assert result == "sample log stream" - - -@pytest.mark.asyncio -async def test_get_read_logs_tool_langchain_ainvoke(mock_ctx): - """Verify get_read_logs_tool exports a LangChain tool that works with ainvoke.""" - tool = get_read_logs_tool(mock_ctx) - assert tool.name == "read_logs" - - with patch( - "artemis.tools.mobile.read_logs.fetch_and_filter_logs", - return_value="langchain log content", - ) as mock_fetch: - result = await tool.ainvoke({"lines": 300}) - mock_fetch.assert_called_once_with( - ctx=mock_ctx, - lines=300, - since_time=None, - until_time=None, - ) - assert result == "langchain log content" diff --git a/tests/unit/tools/test_read_note_pure_tool.py b/tests/unit/tools/test_read_note_pure_tool.py deleted file mode 100644 index 590b38109..000000000 --- a/tests/unit/tools/test_read_note_pure_tool.py +++ /dev/null @@ -1,122 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import MagicMock, patch - -from artemis.context import ArtemisContext -from artemis.tools.base import ArtemisTool -from artemis.tools.scratchpad import ( - ReadNoteArgs, - ReadNotePure, - ReadNotePureTool, - get_read_note_tool_pure, - read_note_pure, -) -from artemis.utils.notes import save_note_content -import pytest - - -@pytest.fixture -def mock_ctx(tmp_path): - ctx = MagicMock(spec=ArtemisContext) - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = str(tmp_path) - return ctx - - -def test_read_note_pure_tool_subclass(): - """Verify ReadNotePureTool is a subclass of ArtemisTool.""" - assert issubclass(ReadNotePureTool, ArtemisTool) - assert issubclass(ReadNotePure, ArtemisTool) - assert isinstance(read_note_pure, ArtemisTool) - assert isinstance(read_note_pure, ReadNotePureTool) - - assert read_note_pure.name == "read_note_pure" - assert read_note_pure.category == "memory" - assert read_note_pure.args_schema == ReadNoteArgs - - # GenAI FunctionDeclaration export - declaration = read_note_pure.to_genai_declaration() - assert declaration.name == "read_note_pure" - assert "key" in declaration.parameters.properties - assert "start_line" in declaration.parameters.properties - assert "end_line" in declaration.parameters.properties - - -@pytest.mark.asyncio -async def test_read_note_pure_direct_execution_success(mock_ctx, tmp_path): - """Verify direct execution of ReadNotePureTool.execute reads saved note.""" - save_note_content(tmp_path, "sample_pure", "line 1\nline 2\nline 3") - - result = await read_note_pure.execute(ctx=mock_ctx, key="sample_pure") - assert "Note 'sample_pure' (" in result - assert "line 1\nline 2\nline 3" in result - - -@pytest.mark.asyncio -async def test_read_note_pure_slice(mock_ctx, tmp_path): - """Verify reading a line slice with start_line and end_line via ReadNotePureTool.""" - save_note_content(tmp_path, "slice_pure", "line 1\nline 2\nline 3\nline 4") - - result = await read_note_pure.execute( - ctx=mock_ctx, - key="slice_pure", - start_line=2, - end_line=3, - ) - assert "Note 'slice_pure' (lines 2 to 3):" in result - assert "line 1" not in result - assert "line 2\nline 3" in result - assert "line 4" not in result - - -@pytest.mark.asyncio -async def test_read_note_pure_callable_execution(mock_ctx, tmp_path): - """Verify invoking read_note_pure directly as a callable.""" - save_note_content(tmp_path, "callable_pure", "hello from pure callable") - - result = await read_note_pure(ctx=mock_ctx, key="callable_pure") - assert "Note 'callable_pure' (" in result - assert "hello from pure callable" in result - - -@pytest.mark.asyncio -async def test_read_note_pure_not_found(mock_ctx): - """Verify error handling when note is missing.""" - result = await read_note_pure.execute(ctx=mock_ctx, key="nonexistent_pure") - assert "Note 'nonexistent_pure' not found" in result - - -@pytest.mark.asyncio -async def test_read_note_pure_other_error(mock_ctx): - """Verify error handling when read_note_content raises an exception.""" - with patch( - "artemis.tools.scratchpad.read_note_content", - side_effect=OSError("Disk error"), - ): - result = await read_note_pure.execute(ctx=mock_ctx, key="err_note") - assert "Failed to read note 'err_note': Disk error" in result - - -@pytest.mark.asyncio -async def test_get_read_note_tool_pure_langchain_ainvoke(mock_ctx, tmp_path): - """Verify get_read_note_tool_pure exports a LangChain tool named 'read_note'.""" - save_note_content(tmp_path, "pure_export", "exported pure note") - - pure_tool = get_read_note_tool_pure(mock_ctx) - assert pure_tool.name == "read_note" - - result = await pure_tool.ainvoke({"key": "pure_export"}) - assert "Note 'pure_export' (" in result - assert "exported pure note" in result diff --git a/tests/unit/tools/test_read_note_tool.py b/tests/unit/tools/test_read_note_tool.py deleted file mode 100644 index 34bec04ca..000000000 --- a/tests/unit/tools/test_read_note_tool.py +++ /dev/null @@ -1,164 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import MagicMock, patch - -from artemis.context import ArtemisContext -from artemis.graph.state import State -from artemis.tools.base import ArtemisTool -from artemis.tools.scratchpad import ( - ReadNote, - ReadNoteArgs, - ReadNoteTool, - get_read_note_tool, - get_read_note_tool_pure, - read_note, - read_note_wrapper, -) -from artemis.utils.notes import save_note_content -from langchain_core.messages import ToolMessage -import pytest - - -@pytest.fixture -def mock_ctx(tmp_path): - ctx = MagicMock(spec=ArtemisContext) - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = str(tmp_path) - return ctx - - -def test_read_note_tool_subclass(): - """Verify ReadNoteTool is a subclass of ArtemisTool.""" - assert issubclass(ReadNoteTool, ArtemisTool) - assert issubclass(ReadNote, ArtemisTool) - assert isinstance(read_note, ArtemisTool) - assert isinstance(read_note, ReadNoteTool) - - assert read_note.name == "read_note" - assert read_note.category == "memory" - assert read_note.args_schema == ReadNoteArgs - - # GenAI FunctionDeclaration export - declaration = read_note.to_genai_declaration() - assert declaration.name == "read_note" - assert "key" in declaration.parameters.properties - assert "start_line" in declaration.parameters.properties - assert "end_line" in declaration.parameters.properties - - # Wrapper check - assert read_note_wrapper is not None - assert read_note_wrapper.tool_fn_getter == get_read_note_tool - - -@pytest.mark.asyncio -async def test_read_note_direct_execution_success(mock_ctx, tmp_path): - """Verify direct execution of ReadNoteTool.execute reads saved note.""" - save_note_content(tmp_path, "sample", "line 1\nline 2\nline 3\nline 4\nline 5") - - result = await read_note.execute(ctx=mock_ctx, key="sample") - assert "Note 'sample' (" in result - assert "line 1\nline 2\nline 3\nline 4\nline 5" in result - - -@pytest.mark.asyncio -async def test_read_note_slice(mock_ctx, tmp_path): - """Verify reading a line slice with start_line and end_line.""" - save_note_content(tmp_path, "sample", "line 1\nline 2\nline 3\nline 4\nline 5") - - result = await read_note.execute( - ctx=mock_ctx, - key="sample", - start_line=2, - end_line=4, - ) - assert "Note 'sample' (lines 2 to 4):" in result - assert "line 1" not in result - assert "line 2\nline 3\nline 4" in result - assert "line 5" not in result - - -@pytest.mark.asyncio -async def test_read_note_callable_execution(mock_ctx, tmp_path): - """Verify invoking read_note directly as a callable.""" - save_note_content(tmp_path, "callable_sample", "hello from callable") - - result = await read_note(ctx=mock_ctx, key="callable_sample") - assert "Note 'callable_sample' (" in result - assert "hello from callable" in result - - -@pytest.mark.asyncio -async def test_read_note_with_state_tool_message(mock_ctx, tmp_path): - """Verify ReadNoteTool.execute with state returns a ToolMessage directly.""" - save_note_content(tmp_path, "state_sample", "state note content") - - mock_state = MagicMock(spec=State) - - result = await read_note.execute( - ctx=mock_ctx, - key="state_sample", - state=mock_state, - tool_call_id="call_read_999", - ) - - assert isinstance(result, ToolMessage) - assert result.tool_call_id == "call_read_999" - assert "Note 'state_sample' (" in result.content - assert "state note content" in result.content - assert result.status == "success" - - -@pytest.mark.asyncio -async def test_read_note_not_found(mock_ctx): - """Verify error handling when a note is not found.""" - result = await read_note.execute(ctx=mock_ctx, key="nonexistent") - assert "Note 'nonexistent' not found" in result - - -@pytest.mark.asyncio -async def test_read_note_other_error(mock_ctx): - """Verify error handling for unexpected exceptions.""" - with patch( - "artemis.tools.scratchpad.read_note_content", - side_effect=ValueError("Corrupted data"), - ): - result = await read_note.execute(ctx=mock_ctx, key="bad_note") - assert "Failed to read note 'bad_note': Corrupted data" in result - - -@pytest.mark.asyncio -async def test_get_read_note_tool_langchain_ainvoke(mock_ctx, tmp_path): - """Verify get_read_note_tool exports a LangChain tool that works with ainvoke.""" - save_note_content(tmp_path, "lc_sample", "langchain content") - - lc_tool = get_read_note_tool(mock_ctx) - assert lc_tool.name == "read_note" - - result = await lc_tool.ainvoke({"key": "lc_sample"}) - assert "Note 'lc_sample' (" in result - assert "langchain content" in result - - -@pytest.mark.asyncio -async def test_get_read_note_tool_pure(mock_ctx, tmp_path): - """Verify get_read_note_tool_pure returns a pure LangChain tool.""" - save_note_content(tmp_path, "pure_sample", "pure content") - - pure_tool = get_read_note_tool_pure(mock_ctx) - assert pure_tool.name == "read_note" - - result = await pure_tool.ainvoke({"key": "pure_sample"}) - assert "Note 'pure_sample' (" in result - assert "pure content" in result diff --git a/tests/unit/tools/test_save_note_pure_tool.py b/tests/unit/tools/test_save_note_pure_tool.py deleted file mode 100644 index 51d6a918c..000000000 --- a/tests/unit/tools/test_save_note_pure_tool.py +++ /dev/null @@ -1,117 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from pathlib import Path -from unittest.mock import MagicMock, patch - -from artemis.context import ArtemisContext -from artemis.tools.base import ArtemisTool -from artemis.tools.scratchpad import ( - SaveNoteArgs, - SaveNotePure, - SaveNotePureTool, - get_save_note_tool_pure, - save_note_pure, -) -import pytest - - -@pytest.fixture -def mock_ctx(tmp_path): - ctx = MagicMock(spec=ArtemisContext) - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = str(tmp_path) - return ctx - - -def test_save_note_pure_tool_subclass(): - """Verify SaveNotePureTool is a subclass of ArtemisTool.""" - assert issubclass(SaveNotePureTool, ArtemisTool) - assert issubclass(SaveNotePure, ArtemisTool) - assert isinstance(save_note_pure, ArtemisTool) - assert isinstance(save_note_pure, SaveNotePureTool) - - assert save_note_pure.name == "save_note_pure" - assert save_note_pure.category == "memory" - assert save_note_pure.args_schema == SaveNoteArgs - - # GenAI FunctionDeclaration export - declaration = save_note_pure.to_genai_declaration() - assert declaration.name == "save_note_pure" - assert "key" in declaration.parameters.properties - assert "content" in declaration.parameters.properties - - -@pytest.mark.asyncio -async def test_save_note_pure_direct_execution_success(mock_ctx, tmp_path): - """Verify direct execution of SaveNotePureTool.execute creates note file.""" - result = await save_note_pure.execute( - ctx=mock_ctx, - key="test_pure_save", - content="Pure saved content", - ) - assert result == "Saved note 'test_pure_save'." - - note_path = Path(tmp_path) / "notes" / "test_pure_save.md" - assert note_path.exists() - assert note_path.read_text(encoding="utf-8") == "Pure saved content" - - -@pytest.mark.asyncio -async def test_save_note_pure_callable_execution(mock_ctx, tmp_path): - """Verify invoking save_note_pure directly as a callable.""" - result = await save_note_pure( - ctx=mock_ctx, - key="callable_pure_save", - content="Callable pure content", - ) - assert result == "Saved note 'callable_pure_save'." - - note_path = Path(tmp_path) / "notes" / "callable_pure_save.md" - assert note_path.exists() - assert note_path.read_text(encoding="utf-8") == "Callable pure content" - - -@pytest.mark.asyncio -async def test_save_note_pure_execution_failure(mock_ctx): - """Verify error handling when save_note_content fails.""" - with patch( - "artemis.tools.scratchpad.save_note_content", - side_effect=PermissionError("Permission denied"), - ): - result = await save_note_pure.execute( - ctx=mock_ctx, - key="fail_save", - content="Some content", - ) - assert "Failed to save note fail_save.md: Permission denied" in result - - -@pytest.mark.asyncio -async def test_get_save_note_tool_pure_langchain_ainvoke(mock_ctx, tmp_path): - """Verify get_save_note_tool_pure exports a LangChain tool named 'save_note'.""" - pure_tool = get_save_note_tool_pure(mock_ctx) - assert pure_tool.name == "save_note" - - result = await pure_tool.ainvoke( - { - "key": "lc_pure_save", - "content": "Saved via LangChain pure tool", - } - ) - assert result == "Saved note 'lc_pure_save'." - - note_path = Path(tmp_path) / "notes" / "lc_pure_save.md" - assert note_path.exists() - assert note_path.read_text(encoding="utf-8") == "Saved via LangChain pure tool" diff --git a/tests/unit/tools/test_save_note_tool.py b/tests/unit/tools/test_save_note_tool.py deleted file mode 100644 index a20f0680d..000000000 --- a/tests/unit/tools/test_save_note_tool.py +++ /dev/null @@ -1,167 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from pathlib import Path -from unittest.mock import MagicMock, patch - -from artemis.context import ArtemisContext -from artemis.graph.state import State -from artemis.tools.base import ArtemisTool -from artemis.tools.scratchpad import ( - SaveNote, - SaveNoteArgs, - SaveNoteTool, - get_save_note_tool, - get_save_note_tool_pure, - save_note, - save_note_wrapper, -) -from langchain_core.messages import ToolMessage -import pytest - - -@pytest.fixture -def mock_ctx(tmp_path): - ctx = MagicMock(spec=ArtemisContext) - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = str(tmp_path) - return ctx - - -def test_save_note_tool_subclass(): - """Verify SaveNoteTool is a subclass of ArtemisTool.""" - assert issubclass(SaveNoteTool, ArtemisTool) - assert issubclass(SaveNote, ArtemisTool) - assert isinstance(save_note, ArtemisTool) - assert isinstance(save_note, SaveNoteTool) - - assert save_note.name == "save_note" - assert save_note.category == "memory" - assert save_note.args_schema == SaveNoteArgs - - # GenAI FunctionDeclaration export - declaration = save_note.to_genai_declaration() - assert declaration.name == "save_note" - assert "key" in declaration.parameters.properties - assert "content" in declaration.parameters.properties - - # Wrapper check - assert save_note_wrapper is not None - assert save_note_wrapper.tool_fn_getter == get_save_note_tool - - -@pytest.mark.asyncio -async def test_save_note_direct_execution_success(mock_ctx, tmp_path): - """Verify direct execution of SaveNoteTool.execute creates note file.""" - result = await save_note.execute( - ctx=mock_ctx, - key="test_note", - content="Hello Artemis memory", - ) - assert result == "Saved note 'test_note'." - - note_path = Path(tmp_path) / "notes" / "test_note.md" - assert note_path.exists() - assert note_path.read_text(encoding="utf-8") == "Hello Artemis memory" - - -@pytest.mark.asyncio -async def test_save_note_callable_execution(mock_ctx, tmp_path): - """Verify invoking save_note directly as a callable.""" - result = await save_note( - ctx=mock_ctx, - key="callable_note", - content="Callable invocation test", - ) - assert result == "Saved note 'callable_note'." - - note_path = Path(tmp_path) / "notes" / "callable_note.md" - assert note_path.exists() - assert note_path.read_text(encoding="utf-8") == "Callable invocation test" - - -@pytest.mark.asyncio -async def test_save_note_with_state_tool_message(mock_ctx, tmp_path): - """Verify SaveNoteTool.execute with state returns a ToolMessage directly.""" - mock_state = MagicMock(spec=State) - - result = await save_note.execute( - ctx=mock_ctx, - key="state_note", - content="Note with state", - state=mock_state, - tool_call_id="call_save_999", - ) - - assert isinstance(result, ToolMessage) - assert result.tool_call_id == "call_save_999" - assert result.content == "Saved note 'state_note'." - assert result.status == "success" - - note_path = Path(tmp_path) / "notes" / "state_note.md" - assert note_path.exists() - assert note_path.read_text(encoding="utf-8") == "Note with state" - - -@pytest.mark.asyncio -async def test_save_note_execution_failure(mock_ctx): - """Verify error handling when saving note fails.""" - with patch( - "artemis.tools.scratchpad.save_note_content", - side_effect=PermissionError("Permission denied"), - ): - result = await save_note.execute( - ctx=mock_ctx, - key="fail_note", - content="Fail content", - ) - assert "Failed to save note fail_note.md: Permission denied" in result - - -@pytest.mark.asyncio -async def test_get_save_note_tool_langchain_ainvoke(mock_ctx, tmp_path): - """Verify get_save_note_tool exports a LangChain tool that works with ainvoke.""" - lc_tool = get_save_note_tool(mock_ctx) - assert lc_tool.name == "save_note" - - result = await lc_tool.ainvoke( - { - "key": "lc_note", - "content": "Saved via LangChain BaseTool", - } - ) - assert result == "Saved note 'lc_note'." - - note_path = Path(tmp_path) / "notes" / "lc_note.md" - assert note_path.exists() - assert note_path.read_text(encoding="utf-8") == "Saved via LangChain BaseTool" - - -@pytest.mark.asyncio -async def test_get_save_note_tool_pure(mock_ctx, tmp_path): - """Verify get_save_note_tool_pure returns a pure LangChain tool.""" - pure_tool = get_save_note_tool_pure(mock_ctx) - assert pure_tool.name == "save_note" - - result = await pure_tool.ainvoke( - { - "key": "pure_note", - "content": "Saved via pure tool", - } - ) - assert result == "Saved note 'pure_note'." - - note_path = Path(tmp_path) / "notes" / "pure_note.md" - assert note_path.exists() - assert note_path.read_text(encoding="utf-8") == "Saved via pure tool" diff --git a/tests/unit/tools/test_search_logs.py b/tests/unit/tools/test_search_logs.py deleted file mode 100644 index 676376fb2..000000000 --- a/tests/unit/tools/test_search_logs.py +++ /dev/null @@ -1,283 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import MagicMock, patch - -from artemis.context import ArtemisContext -from artemis.tools.base import ArtemisTool -from artemis.tools.mobile.search_logs import ( - SearchLogs, - SearchLogsArgs, - SearchLogsTool, - SearchLogsToolAlias, - get_search_logs_tool, - search_and_merge_logs, - search_logs, -) -import pytest - - -def test_search_and_merge_logs_no_matches(): - logs = "line 0: info\nline 1: info\nline 2: info" - result = search_and_merge_logs(logs, "ERROR", context_lines=2) - assert result == "No matches found for 'ERROR'." - - -def test_search_and_merge_logs_single_match(): - logs = "\n".join([f"line {i}" if i != 5 else "ERROR: failure" for i in range(10)]) - result = search_and_merge_logs(logs, "ERROR", context_lines=2) - - expected = ( - "--- Match at line(s) 5 ---\n" - " 3: line 3\n" - " 4: line 4\n" - " 5: [MATCH] ERROR: failure\n" - " 6: line 6\n" - " 7: line 7\n" - "--------------------" - ) - assert result == expected - - -def test_search_and_merge_logs_overlapping_matches(): - logs = "\n".join([f"line {i}" if i not in [4, 6] else "ERROR: failure" for i in range(10)]) - result = search_and_merge_logs(logs, "ERROR", context_lines=2) - - # Match at 4 (range 2 to 6 inclusive) - # Match at 6 (range 4 to 8 inclusive) - # Merged range: 2 to 8 inclusive - expected = ( - "--- Match at line(s) 4, 6 ---\n" - " 2: line 2\n" - " 3: line 3\n" - " 4: [MATCH] ERROR: failure\n" - " 5: line 5\n" - " 6: [MATCH] ERROR: failure\n" - " 7: line 7\n" - " 8: line 8\n" - "--------------------" - ) - assert result == expected - - -def test_search_and_merge_logs_adjacent_matches(): - # Match at 3 (range 1 to 5 inclusive) - # Match at 6 (range 4 to 8 inclusive) - # Overlap is at lines 4-5. They are adjacent/overlapping and should merge. - logs = "\n".join([f"line {i}" if i not in [3, 6] else "ERROR: failure" for i in range(10)]) - result = search_and_merge_logs(logs, "ERROR", context_lines=2) - - expected = ( - "--- Match at line(s) 3, 6 ---\n" - " 1: line 1\n" - " 2: line 2\n" - " 3: [MATCH] ERROR: failure\n" - " 4: line 4\n" - " 5: line 5\n" - " 6: [MATCH] ERROR: failure\n" - " 7: line 7\n" - " 8: line 8\n" - "--------------------" - ) - assert result == expected - - -def test_search_and_merge_logs_multiple_separate_blocks(): - # Match at 2 (range 0 to 4 inclusive) - # Match at 8 (range 6 to 9 inclusive since len is 10) - # Non-overlapping since end of first is 4, start of second is 6. - logs = "\n".join([f"line {i}" if i not in [2, 8] else "ERROR: failure" for i in range(10)]) - result = search_and_merge_logs(logs, "ERROR", context_lines=2) - - expected = ( - "--- Match at line(s) 2 ---\n" - " 0: line 0\n" - " 1: line 1\n" - " 2: [MATCH] ERROR: failure\n" - " 3: line 3\n" - " 4: line 4\n" - "--------------------\n" - "--- Match at line(s) 8 ---\n" - " 6: line 6\n" - " 7: line 7\n" - " 8: [MATCH] ERROR: failure\n" - " 9: line 9\n" - "--------------------" - ) - assert result == expected - - -def test_search_and_merge_logs_regex(): - logs = "line 0\nERR_123: failed\nline 2\nERR_456: critical" - result = search_and_merge_logs(logs, r"ERR_\d+", context_lines=1, is_regex=True) - - expected = ( - "--- Match at line(s) 1, 3 ---\n" - " 0: line 0\n" - " 1: [MATCH] ERR_123: failed\n" - " 2: line 2\n" - " 3: [MATCH] ERR_456: critical\n" - "--------------------" - ) - assert result == expected - - -def test_search_and_merge_logs_header_compresses_consecutive_matches(): - # 100 consecutive matches at lines 699-798 collapse into one range. - logs = "\n".join("ERROR: failure" if 699 <= i <= 798 else f"line {i}" for i in range(900)) - result = search_and_merge_logs(logs, "ERROR", context_lines=0) - - assert result.splitlines()[0] == "--- Match at line(s) 699-798 ---" - assert "699, 700" not in result - - -def test_search_and_merge_logs_header_mixes_singles_and_ranges(): - # Matches at 6 and 69-79 sit in one block once context windows overlap. - hits = {6} | set(range(69, 80)) - logs = "\n".join("ERROR: failure" if i in hits else f"line {i}" for i in range(100)) - result = search_and_merge_logs(logs, "ERROR", context_lines=40) - - assert result.splitlines()[0] == "--- Match at line(s) 6, 69-79 ---" - - -def test_search_and_merge_logs_header_caps_range_count(): - # Every other line matches: 50 single-line ranges, capped at 8 plus "...". - logs = "\n".join("ERROR: failure" if i % 2 == 0 else f"line {i}" for i in range(100)) - result = search_and_merge_logs(logs, "ERROR", context_lines=1) - - assert result.splitlines()[0] == "--- Match at line(s) 0, 2, 4, 6, 8, 10, 12, 14, ... ---" - - -def test_search_and_merge_logs_invalid_regex(): - logs = "line 0" - result = search_and_merge_logs(logs, "[invalid", is_regex=True) - assert result.startswith("Error: Invalid regex:") - - -# --------------------------------------------------------------------------- -# SearchLogsTool Unit Tests -# --------------------------------------------------------------------------- -@pytest.fixture -def mock_ctx(): - return MagicMock(spec=ArtemisContext) - - -def test_search_logs_tool_subclass(): - """Verify SearchLogsTool is a subclass of ArtemisTool.""" - assert issubclass(SearchLogsTool, ArtemisTool) - assert issubclass(SearchLogs, ArtemisTool) - assert issubclass(SearchLogsToolAlias, ArtemisTool) - assert isinstance(search_logs, ArtemisTool) - assert isinstance(search_logs, SearchLogsTool) - - assert search_logs.name == "search_logs" - assert search_logs.category == "diagnostic" - assert search_logs.args_schema == SearchLogsArgs - - # GenAI FunctionDeclaration export - declaration = search_logs.to_genai_declaration() - assert declaration.name == "search_logs" - assert "keyword" in declaration.parameters.properties - assert "is_regex" in declaration.parameters.properties - assert "lines" in declaration.parameters.properties - assert "since_time" in declaration.parameters.properties - assert "until_time" in declaration.parameters.properties - assert "context_lines" in declaration.parameters.properties - - -@pytest.mark.asyncio -async def test_search_logs_direct_execution(mock_ctx): - """Verify direct execution of SearchLogsTool.""" - mock_log_output = "line 1: FATAL: crash occurred\nline 2: normal" - with patch( - "artemis.tools.mobile.search_logs.fetch_and_filter_logs", - return_value=mock_log_output, - ) as mock_fetch: - result = await search_logs.execute( - ctx=mock_ctx, - keyword="FATAL", - is_regex=False, - lines=5000, - since_time="08-12 10:00:00", - until_time="08-12 10:05:00", - context_lines=1, - ) - mock_fetch.assert_called_once_with( - ctx=mock_ctx, - lines=5000, - since_time="08-12 10:00:00", - until_time="08-12 10:05:00", - ) - assert "[MATCH] line 1: FATAL: crash occurred" in result - - -@pytest.mark.asyncio -async def test_search_logs_default_args(mock_ctx): - """Verify search_logs defaults lines to 10000 and context_lines to 0.""" - with patch( - "artemis.tools.mobile.search_logs.fetch_and_filter_logs", - return_value="info line\nerror line", - ) as mock_fetch: - result = await search_logs.execute( - ctx=mock_ctx, keyword="error", lines=None, context_lines=None - ) - mock_fetch.assert_called_once_with( - ctx=mock_ctx, - lines=10000, - since_time=None, - until_time=None, - ) - assert "[MATCH] error line" in result - - -@pytest.mark.asyncio -async def test_search_logs_exception_handling(mock_ctx): - """Verify error handling when searching logs raises an exception.""" - with patch( - "artemis.tools.mobile.search_logs.fetch_and_filter_logs", - side_effect=RuntimeError("Device unreachable"), - ): - result = await search_logs.execute(ctx=mock_ctx, keyword="test") - assert "Error searching logs: Device unreachable" in result - - -@pytest.mark.asyncio -async def test_search_logs_callable_execution(mock_ctx): - """Verify invoking search_logs directly as a callable.""" - with patch( - "artemis.tools.mobile.search_logs.fetch_and_filter_logs", - return_value="NullPointerException in activity", - ): - result = await search_logs(ctx=mock_ctx, keyword="NullPointer", context_lines=0) - assert "[MATCH] NullPointerException in activity" in result - - -@pytest.mark.asyncio -async def test_get_search_logs_tool_langchain_ainvoke(mock_ctx): - """Verify get_search_logs_tool exports a LangChain tool that works with ainvoke.""" - tool = get_search_logs_tool(mock_ctx) - assert tool.name == "search_logs" - - with patch( - "artemis.tools.mobile.search_logs.fetch_and_filter_logs", - return_value="critical error detected", - ) as mock_fetch: - result = await tool.ainvoke({"keyword": "critical", "lines": 2000}) - mock_fetch.assert_called_once_with( - ctx=mock_ctx, - lines=2000, - since_time=None, - until_time=None, - ) - assert "[MATCH] critical error detected" in result diff --git a/tests/unit/tools/test_tool_failure.py b/tests/unit/tools/test_tool_failure.py deleted file mode 100644 index 5dcc0f9a9..000000000 --- a/tests/unit/tools/test_tool_failure.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Helper-tool failures are structural (``ToolFailure`` / ``ToolMessage.status``), -never inferred from the words of the text.""" - -from unittest.mock import Mock - -from langchain_core.messages import ToolMessage -from langchain_core.tools import StructuredTool -import pytest - -from artemis.core.tool_failure import ToolFailure, is_tool_failure -from artemis.data_engine import engine as engine_mod -from artemis.tools.tool_wrapper import invoke_tool_with_injection -from artemis.utils.notes import format_list_notes_failure, format_read_note_failure - - -def test_tool_failure_is_a_plain_string_to_every_text_consumer(): - failure = ToolFailure("Failed to save note plan.md: disk full") - assert isinstance(failure, str) - assert failure == "Failed to save note plan.md: disk full" - assert f"{failure}" == "Failed to save note plan.md: disk full" - assert failure.startswith("Failed") - - -def test_is_tool_failure_reads_the_structure_not_the_words(): - assert is_tool_failure(ToolFailure("Error: note not found")) - assert is_tool_failure(ToolFailure("plain wording still counts")) - # Free-form text may legitimately start with "Error" or "Failed". - assert not is_tool_failure("Error 404 was typed into the search box") - assert not is_tool_failure("Failed login dialog is visible") - assert not is_tool_failure("Successfully saved note") - assert not is_tool_failure(None) - - -def test_is_tool_failure_honours_the_tool_message_status(): - assert is_tool_failure(ToolMessage(tool_call_id="t", content="anything", status="error")) - assert not is_tool_failure( - ToolMessage(tool_call_id="t", content="Error-looking text", status="success") - ) - - -def test_is_tool_failure_finds_a_failure_text_block(): - assert is_tool_failure([{"type": "text", "text": ToolFailure("boom")}]) - assert is_tool_failure([ToolFailure("boom"), {"type": "image_url", "image_url": {}}]) - assert not is_tool_failure([{"type": "text", "text": "Error: fine"}]) - - -def test_note_failure_formatters_report_structurally(): - assert is_tool_failure(format_read_note_failure("plan", "file not found")) - assert is_tool_failure(format_read_note_failure("plan", "permission denied")) - assert is_tool_failure(format_list_notes_failure("boom")) - - -def test_tool_message_content_coerces_a_tool_failure_to_plain_str(): - """Pydantic drops the ``str`` subclass: the failure must be read from the raw - tool return *before* the message is built, and carried as ``status``.""" - raw = ToolFailure("boom") - assert is_tool_failure(raw) - - msg = ToolMessage(tool_call_id="t", content=raw) - assert type(msg.content) is str - assert is_tool_failure(msg.content) is False - assert is_tool_failure(msg) is False # default status is "success" - - explicit = ToolMessage( - tool_call_id="t", content=raw, status="error" if is_tool_failure(raw) else "success" - ) - assert is_tool_failure(explicit) - - -# --- invoke_tool_with_injection: the trace span follows the structure --------------- - - -def _text_tool(name: str, result): - async def _run(query: str) -> str: - return result - - return StructuredTool.from_function(coroutine=_run, name=name, description=name) - - -def _stub_engine(monkeypatch): - engine = Mock() - engine.current_step_id = "step-1" - monkeypatch.setattr(engine_mod, "_CURRENT_DATA_ENGINE", engine, raising=False) - return engine - - -def _final_trace_call(engine): - calls = [c for c in engine.record_trace.call_args_list if c.kwargs.get("status") != "running"] - assert len(calls) == 1 - return calls[0].kwargs - - -@pytest.mark.asyncio -async def test_invoke_tool_with_injection_traces_a_tool_failure_as_failed(monkeypatch): - engine = _stub_engine(monkeypatch) - tool = _text_tool("read_note", ToolFailure("Failed to read note plan.md: not found")) - - result = await invoke_tool_with_injection(tool, {"query": "plan.md"}, "tc-1") - - assert is_tool_failure(result) - span = _final_trace_call(engine) - assert span["status"] == "failed" - assert span["name"] == "read_note" - assert span["payload"]["error"] == "Failed to read note plan.md: not found" - assert "result" not in span["payload"] - - -@pytest.mark.asyncio -async def test_invoke_tool_with_injection_traces_plain_error_text_as_success(monkeypatch): - engine = _stub_engine(monkeypatch) - tool = _text_tool("search_history", "Error: 3 matches for the typed search term") - - result = await invoke_tool_with_injection(tool, {"query": "Error:"}, "tc-2") - - assert not is_tool_failure(result) - span = _final_trace_call(engine) - assert span["status"] == "success" - assert span["payload"]["result"] == "Error: 3 matches for the typed search term" - assert "error" not in span["payload"] diff --git a/tests/unit/tools/test_update_note_pure_tool.py b/tests/unit/tools/test_update_note_pure_tool.py deleted file mode 100644 index 22eeace6a..000000000 --- a/tests/unit/tools/test_update_note_pure_tool.py +++ /dev/null @@ -1,141 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from pathlib import Path -from unittest.mock import MagicMock, patch - -from artemis.context import ArtemisContext -from artemis.tools.base import ArtemisTool -from artemis.tools.scratchpad import ( - UpdateNoteArgs, - UpdateNotePure, - UpdateNotePureTool, - get_update_note_tool_pure, - update_note_pure, -) -from artemis.utils.notes import save_note_content -import pytest - - -@pytest.fixture -def mock_ctx(tmp_path): - ctx = MagicMock(spec=ArtemisContext) - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = str(tmp_path) - return ctx - - -def test_update_note_pure_tool_subclass(): - """Verify UpdateNotePureTool is a subclass of ArtemisTool.""" - assert issubclass(UpdateNotePureTool, ArtemisTool) - assert issubclass(UpdateNotePure, ArtemisTool) - assert isinstance(update_note_pure, ArtemisTool) - assert isinstance(update_note_pure, UpdateNotePureTool) - - assert update_note_pure.name == "update_note_pure" - assert update_note_pure.category == "memory" - assert update_note_pure.args_schema == UpdateNoteArgs - - # GenAI FunctionDeclaration export - declaration = update_note_pure.to_genai_declaration() - assert declaration.name == "update_note_pure" - assert "key" in declaration.parameters.properties - assert "target" in declaration.parameters.properties - assert "replacement" in declaration.parameters.properties - - -@pytest.mark.asyncio -async def test_update_note_pure_direct_execution_success(mock_ctx, tmp_path): - """Verify direct execution of UpdateNotePureTool.execute updates existing note.""" - save_note_content(tmp_path, "plan", "Step 1: Pending\nStep 2: Done") - - result = await update_note_pure.execute( - ctx=mock_ctx, - key="plan", - target="Step 1: Pending", - replacement="Step 1: Completed", - ) - assert result == "Updated note 'plan'." - - note_path = Path(tmp_path) / "notes" / "plan.md" - assert note_path.read_text(encoding="utf-8") == "Step 1: Completed\nStep 2: Done" - - -@pytest.mark.asyncio -async def test_update_note_pure_with_warning(mock_ctx, tmp_path): - """Verify update note pure handles warning from fuzzy matching.""" - save_note_content(tmp_path, "fuzzy_note", "Target String") - - with patch( - "artemis.tools.scratchpad.update_note_content", - return_value="Fuzzy match applied", - ): - result = await update_note_pure.execute( - ctx=mock_ctx, - key="fuzzy_note", - target="target string", - replacement="New String", - ) - assert "Updated note 'fuzzy_note'." in result - assert "WARNING: Fuzzy match applied" in result - - -@pytest.mark.asyncio -async def test_update_note_pure_callable_execution(mock_ctx, tmp_path): - """Verify invoking update_note_pure directly as a callable.""" - save_note_content(tmp_path, "callable_plan", "alpha beta") - - result = await update_note_pure( - ctx=mock_ctx, - key="callable_plan", - target="beta", - replacement="gamma", - ) - assert result == "Updated note 'callable_plan'." - - note_path = Path(tmp_path) / "notes" / "callable_plan.md" - assert note_path.read_text(encoding="utf-8") == "alpha gamma" - - -@pytest.mark.asyncio -async def test_update_note_pure_execution_failure(mock_ctx): - """Verify error handling when note update fails.""" - result = await update_note_pure.execute( - ctx=mock_ctx, - key="missing_note", - target="old", - replacement="new", - ) - assert "Failed to update note 'missing_note':" in result - - -@pytest.mark.asyncio -async def test_get_update_note_tool_pure_langchain_ainvoke(mock_ctx, tmp_path): - """Verify get_update_note_tool_pure exports a LangChain tool named 'update_note'.""" - save_note_content(tmp_path, "export_plan", "initial value") - - pure_tool = get_update_note_tool_pure(mock_ctx) - assert pure_tool.name == "update_note" - - result = await pure_tool.ainvoke( - { - "key": "export_plan", - "target": "initial", - "replacement": "updated", - } - ) - assert result == "Updated note 'export_plan'." - - note_path = Path(tmp_path) / "notes" / "export_plan.md" - assert note_path.read_text(encoding="utf-8") == "updated value" diff --git a/tests/unit/tools/test_update_note_tool.py b/tests/unit/tools/test_update_note_tool.py deleted file mode 100644 index 5c0ed8198..000000000 --- a/tests/unit/tools/test_update_note_tool.py +++ /dev/null @@ -1,195 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from pathlib import Path -from unittest.mock import MagicMock, patch - -from artemis.context import ArtemisContext -from artemis.graph.state import State -from artemis.tools.base import ArtemisTool -from artemis.tools.scratchpad import ( - UpdateNote, - UpdateNoteArgs, - UpdateNoteTool, - get_update_note_tool, - get_update_note_tool_pure, - update_note, - update_note_wrapper, -) -from artemis.utils.notes import save_note_content -from langchain_core.messages import ToolMessage -import pytest - - -@pytest.fixture -def mock_ctx(tmp_path): - ctx = MagicMock(spec=ArtemisContext) - ctx.data_engine = MagicMock() - ctx.data_engine.base_dir = str(tmp_path) - return ctx - - -def test_update_note_tool_subclass(): - """Verify UpdateNoteTool is a subclass of ArtemisTool.""" - assert issubclass(UpdateNoteTool, ArtemisTool) - assert issubclass(UpdateNote, ArtemisTool) - assert isinstance(update_note, ArtemisTool) - assert isinstance(update_note, UpdateNoteTool) - - assert update_note.name == "update_note" - assert update_note.category == "memory" - assert update_note.args_schema == UpdateNoteArgs - - # GenAI FunctionDeclaration export - declaration = update_note.to_genai_declaration() - assert declaration.name == "update_note" - assert "key" in declaration.parameters.properties - assert "target" in declaration.parameters.properties - assert "replacement" in declaration.parameters.properties - - # Wrapper check - assert update_note_wrapper is not None - assert update_note_wrapper.tool_fn_getter == get_update_note_tool - - -@pytest.mark.asyncio -async def test_update_note_direct_execution_success(mock_ctx, tmp_path): - """Verify direct execution of UpdateNoteTool.execute updates existing note.""" - save_note_content(tmp_path, "plan", "- [ ] Step 1\n- [ ] Step 2") - - result = await update_note.execute( - ctx=mock_ctx, - key="plan", - target="- [ ] Step 1", - replacement="- [x] Step 1", - ) - assert result == "Updated note 'plan'." - - note_path = Path(tmp_path) / "notes" / "plan.md" - assert note_path.read_text(encoding="utf-8") == "- [x] Step 1\n- [ ] Step 2" - - -@pytest.mark.asyncio -async def test_update_note_with_warning(mock_ctx, tmp_path): - """Verify update note handles warning from fuzzy/relaxed matching.""" - save_note_content(tmp_path, "fuzzy_plan", "- [ ] Step 1") - - with patch( - "artemis.tools.scratchpad.update_note_content", - return_value="Fuzzy match applied", - ): - result = await update_note.execute( - ctx=mock_ctx, - key="fuzzy_plan", - target="Step 1", - replacement="Step 1 Done", - ) - assert "Updated note 'fuzzy_plan'." in result - assert "WARNING: Fuzzy match applied" in result - - -@pytest.mark.asyncio -async def test_update_note_callable_execution(mock_ctx, tmp_path): - """Verify invoking update_note directly as a callable.""" - save_note_content(tmp_path, "callable_plan", "- [ ] Task 1\n- [ ] Task 2") - - result = await update_note( - ctx=mock_ctx, - key="callable_plan", - target="- [ ] Task 2", - replacement="- [x] Task 2", - ) - assert result == "Updated note 'callable_plan'." - - note_path = Path(tmp_path) / "notes" / "callable_plan.md" - assert note_path.read_text(encoding="utf-8") == "- [ ] Task 1\n- [x] Task 2" - - -@pytest.mark.asyncio -async def test_update_note_with_state_tool_message(mock_ctx, tmp_path): - """Verify UpdateNoteTool.execute with state returns a ToolMessage directly.""" - save_note_content(tmp_path, "state_plan", "- [ ] Step 1") - - mock_state = MagicMock(spec=State) - - result = await update_note.execute( - ctx=mock_ctx, - key="state_plan", - target="- [ ] Step 1", - replacement="- [x] Step 1", - state=mock_state, - tool_call_id="call_update_999", - ) - - assert isinstance(result, ToolMessage) - assert result.tool_call_id == "call_update_999" - assert result.content == "Updated note 'state_plan'." - assert result.status == "success" - - note_path = Path(tmp_path) / "notes" / "state_plan.md" - assert note_path.read_text(encoding="utf-8") == "- [x] Step 1" - - -@pytest.mark.asyncio -async def test_update_note_execution_failure(mock_ctx): - """Verify error handling when updating a note fails.""" - result = await update_note.execute( - ctx=mock_ctx, - key="non_existent_note", - target="old", - replacement="new", - ) - assert "Failed to update note 'non_existent_note':" in result - - -@pytest.mark.asyncio -async def test_get_update_note_tool_langchain_ainvoke(mock_ctx, tmp_path): - """Verify get_update_note_tool exports a LangChain tool that works with ainvoke.""" - save_note_content(tmp_path, "lc_plan", "alpha beta gamma") - - lc_tool = get_update_note_tool(mock_ctx) - assert lc_tool.name == "update_note" - - result = await lc_tool.ainvoke( - { - "key": "lc_plan", - "target": "beta", - "replacement": "delta", - } - ) - assert result == "Updated note 'lc_plan'." - - note_path = Path(tmp_path) / "notes" / "lc_plan.md" - assert note_path.read_text(encoding="utf-8") == "alpha delta gamma" - - -@pytest.mark.asyncio -async def test_get_update_note_tool_pure(mock_ctx, tmp_path): - """Verify get_update_note_tool_pure returns a pure LangChain tool.""" - save_note_content(tmp_path, "pure_plan", "hello world") - - pure_tool = get_update_note_tool_pure(mock_ctx) - assert pure_tool.name == "update_note" - - result = await pure_tool.ainvoke( - { - "key": "pure_plan", - "target": "world", - "replacement": "Artemis", - } - ) - assert result == "Updated note 'pure_plan'." - - note_path = Path(tmp_path) / "notes" / "pure_plan.md" - assert note_path.read_text(encoding="utf-8") == "hello Artemis" diff --git a/tests/unit/tools/test_video_analyzer_pure_tool.py b/tests/unit/tools/test_video_analyzer_pure_tool.py deleted file mode 100644 index 597076dfb..000000000 --- a/tests/unit/tools/test_video_analyzer_pure_tool.py +++ /dev/null @@ -1,134 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import AsyncMock, MagicMock, patch - -from artemis.context import ArtemisContext -from artemis.tools.base import ArtemisTool -from artemis.tools.video_tool import ( - VideoAnalyzerArgs, - VideoAnalyzerPure, - VideoAnalyzerPureTool, - get_video_analyzer_tool_pure, - video_analyzer_pure, -) -import pytest - - -@pytest.fixture -def mock_ctx(): - ctx = MagicMock(spec=ArtemisContext) - return ctx - - -def test_video_analyzer_pure_tool_subclass(): - """Verify VideoAnalyzerPureTool is a subclass of ArtemisTool.""" - assert issubclass(VideoAnalyzerPureTool, ArtemisTool) - assert issubclass(VideoAnalyzerPure, ArtemisTool) - assert isinstance(video_analyzer_pure, ArtemisTool) - assert isinstance(video_analyzer_pure, VideoAnalyzerPureTool) - - assert video_analyzer_pure.name == "video_analyzer_pure" - assert video_analyzer_pure.category == "perception" - assert video_analyzer_pure.args_schema == VideoAnalyzerArgs - - # GenAI FunctionDeclaration export - declaration = video_analyzer_pure.to_genai_declaration() - assert declaration.name == "video_analyzer_pure" - assert "time_description" in declaration.parameters.properties - assert "purpose" in declaration.parameters.properties - - -@pytest.mark.asyncio -async def test_video_analyzer_pure_direct_execution_success(mock_ctx): - """Verify direct execution of VideoAnalyzerPureTool.execute returns outcome directly.""" - with patch( - "artemis.tools.video_tool.VideoAnalyzer.run", - new_callable=AsyncMock, - return_value=("Pure video analysis success", "success"), - ): - result = await video_analyzer_pure.execute( - ctx=mock_ctx, - time_description="from 0s to 5s", - purpose="Verify video playback", - ) - assert result == "Pure video analysis success" - - -@pytest.mark.asyncio -async def test_video_analyzer_pure_direct_execution_failed(mock_ctx): - """Verify direct execution handles failed status properly.""" - with patch( - "artemis.tools.video_tool.VideoAnalyzer.run", - new_callable=AsyncMock, - return_value=("Pure could not find element", "failed"), - ): - result = await video_analyzer_pure.execute( - ctx=mock_ctx, - time_description="from 10s to 15s", - purpose="Check button click", - ) - assert result == "Video analysis failed: Pure could not find element" - - -@pytest.mark.asyncio -async def test_video_analyzer_pure_callable_execution(mock_ctx): - """Verify invoking video_analyzer_pure directly as a callable.""" - with patch( - "artemis.tools.video_tool.VideoAnalyzer.run", - new_callable=AsyncMock, - return_value=("Pure callable output", "success"), - ): - result = await video_analyzer_pure( - ctx=mock_ctx, - time_description="from 1s to 2s", - purpose="Callable check", - ) - assert result == "Pure callable output" - - -@pytest.mark.asyncio -async def test_video_analyzer_pure_exception_handling(mock_ctx): - """Verify VideoAnalyzerPureTool gracefully handles exceptions.""" - with patch( - "artemis.tools.video_tool.VideoAnalyzer.run", - new_callable=AsyncMock, - side_effect=RuntimeError("Video processing error"), - ): - result = await video_analyzer_pure.execute( - ctx=mock_ctx, - time_description="from 0s to 5s", - purpose="Test error", - ) - assert "Error running video analyzer: Video processing error" in result - - -@pytest.mark.asyncio -async def test_get_video_analyzer_tool_pure_ainvoke(mock_ctx): - """Verify get_video_analyzer_tool_pure exports a LangChain tool named 'video_analyzer'.""" - pure_tool = get_video_analyzer_tool_pure(mock_ctx) - assert pure_tool.name == "video_analyzer" - - with patch( - "artemis.tools.video_tool.VideoAnalyzer.run", - new_callable=AsyncMock, - return_value=("Exported pure tool output", "success"), - ): - result = await pure_tool.ainvoke( - { - "time_description": "from 2s to 4s", - "purpose": "Pure invoke check", - } - ) - assert result == "Exported pure tool output" diff --git a/tests/unit/tools/test_video_analyzer_tool.py b/tests/unit/tools/test_video_analyzer_tool.py deleted file mode 100644 index 121c702e0..000000000 --- a/tests/unit/tools/test_video_analyzer_tool.py +++ /dev/null @@ -1,187 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from unittest.mock import AsyncMock, MagicMock, patch - -from artemis.context import ArtemisContext -from artemis.graph.state import State -from artemis.tools.base import ArtemisTool -from artemis.tools.video_tool import ( - DIAGNOSER_VIDEO_ANALYZER_DOCSTRING, - OPERATOR_VIDEO_ANALYZER_DOCSTRING, - VideoAnalyzerArgs, - VideoAnalyzerTool, - get_video_analyzer_tool, - get_video_analyzer_tool_pure, - video_analyzer, -) -from langchain_core.messages import ToolMessage -import pytest - - -@pytest.fixture -def mock_ctx(): - ctx = MagicMock(spec=ArtemisContext) - return ctx - - -@pytest.fixture -def mock_state(): - return MagicMock(spec=State) - - -def test_video_analyzer_tool_subclass(): - """Verify VideoAnalyzerTool is a subclass of ArtemisTool.""" - assert issubclass(VideoAnalyzerTool, ArtemisTool) - assert isinstance(video_analyzer, ArtemisTool) - assert isinstance(video_analyzer, VideoAnalyzerTool) - - assert video_analyzer.name == "video_analyzer" - assert video_analyzer.category == "perception" - assert video_analyzer.args_schema == VideoAnalyzerArgs - - # GenAI FunctionDeclaration export - declaration = video_analyzer.to_genai_declaration() - assert declaration.name == "video_analyzer" - assert "time_description" in declaration.parameters.properties - assert "purpose" in declaration.parameters.properties - - -@pytest.mark.asyncio -async def test_video_analyzer_direct_execution_success(mock_ctx): - """Verify direct execution of VideoAnalyzerTool.execute returns outcome directly.""" - with patch( - "artemis.tools.video_tool.VideoAnalyzer.run", - new_callable=AsyncMock, - return_value=("Video analysis success details", "success"), - ): - result = await video_analyzer.execute( - ctx=mock_ctx, - time_description="from 0s to 5s", - purpose="Verify video playback", - ) - assert result == "Video analysis success details" - - -@pytest.mark.asyncio -async def test_video_analyzer_direct_execution_failed(mock_ctx): - """Verify direct execution handles failed status properly.""" - with patch( - "artemis.tools.video_tool.VideoAnalyzer.run", - new_callable=AsyncMock, - return_value=("Could not find target element", "failed"), - ): - result = await video_analyzer.execute( - ctx=mock_ctx, - time_description="from 10s to 15s", - purpose="Check button click", - ) - assert result == "Video analysis failed: Could not find target element" - - -@pytest.mark.asyncio -async def test_video_analyzer_with_state_command(mock_ctx, mock_state): - """Verify execute returns a ToolMessage when state is provided.""" - with patch( - "artemis.tools.video_tool.VideoAnalyzer.run", - new_callable=AsyncMock, - return_value=("Action verified in video", "success"), - ): - cmd = await video_analyzer.execute( - ctx=mock_ctx, - time_description="from 5s to 10s", - purpose="Verify action", - tool_call_id="call_video_123", - state=mock_state, - ) - assert isinstance(cmd, ToolMessage) - assert cmd.tool_call_id == "call_video_123" - assert cmd.content == "Action verified in video" - assert cmd.status == "success" - - -@pytest.mark.asyncio -async def test_video_analyzer_exception_handling(mock_ctx, mock_state): - """Verify execute gracefully handles exceptions.""" - with patch( - "artemis.tools.video_tool.VideoAnalyzer.run", - new_callable=AsyncMock, - side_effect=RuntimeError("Video processing error"), - ): - # Without state - res = await video_analyzer.execute( - ctx=mock_ctx, - time_description="from 0s to 5s", - purpose="Test error", - ) - assert "Error running video analyzer: Video processing error" in res - - # With state - cmd = await video_analyzer.execute( - ctx=mock_ctx, - time_description="from 0s to 5s", - purpose="Test error", - tool_call_id="call_err", - state=mock_state, - ) - assert isinstance(cmd, ToolMessage) - assert cmd.status == "error" - assert "Error running video analyzer" in cmd.content - - -@pytest.mark.asyncio -async def test_video_analyzer_callable_execution(mock_ctx): - """Verify invoking video_analyzer directly as a callable.""" - with patch( - "artemis.tools.video_tool.VideoAnalyzer.run", - new_callable=AsyncMock, - return_value=("Callable output", "success"), - ): - result = await video_analyzer( - ctx=mock_ctx, - time_description="from 1s to 2s", - purpose="Callable check", - ) - assert result == "Callable output" - - -def test_get_video_analyzer_tool_roles(mock_ctx): - """Verify get_video_analyzer_tool returns tool with role-specific description.""" - op_tool = get_video_analyzer_tool(mock_ctx, role="operator") - assert op_tool.name == "video_analyzer" - assert op_tool.description.strip() == OPERATOR_VIDEO_ANALYZER_DOCSTRING.strip() - - diag_tool = get_video_analyzer_tool(mock_ctx, role="diagnoser") - assert diag_tool.name == "video_analyzer" - assert diag_tool.description.strip() == DIAGNOSER_VIDEO_ANALYZER_DOCSTRING.strip() - - -@pytest.mark.asyncio -async def test_get_video_analyzer_tool_pure_ainvoke(mock_ctx): - """Verify get_video_analyzer_tool_pure exports a LangChain tool.""" - pure_tool = get_video_analyzer_tool_pure(mock_ctx) - assert pure_tool.name == "video_analyzer" - - with patch( - "artemis.tools.video_tool.VideoAnalyzer.run", - new_callable=AsyncMock, - return_value=("Pure tool output", "success"), - ): - result = await pure_tool.ainvoke( - { - "time_description": "from 2s to 4s", - "purpose": "Pure invoke check", - } - ) - assert result == "Pure tool output" diff --git a/tests/unit/tools/test_wait_tool.py b/tests/unit/tools/test_wait_tool.py deleted file mode 100644 index 1c63b2e0e..000000000 --- a/tests/unit/tools/test_wait_tool.py +++ /dev/null @@ -1,109 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio -import time -from unittest.mock import AsyncMock, MagicMock - -from artemis.context import ArtemisContext -from artemis.tools.base import ArtemisTool -from artemis.tools.wait_tool import ( - Wait, - WaitArgs, - WaitTool, - get_wait_tool, - wait, - wait_wrapper, -) -import pytest - - -@pytest.fixture -def mock_ctx(): - ctx = MagicMock(spec=ArtemisContext) - return ctx - - -def test_wait_tool_subclass(): - """Verify WaitTool is a subclass of ArtemisTool.""" - assert issubclass(WaitTool, ArtemisTool) - assert issubclass(Wait, ArtemisTool) - assert isinstance(wait, ArtemisTool) - assert isinstance(wait, WaitTool) - - assert wait.name == "wait" - assert wait.category == "system" - assert wait.args_schema == WaitArgs - - # GenAI FunctionDeclaration export - declaration = wait.to_genai_declaration() - assert declaration.name == "wait" - assert "seconds" in declaration.parameters.properties - - # Wrapper check - assert wait_wrapper is not None - assert wait_wrapper.tool_fn_getter == get_wait_tool - - -@pytest.mark.asyncio -async def test_wait_direct_execution(mock_ctx): - """Verify direct execution of WaitTool.execute.""" - with pytest.MonkeyPatch.context() as mp: - mock_sleep = AsyncMock() - mp.setattr(asyncio, "sleep", mock_sleep) - - result = await wait.execute(ctx=mock_ctx, seconds=5) - mock_sleep.assert_called_once_with(5) - assert result == "Waited 5 seconds." - - -@pytest.mark.asyncio -async def test_wait_callable_execution(mock_ctx): - """Verify invoking wait directly as a callable.""" - with pytest.MonkeyPatch.context() as mp: - mock_sleep = AsyncMock() - mp.setattr(asyncio, "sleep", mock_sleep) - - result = await wait(ctx=mock_ctx, seconds=3) - mock_sleep.assert_called_once_with(3) - assert result == "Waited 3 seconds." - - -@pytest.mark.asyncio -async def test_wait_tool(mock_ctx): - wait_tool = get_wait_tool(mock_ctx) - - start_time = time.time() - result = await wait_tool.ainvoke({"seconds": 2}) - end_time = time.time() - - duration = end_time - start_time - assert duration >= 1.9 # Allow slight timing variations - assert "Waited 2 seconds" in result - - -@pytest.mark.asyncio -async def test_wait_tool_bounds(mock_ctx): - wait_tool = get_wait_tool(mock_ctx) - - with pytest.MonkeyPatch.context() as mp: - mock_sleep = AsyncMock() - mp.setattr(asyncio, "sleep", mock_sleep) - - await wait_tool.ainvoke({"seconds": 120}) - mock_sleep.assert_called_once_with(60) - - mock_sleep.reset_mock() - await wait_tool.ainvoke({"seconds": -5}) - mock_sleep.assert_called_once_with(1) diff --git a/tests/unit/utils/test_attempt_markers.py b/tests/unit/utils/test_attempt_markers.py deleted file mode 100644 index 3e116d464..000000000 --- a/tests/unit/utils/test_attempt_markers.py +++ /dev/null @@ -1,50 +0,0 @@ -"""The ``attempts`` marker contract between the Validator and the renderers. - -The Validator writes ``"Dispatched"`` as the terminal attempt of an action the -device accepted (kept only when a retry preceded it); every failed attempt keeps -its error text. The renderers must read a retried-then-dispatched action as a -clean dispatch, never as a failure. -""" - -from artemis.utils.task_tree import ( - _burst_member_status, - failed_execution_error, - format_result_clean, -) - - -def _report(attempts: list[str], status: str = "dispatched") -> dict: - return { - "status": status, - "execution": [ - {"action": "tap", "normalized_coordinates": [500, 500], "attempts": attempts} - ], - } - - -def test_retried_then_dispatched_action_is_not_a_failure(): - report = _report(["Error: tap rejected", "Dispatched"]) - assert failed_execution_error(report) is None - assert format_result_clean(report) is None - - -def test_terminal_failure_keeps_every_attempt(): - report = _report(["Error: tap rejected", "Error: tap rejected again"], status="failed") - assert failed_execution_error(report) == "Error: tap rejected | Error: tap rejected again" - assert format_result_clean(report) == "Error: tap rejected | Error: tap rejected again" - - -def test_burst_member_labels_follow_the_dispatch_vocabulary(): - report = { - "status": "failed", - "execution": [ - {"action": "tap"}, - {"action": "tap", "attempts": ["Error: boom"]}, - {"action": "tap", "attempts": ["Skipped (burst aborted)"]}, - ], - } - assert _burst_member_status(report, 0) == " (dispatched)" - assert _burst_member_status(report, 1) == " (FAILED: Error: boom)" - assert _burst_member_status(report, 2) == " (skipped)" - assert _burst_member_status(report, 3) == " (not dispatched)" - assert _burst_member_status({"execution": [{"attempts": ["Dispatched"]}]}, 0) == " (dispatched)" diff --git a/tests/unit/utils/test_coordinate_space.py b/tests/unit/utils/test_coordinate_space.py deleted file mode 100644 index c7bb06863..000000000 --- a/tests/unit/utils/test_coordinate_space.py +++ /dev/null @@ -1,104 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Coordinate-space contract of recorded actions. - -Pro records pixels (``coordinate_space="pixel"``), Flash records the model's -own 0–1000 values (``coordinate_space="normalized"``); every model-facing -render normalizes, and the explicit marker — never a magnitude heuristic — -is what makes that pass idempotent. -""" - -from artemis.utils.coordinates import ( - COORDINATE_SPACE_KEY, - COORDINATE_SPACE_NORMALIZED, - COORDINATE_SPACE_PIXEL, - normalize_action_dict, - normalize_any_structure, -) - -W, H = 1080, 2400 - - -def test_pixel_record_is_normalized_once_and_stamped(): - pixel = { - "action": "tap", - "coordinates": [540, 1200], - "normalized_coordinates": [500, 500], - COORDINATE_SPACE_KEY: COORDINATE_SPACE_PIXEL, - } - once = normalize_action_dict(pixel, W, H) - assert once["coordinates"] == [500, 500] - assert once[COORDINATE_SPACE_KEY] == COORDINATE_SPACE_NORMALIZED - assert pixel["coordinates"] == [540, 1200] # input untouched - - twice = normalize_action_dict(once, W, H) - assert twice == once - - -def test_normalized_record_is_returned_verbatim_even_when_values_look_like_pixels(): - """No magnitude guessing: a Flash record of [320, 399] stays [320, 399] - (the buggy double pass used to turn it into [296, 166]).""" - flash = { - "action": "click", - "coordinates": [320, 399], - COORDINATE_SPACE_KEY: COORDINATE_SPACE_NORMALIZED, - "args": {"target": [320, 399]}, - } - out = normalize_action_dict(flash, W, H) - assert out == flash - assert out is not flash - - big = dict(flash, coordinates=[1080, 2400]) - assert normalize_action_dict(big, W, H)["coordinates"] == [1080, 2400] - - -def test_legacy_unstamped_record_is_converted_once_then_idempotent(): - legacy = {"action": "swipe", "coordinates": [540, 1800, 540, 600]} - once = normalize_action_dict(legacy, W, H) - assert once["coordinates"] == [500, 750, 500, 250] - assert once[COORDINATE_SPACE_KEY] == COORDINATE_SPACE_NORMALIZED - assert normalize_action_dict(once, W, H) == once - - # A dict without coordinate fields gets no marker (nothing was converted). - assert COORDINATE_SPACE_KEY not in normalize_action_dict({"action": "press_key"}, W, H) - - -def test_normalize_any_structure_recurses_into_incident_containers(): - """An execution incident holds an ``action`` *dict* — it is a container, - not an action item, so its nested action is normalized too.""" - report = { - "status": "failed", - "execution": [ - { - "action": "tap", - "coordinates": [540, 1200], - COORDINATE_SPACE_KEY: COORDINATE_SPACE_PIXEL, - "attempts": ["Error"], - } - ], - "incident": { - "kind": "exec_error", - "action": { - "action": "tap", - "coordinates": [540, 1200], - COORDINATE_SPACE_KEY: COORDINATE_SPACE_PIXEL, - }, - }, - } - out = normalize_any_structure(report, W, H) - assert out["execution"][0]["coordinates"] == [500, 500] - assert out["incident"]["action"]["coordinates"] == [500, 500] - assert out["incident"]["kind"] == "exec_error" - assert normalize_any_structure(out, W, H) == out diff --git a/tests/unit/utils/test_coordinates_sequence.py b/tests/unit/utils/test_coordinates_sequence.py deleted file mode 100644 index 133fec695..000000000 --- a/tests/unit/utils/test_coordinates_sequence.py +++ /dev/null @@ -1,105 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests for sequence coordinate normalization and overlay visualization.""" - -import io -from PIL import Image -from artemis.utils.coordinates import normalize_step_actions -from artemis.utils.visualization import draw_action_overlay_on_image - - -def test_normalize_step_actions_click_sequence_does_not_inherit_single_action(): - """Verify that click_sequence does NOT inherit single coordinates from action_taken.""" - step_dict = { - "step_id": "step-123", - "action_taken": [ - { - "action": "tap", - "coordinates": [540, 720], - "normalized_coordinates": [500, 300], - } - ], - "generic_tools": [ - { - "name": "click_sequence", - "payload": { - "args": { - "sequence": "[[500, 300], [876, 360]]", - "delay_ms": "50", - }, - "result": { - "status": "success", - "outcome": "Sequence clicked successfully: Tapped at [540, 720]; Tapped at [946, 864]", - }, - }, - } - ], - "extra_metadata": {"width": 1080, "height": 2400}, - } - - normalized = normalize_step_actions(step_dict) - tool = normalized["generic_tools"][0] - args = tool["payload"]["args"] - - # Coordinates from first_act must NOT be injected into click_sequence - assert "coordinates" not in args or args.get("coordinates") is None - assert "normalized_coordinates" not in args or args.get("normalized_coordinates") is None - - # Sequence must be parsed into a list and normalized - assert args["sequence"] == [[500, 300], [876, 360]] - assert args["normalized_sequence"] == [[500, 300], [876, 360]] - - -def test_normalize_step_actions_physical_sequence(): - """Verify that physical coordinates in sequence are converted into normalized_sequence.""" - step_dict = { - "step_id": "step-456", - "action_taken": [], - "generic_tools": [ - { - "name": "click_sequence", - "payload": { - "args": { - "sequence": [[540, 1200], [1080, 2400]], - } - }, - } - ], - "extra_metadata": {"width": 1080, "height": 2400}, - } - - normalized = normalize_step_actions(step_dict) - args = normalized["generic_tools"][0]["payload"]["args"] - - assert args["normalized_sequence"] == [[500, 500], [1000, 1000]] - - -def test_draw_action_overlay_click_sequence(): - """Verify PIL overlay drawing for click_sequence with stringified sequence.""" - img = Image.new("RGB", (1080, 2400), color="blue") - buf = io.BytesIO() - img.save(buf, format="JPEG") - raw_bytes = buf.getvalue() - - action_args = { - "sequence": "[[500, 300], [876, 360]]", - } - - annotated_bytes = draw_action_overlay_on_image(raw_bytes, "click_sequence", action_args) - assert annotated_bytes is not None - assert len(annotated_bytes) > 0 - - res_img = Image.open(io.BytesIO(annotated_bytes)) - assert res_img.size == (1080, 2400) diff --git a/tests/unit/utils/test_element_hit_test.py b/tests/unit/utils/test_element_hit_test.py deleted file mode 100644 index 19b4fe738..000000000 --- a/tests/unit/utils/test_element_hit_test.py +++ /dev/null @@ -1,97 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Tests for element hit testing (used by the Explorer's candidate de-dup).""" - -from artemis.utils.element_hit_test import find_element_at_point - - -def _xml_el(text, bounds, resource_id=None, cls="android.widget.TextView"): - return { - "text": text, - "bounds": bounds, - "class": cls, - "resource_id": resource_id, - "is_ocr": False, - } - - -def test_smallest_covering_element_wins(): - elements = [ - _xml_el("Whole screen", [0, 0, 1080, 2400]), - _xml_el("Card", [100, 100, 500, 500]), - _xml_el("Button", [200, 200, 300, 260], resource_id="btn_ok"), - ] - el, source = find_element_at_point(elements, 250, 230) - assert source == "hit_test" - assert el["text"] == "Button" - - -def test_xml_element_preferred_over_smaller_ocr_element(): - elements = [ - _xml_el("Login", [100, 100, 500, 300]), - { - "text": "Login OCR", - "bounds": [200, 150, 260, 180], - "class": None, - "resource_id": None, - "is_ocr": True, - }, - ] - el, source = find_element_at_point(elements, 220, 160) - assert source == "hit_test" - assert el["text"] == "Login" - - -def test_ocr_fallback_when_no_xml_element_covers_point(): - elements = [ - _xml_el("Elsewhere", [800, 800, 900, 900]), - { - "text": "OCR Label", - "bounds": [100, 100, 300, 200], - "class": None, - "resource_id": None, - "is_ocr": True, - }, - ] - el, source = find_element_at_point(elements, 150, 150) - assert source == "ocr" - assert el["text"] == "OCR Label" - - -def test_string_bounds_are_parsed(): - elements = [_xml_el("Item", "[100,100][300,500]")] - el, source = find_element_at_point(elements, 200, 300) - assert source == "hit_test" - assert el["text"] == "Item" - - -def test_graceful_degradation_to_none(): - assert find_element_at_point(None, 100, 100) == (None, "none") - assert find_element_at_point([], 100, 100) == (None, "none") - - # Point outside every element - elements = [_xml_el("Item", [0, 0, 50, 50])] - assert find_element_at_point(elements, 500, 500) == (None, "none") - - # Malformed element data must not raise - malformed = [ - {"text": "no bounds"}, - {"text": "bad bounds", "bounds": "garbage"}, - {"text": "short bounds", "bounds": [1, 2]}, - {"text": "non-numeric", "bounds": ["a", "b", "c", "d"]}, - "not-a-dict", - None, - ] - assert find_element_at_point(malformed, 10, 10) == (None, "none") diff --git a/tests/unit/utils/test_image_hash.py b/tests/unit/utils/test_image_hash.py deleted file mode 100644 index f32cadc20..000000000 --- a/tests/unit/utils/test_image_hash.py +++ /dev/null @@ -1,81 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Perceptual-hash stability and distance behavior (M4 similarity hint).""" - -import io - -from PIL import Image, ImageDraw - -from artemis.utils.image_hash import HASH_SIZE, dhash_hex, hamming_distance_hex - - -def _screen(draw_fn, size=(270, 600), quality=85) -> bytes: - img = Image.new("RGB", size, "white") - draw_fn(ImageDraw.Draw(img)) - buf = io.BytesIO() - img.save(buf, format="JPEG", quality=quality) - return buf.getvalue() - - -def _login_screen(d): - d.rectangle([20, 40, 250, 90], fill="#3355ff") - d.rectangle([30, 200, 240, 250], outline="#333333", width=4) - d.rectangle([30, 300, 240, 350], outline="#333333", width=4) - d.rectangle([60, 450, 210, 510], fill="#22aa66") - - -def _settings_screen(d): - for y in range(60, 560, 80): - d.rectangle([10, y, 260, y + 50], fill="#dddddd") - d.ellipse([200, 20, 250, 45], fill="#aa2222") - - -def test_dhash_is_stable_for_identical_bytes(): - img = _screen(_login_screen) - assert dhash_hex(img) == dhash_hex(img) - assert len(dhash_hex(img)) == HASH_SIZE * HASH_SIZE // 4 - - -def test_dhash_survives_jpeg_reencoding_with_small_distance(): - original = _screen(_login_screen, quality=95) - with Image.open(io.BytesIO(original)) as img: - buf = io.BytesIO() - img.save(buf, format="JPEG", quality=60) - reencoded = buf.getvalue() - - distance = hamming_distance_hex(dhash_hex(original), dhash_hex(reencoded)) - assert distance is not None and distance <= 4 - - -def test_dhash_separates_different_screens(): - distance = hamming_distance_hex( - dhash_hex(_screen(_login_screen)), dhash_hex(_screen(_settings_screen)) - ) - assert distance is not None and distance > 8 - - -def test_dhash_best_effort_on_garbage(): - assert dhash_hex(None) is None - assert dhash_hex(b"") is None - assert dhash_hex(b"not an image at all") is None - - -def test_hamming_distance_edge_cases(): - assert hamming_distance_hex(None, "ff") is None - assert hamming_distance_hex("ff", None) is None - assert hamming_distance_hex("ff", "ffff") is None # length mismatch - assert hamming_distance_hex("zz", "aa") is None # not hex - assert hamming_distance_hex("00ff", "00ff") == 0 - assert hamming_distance_hex("0000", "000f") == 4 diff --git a/tests/unit/utils/test_media.py b/tests/unit/utils/test_media.py deleted file mode 100644 index 423b72c7a..000000000 --- a/tests/unit/utils/test_media.py +++ /dev/null @@ -1,42 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json - -from third_party.mobile_use.utils.media import ( - create_steps_json_from_trace_folder, - remove_steps_json_from_trace_folder, -) - - -def test_step_compilation_preserves_recording_manifest(tmp_path): - step_file = tmp_path / "123.json" - step_file.write_text('{"action": "click"}', encoding="utf-8") - recording_manifest = tmp_path / "recording.json" - recording_manifest.write_text( - '{"version": 1, "segments": [{"file": "recording.mp4"}]}', - encoding="utf-8", - ) - - create_steps_json_from_trace_folder(tmp_path) - - compiled = json.loads((tmp_path / "steps.json").read_text(encoding="utf-8")) - assert compiled == [{"timestamp": 123, "data": '{"action": "click"}'}] - assert recording_manifest.exists() - - remove_steps_json_from_trace_folder(tmp_path) - - assert not step_file.exists() - assert recording_manifest.exists() - assert (tmp_path / "steps.json").exists() diff --git a/tests/unit/utils/test_notes.py b/tests/unit/utils/test_notes.py deleted file mode 100644 index 6671c6d2b..000000000 --- a/tests/unit/utils/test_notes.py +++ /dev/null @@ -1,165 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from artemis.utils.notes import ( - append_note_content, - list_notes_info, - read_note_content, - save_note_content, - update_note_content, -) -import pytest - - -def test_list_notes_info(tmp_path): - base_dir = tmp_path - - # Create some notes - save_note_content(base_dir, "note1", "line1\nline2\nline3") - save_note_content(base_dir, "note2", "line1\nline2") - - info = list_notes_info(base_dir) - assert info == {"note1": 3, "note2": 2} - - -def test_read_note_content_range(tmp_path): - base_dir = tmp_path - save_note_content(base_dir, "note1", "line1\nline2\nline3\nline4") - - # Read full - assert read_note_content(base_dir, "note1") == "line1\nline2\nline3\nline4" - - # Read range - assert read_note_content(base_dir, "note1", start_line=2, end_line=3) == "line2\nline3" - - # Read from start to line - assert read_note_content(base_dir, "note1", end_line=2) == "line1\nline2" - - # Read from line to end - assert read_note_content(base_dir, "note1", start_line=3) == "line3\nline4" - - -def test_update_note_exact_match(tmp_path): - base_dir = tmp_path - original_content = 'def demo():\n print("Hello, World!")\n return True\n' - save_note_content(base_dir, "task_plan", original_content) - - # Exact match - warning = update_note_content( - base_dir, - "task_plan", - ' print("Hello, World!")', - ' print("Hello, Jetski!")', - ) - assert warning is None - - updated_content = read_note_content(base_dir, "task_plan") - assert 'print("Hello, Jetski!")' in updated_content - assert 'print("Hello, World!")' not in updated_content - - -def test_update_note_relaxed_match(tmp_path): - base_dir = tmp_path - # Multi-line content with checkboxes and indentation - original_content = ( - "- [ ] Step 1: Research the issue\n - [ ] Step 2: Write code\n - [ ] Step 3: Run tests\n" - ) - save_note_content(base_dir, "task_plan", original_content) - - # Relaxed match: case difference, quote difference, checkbox bullet difference, trailing whitespace - target = " * [ ] step 2: write code " - replacement = " - [/] Step 2: In progress" - - warning = update_note_content(base_dir, "task_plan", target, replacement) - - # Warning should be returned - assert warning is not None - assert "We did our best to apply changes despite some inaccuracies" in warning - - updated_content = read_note_content(base_dir, "task_plan") - assert "Step 2: In progress" in updated_content - assert "Step 2: Write code" not in updated_content - # Check indentation is preserved from replacement - assert " - [/] Step 2: In progress\n" in updated_content - - -def test_update_note_fuzzy_match(tmp_path): - base_dir = tmp_path - original_content = 'print("Alpha message")\nprint("Gamma message")\nprint("Delta message")\n' - save_note_content(base_dir, "task_plan", original_content) - - # Fuzzy match: typo (Gemma vs Gamma) - target = 'print("Gemma message")' - replacement = 'print("Gamma updated!")' - - warning = update_note_content(base_dir, "task_plan", target, replacement) - assert warning is not None - assert "We did our best to apply changes despite some inaccuracies" in warning - - updated_content = read_note_content(base_dir, "task_plan") - assert 'print("Gamma updated!")' in updated_content - assert 'print("Gamma message")' not in updated_content - - -def test_update_note_ambiguous_match(tmp_path): - base_dir = tmp_path - # Multiple lines that could match target - original_content = 'print("Beta message")\nprint("Zeta message")\nprint("Eta message")\n' - save_note_content(base_dir, "task_plan", original_content) - - # Target is highly ambiguous (Xeta is almost equally close to Beta and Zeta) - target = 'print("Xeta message")' - replacement = 'print("Ambiguous match!")' - - # Should raise ValueError due to ambiguity or inability to find unique match - with pytest.raises(ValueError) as exc_info: - update_note_content(base_dir, "task_plan", target, replacement) - - assert "Target string 'print(\"Xeta message\")' not found" in str( - exc_info.value - ) or "not unique" in str(exc_info.value) - - -def test_update_note_not_found(tmp_path): - base_dir = tmp_path - original_content = 'print("Alpha message")\n' - save_note_content(base_dir, "task_plan", original_content) - - # Totally different target - target = 'print("Something totally different")' - replacement = 'print("Hello")' - - with pytest.raises(ValueError) as exc_info: - update_note_content(base_dir, "task_plan", target, replacement) - assert "not found" in str(exc_info.value) - - -def test_append_note_content(tmp_path): - base_dir = tmp_path - - # Case 1: File does not exist - append_note_content(base_dir, "new_note", "hello") - assert read_note_content(base_dir, "new_note") == "hello\n" - - # Case 2: File exists and ends with a newline - append_note_content(base_dir, "new_note", "world") - assert read_note_content(base_dir, "new_note") == "hello\nworld\n" - - # Case 3: File exists and does not end with a newline - file_path = base_dir / "notes" / "no_newline.md" - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.write_text("no newline", encoding="utf-8") - - append_note_content(base_dir, "no_newline", "added text") - assert read_note_content(base_dir, "no_newline") == "no newline\nadded text\n" diff --git a/tests/unit/utils/test_ocr_xml_fusion.py b/tests/unit/utils/test_ocr_xml_fusion.py deleted file mode 100644 index e99e60f43..000000000 --- a/tests/unit/utils/test_ocr_xml_fusion.py +++ /dev/null @@ -1,248 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import base64 -from io import BytesIO -import unittest -from artemis.utils.ocr_xml_fusion import ( - _calculate_overlap_ratio, - _crop_image_remove_status_bar, - _detect_status_bar_height, - _is_low_value_text, - _map_coordinates_back, - _parse_ocr_position, - fuse_ocr_with_xml, -) -from PIL import Image - - -class TestOCRFusion(unittest.TestCase): - def test_is_low_value_text(self): - # Test length 1 - self.assertTrue(_is_low_value_text("a")) - self.assertFalse(_is_low_value_text("Yes")) - self.assertFalse(_is_low_value_text("1")) - - # Test placeholders - self.assertTrue(_is_low_value_text("search")) - - # Test default markers - self.assertTrue(_is_low_value_text("[Icon]")) - - # Test valid text - self.assertFalse(_is_low_value_text("Click Me")) - - def test_parse_ocr_position(self): - position = [ - {"x": 10, "y": 20}, - {"x": 50, "y": 20}, - {"x": 50, "y": 60}, - {"x": 10, "y": 60}, - ] - bounds = _parse_ocr_position(position) - self.assertEqual(bounds, {"left": 10, "top": 20, "right": 50, "bottom": 60}) - - def test_calculate_overlap_ratio(self): - ocr_bounds = {"left": 10, "top": 10, "right": 30, "bottom": 30} - xml_bounds = {"left": 0, "top": 0, "right": 40, "bottom": 40} - # OCR is fully inside XML - self.assertEqual(_calculate_overlap_ratio(ocr_bounds, xml_bounds), 1.0) - - xml_bounds = {"left": 20, "top": 20, "right": 40, "bottom": 40} - # Intersection is 10x10 = 100. OCR area is 20x20 = 400. Ratio = 0.25 - self.assertEqual(_calculate_overlap_ratio(ocr_bounds, xml_bounds), 0.25) - - def test_detect_status_bar_height(self): - xml = [ - {"package": "com.android.systemui", "bounds": "[0,0][1080,120]"}, - {"package": "com.example.app", "bounds": "[0,120][1080,2400]"}, - ] - # Pass screen_height=2400, should find 120 from XML - self.assertEqual(_detect_status_bar_height(xml, screen_height=2400), 120) - - # Fallback: should be 4% of 2400 = 96 - xml = [{"package": "com.example.app", "bounds": "[0,0][1080,2400]"}] - self.assertEqual(_detect_status_bar_height(xml, screen_height=2400), 96) - - def test_crop_image_remove_status_bar(self): - # Create a dummy image - img = Image.new("RGB", (100, 100), color="red") - buffered = BytesIO() - img.save(buffered, format="PNG") - img_b64 = base64.b64encode(buffered.getvalue()).decode("utf-8") - - cropped_b64, w, h = _crop_image_remove_status_bar(img_b64, crop_height=20) - self.assertEqual(w, 100) - self.assertEqual(h, 100) - - # Verify cropped size - cropped_img = Image.open(BytesIO(base64.b64decode(cropped_b64))) - self.assertEqual(cropped_img.size, (100, 80)) - - def test_map_coordinates_back(self): - ocr_results = [ - { - "text": "test", - "position": [{"x": 10, "y": 10}, {"x": 20, "y": 20}], - } - ] - mapped = _map_coordinates_back(ocr_results, status_bar_height=100) - self.assertEqual(mapped[0]["position"][0]["y"], 110) - self.assertEqual(mapped[0]["position"][1]["y"], 120) - - def test_fuse_ocr_with_xml(self): - xml = [ - { - "class": "android.widget.Button", - "bounds": "[10,100][200,150]", - "text": "Submit", - }, - { - "class": "android.widget.TextView", - "bounds": "[10,200][200,250]", - "text": "", - }, - ] - ocr = [ - # Should be discarded (similar to Submit) - { - "text": "Submit", - "position": [{"x": 15, "y": 105}, {"x": 95, "y": 145}], - }, - # Multiple OCRs matching the same node (TextView at [10,200]) - # They are on the same line, should be merged in order - { - "text": "Result", - "position": [{"x": 15, "y": 205}, {"x": 95, "y": 245}], - }, - { - "text": "Found", - "position": [{"x": 105, "y": 205}, {"x": 185, "y": 245}], - }, - # Unmatched OCRs that are close to each other (should be merged into one virtual node) - { - "text": "Floating", - "position": [{"x": 15, "y": 305}, {"x": 95, "y": 345}], - }, - { - "text": "Text", - "position": [{"x": 105, "y": 305}, {"x": 185, "y": 345}], - }, - # Unmatched OCR far away (should be separate virtual node) - { - "text": "Far", - "position": [{"x": 15, "y": 405}, {"x": 95, "y": 445}], - }, - ] - - fused = fuse_ocr_with_xml(xml, ocr) - - # Check first node (Submit) - should NOT have ocr_text - self.assertNotIn("ocr_text", fused[0]) - - # Check second node (Result Found) - should have aggregated ocr_elements - self.assertEqual(fused[1]["ocr_elements"][0]["text"], "Result Found") - self.assertIn("[OCR]", fused[1]["class"]) - - # We expect 0 virtual nodes now as per new strategy. - # Total nodes should be just the original 2 XML nodes. - self.assertEqual(len(fused), 2) - - def test_banded_overlap_matching(self): - # Screen is 1080x2400 - # XML has a giant container and a small correct button. - xml = [ - # Giant container (FrameLayout, covers a lot of screen) - { - "class": "android.widget.FrameLayout", - "bounds": "[0,80][1080,1000]", - "text": "", - }, - # Small correct button - { - "class": "android.widget.Button", - "bounds": "[185,84][512,114]", - "text": "", - }, - ] - - # OCR Search is slightly shifted to the left of the Button. - # Button: [185,84][512,114] -> area 327 * 30 = 9810 - # OCR: [174,89][326,109] -> area 152 * 20 = 3040 - # Overlap with Button: - # Intersection: [185,89][326,109] -> area 141 * 20 = 2820 - # Ratio: 2820 / 3040 = 0.927 (Band 1) - # Overlap with FrameLayout: - # OCR is fully inside FrameLayout [0,80][1080,1000] - # Ratio: 1.0 (Band 1) - # Both are in Band 1 (>=0.9). - # The Button has MUCH smaller area than FrameLayout (9810 < 993600). - # So Button should win despite having lower overlap (0.927 < 1.0). - ocr = [ - { - "text": "Search", - "position": [ - {"x": 174, "y": 89}, - {"x": 326, "y": 89}, - {"x": 326, "y": 109}, - {"x": 174, "y": 109}, - ], - } - ] - - fused = fuse_ocr_with_xml(xml, ocr) - - # Check that OCR text "Search" is mounted on the Button node (index 1) - self.assertIn("ocr_elements", fused[1]) - self.assertEqual(fused[1]["ocr_elements"][0]["text"], "Search") - - # And NOT on the FrameLayout node (index 0) - self.assertNotIn("ocr_elements", fused[0]) - - def test_giant_node_exclusion(self): - # Screen is 1080x2400. Area = 2,592,000. - # 10% area = 259,200. - # FrameLayout is 1080x1000 -> area 1,080,000 (>10%). - # It should be excluded from matching entirely. - # Since there is no other node, the OCR should be discarded. - xml = [ - { - "class": "android.widget.FrameLayout", - "bounds": "[0,0][1080,1000]", - "text": "", - }, - ] - ocr = [ - # Some floating developer text "dX : 0.0" - { - "text": "dX : 0.0", - "position": [ - {"x": 144, "y": 55}, - {"x": 215, "y": 55}, - {"x": 215, "y": 63}, - {"x": 144, "y": 63}, - ], - } - ] - - fused = fuse_ocr_with_xml(xml, ocr) - - # The giant FrameLayout should NOT have ocr_elements - self.assertNotIn("ocr_elements", fused[0]) - # The class should NOT be updated with [OCR] - self.assertNotIn("[OCR]", fused[0].get("class", "")) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/utils/test_optional_ocr.py b/tests/unit/utils/test_optional_ocr.py deleted file mode 100644 index d8aa0dcd7..000000000 --- a/tests/unit/utils/test_optional_ocr.py +++ /dev/null @@ -1,185 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Unit tests verifying that OCR is optional and the system degrades gracefully.""" - -from unittest.mock import AsyncMock, MagicMock, patch -import pytest - -from artemis.agents.explorer.explorer import Explorer -from artemis.config.settings import Settings -from artemis.graph.perception import perception_node -from artemis.graph.state import State -from artemis.tools.index import get_tools_from_wrappers -from artemis.tools.mobile.ocr import ( - ocr_recognition, - ocr_recognition_wrapper, -) -from artemis.utils.ocr_api import is_ocr_configured, perform_ocr -from artemis.utils.ocr_xml_fusion import fuse_ocr_with_xml -from artemis.utils.visualization import format_minimal_list_with_elements - - -@pytest.mark.asyncio -async def test_is_ocr_configured_false_when_unset(monkeypatch): - """Verify is_ocr_configured returns False when keys are unset or placeholder.""" - monkeypatch.delenv("OCR_API_KEY", raising=False) - monkeypatch.delenv("VISION_API_KEY", raising=False) - monkeypatch.delenv("API_KEY", raising=False) - monkeypatch.delenv("GOOGLE_API_KEY", raising=False) - - with patch.object(Settings, "get_api_key", return_value=None): - assert not is_ocr_configured() - - -@pytest.mark.asyncio -async def test_perform_ocr_returns_empty_list_when_unconfigured(monkeypatch): - """Verify perform_ocr returns [] when no OCR key is present.""" - with ( - patch.object(Settings, "get_api_key", return_value=None), - patch.dict("os.environ", {}, clear=True), - ): - result = await perform_ocr(screenshot_b64="dummy_b64") - assert result == [] - - -@pytest.mark.asyncio -async def test_perform_ocr_executes_api_call_when_configured(): - """Verify perform_ocr makes API request and parses responses when configured.""" - mock_key = MagicMock() - mock_key.get_secret_value.return_value = "test_key" - - with ( - patch.object(Settings, "get_api_key", return_value=mock_key), - patch("artemis.utils.ocr_api.get_http_client") as mock_client, - ): - mock_resp = MagicMock() - mock_resp.status_code = 200 - mock_resp.json.return_value = { - "responses": [ - { - "textAnnotations": [ - {"description": "Full text\nLogin", "boundingPoly": {}}, - { - "description": "Login", - "boundingPoly": { - "vertices": [{"x": 100, "y": 200}, {"x": 300, "y": 250}] - }, - }, - ] - } - ] - } - mock_http = AsyncMock() - mock_http.post.return_value = mock_resp - mock_client.return_value = mock_http - - result = await perform_ocr(screenshot_b64="dummy_b64") - assert len(result) == 1 - assert result[0]["text"] == "Login" - - -def test_fuse_ocr_with_xml_empty_ocr_retains_xml_pruning(): - """Verify fuse_ocr_with_xml preserves full XML structure and allows downstream pruning.""" - sample_xml = [ - { - "class": "android.widget.TextView", - "text": "Submit Button", - "bounds": "[100,200][300,250]", - }, - { - "class": "android.widget.ImageView", - "content-desc": "Settings Icon", - "bounds": "[900,50][980,130]", - }, - ] - - # Fusing with empty OCR results - fused_xml = fuse_ocr_with_xml(sample_xml, []) - assert len(fused_xml) == 2 - assert fused_xml[0]["text"] == "Submit Button" - - # Downstream XML formatting / pruning - minimal_list, elements, labels = format_minimal_list_with_elements( - fused_xml, width=1080, height=2400 - ) - assert len(elements) == 2 - assert "Submit Button" in minimal_list - assert "Settings Icon" in minimal_list - assert labels == ["1", "2"] - - -def test_ocr_tool_not_exposed_when_unconfigured(): - """Verify ocr_recognition tool is hidden and not exposed when OCR is unconfigured.""" - with patch("artemis.tools.mobile.ocr.is_ocr_configured", return_value=False): - assert not ocr_recognition.is_available() - - # get_tools_from_wrappers excludes ocr_recognition_wrapper - mock_ctx = MagicMock() - wrapped_tools = get_tools_from_wrappers(mock_ctx, [ocr_recognition_wrapper]) - assert len(wrapped_tools) == 0 - - -def test_explorer_does_not_expose_get_ocr_list_when_unconfigured(): - """Verify Explorer does not expose get_ocr_list tool declaration when OCR is unconfigured.""" - with patch("artemis.agents.explorer.explorer.is_ocr_configured", return_value=False): - mock_ctx = MagicMock() - mock_ctx.agent_config = None - explorer = Explorer(mock_ctx) - explorer.denylisted_tools = set() - - exposed = explorer.get_exposed_tools() - exposed_names = [t.name for t in exposed] - assert "get_ocr_list" not in exposed_names - - -@pytest.mark.asyncio -async def test_perception_node_runs_smoothly_without_ocr(): - """Verify perception_node runs from start to finish with pure XML when OCR is unconfigured.""" - with ( - patch("artemis.graph.perception.is_ocr_configured", return_value=False), - patch("artemis.graph.perception.UnifiedMobileController") as mock_controller_cls, - ): - mock_controller = MagicMock() - mock_device_data = MagicMock() - mock_device_data.width = 1080 - mock_device_data.height = 2400 - mock_device_data.base64 = "ZHVtbXk=" - mock_device_data.bytes = b"dummy" - mock_device_data.elements = [ - { - "class": "android.widget.Button", - "text": "Login", - "bounds": "[100,500][400,600]", - } - ] - mock_controller.get_screen_data = AsyncMock(return_value=mock_device_data) - mock_controller_cls.return_value = mock_controller - - mock_ctx = MagicMock() - mock_ctx.data_engine = MagicMock() - mock_ctx.data_engine.current_step_id = "step_1" - mock_ctx.device = MagicMock() - mock_ctx.device.device_width = 1080 - mock_ctx.device.device_height = 2400 - - mock_state = MagicMock(spec=State) - mock_state.structured_decisions = [] - - update = await perception_node(mock_state, mock_ctx) - assert update is not None - assert "latest_ui_hierarchy" in update - assert len(update["latest_ui_hierarchy"]) == 1 - assert update["latest_ui_hierarchy"][0]["text"] == "Login" - assert update["operator_raw_data"]["ocr_results"] == [] diff --git a/tests/unit/utils/test_plan_grammar.py b/tests/unit/utils/test_plan_grammar.py deleted file mode 100644 index c6d0de555..000000000 --- a/tests/unit/utils/test_plan_grammar.py +++ /dev/null @@ -1,321 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json -from pathlib import Path - -from artemis.utils.plan_grammar import ( - CONTINUOUS_LOOP_TAG, - PLAN_GRAMMAR_SPEC, - check_items_changed, - milestones_changed, - missing_check_items, - new_top_level_completions, - parse_plan, - render_plan_grammar_spec, - restore_missing_check_items, - subgoal_hash, - unintended_milestone_edits, -) - -PLAN = """# Task Plan -Some free-form context the parser must ignore. -- [x] Open the email app -- [/] [Loop] Inspect candidates (Exit: all inspected; Interval: n/a) - - [x] Candidate #1 - - [/] Candidate #2 -- [ ] [Loop:continuous] Monitor inbox (Interval: every 5 minutes) -- [!] Blocked milestone -""" - - -def test_parse_plan_machine_channel_only(): - snap = parse_plan(PLAN) - assert len(snap.items) == 6 - assert len(snap.top_level) == 4 - assert snap.milestone_texts[0] == "Open the email app" - - top = snap.top_level - assert top[0].is_done - assert top[1].is_active and top[1].is_loop and not top[1].is_continuous - assert top[2].is_pending and top[2].is_loop and top[2].is_continuous - assert top[3].status == "!" - - sub = [i for i in snap.items if not i.is_top_level] - assert all(i.indent == 2 for i in sub) - - -def test_parse_plan_empty_and_none(): - assert parse_plan(None).items == () - assert parse_plan("").items == () - assert not parse_plan("just prose, no checkboxes").has_top_level - - -def test_snapshot_termination_properties(): - assert not parse_plan(PLAN).all_top_level_done - done = "- [x] A\n- [x] B" - assert parse_plan(done).all_top_level_done - assert parse_plan("- [x] A\n- [x] [Loop:continuous] M").continuous_top_level - - -def test_active_item_navigation(): - snap = parse_plan(PLAN) - assert snap.first_active().text.startswith("[Loop] Inspect") - assert snap.last_active().text == "Candidate #2" - parent = snap.parent_of(snap.last_active()) - assert parent is not None and parent.text.startswith("[Loop] Inspect") - - -def test_milestones_changed_only_on_text(): - before = parse_plan("- [ ] A\n- [ ] B") - status_flip = parse_plan("- [x] A\n- [/] B") - reworded = parse_plan("- [ ] A\n- [ ] B improved") - added = parse_plan("- [ ] A\n- [ ] B\n- [ ] C") - assert not milestones_changed(before, status_flip) - assert milestones_changed(before, reworded) - assert milestones_changed(before, added) - - -def test_new_top_level_completions_ordered(): - before = parse_plan("- [x] A\n- [/] B\n- [ ] C") - after = parse_plan("- [x] A\n- [x] B\n- [x] C") - assert new_top_level_completions(before, after) == ["B", "C"] - # Sub-task completions never trigger - sub_after = parse_plan("- [x] A\n- [/] B\n - [x] B1\n- [ ] C") - assert new_top_level_completions(before, sub_after) == [] - - -def test_unintended_milestone_edits_pairing(): - before = parse_plan("- [x] A\n- [/] B\n- [ ] C") - reword_untouched = parse_plan("- [x] A modified\n- [/] B\n- [ ] C") - assert unintended_milestone_edits(before, reword_untouched) == [("A", "A modified")] - - # Status changed on the same line: counts as an intentional touch - complete_and_annotate = parse_plan("- [x] A\n- [x] B (verified)\n- [ ] C") - assert unintended_milestone_edits(before, complete_and_annotate) == [] - - # Structural change: not this check's business - removed = parse_plan("- [x] A\n- [/] B") - assert unintended_milestone_edits(before, removed) == [] - - -def test_subgoal_hash_stability(): - assert subgoal_hash(" A ") == subgoal_hash("A") - item = parse_plan("- [/] Goal text").items[0] - assert item.key == subgoal_hash("Goal text") - - -CHECK_PLAN = """# Task Plan -- [x] Create the 7:30 AM alarm in the Clock app - - [ ] open clock app - - verify: the alarm list shows 7:30 AM with its toggle ON - - assert: an "Alarm set" toast should appear after creation -- [ ] Send the report email - - verify: the email appears in the Sent folder -- assert@end: no application crash dialog should ever appear -- verify@end: the device is back on the home screen -""" - - -def test_check_lines_parse_with_anchor_and_timing(): - snap = parse_plan(CHECK_PLAN) - checks = snap.all_check_items - assert len(checks) == 5 - - alarm = next(i for i in snap.top_level if i.text.startswith("Create the")) - alarm_checks = snap.check_items_of(alarm) - assert [c.kind for c in alarm_checks] == ["verify", "assert"] - assert all(c.when == "on_complete" for c in alarm_checks) - assert alarm_checks[0].text == "the alarm list shows 7:30 AM with its toggle ON" - assert all(c.parent_key == alarm.key for c in alarm_checks) - - task_level = snap.task_level_check_items - assert len(task_level) == 2 - assert all(c.when == "at_end" for c in task_level) - assert all(c.parent_key is None for c in task_level) - assert task_level[0].kind == "assert" - assert task_level[1].kind == "verify" - - -def test_check_lines_do_not_disturb_machine_channel(): - """Check lines must be invisible to milestone hashing, drift detection, - and completion triggers.""" - without = CHECK_PLAN - for line in list(without.splitlines()): - if "verify" in line or ("assert" in line and "- [" not in line): - without = without.replace(line + "\n", "") - with_snap = parse_plan(CHECK_PLAN) - without_snap = parse_plan(without) - - assert with_snap.milestone_texts == without_snap.milestone_texts - assert [i.key for i in with_snap.items] == [i.key for i in without_snap.items] - assert not milestones_changed(without_snap, with_snap) - assert new_top_level_completions(without_snap, with_snap) == [] - assert unintended_milestone_edits(without_snap, with_snap) == [] - - -def test_check_items_changed_multiset(): - a = parse_plan("- [ ] G\n - verify: X\n - verify: X") - b = parse_plan("- [ ] G\n - verify: X") - c = parse_plan("- [ ] G\n - verify: X\n - verify: X") - assert check_items_changed(a, b) - assert not check_items_changed(a, c) - # Status flips never register as check changes - d = parse_plan("- [x] G\n - verify: X\n - verify: X") - assert not check_items_changed(a, d) - - -def test_restore_missing_check_items_reanchors_under_parent(): - before = "- [ ] G1\n - verify: V1\n- [ ] G2\n" - after = "- [x] G1\n- [ ] G2\n" - merged = restore_missing_check_items(before, after) - assert merged is not None - lines = merged.splitlines() - assert lines[0] == "- [x] G1" - assert lines[1] == " - verify: V1" - assert lines[2] == "- [ ] G2" - # Idempotent: nothing missing after the merge - assert restore_missing_check_items(before, merged) is None - - -def test_restore_missing_check_items_orphan_becomes_task_level_at_end(): - before = "- [ ] G1\n - assert: A1\n- [ ] G2\n" - after = "- [ ] G2\n" # parent subgoal removed entirely - merged = restore_missing_check_items(before, after) - assert merged is not None - assert merged.splitlines()[-1] == "- assert@end: A1" - - -def test_restore_missing_check_items_keeps_additions(): - before = "- [ ] G1\n - verify: V1\n" - after = "- [ ] G1\n - verify: V1\n - verify: V2 (new)\n" - assert restore_missing_check_items(before, after) is None - assert not missing_check_items(parse_plan(before), parse_plan(after)) - - -def test_render_plan_grammar_spec_conditional(): - base = render_plan_grammar_spec(midway=False, final=False) - extended = render_plan_grammar_spec(midway=True, final=True) - # Both-gates-off output carries zero trace of the checking feature - assert base == PLAN_GRAMMAR_SPEC - assert "verify" not in base and "assert" not in base - assert extended.startswith(base) - assert "- verify:" in extended and "- assert:" in extended and "@end" in extended - # The capability boundary is declared honestly - assert "POST-HOC" in extended.upper() or "post-hoc" in extended - # Compatibility shim: ``include_checks`` means "both gates". - assert render_plan_grammar_spec(include_checks=True) == extended - assert render_plan_grammar_spec(include_checks=False) == base - - -def test_render_plan_grammar_spec_words_the_judgment_moment_per_gate(): - """The grammar tells the truth about when a check line is judged: a - completion-time Checker with a repair loop only exists with midway checks; - the factory layering (final review only) judges once at task exit.""" - midway = render_plan_grammar_spec(midway=True, final=True) - assert "at the moment the milestone is marked completed" in midway - assert "reopens the milestone for repair" in midway - - final_only = render_plan_grammar_spec(midway=False, final=True) - assert ( - "judged once at task exit from the recorded evidence around the milestone's" - " completion; there is no midway repair loop" in final_only - ) - assert "at the moment the milestone is marked completed" not in final_only - assert "reopens the milestone" not in final_only - assert "- assert:" in final_only and "@end" in final_only - - -def test_grammar_spec_renders_constants(): - assert CONTINUOUS_LOOP_TAG in PLAN_GRAMMAR_SPEC - assert "[Loop]" in PLAN_GRAMMAR_SPEC - assert "[/]" in PLAN_GRAMMAR_SPEC and "[x]" in PLAN_GRAMMAR_SPEC - - -def test_prompts_carry_the_single_sourced_grammar(): - """Drift guard: the prompts that teach the plan format must splice in the - machine-enforced grammar spec and reference the formal continuous tag, - and must no longer key loop protection on Exit wording.""" - agents_dir = Path(__file__).resolve().parents[3] / "artemis" / "agents" - - planner_raw = (agents_dir / "planner" / "planner.json").read_text(encoding="utf-8") - operator_raw = (agents_dir / "operator" / "operator.json").read_text(encoding="utf-8") - - for raw in (planner_raw, operator_raw): - assert "{{ plan_grammar }}" in raw - assert "[Loop:continuous]" in raw - - # The old wording-keyed protection contract must not resurface - planner = json.loads(planner_raw) - guidelines = planner["blocks"]["validator_guidelines"] - assert "until manually stopped" not in guidelines - - -# --- Finding lines (system channel; single-direction projection) --------------------- - - -def test_finding_lines_are_invisible_to_the_machine_channel(): - from artemis.utils.plan_grammar import apply_finding_lines - - plan = "- [x] G1\n - verify: V1\n- [ ] G2\n" - key = subgoal_hash("G1") - with_finding = apply_finding_lines( - plan, {key: "verify failed — 'V1' (details: note 'checker-x')"} - ) - - assert "- finding: verify failed" in with_finding - - before, after = parse_plan(plan), parse_plan(with_finding) - # Same items, same hashes, same check items: no milestone drift, no - # planner-validation trigger, no check multiset change. - assert [i.key for i in after.items] == [i.key for i in before.items] - assert milestones_changed(before, after) is False - assert check_items_changed(before, after) is False - assert new_top_level_completions(before, after) == [] - assert unintended_milestone_edits(before, after) == [] - - -def test_apply_finding_lines_projection_is_idempotent_and_anchored(): - from artemis.utils.plan_grammar import apply_finding_lines, strip_finding_lines - - plan = "- [x] G1\n - verify: V1\n- [/] G2\n" - findings = {subgoal_hash("G1"): "headline one"} - once = apply_finding_lines(plan, findings) - twice = apply_finding_lines(once, findings) - assert once == twice # idempotent projection - # Anchored directly under its parent subgoal - lines = once.splitlines() - assert lines[lines.index("- [x] G1") + 1] == " - finding: headline one" - - # Resolution: an empty registry strips the line back out - assert apply_finding_lines(once, {}) == plan - assert strip_finding_lines(once) == plan - - -def test_apply_finding_lines_drops_entries_for_missing_subgoals(): - from artemis.utils.plan_grammar import apply_finding_lines - - plan = "- [x] G1 (rephrased)\n" - projected = apply_finding_lines(plan, {subgoal_hash("G1"): "stale headline"}) - assert "finding" not in projected - - -def test_finding_grammar_doc_is_conditional(): - base = render_plan_grammar_spec(midway=False, final=False) - midway = render_plan_grammar_spec(midway=True, final=False) - final_only = render_plan_grammar_spec(midway=False, final=True) - assert "finding" not in base - assert "- finding:" in midway and "checker-" in midway - # Finding lines are projected by the midway checkpoint machinery only. - assert "- finding:" not in final_only diff --git a/tests/unit/utils/test_task_tree.py b/tests/unit/utils/test_task_tree.py deleted file mode 100644 index 190abd48c..000000000 --- a/tests/unit/utils/test_task_tree.py +++ /dev/null @@ -1,1001 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import hashlib -from artemis.utils.task_tree import ( - format_step_action_result, - build_plan_and_history, - get_active_subgoal_hashes, - get_recent_subgoal_hashes, -) - - -def test_build_plan_and_history_separated_layout(): - plan = """- [ ] Open Settings app -- [ ] Navigate to System settings""" - - # No steps executed - output = build_plan_and_history(plan, [], "default") - assert plan in output - assert "--- Execution History ---" in output - assert "No steps executed yet." in output - - # With steps - steps = [ - { - "step_id": "step_1", - "step_number": 1, - "relative_time": "2.5s", - "summary": "Launched settings app", - "operator_raw_thinking": "Thought 1", - "action_taken": [{"action": "launch_app"}], - "last_execution_result": {"status": "success"}, - } - ] - output = build_plan_and_history(plan, steps, "default", last_n_detailed=1) - assert plan in output - assert "--- Execution History ---" in output - assert "- **Step 1 (Most Recent Step, Start: 2.5s)**" in output - assert " - Thought 1" in output - assert " * [Action]: Launched app 'None'" in output - - -def test_build_plan_and_history_granularities(): - plan = """- [x] Open Settings app -- [/] Navigate to System settings -- [ ] Navigate to Languages & input""" - - subgoal_hash_1 = hashlib.md5(b"Open Settings app").hexdigest() - subgoal_hash_2 = hashlib.md5(b"Navigate to System settings").hexdigest() - - steps = [ - { - "step_id": "step_1", - "step_number": 1, - "relative_time": "2.5s", - "summary": "Launched settings", - "operator_raw_thinking": "Thought 1", - "action_taken": [{"action": "launch_app"}], - "last_execution_result": {"status": "success"}, - "extra_metadata": {"subgoal_hash": subgoal_hash_1}, - }, - { - "step_id": "step_2", - "step_number": 2, - "relative_time": "8.0s", - "summary": "Swiped down", - "operator_raw_thinking": "Thought 2", - "action_taken": [{"action": "swipe"}], - "last_execution_result": {"status": "success"}, - "extra_metadata": {"subgoal_hash": subgoal_hash_2}, - }, - { - "step_id": "step_3", - "step_number": 3, - "relative_time": "15.0s", - "summary": "Clicked System settings", - "operator_raw_thinking": "Thought 3", - "action_taken": [{"action": "click"}], - "last_execution_result": {"status": "success"}, - "extra_metadata": {"subgoal_hash": subgoal_hash_2}, - }, - ] - - # Test last_n_detailed = 0 (high-level analysis context, all summarized) - output_0 = build_plan_and_history(plan, steps, subgoal_hash_2, last_n_detailed=0) - assert "- *Step 1 (Start: 2.5s): Launched settings*" in output_0 - assert "- *Step 2 (Start: 8.0s): Swiped down*" in output_0 - assert "- *Step 3 (Start: 15.0s): Clicked System settings*" in output_0 - assert "*Thought*: Thought 3" not in output_0 - - # Test last_n_detailed = 1 (normal execution context, only last detailed) - output_1 = build_plan_and_history(plan, steps, subgoal_hash_2, last_n_detailed=1) - assert "- *Step 1 (Start: 2.5s): Launched settings*" in output_1 - assert "- *Step 2 (Start: 8.0s): Swiped down*" in output_1 - assert "- **Step 3 (Most Recent Step, Start: 15.0s)**" in output_1 - assert " - Thought 3" in output_1 - - # Test last_n_detailed = 3 (committee context, last 3 detailed) - output_3 = build_plan_and_history(plan, steps, subgoal_hash_2, last_n_detailed=3) - assert "- **Step 1 (Start: 2.5s)**" in output_3 - assert " - Thought 1" in output_3 - assert "- **Step 2 (Start: 8.0s)**" in output_3 - assert " - Thought 2" in output_3 - assert "- **Step 3 (Most Recent Step, Start: 15.0s)**" in output_3 - assert " - Thought 3" in output_3 - - # Test keep_subgoal_hashes (milestone filtering for checker/diagnoser) - # Only keep steps for subgoal_hash_2 - output_filter = build_plan_and_history( - plan, steps, subgoal_hash_2, keep_subgoal_hashes={subgoal_hash_2} - ) - assert "Launched settings" not in output_filter # Step 1 is filtered out! - assert "Swiped down" in output_filter # Step 2 belongs to subgoal_hash_2, kept! - assert " - Thought 3" in output_filter # Step 3 is the last step, kept detailed! - - -def test_get_active_subgoal_hashes_fallback(): - # Scenario 1: Normal Active Subgoal (Level 1 [/] active) - plan_normal = """- [x] Open Settings app -- [/] Navigate to System settings -- [ ] Navigate to Languages & input""" - - h_settings = hashlib.md5(b"Open Settings app").hexdigest() - h_system = hashlib.md5(b"Navigate to System settings").hexdigest() - - parent, sub = get_active_subgoal_hashes(plan_normal) - assert parent == h_system - assert sub is None - - # Scenario 2: Indented Sub-subgoal Active (Level 2 [/] active, consolidated to parent) - plan_indented = """- [x] Open Settings app -- [/] Navigate to System settings - - [/] Scroll down to find System settings -- [ ] Navigate to Languages & input""" - - parent, sub = get_active_subgoal_hashes(plan_indented) - assert parent == h_system - assert sub == hashlib.md5(b"Scroll down to find System settings").hexdigest() - - # Scenario 3: Safe Fallback (All pending "[ ]", no active subgoal) - plan_fallback = """- [ ] Open Settings app -- [ ] Navigate to System settings -- [ ] Navigate to Languages & input""" - - parent, sub = get_active_subgoal_hashes(plan_fallback) - assert parent == h_settings # Successfully fell back to the first subgoal's hash! - assert sub is None - - # Scenario 4: All Completed (All "[x]", no active subgoal) - plan_all_done = """- [x] Open Settings app -- [x] Navigate to System settings -- [x] Navigate to Languages & input""" - - parent, sub = get_active_subgoal_hashes(plan_all_done) - assert parent == "default" - assert sub is None - - -def test_build_plan_and_history_sliding_window(): - plan = """- [x] Subgoal 1 -- [x] Subgoal 2 -- [/] Subgoal 3""" - - subgoal_hash_1 = hashlib.md5(b"Subgoal 1").hexdigest() - subgoal_hash_2 = hashlib.md5(b"Subgoal 2").hexdigest() - subgoal_hash_3 = hashlib.md5(b"Subgoal 3").hexdigest() - - steps = [ - { - "step_id": "step_1", - "step_number": 1, - "summary": "Step 1 summary", - "extra_metadata": {"subgoal_hash": subgoal_hash_1}, - }, - { - "step_id": "step_2", - "step_number": 2, - "summary": "Step 2 summary", - "extra_metadata": {"subgoal_hash": subgoal_hash_1}, - }, - { - "step_id": "step_3", - "step_number": 3, - "summary": "Step 3 summary", - "extra_metadata": {"subgoal_hash": subgoal_hash_2}, - }, - { - "step_id": "step_4", - "step_number": 4, - "summary": "Step 4 summary", - "extra_metadata": {"subgoal_hash": subgoal_hash_3}, - }, - { - "step_id": "step_5", - "step_number": 5, - "summary": "Step 5 summary", - "extra_metadata": {"subgoal_hash": subgoal_hash_3}, - }, - { - "step_id": "step_6", - "step_number": 6, - "summary": "Step 6 summary", - "extra_metadata": {"subgoal_hash": subgoal_hash_3}, - }, - { - "step_id": "step_7", - "step_number": 7, - "summary": "Step 7 summary", - "extra_metadata": {"subgoal_hash": subgoal_hash_3}, - }, - ] - - # Case 1: min_summaries = 2 - # subgoal 3 has 4 steps (4, 5, 6, 7). This already exceeds min_summaries=2. - # So older steps (1, 2, 3) from completed subgoals should be compressed/hidden! - output_c1 = build_plan_and_history( - plan, steps, subgoal_hash_3, min_summaries=2, last_n_detailed=0 - ) - assert "Step 1 summary" not in output_c1 - assert "Step 2 summary" not in output_c1 - assert "Step 3 summary" not in output_c1 - assert "Step 4 summary" in output_c1 - assert "Step 5 summary" in output_c1 - assert "Step 6 summary" in output_c1 - assert "Step 7 summary" in output_c1 - - # Case 2: min_summaries = 5 - # subgoal 3 has 4 steps (4, 5, 6, 7), which translates to 3 non-last steps. - # To reach min_summaries=5 non-last steps, the sliding window slides back to keep Step 3 and Step 2 visible! - # But Step 1 should still be compressed/hidden. - output_c2 = build_plan_and_history( - plan, steps, subgoal_hash_3, min_summaries=5, last_n_detailed=0 - ) - assert "Step 1 summary" not in output_c2 - assert "Step 2 summary" in output_c2 # Kept visible by sliding window! - assert "Step 3 summary" in output_c2 # Kept visible by sliding window! - assert "Step 4 summary" in output_c2 - assert "Step 5 summary" in output_c2 - assert "Step 6 summary" in output_c2 - assert "Step 7 summary" in output_c2 - - -def test_build_plan_and_history_clean_success_result(): - plan = "- [ ] Subgoal 1" - steps = [ - { - "step_id": "step_1", - "step_number": 1, - "relative_time": "2.5s", - "summary": "Swiped screen", - "action_taken": [{"action": "swipe"}], - "last_execution_result": { - "executed_actions": [{"action": "swipe"}], - "status": "success", - }, - } - ] - output = build_plan_and_history(plan, steps, "default", last_n_detailed=1) - - # The output should contain "status": "success" but NOT the redundant "executed_actions" - # Success results are stripped under Factual Realism - assert "* [Validator Execution Result]" not in output - assert "executed_actions" not in output - - -def test_build_plan_and_history_with_tool_calls(): - plan = "- [ ] Subgoal 1" - steps = [ - { - "step_id": "step_1", - "step_number": 1, - "relative_time": "2.5s", - "summary": "Read note and swiped", - "action_taken": [{"action": "swipe"}], - "last_execution_result": {"status": "success"}, - "tool_calls": [ - { - "name": "read_note", - "payload": { - "args": {"key": "other_note"}, - "result": "- [ ] Milestone", - }, - "status": "success", - } - ], - } - ] - output = build_plan_and_history(plan, steps, "default", last_n_detailed=1) - - assert "* [Reasoning & tool calls]:" in output - assert '`read_note({"key": "other_note"})` -> - [ ] Milestone' in output - - -def test_build_plan_and_history_with_interleaved_events(): - plan = "- [ ] Subgoal 1" - steps = [ - { - "step_id": "step_1", - "step_number": 1, - "relative_time": "2.5s", - "summary": "Read note and thought", - "action_taken": [{"action": "swipe"}], - "last_execution_result": {"status": "success"}, - "interleaved_events": [ - {"type": "thought", "content": "I want to search for files."}, - { - "type": "tool_call", - "name": "list_notes", - "args": {"dir": "/notes"}, - "result": ["notes.txt"], - }, - {"type": "thought", "content": "I see notes.txt, let's read it."}, - { - "type": "tool_call", - "name": "read_note", - "args": {"file": "notes.txt"}, - "result": "Milestone plan", - }, - ], - } - ] - output = build_plan_and_history(plan, steps, "default", last_n_detailed=1) - - # It should render interleaved thoughts and tool calls in natural order - assert " - I want to search for files." in output - assert ' - [Tool Call]: `list_notes({"dir": "/notes"})` -> [\'notes.txt\']' in output - assert " - I see notes.txt, let's read it." in output - assert ' - [Tool Call]: `read_note({"file": "notes.txt"})` -> Milestone plan' in output - - # It should use the new section header - assert "* [Reasoning & tool calls]:" in output - - -def test_build_plan_and_history_concatenates_thoughts(): - plan = "- [ ] Subgoal 1" - steps = [ - { - "step_id": "step_1", - "step_number": 1, - "relative_time": "2.5s", - "summary": "Step with multiple thoughts", - "action_taken": [{"action": "click", "thought": "Action thought"}], - "operator_raw_thinking": "Raw thought", - "last_execution_result": {"status": "success"}, - "interleaved_events": [ - {"type": "thought", "content": "Event thought"}, - ], - } - ] - output = build_plan_and_history(plan, steps, "default", last_n_detailed=1) - - # Check that all thoughts are concatenated and visible in the output - assert " - Action thought" in output - assert " - Event thought" in output - assert " - Raw thought" in output - - -def test_build_plan_and_history_interleaved_decision_loop(): - plan = "- [ ] Subgoal 1" - steps = [ - { - "step_id": "step_1", - "step_number": 1, - "relative_time": "2.5s", - "summary": "Completed search", - "action_taken": [{"action": "click", "target_text": "Search"}], - "last_execution_result": {"status": "success"}, - "interleaved_events": [ - {"type": "thought", "content": "I should find search icon."}, - { - "type": "tool_call", - "name": "ask_explorer", - "args": {"query": "search icon"}, - "result": {"center": [500, 600]}, - }, - { - "type": "native_thought", - "content": "Clicking coordinates [500, 600] now.", - }, - ], - } - ] - output = build_plan_and_history(plan, steps, "default", last_n_detailed=1) - - assert plan in output - assert "--- Execution History ---" in output - assert "- **Step 1 (Most Recent Step, Start: 2.5s)**" in output - assert "* [Reasoning & tool calls]:" in output - assert " - I should find search icon." in output - assert ( - '- [Tool Call]: `ask_explorer({"query": "search icon"})` -> {"center":' - " [500, 600]}" in output - ) - assert " - Clicking coordinates [500, 600] now." in output - assert "* [Action]: Tapped 'Search' at None" in output - assert "* [Validator Execution Result]" not in output - - -def test_build_plan_and_history_safety_net_and_incident(): - plan = "- [ ] Subgoal 1" - steps = [ - { - "step_id": "step_1", - "step_number": 1, - "relative_time": "5.0s", - "summary": "Tried to click search", - "action_taken": [{"action": "click", "target_text": "Search"}], - "last_execution_result": { - "status": "failed", - "burst": False, - "execution": [ - { - "action": "click", - "target_text": "Search", - "attempts": [ - "Pre-execution validation failed: Target button is not visible" - ], - } - ], - "incident": { - "kind": "safety_net", - "category": "target_disappeared", - "reason": "Target button is not visible", - "action": {"action": "click", "target_text": "Search"}, - "action_description": "Tapped 'Search' at None", - "action_index": 0, - "burst_size": 1, - "step_number": 1, - "consecutive_failures": 2, - "evidence": {}, - }, - }, - "interleaved_events": [ - {"type": "thought", "content": "Let's click search."}, - # Safety net failure - { - "type": "tool_call", - "name": "safety_net_validation", - "args": {"target": "Search"}, - "result": [ - False, - "TARGET_DISAPPEARED", - "Target button is not visible", - ], - }, - ], - } - ] - output = build_plan_and_history(plan, steps, "default", last_n_detailed=1) - - assert "- **Step 1 (Most Recent Step, Start: 5.0s)**" in output - assert "* [Reasoning & tool calls]:" in output - assert " - Let's click search." in output - assert "* [Action]: Tapped 'Search' at None (Intercepted by Pre-Execution Safety Net)" in output - assert "* [Pre-Execution Safety Net]:" in output - assert ( - "- [Safety Net Check]: (Intercepted by Pre-Execution Safety Net: Target button is" - " not visible)" in output - ) - # No repair agent: the incident IS the result line, with its escalation count. - assert "Failure Analyzer" not in output - assert ( - "* [Result]: Error: Intercepted by Pre-Execution Safety Net" - " (target_disappeared, consecutive failure #2) on action `tap 'Search' at" - " None`: Target button is not visible" in output - ) - - -def test_build_plan_and_history_fast_action_burst(): - plan = "- [ ] Subgoal 1" - burst = [ - {"action": "click", "coordinates": [500, 900]}, - {"action": "click", "coordinates": [880, 120], "target_text": "Skip"}, - {"action": "press_key", "keycode": "BACK"}, - ] - steps = [ - { - "step_id": "step_1", - "step_number": 1, - "relative_time": "2.5s", - "summary": "Woke the control bar and tapped Skip", - "action_taken": burst, - "last_execution_result": { - "status": "failed", - "burst": True, - "execution": [ - {"action": "click", "coordinates": [500, 900]}, - { - "action": "click", - "coordinates": [880, 120], - "target_text": "Skip", - "attempts": ["Error: tap rejected"], - }, - { - "action": "press_key", - "keycode": "BACK", - "attempts": ["Skipped (burst aborted)"], - }, - ], - "incident": { - "kind": "exec_error", - "category": "general", - "reason": "Error: tap rejected", - "action": burst[1], - "action_description": "Tapped 'Skip' at [880, 120]", - "action_index": 1, - "burst_size": 3, - "step_number": 1, - "consecutive_failures": 1, - "evidence": {}, - }, - }, - } - ] - # Detailed view: every member with its outcome. - output = build_plan_and_history(plan, steps, "default", last_n_detailed=1) - assert "* [Fast-Action Burst]: 3 actions fired back to back without the safety net" in output - assert " 1. Tapped element at [500, 900] (dispatched)" in output - assert " 2. Tapped 'Skip' at [880, 120] (FAILED: Error: tap rejected)" in output - assert " 3. Pressed key 'BACK' (skipped)" in output - # The failed member is quoted in intent form: it did not happen. - assert ( - "* [Result]: Error: Execution failed (general, consecutive failure #1) on burst" - " action 2/3 `tap 'Skip' at [880, 120]`: Error: tap rejected; the remaining" - " burst actions were not executed" in output - ) - - # Compact view: the whole burst on one line. - compact = format_step_action_result(steps[0]) - assert compact.startswith( - "Fast-action burst (3 actions, unvetted): Tapped element at [500, 900] ->" - " Tapped 'Skip' at [880, 120] -> Pressed key 'BACK'" - ) - assert "(Execution failed: Error: tap rejected)" in compact - - -def test_get_recent_subgoal_hashes_robust_milestone_exclusion(tmp_path): - # Scenario: - # 1. Milestone 1 is completed (hash_completed_1). - # 2. We had a failed attempt 'hash_failed_old' during Milestone 1. - # 3. Milestone 2 is completed (hash_completed_2). - # 4. We are now working on Milestone C (hash_active_c). - # 5. Milestone C had two previous failed renamed versions: Failed Attempt A and Failed Attempt B. - # 6. subgoal_hash_chain.json maps: A -> B -> C. - - base_dir = tmp_path - notes_dir = base_dir / "notes" - notes_dir.mkdir(parents=True, exist_ok=True) - chain_path = notes_dir / "subgoal_hash_chain.json" - - hash_completed_1 = hashlib.md5(b"Completed Milestone 1").hexdigest() - hash_completed_2 = hashlib.md5(b"Completed Milestone 2").hexdigest() - hash_active_c = hashlib.md5(b"Active Milestone C").hexdigest() - - hash_failed_old = hashlib.md5(b"Failed Attempt Old").hexdigest() - hash_failed_a = hashlib.md5(b"Failed Attempt A").hexdigest() - hash_failed_b = hashlib.md5(b"Failed Attempt B").hexdigest() - - # Write the hash chain representing: A -> B -> C - import json - - chain_data = {hash_failed_a: hash_failed_b, hash_failed_b: hash_active_c} - chain_path.write_text(json.dumps(chain_data), encoding="utf-8") - - steps = [ - # Steps from Completed Milestone 1 - {"step_id": "s1", "extra_metadata": {"subgoal_hash": hash_completed_1}}, - # Old failed attempt during Milestone 1 - {"step_id": "s2", "extra_metadata": {"subgoal_hash": hash_failed_old}}, - # Steps from Completed Milestone 2 (Most recent completed milestone) - {"step_id": "s3", "extra_metadata": {"subgoal_hash": hash_completed_2}}, - {"step_id": "s4", "extra_metadata": {"subgoal_hash": hash_completed_2}}, - # Steps from Failed Attempt A (Part of the active task slot's past renames) - {"step_id": "s5", "extra_metadata": {"subgoal_hash": hash_failed_a}}, - {"step_id": "s6", "extra_metadata": {"subgoal_hash": hash_failed_a}}, - # Steps from Failed Attempt B (Part of the active task slot's past renames) - {"step_id": "s7", "extra_metadata": {"subgoal_hash": hash_failed_b}}, - {"step_id": "s8", "extra_metadata": {"subgoal_hash": hash_failed_b}}, - # Steps from Active Milestone C - {"step_id": "s9", "extra_metadata": {"subgoal_hash": hash_active_c}}, - ] - - # Test raw transitive alias resolver - from artemis.utils.task_tree import get_all_subgoal_aliases - - aliases = get_all_subgoal_aliases(hash_active_c, base_dir) - assert aliases == {hash_active_c, hash_failed_b, hash_failed_a} - - keep_hashes = get_recent_subgoal_hashes(steps, hash_active_c, base_dir) - - # Should keep: - # 1. The current active subgoal (hash_active_c) - assert hash_active_c in keep_hashes - - # 2. The failed/renamed subgoals during active slot (hash_failed_b, hash_failed_a) - resolved transitively via chain - assert hash_failed_b in keep_hashes - assert hash_failed_a in keep_hashes - - # 3. The single most recent completed subgoal (hash_completed_2) for continuity context - assert hash_completed_2 in keep_hashes - - # Should prune: - # 4. Older completed subgoals (hash_completed_1) - assert hash_completed_1 not in keep_hashes - - # 5. Older failed subgoals from previously completed milestones (hash_failed_old) - assert hash_failed_old not in keep_hashes - - -# --- Step replay (shared renderer for replay_steps / MCP view_step_details) ---------- - - -def _replay_step(tool_result, **overrides): - step = { - "step_id": "step_4", - "step_number": 4, - "relative_time": "12.0s", - "summary": "Confirmed the Wi-Fi toggle state via the explorer.", - "action_taken": {"action": "click", "target": [0.5, 0.5], "target_text": "Save"}, - "last_execution_result": {"status": "success"}, - "interleaved_events": [ - {"type": "thought", "content": "Need to confirm the toggle before saving."}, - { - "type": "tool_call", - "name": "ask_explorer", - "args": {"question": "Is the Wi-Fi toggle on?"}, - "result": tool_result, - }, - {"type": "tool_call", "name": "click", "args": {"target": [0.5, 0.5]}, "result": "ok"}, - ], - } - step.update(overrides) - return step - - -def test_render_step_replay_shows_summary_thoughts_and_full_tool_results(): - from artemis.utils.task_tree import render_step_replay - - long_result = "The Wi-Fi toggle is ON and the SSID row reads 'HomeNet'. " * 60 # ~3.5k chars - out = render_step_replay(_replay_step(long_result)) - - assert "- **Step 4 (Start: 12.0s)**" in out - assert "[Screen]: Confirmed the Wi-Fi toggle state via the explorer." in out - assert "Need to confirm the toggle before saving." in out - # The tool call is replayed verbatim: name, args and the whole result. - assert "`ask_explorer(" in out - assert long_result in out - # Action tools are the planned action, not a tool-call line. - assert "[Action]: Tapped 'Save' at [0.5, 0.5]" in out - assert "`click(" not in out - - -def test_render_step_replay_clamps_only_pathological_results(): - from artemis.utils.task_tree import REPLAY_RESULT_CHARS, render_step_replay - - huge = "y" * (REPLAY_RESULT_CHARS + 500) - out = render_step_replay(_replay_step(huge)) - assert huge not in out - assert ("y" * REPLAY_RESULT_CHARS) + "..." in out - - -def test_live_window_keeps_the_tight_tool_result_clamp(): - """The Operator's live context window still clamps tool results tightly; - only the replay renderer is loose.""" - from artemis.utils.task_tree import LIVE_RESULT_CHARS - - long_result = "z" * 3000 - out = build_plan_and_history( - "- [ ] Check toggle", [_replay_step(long_result)], "default", last_n_detailed=1 - ) - assert long_result not in out - assert ("z" * LIVE_RESULT_CHARS) + "..." in out - # No screen-description line in the live detailed view. - assert "[Screen]:" not in out - - -def test_render_step_replay_reports_missing_screen_description_status(): - from artemis.utils.task_tree import render_step_replay - - pending = render_step_replay( - _replay_step("ok", summary=None, extra_metadata={"summary_status": "pending"}) - ) - assert "[Screen]: (screen description pending)" in pending - failed = render_step_replay( - _replay_step("ok", summary="", extra_metadata={"summary_status": "failed"}) - ) - assert "[Screen]: (screen description unavailable)" in failed - assert "[Screen]" not in render_step_replay(_replay_step("ok", summary=None)) - - -def test_render_step_replay_shows_described_images_in_tool_results(): - from artemis.utils.task_tree import render_step_replay - - result = [ - {"type": "text", "text": "--- pre-action screenshot of Step 1 ---"}, - {"type": "text", "text": "[screenshot: pre-action of Step 1]", "image_name": "ab" * 32}, - ] - out = render_step_replay(_replay_step(result)) - assert "[screenshot: pre-action of Step 1]" in out - - -def test_render_step_replay_header_uses_the_session_clock(): - """The replay header carries the same ``T+mm:ss`` offset the prompts and - ``search_history`` use; the legacy ``Start:`` text only remains when no - session clock / step timestamp exists.""" - from artemis.utils.task_tree import render_step_replay, replay_time_label - - step = _replay_step("ok", timestamp=1000.0 + 61.0) - out = render_step_replay(step, session_start=1000.0) - assert "- **Step 4 (T+01:01)**" in out - assert "Start:" not in out - - # No session start (or a non-numeric one, e.g. a mocked reader): fallback. - assert "- **Step 4 (Start: 12.0s)**" in render_step_replay(step) - assert "- **Step 4 (Start: 12.0s)**" in render_step_replay(step, session_start=object()) - # A step without a timestamp cannot be placed on the session clock. - assert replay_time_label(_replay_step("ok"), 1000.0) == "Start: 12.0s" - - -def test_render_step_replay_labels_are_role_neutral(): - """Flash has no Operator, so the block labels name what they hold.""" - from artemis.utils.task_tree import render_step_replay - - out = render_step_replay(_replay_step("ok")) - assert " * [Reasoning & tool calls]:" in out - assert " * [Action]: Tapped 'Save' at [0.5, 0.5]" in out - assert "Operator Decision Loop" not in out - assert "Planned Action" not in out - - -def test_render_step_replay_prints_a_failure_once(): - """A failed step shows its error on the [Result] line only, not appended - to the action line as well.""" - from artemis.utils.task_tree import render_step_replay - - out = render_step_replay( - _replay_step( - "ok", - last_execution_result={ - "status": "failed", - "execution": [{"action": "click", "attempts": ["Error: tap rejected"]}], - }, - ) - ) - assert " * [Action]: Tapped 'Save' at [0.5, 0.5]\n" in out - assert " * [Result]: Error: tap rejected" in out - assert out.count("tap rejected") == 1 - assert "Execution failed:" not in out - - -def test_render_step_replay_indents_multiline_reasoning(): - from artemis.utils.task_tree import render_step_replay - - step = _replay_step( - "ok", - interleaved_events=[ - {"type": "thought", "content": "- Save findings first.\nTap Wi-Fi.\nThen verify."} - ], - ) - out = render_step_replay(step) - assert "\n - - Save findings first.\n Tap Wi-Fi.\n Then verify." in out - - -def test_format_action_clean_directional_swipe_shows_direction_first(): - """``swipe(direction="up")`` replays as the direction the agent issued; the - recorded path is a parenthesised detail (Flash records both).""" - from artemis.utils.task_tree import format_action_clean - - flash_record = { - "action": "swipe", - "coordinates": [600, 700, 600, 300], - "coordinate_space": "normalized", - "normalized_start_coordinates": [600, 700], - "normalized_end_coordinates": [600, 300], - "args": {"direction": "up", "duration": 800}, - } - assert ( - format_action_clean(flash_record) == "Swiped up (from [600, 700] to [600, 300]) over 800ms" - ) - assert format_action_clean({"action": "swipe", "direction": "down"}) == "Swiped down" - assert ( - format_action_clean( - { - "action": "swipe", - "gesture": "left", - "start_coordinates": [750, 500], - "end_coordinates": [250, 500], - } - ) - == "Swiped left (from [750, 500] to [250, 500])" - ) - # Without a direction the path is the headline, as before. - assert ( - format_action_clean({"action": "swipe", "coordinates": [600, 700, 600, 300]}) - == "Swiped from [600, 700] to [600, 300]" - ) - - -def test_action_intent_phrase_and_incident_line_use_intent_form(): - """Failure contexts describe what the agent tried to do, not an outcome.""" - from artemis.utils.task_tree import ( - action_intent_phrase, - format_action_intent, - format_incident_clean, - ) - - assert format_action_intent({"action": "launch_app", "app_name": "NonexistentApp"}) == ( - "launch app 'NonexistentApp'" - ) - assert ( - format_action_intent( - {"action": "click", "coordinates": [500, 520], "target_description": "Wi-Fi row"} - ) - == "tap 'Wi-Fi row' (self-described) at [500, 520]" - ) - assert format_action_intent({"action": "swipe", "direction": "up"}) == "swipe up" - assert ( - format_action_intent( - { - "action": "input_text", - "text": "hello", - "target_text": "Search", - "coordinates": [1, 2], - } - ) - == "type 'hello' into 'Search' at [1, 2]" - ) - assert format_action_intent({"action": "press_key", "keycode": "BACK"}) == "press key 'BACK'" - assert format_action_intent({"action": "wait_for_delay", "delay_ms": 200}) == "wait 200ms" - assert format_action_intent({"action": "long_press", "coordinates": [1, 2]}) == ( - "long press element at [1, 2]" - ) - assert format_action_intent({"action": "click", "coordinates": [1, 2], "times": 2}) == ( - "double tap element at [1, 2]" - ) - # Unknown phrases pass through untouched. - assert action_intent_phrase("Fast-action burst (2 actions, unvetted): x") == ( - "Fast-action burst (2 actions, unvetted): x" - ) - assert action_intent_phrase("") == "" - - line = format_incident_clean( - { - "kind": "exec_error", - "category": "general", - "consecutive_failures": 2, - "action": {"action": "launch_app", "app_name": "NonexistentApp"}, - "action_description": "Launched app 'NonexistentApp'", - "reason": "Error finding package for app: NonexistentApp", - } - ) - assert line == ( - "Error: Execution failed (general, consecutive failure #2) on action" - " `launch app 'NonexistentApp'`: Error finding package for app: NonexistentApp" - ) - intercepted = format_incident_clean( - { - "kind": "safety_net", - "category": "target_disappeared", - "action": {"action": "click", "target_text": "Skip", "coordinates": [880, 120]}, - "reason": "Target element 'Skip' was not found on the screen.", - } - ) - assert intercepted.startswith( - "Error: Intercepted by Pre-Execution Safety Net (target_disappeared, consecutive" - " failure #1) on action `tap 'Skip' at [880, 120]`:" - ) - - -def test_format_action_clean_reads_flash_arguments_and_maps_manage_app(): - """Read Flash action arguments from ``args`` when formatting app launches.""" - from artemis.utils.task_tree import format_action_clean - - assert ( - format_action_clean( - { - "action": "manage_app", - "coordinates": None, - "args": {"action": "launch", "app_name": "com.android.settings"}, - } - ) - == "Launched app 'com.android.settings'" - ) - assert ( - format_action_clean({"action": "manage_app", "args": {"action": "stop", "app_name": "x"}}) - == "Stopped app 'x'" - ) - # Pro's own shape keeps rendering as before. - assert format_action_clean({"action": "launch_app", "app_name": "x"}) == "Launched app 'x'" - assert ( - format_action_clean({"action": "manage_app", "app_name": "x", "intent": "clear"}) - == "Managed app 'x' (action: clear)" - ) - # Top-level fields win over args; args only fill the gaps. - assert ( - format_action_clean( - { - "action": "click", - "coordinates": [1, 2], - "target_text": "Save", - "args": {"target": [9, 9]}, - } - ) - == "Tapped 'Save' at [1, 2]" - ) - - -def test_format_action_clean_marks_self_described_coordinate_targets(): - """A coordinate target's label is the model's own description, and every - later reader sees it marked as such; an index target's observed element - text carries no marker.""" - from artemis.utils.task_tree import SELF_DESCRIBED_MARKER, format_action_clean - - assert SELF_DESCRIBED_MARKER == "(self-described)" - assert ( - format_action_clean( - {"action": "tap", "coordinates": [540, 1440], "target_description": "play button"} - ) - == "Tapped 'play button' (self-described) at [540, 1440]" - ) - # Observed text outranks the description when both are present, unmarked. - assert ( - format_action_clean( - { - "action": "tap", - "coordinates": [1, 2], - "target_text": "Play", - "target_description": "play button", - } - ) - == "Tapped 'Play' at [1, 2]" - ) - assert ( - format_action_clean( - { - "action": "long_press_on", - "coordinates": [1, 2], - "duration": 1500, - "target_description": "app icon", - } - ) - == "Long pressed 'app icon' (self-described) at [1, 2] for 1500ms" - ) - assert ( - format_action_clean( - { - "action": "focus_and_input_text", - "coordinates": [3, 4], - "text": "hello", - "target_description": "search box", - } - ) - == "Inputted 'hello' into 'search box' (self-described) at [3, 4]" - ) - assert ( - format_action_clean( - { - "action": "swipe", - "coordinates": [100, 500, 900, 500], - "duration": 800, - "target_description": "brightness knob", - } - ) - == "Swiped 'brightness knob' (self-described) from [100, 500] to [900, 500] over 800ms" - ) - # Flash keeps the description under ``args``; it is read from there too. - assert ( - format_action_clean( - { - "action": "click", - "coordinates": [320, 399], - "args": {"target": [320, 399], "target_description": "skip ad"}, - } - ) - == "Tapped 'skip ad' (self-described) at [320, 399]" - ) - assert format_action_clean( - { - "action": "click_sequence", - "coordinates": [[500, 280], [885, 362]], - "target_descriptions": ["video body", "skip button"], - } - ) == ( - "Tapped sequence of targets: 'video body' (self-described) at [500, 280]," - " 'skip button' (self-described) at [885, 362]" - ) - # Unnamed targets stay unnamed: no marker without a description. - assert format_action_clean({"action": "tap", "coordinates": [1, 2]}) == ( - "Tapped element at [1, 2]" - ) diff --git a/tests/unit/utils/test_ui_filter.py b/tests/unit/utils/test_ui_filter.py index f7628389b..4075eef66 100644 --- a/tests/unit/utils/test_ui_filter.py +++ b/tests/unit/utils/test_ui_filter.py @@ -331,19 +331,3 @@ def test_fixed_system_bars_clamping_and_discarding(): clamped_item = next(el for el in filtered if el.get("text") == "Partially occluded video item") # Its original bottom was 2300, clamped safely to the bar's top at 2250 assert clamped_item["bounds"] == "[0,2100][1080,2250]" - - -def test_mutual_occlusion_warning_injection(): - from artemis.utils.visualization import format_minimal_list_with_elements - - # Simulate two overlapping non-ancestor interactive items with >= 50% overlap - fused_xml = [ - {"text": "Search Video Result", "bounds": "[50,1800][1000,2000]"}, - {"text": "Create Floating Button", "bounds": "[750,1850][1000,2000]"}, - ] - - minimal_list, elements, labels = format_minimal_list_with_elements( - fused_xml, width=1080, height=2400 - ) - assert "WARNING: may overlap with [2], possible occlusion" in minimal_list - assert "WARNING: may overlap with [1], possible occlusion" in minimal_list diff --git a/third_party/mobile_use/config/llm.py b/third_party/mobile_use/config/llm.py index be9f509a9..264a95ef2 100644 --- a/third_party/mobile_use/config/llm.py +++ b/third_party/mobile_use/config/llm.py @@ -52,50 +52,78 @@ def validate_vertex_ai_credentials() -> None: class LLM(BaseModel): - """Base model representing an LLM model provider and runtime parameters.""" + """Base model representing an LLM model provider and runtime parameters. + + An node may stay unconfigured (``provider``/``model`` unset): the factory + default endpoint only applies when its credentials exist, otherwise the + deployment default stays empty and must be configured explicitly. + """ model_config = {"ignored_types": (CyFunctionDetector,)} - provider: LLMProvider - model: str + provider: LLMProvider | None = None + model: str | None = None temperature: float | None = None thinking_budget: int | None = None thinking_level: Literal["minimal", "low", "medium", "high"] | None = None reasoning_effort: Literal["none", "low", "medium", "high"] | None = None include_thoughts: bool | None = None enable_grounding: bool | None = None + # Custom endpoint overrides. Unset fields inherit the deployment default + # (the "default" entry of artemis.jsonc); an explicit value wins over the + # provider's official endpoint and over environment variables. + api_base: str | None = None + api_key: str | None = None + api_key_env: str | None = None + + def _credential_present(self, settings_key: object) -> bool: + """Whether a usable credential exists: config api_key, its env var, or the provider env key.""" + if self.api_key: + return True + if self.api_key_env and os.environ.get(self.api_key_env): + return True + return bool(settings_key) def validate_provider(self, name: str) -> None: - """Ensure the required API key or credentials exist in settings for this provider.""" + """Ensure the required API key or credentials exist for this provider.""" + if not self.provider or not self.model: + return if self.provider == "openai": - if not settings.OPENAI_API_KEY: - raise Exception(f"{name} requires OPENAI_API_KEY in .env") + if not self._credential_present(settings.OPENAI_API_KEY): + raise Exception( + f"{name} requires OPENAI_API_KEY in .env " + "(or api_key/api_key_env on the LLM config node)" + ) elif self.provider == "google": - if not settings.GOOGLE_API_KEY: + if not self._credential_present(settings.GOOGLE_API_KEY): raise Exception(f"{name} requires GOOGLE_API_KEY in .env") elif self.provider == "vertexai": validate_vertex_ai_credentials() elif self.provider == "anthropic": - if not (settings.ANTHROPIC_API_KEY or os.environ.get("ANTHROPIC_API_KEY")): + if not self._credential_present(settings.ANTHROPIC_API_KEY): raise Exception(f"{name} requires ANTHROPIC_API_KEY in .env") elif self.provider == "openrouter": - if not settings.OPEN_ROUTER_API_KEY: + if not self._credential_present(settings.OPEN_ROUTER_API_KEY): raise Exception(f"{name} requires OPEN_ROUTER_API_KEY in .env") elif self.provider == "xai": - if not settings.XAI_API_KEY: + if not self._credential_present(settings.XAI_API_KEY): raise Exception(f"{name} requires XAI_API_KEY in .env") def __str__(self) -> str: + if not self.provider or not self.model: + return "unconfigured" return f"{self.provider}/{self.model}" class LLMWithFallback(LLM): """LLM configuration with automatic secondary fallback and timeout specs.""" - fallback: LLM + fallback: LLM | None = None fix_model: str | None = None timeout: float | None = None def __str__(self) -> str: + if not self.provider or not self.model: + return "unconfigured" return f"{self.provider}/{self.model} (fallback: {self.fallback})" diff --git a/third_party/mobile_use/sdk/agent.py b/third_party/mobile_use/sdk/agent.py index c6a812031..d97e46b52 100644 --- a/third_party/mobile_use/sdk/agent.py +++ b/third_party/mobile_use/sdk/agent.py @@ -23,6 +23,7 @@ """ import asyncio +import json import os try: @@ -733,7 +734,7 @@ async def _safe_stop_recording(): cancelled=True, ) if context.data_engine: - context.data_engine.end_session("cancelled") + context.data_engine.end_session("cancelled", error_message=err) raise except Exception as e: @@ -745,7 +746,7 @@ async def _safe_stop_recording(): error=err, ) if context.data_engine: - context.data_engine.end_session("failed") + context.data_engine.end_session("failed", error_message=err) raise finally: @@ -889,7 +890,8 @@ async def _finalize_tracing(self, task: Task, context: ArtemisContext): session_status = "failed" elif task.status == "cancelled": session_status = "cancelled" - context.data_engine.end_session(status=session_status) + task_error = task.result.error if task.result else None + context.data_engine.end_session(status=session_status, error_message=task_error) exec_setup_ctx = context.execution_setup if not exec_setup_ctx: @@ -917,7 +919,12 @@ async def _finalize_tracing(self, task: Task, context: ArtemisContext): traces_output_path = Path(task.request.trace_path).resolve() logger.info(f"[{task_name}] Compiling trace FROM FOLDER: " + str(temp_trace_path)) - create_gif_from_trace_folder(temp_trace_path) + self._materialize_session_trace_artifacts(context, temp_trace_path) + try: + create_gif_from_trace_folder(temp_trace_path) + except Exception as e: + # The GIF needs ffmpeg; its absence must not cost us the steps.json. + logger.warning(f"[{task_name}] GIF compilation skipped: {e}") create_steps_json_from_trace_folder(temp_trace_path) logger.info(f"[{task_name}] Video created, removing dust...") @@ -932,6 +939,44 @@ async def _finalize_tracing(self, task: Task, context: ArtemisContext): if new_video_path.exists() and context.data_engine: context.data_engine.update_video_path(new_video_path) + def _materialize_session_trace_artifacts( + self, context: ArtemisContext, temp_trace_path: Path + ) -> None: + """Copy the session's screenshots and step payloads into the flat trace + layout the compilers expect (``.jpeg`` frames, ``.json`` steps). + + The DataEngine persists steps in SQLite and screenshots under + ``/images/`` while the legacy compilers only scan this temp + folder; without this step the compiled replay artifacts would always + come out empty. + """ + engine = getattr(context, "data_engine", None) + session_id = getattr(engine, "current_session_id", None) if engine else None + if not session_id: + return + try: + steps = engine.storage.get_steps(session_id) + images_dir = self._tmp_traces_dir / "images" + temp_trace_path.mkdir(parents=True, exist_ok=True) + for step in steps: + image_name = step.post_image_name or step.pre_image_name + if image_name: + src = images_dir / f"{image_name}.jpg" + if not src.exists(): + src = images_dir / f"{image_name}.jpeg" + if src.exists(): + shutil.copy(src, temp_trace_path / f"{step.step_number}.jpeg") + step_payload = json.dumps( + step.model_dump(mode="json"), + ensure_ascii=False, + default=str, + ) + step_path = temp_trace_path / f"{int(step.timestamp * 1000)}.json" + step_path.write_text(step_payload, encoding="utf-8") + logger.info(f"Materialized {len(steps)} session steps into the trace folder.") + except Exception as e: + logger.warning(f"Could not materialize session trace artifacts: {e}") + def _prepare_output_files(self, task: Task): if task.request.llm_output_path: _validate_and_prepare_file(file_path=task.request.llm_output_path) diff --git a/third_party/mobile_use/testing.py b/third_party/mobile_use/testing.py deleted file mode 100644 index f4c487bcc..000000000 --- a/third_party/mobile_use/testing.py +++ /dev/null @@ -1,59 +0,0 @@ -# Copyright 2025-2026 Minitap, Inc. -# Modifications Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# Originally from mobile-use (https://github.com/minitap-ai/mobile-use). -# See third_party/mobile_use/METADATA for the upstream source and local modifications. - -"""Pytest fixtures for agent node tests (upstream ``agents/outputter/test_outputter.py``). - -Test-only: imports pytest, so it must not be imported by runtime code. -""" - -from unittest.mock import Mock - -import pytest - - -class DummyState: - def __init__(self, messages, initial_goal, operator_raw_data=None): - self.messages = messages - self.initial_goal = initial_goal - self.operator_raw_data = operator_raw_data - - -@pytest.fixture -def mock_context(): - """Create a properly mocked context with all required fields.""" - from artemis.config import LLM - from artemis.context import ArtemisContext - - ctx = Mock(spec=ArtemisContext) - ctx.llm_config = { - "planner": LLM(provider="openai", model="gpt-5-nano"), - "operator": LLM(provider="openai", model="gpt-5-nano"), - "validator": LLM(provider="openai", model="gpt-5-nano"), - } - ctx.device = Mock() - ctx.data_engine = None - return ctx - - -@pytest.fixture -def mock_state(): - """Create a mock state with test data.""" - return DummyState( - messages=[], - initial_goal="Find a green product on my website", - ) diff --git a/third_party/mobile_use/tools/mobile/launch_app.py b/third_party/mobile_use/tools/mobile/launch_app.py index f149b79c9..3f2762335 100644 --- a/third_party/mobile_use/tools/mobile/launch_app.py +++ b/third_party/mobile_use/tools/mobile/launch_app.py @@ -16,10 +16,16 @@ # Originally from mobile-use (https://github.com/minitap-ai/mobile-use). # See third_party/mobile_use/METADATA for the upstream source and local modifications. -"""Resolve a natural-language app name to an installed package via the Hopper agent.""" +"""Resolve an application name to an installed package (deterministic). + +Natural-language app-name resolution previously ran through an LLM "Hopper" +subagent. Under the DSH plugin split (decision D18) the LLM lives in the host, so +the device surface only resolves a concrete package id (an exact installed +match) and caches it; fuzzy/natural-language matching is the host's job before it +calls the device action. +""" from artemis.context import ArtemisContext -from third_party.mobile_use.agents.hopper.hopper import HopperOutput, hopper from third_party.mobile_use.controllers.platform_specific_commands_controller import ( list_packages_async, ) @@ -29,14 +35,16 @@ LAUNCH_APP_DOCSTRING = ( - "[ACTION] Finds and launches an application on the device using its natural language name." + "[ACTION] Finds and launches an application on the device using its package name." ) async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool = True) -> str | None: - """Finds the package name for a given application name. + """Return the package id when ``app_name`` is installed, else None. - Returns None if package not found or on error. + Results are cached on ``ctx.package_cache``. The ``use_fallback`` argument is + retained for signature compatibility with the old Hopper-based call; it no + longer triggers LLM resolution (the host resolves natural-language names). """ package_cache = getattr(ctx, "package_cache", None) if package_cache is None or not isinstance(package_cache, dict): @@ -54,36 +62,18 @@ async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool = all_packages = await list_packages_async(ctx=ctx) package_set = {p.strip() for p in all_packages.split("\n") if p.strip()} - # Fast path: If app_name is already directly an installed package name + # Deterministic path: accept the name only when it is physically installed. if app_name in package_set: if isinstance(package_cache, dict): package_cache[app_name] = app_name return app_name - hopper_output: HopperOutput = await hopper( - ctx=ctx, - request=(f"I'm looking for the package name of the following app: '{app_name}'"), - data=all_packages, - use_fallback=use_fallback, - ) - if not hopper_output.found or not hopper_output.output: - if isinstance(package_cache, dict): - package_cache[app_name] = None - return None - - package_name = hopper_output.output.strip() - if package_name not in package_set: - logger.warning( - f"Hopper returned package '{package_name}' for '{app_name}', " - "but it is NOT physically installed on the device!" - ) - if isinstance(package_cache, dict): - package_cache[app_name] = None - return None - + # Not an installed package id. Natural-language resolution is the host + # LLM's responsibility (D18), so the device surface reports "not found" + # rather than guessing with an on-device model. if isinstance(package_cache, dict): - package_cache[app_name] = package_name - return package_name + package_cache[app_name] = None + return None except Exception as e: # pylint: disable=broad-exception-caught logger.error(f"Failed to find package for '{app_name}': {e}") return None diff --git a/third_party/mobile_use/utils/app_launch_utils.py b/third_party/mobile_use/utils/app_launch_utils.py index 3e7f76458..1ea266da2 100644 --- a/third_party/mobile_use/utils/app_launch_utils.py +++ b/third_party/mobile_use/utils/app_launch_utils.py @@ -22,7 +22,6 @@ from artemis.context import AppLaunchResult, ArtemisContext from artemis.controllers.unified_controller import UnifiedMobileController -from artemis.data_engine.trace import TraceSpan from artemis.utils.app_launch_utils import current_foreground, observe_foreground from third_party.mobile_use.utils.logger import get_logger @@ -109,48 +108,34 @@ async def launch_app_with_retries( for attempt in range(1, max_retries + 1): logger.info(f"Launch attempt {attempt}/{max_retries} for app {app_package}") - with TraceSpan( - name=f"Launch Attempt {attempt}", - trace_type="span", - ctx=ctx, - ) as span: - span.payload = {"attempt": attempt, "app_package": app_package} - - controller = UnifiedMobileController(ctx) - if attempt > 1: - logger.warning( - f"Attempt {attempt - 1} failed. Force stopping" - f" '{app_package}' to clear frozen state before retrying..." - ) - await controller.terminate_app(app_package) - await asyncio.sleep(1.0) - - launch_success = await controller.launch_app(app_package) - if not launch_success: - error_msg = f"Failed to execute launch command for {app_package}" - logger.error(error_msg) - span.status = "failed" - span.error = error_msg - if attempt == max_retries: - return False, error_msg - await asyncio.sleep(2) - continue + controller = UnifiedMobileController(ctx) + if attempt > 1: + logger.warning( + f"Attempt {attempt - 1} failed. Force stopping" + f" '{app_package}' to clear frozen state before retrying..." + ) + await controller.terminate_app(app_package) + await asyncio.sleep(1.0) - await asyncio.sleep(1) + launch_success = await controller.launch_app(app_package) + if not launch_success: + error_msg = f"Failed to execute launch command for {app_package}" + logger.error(error_msg) + if attempt == max_retries: + return False, error_msg + await asyncio.sleep(2) + continue - success, error_msg = await _poll_for_app_ready(ctx, app_package, max_poll_seconds) + await asyncio.sleep(1) - if success: - span.status = "success" - span.result = "App is ready" - return True, None + success, error_msg = await _poll_for_app_ready(ctx, app_package, max_poll_seconds) - span.status = "failed" - span.error = error_msg + if success: + return True, None - if attempt < max_retries: - logger.warning(f"Attempt {attempt} failed: {error_msg}. Retrying...") - await asyncio.sleep(1) + if attempt < max_retries: + logger.warning(f"Attempt {attempt} failed: {error_msg}. Retrying...") + await asyncio.sleep(1) error_msg = f"Failed to launch {app_package} after {max_retries} attempts" logger.error(error_msg) diff --git a/third_party/mobile_use/utils/cli_helpers.py b/third_party/mobile_use/utils/cli_helpers.py deleted file mode 100644 index 4b9c687bc..000000000 --- a/third_party/mobile_use/utils/cli_helpers.py +++ /dev/null @@ -1,60 +0,0 @@ -# Copyright 2025-2026 Minitap, Inc. -# Modifications Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# Originally from mobile-use (https://github.com/minitap-ai/mobile-use). -# See third_party/mobile_use/METADATA for the upstream source and local modifications. - -from shutil import which -import sys - -from adbutils import AdbClient -from rich.console import Console - -from third_party.mobile_use.utils.logger import get_logger - -logger = get_logger(__name__) - - -def display_device_status(console: Console, adb_client: AdbClient | None = None): - """Checks for connected devices and displays the status.""" - console.print("\n[bold]📱 Device Status[/bold]") - devices = None - if adb_client is not None: - devices = adb_client.device_list() - if devices: - console.print("✅ [bold green]Android device(s) connected:[/bold green]") - for device in devices: - console.print(f" - {device.serial}") - else: - console.print("❌ [bold red]No Android device found.[/bold red]") - command = "emulator -avd " - if sys.platform not in ["win32", "darwin"]: - command = f"./{command}" - console.print( - f"You can start an emulator using a command like: [bold]'{command}'[/bold]" - ) - - -def display_local_device_status(console: Console, host: str | None, port: int | None) -> None: - """Display connected Android devices, using a local ADB client when ``adb`` is available.""" - adb_client = None - try: - if which("adb"): - adb_client = AdbClient(host=host or "localhost", port=port or 5037) - except Exception as exc: - # Optional cosmetic device-status display; run continues without it. - logger.debug(f"Could not create ADB client for device status display: {exc}") - - display_device_status(console, adb_client=adb_client) diff --git a/third_party/mobile_use/utils/file.py b/third_party/mobile_use/utils/file.py index e7930014e..bf9eec4a5 100644 --- a/third_party/mobile_use/utils/file.py +++ b/third_party/mobile_use/utils/file.py @@ -22,9 +22,21 @@ def strip_json_comments(text: str) -> str: - text = re.sub(r"//.*?$", "", text, flags=re.MULTILINE) - text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL) - return text + """Strip // and /* */ comments from JSONC text while preserving string literals. + + String-aware: ``//`` inside a quoted value (e.g. an ``http://`` api_base + URL) must not start a comment, so quoted strings are matched and kept + verbatim before comment patterns get a chance. + """ + pattern = r"//.*?$|/\*.*?\*/|'(?:\\.|[^\\'])*'|\"(?:\\.|[^\\\"])*\"" + + def replacer(match: re.Match) -> str: + s = match.group(0) + if s.startswith("/"): + return "" + return s + + return re.sub(pattern, replacer, text, flags=re.DOTALL | re.MULTILINE) def load_jsonc(file: IO) -> dict: diff --git a/third_party/mobile_use/utils/logger.py b/third_party/mobile_use/utils/logger.py index 5f39a6be1..ad6abea75 100644 --- a/third_party/mobile_use/utils/logger.py +++ b/third_party/mobile_use/utils/logger.py @@ -20,14 +20,10 @@ import logging import os from pathlib import Path -import queue import sys -import threading from colorama import Fore, Style, init -from artemis.data_engine.context_vars import CURRENT_TRACE_ID - init(autoreset=True) @@ -40,69 +36,6 @@ class LogLevel(Enum): CRITICAL = ("CRITICAL", Fore.RED + Style.BRIGHT, "💥") -class DataEngineHandler(logging.Handler): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._log_queue = queue.Queue(maxsize=5000) - self._worker_thread = threading.Thread(target=self._drain_queue, daemon=True) - self._worker_thread.start() - - def _drain_queue(self): - while True: - item = self._log_queue.get() - if item is None: - break - engine_mod = sys.modules.get("artemis.data_engine.engine") - if engine_mod and getattr(engine_mod, "_CURRENT_DATA_ENGINE", None): - _CURRENT_DATA_ENGINE = engine_mod._CURRENT_DATA_ENGINE - if _CURRENT_DATA_ENGINE and _CURRENT_DATA_ENGINE.current_session_id: - record_name, message, level_name, formatted, parent_id = item - _CURRENT_DATA_ENGINE.record_trace( - type="log", - name=record_name, - payload={ - "message": message, - "level": level_name, - "formatted": formatted, - }, - status="success", - parent_trace_id=parent_id, - ) - self._log_queue.task_done() - - def emit(self, record): - engine_mod = sys.modules.get("artemis.data_engine.engine") - if not engine_mod or not getattr(engine_mod, "_CURRENT_DATA_ENGINE", None): - return - _CURRENT_DATA_ENGINE = engine_mod._CURRENT_DATA_ENGINE - - if _CURRENT_DATA_ENGINE and _CURRENT_DATA_ENGINE.current_session_id: - log_entry = self.format(record) - log_level = getattr(record, "log_level", None) - if log_level and hasattr(log_level, "value"): - level_name, _color, _symbol = log_level.value - else: - level_name = record.levelname - - parent_id = CURRENT_TRACE_ID.get() - - try: - self._log_queue.put_nowait( - ( - record.name, - record.getMessage(), - level_name, - log_entry, - parent_id, - ) - ) - except queue.Full as e: - raise RuntimeError( - "DataEngine log queue is full - cannot accept more log" - " entries without dropping records" - ) from e - - class ArtemisLogger: def __init__( self, @@ -124,12 +57,7 @@ def __init__( self._setup_console_handler(console_level) - # Add DataEngineHandler - data_engine_handler = DataEngineHandler() - data_engine_handler.setLevel(logging.DEBUG) formatter = logging.Formatter(fmt="%(asctime)s | %(name)s | %(levelname)s | %(message)s") - data_engine_handler.setFormatter(formatter) - self.logger.addHandler(data_engine_handler) if os.environ.get("ARTEMIS_MCP_SERVER") == "true": traces_dir = os.environ.get("ARTEMIS_TRACES_PATH", "traces") diff --git a/uv.lock b/uv.lock index dc2d35b8c..6499d337b 100644 --- a/uv.lock +++ b/uv.lock @@ -9,12 +9,6 @@ resolution-markers = [ "python_full_version < '3.13'", ] -[manifest] -members = [ - "artemis", - "artemis-client", -] - [[package]] name = "adbutils" version = "2.12.0" @@ -229,7 +223,6 @@ version = "1.0" source = { editable = "." } dependencies = [ { name = "adbutils" }, - { name = "artemis-client" }, { name = "colorama" }, { name = "fastapi" }, { name = "google-genai" }, @@ -295,7 +288,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "adbutils", specifier = ">=2.12.0" }, - { name = "artemis-client", editable = "packages/artemis-client" }, { name = "colorama", specifier = ">=0.4.6" }, { name = "fastapi", specifier = ">=0.111.0" }, { name = "google-genai", specifier = ">=2.22.0" }, @@ -355,11 +347,6 @@ dev = [ { name = "ruff", specifier = ">=0.15.2" }, ] -[[package]] -name = "artemis-client" -version = "0.1.0" -source = { editable = "packages/artemis-client" } - [[package]] name = "asttokens" version = "3.0.2" @@ -2971,7 +2958,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "python_full_version < '3.13' or sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [