28 KiB
AGENTS.md
This file provides guidance to Code Agents when working with code in this repository. Respond in whatever language the user writes to you in — if they ask in Chinese, reply in Chinese; if they ask in English, reply in English.
Project Overview
mobile-gym is a simulated Mobile environment (Android-like) built with React + Vite + TypeScript + Tailwind CSS v4. It serves as a training and benchmarking platform for mobile GUI Agents. The simulator runs in a browser and exposes JavaScript APIs (__SIM__, __OS__, __SIM_INPUT__, __SIM_QUERY__, __SIM_TIME__, __SIM_LOCATION__, __SIM_FS__) for task management, trajectory data synthesis, and benchmark orchestration. The Agent only sees screenshots.
User-facing documentation under docs/ and bench_env/docs/ is in English. New user-visible App text must also follow the i18n resource contract: keep it in res/strings.ts with localized overrides such as res/strings.en.ts, and consume it through useAppStrings / resolveAppStrings instead of scattering Chinese or English literals through JSX. See docs/platform/tooling/i18n.md and docs/platform/app/resources.md.
Type-checking strategy
- Small changes (touching a few files, styling tweaks, data updates) — no need to run
tsc --noEmit; rely on the IDE's live checker - Large changes — run
npx tsc --noEmitonce on completion to confirm there are no type errors
ESLint
npm run lint # Lint runtime code under os/ and apps/
Current rule: bare Date.now() and any form of new Date(...) (including parameterised forms — they must go through TimeService) are forbidden. Config lives in eslint.config.js.
Navigation Artifact Generation (run after modifying navigation declarations)
# One-shot: consistency check + schema nav graph + action tasks
node scripts/build_nav_artifacts.mjs <AppName>
# With data graph generation
node scripts/build_nav_artifacts.mjs <AppName> --data data/index.ts
# Skip tasks, only update graphs
node scripts/build_nav_artifacts.mjs <AppName> --skip-tasks
Consistency Checking
node scripts/check_navigation_declaration_consistency.mjs <AppName> --actions
Benchmark Environment (Python)
To run Python, prefer the conda environment — it should already be installed on this machine. For everything else about the benchmark (installing dependencies, CLI usage, supported agent types, task running), see bench_env/README.md.
When docs and code disagree
When you're unsure about what a doc says, or you suspect it's wrong, read the source code. Docs are a convenience; the code is what actually runs. But before deciding which side to trust in a conflict, identify which kind of doc you're looking at:
- Descriptive docs describe what the code currently does. If they disagree with the code, the code wins — the doc is stale, fix the doc.
- Prescriptive docs define what the code is supposed to do. If they disagree with the code, the doc usually wins — the code may be a bug. Surface the discrepancy to the user rather than silently rewriting either side.
Heuristic for telling them apart: "X does Y" → descriptive. "X must Y" / "X is forbidden to Y" / "Rule: X" → prescriptive. A single file (or even a single section) can mix both — judge each statement by its phrasing.
Architecture
The project has three main layers plus dev tooling. It is a single Vite project (not a monorepo). Path alias: @/* maps to the project root.
OS Layer (os/)
The simulated Android system:
OSContext.tsx— Thin React Context Provider; delegates to TaskManager, BackDispatcher, IntentResolver; exposeswindow.__OS__andwindow.__SIM__global APIsTaskManager.ts— Task/Activity stack management (volatile — refresh = restart; state is readable via__SIM__.getState()). Each Task hasstack: ActivityInstance[]to support multiple Activities.finishActivity(): when stack>1 it pops the top Activity; when stack=1 it never destroys the Task — iflaunchedByTaskIdis set it activates the caller and consumes the marker, otherwise it callsgoHome()and the Task stays in Recents (destruction requires an explicit__OS__.closeAppor a Recents swipe). ThewasExternallyRoutedflag currently only affects whetherLAUNCH_APPclearslaunchedByTaskIdwhen reactivating from the desktop (details indocs/platform/os/task-lifecycle.md)BackDispatcher.ts— Priority-based back key handler. Components register with priority (e.g., PermissionDialog:1000, Shade:800, Keyboard:700, App:100). Includes frame-level deduplication to prevent double-back when edge-swipe gesture and backdrop click fire in the same frameIntentResolver.ts— Intent matching, chooser state management, startActivityForResultAppNavigatorRegistry.ts— Event-driven app/activity navigator registration. Uses CustomEvent + Promise pattern (replaces polling). Navigatornavigate(path, options?)accepts optional{ replace?: boolean }— OS uses this to control push (existing tasks) vs replace (new tasks) when routing viaopenAppSystemShell.tsx— Desktop, status bar, gesture handling, app rendering container. Apps stay mounted when backgrounded (hidden viadisplay:none), preserving React state. Implements adjustResize: wraps each Activity in adata-adjust-resizediv that shrinks by keyboard height when keyboard is visible, so App flex layouts auto-adapt. When keyboard is active, the container getsdata-keyboard-activeattribute — elements withdata-hide-on-keyboardare automatically hidden via global CSSAppStateRegistry.ts— Dual-layer state: runtime registry (from mounted apps) + persistent readers (localStorage fallback). External access viagetAllAppStates()types.ts— Core type definitions (AppId = string).AppIdis a plain string alias — apps are auto-discovered, no manual type union neededtypes/manifest.ts—AppManifesttype definition (id, packageName, displayName, displayNameEn, aliases, version, icon, theme, etc.)data/appRegistry.tsx— App registry: auto-discovers manifests (apps/*/manifest.ts,system/*/manifest.ts) and entry components (apps/*/*App.tsx,system/*/*App.tsx) viaimport.meta.glob. New apps do NOT need to register herehooks/useTriggerGestures.ts— Unified gesture hook producingdata-trigger-*/data-action-*DOM attributes for task definition, trajectory synthesis, and navigation graph generation (NOT for Agent observation — Agent is pure-vision, screenshot only). Globally interceptssystem.backtriggers and routes them towindow.__OS__?.handleBack()— individual app gesture hooks must NOT handlesystem.backthemselveshooks/useAppNavigationHandler.ts— Unified App navigation registration hook. Registers withAppNavigatorRegistry/BackDispatcher/AppLifecycle; keeps a shadowHistoryTrackerin sync to supportpopTo.openApp: passesreplace=truefor a new Task,replace=falsefor an existing Task (MemoryRouter push).startActivity({newTask:true})pushes a new Activity at the OS layer (with its ownactivityId, finishable independently viafinishActivity()). Foreign Task isolation: whentask.rootAppId !== appId, app-level registration is skipped and only the activity-level navigator is usedutils/memoryHistory{Tracker,PopTo}.ts— Shadow history stack (react-router-dom@7 MemoryHistory does not expose entries).HistoryTrackermirrors MemoryRouter location changes;findPopToDelta()returns thego(-delta)step count;popTo()callsnavigator.go(-delta)to rewind, then the caller invokesnavigate(url)to finish the push/replace (mirroring Android'spopUpTo)createOsStore.ts— OS-layer Zustand store factory. ProvidescreateOsStore(persistent) andcreateVolatileOsStore(non-persistent), with a built-in store registry (resetAllOsStores()/snapshotOsStores()) used by__SIM__.reset()and__SIM__.getState(). PassregisterToServiceRegistry: falseto opt out of registration (e.g. OsStateStore, Providers)OsStateStore.ts— Unified Android data-model store, holdingsettings(global / system / secure / app-specific),hardware(battery / wifi / cellular / sensors),permissions,preferences. Persisted under theos_statelocalStorage key.buildandtelephonyinfo are managed via overrides inmanagers/registry.ts(which also supports bench_env scenario injection)- Managers (
os/managers/) —ConnectivityManager,BatteryManager,AudioManager,DisplayManagerare write facades over OsStateStore-specific domains; they encapsulate constraint logic (e.g., airplane mode cascade-disables Wi-Fi / BT / cellular, volume clamping, brightness range) and side effects (broadcast notifications).managers/registry.tshandles preference-key → Manager routing and build/telephony overrides - System Services — Persistence rule: data persists, UI / runtime state does not (refresh = restart). Apps must use OS services in place of native APIs:
Date.now()→TimeService;navigator.geolocation→LocationService;fetch→NetworkService(netJson/netFetch). Services are accessed as sub-properties ofwindow.__OS__(e.g.__OS__.notifications,__OS__.keyboard).ClipboardServiceis persistent;NotificationService/KeyboardService/PermissionServiceetc. are volatile - System Providers — Shared data such as contacts / SMS / media lives in
os/providers/*Provider.ts, persisted independently viacreateOsStore(registerToServiceRegistry: false, not part of theos.servicessnapshot); Apps access them throughContentResolver.query / insert / update / delete.__SIM__.getState()exposes Provider snapshots explicitly underos.providers.*
Apps Layer (apps/<AppName>/, system/<AppName>/)
Each app follows a standard structure:
manifest.ts— App identity (AndroidManifest-like): id, displayName, displayNameEn, aliases, icon, theme Tier-1 colors,intentFilters(deep links). This is the only file needed to register an app with the OS<AppName>App.tsx— Entry point withMemoryRouter,useAppNavigationHandlerhook (registers navigator, back handler, and lifecycle events with the OS viaAppNavigatorRegistry+BackDispatcher+AppLifecycle), and the "main tabs persistent + subpages exclusive" layout. Must haveexport default— the OS discovers it viaimport.meta.glob(['apps/*/*App.tsx', 'system/*/*App.tsx'])navigation.declaration.ts— Declarative navigation: all routes, transitions, actions, UI states. Source of truth for static analysis, graph generation, and task generationnavigation.ts— Navigation hook (useAppNavigatewithgo/back). Supportsgo(id, params, { mode, popTo, popToInclusive, state }). Business pages must NOT useuseNavigate()directlyhooks/use<AppName>Gestures.ts— App-specific gesture hook wrappinguseTriggerGesturescontext/<AppName>Context.tsx— State management via React Context; registers withAppStateRegistryon mountres/— App resources aligned with Androidres/values/*:colors.ts,strings.ts,dimens.ts(and optionalcolors.states.ts,icons.tsx)
assets/— App-owned binary assets (images/icons/raw/fonts, etc.) loaded via Viteimport(avoidpublic/<appName>/...URLs)types.ts— App-level types (standard location)constants.ts— Structural constants only (tabs, service grids, config flags). Resource-like constants should live inres/data/index.ts— Data entry point: merges constants +defaults.json, exports<APPNAME>_CONFIGdata/defaults.json— Default data (users, content, history) as replaceable JSONpages/— Page components
Benchmark Layer (bench_env/)
Python-based evaluation framework using Playwright. Tasks are organized into suites under bench_env/task/<suite>/, where a suite is a single App (wechat/, alipay/), a cross-app workflow (crossapp_commerce/, crossapp_life/ ...), or a functional category (payment/, launcher/, account/). See TASK_AUTHORING_GUIDE.md §1.4 for the single-app vs cross-app suite distinction. The framework provides state-based judging, VLM evaluation, parameter sampling, and Pass@k statistics.
Before authoring or modifying a task, you must read bench_env/docs/task/TASK_AUTHORING_GUIDE.md (authoring workflow), bench_env/docs/task/TASK_CODE_SPEC.md (hard code spec / CRUD judging rules), bench_env/docs/task/TASK_TESTING_GUIDE.md (offline testing spec), bench_env/docs/task/GROUNDED_MODE.md (grounded-mode answer sheet), and bench_env/README.md. bench_env/docs/REFERENCE.md is the canonical lookup table for CLI flags and JudgeInput / JudgeResult fields.
Scripts (scripts/)
build_nav_artifacts.mjs— One-shot: consistency check + nav graph + action taskscheck_navigation_declaration_consistency.mjs— Validates declaration-to-source-code consistencynavigation_declaration_analyzer.mjs— Generates nav graph JSON (schema and data modes)generate_action_tasks_from_nav_graph.mjs— Enumerates action trajectories from nav graphsnav_path_finder.py— Shortest path search on nav graphs for verificationime/build_pinyin_dict.mjs— Generates IME pinyin dictionary from Rime dict sourceslint_store_getters.mjs— Detects query getter functions in store actions and consumer subscriptions to them (violating the "Query-style getters in actions" rule indocs/platform/state/model.md). Usage:node scripts/lint_store_getters.mjs [AppName...]
Key Development Rules
The authoritative platform references live under docs/platform/ (app/module-contract.md, state/model.md, navigation/declaration.md, os/overview.md, os/intent-system.md, os/cross-app-launch.md, os/services/README.md, android-mapping.md). When conflicts arise, flag them rather than silently overriding. Before navigation/actions/condition changes, review docs/platform/navigation/declaration.md.
Navigation
- Every app maintains
navigation.declaration.tswith routes (includinguiStates,queryParams,scrollContainers) and transitions - All discrete UI state changes (tabs, modals, menus) must go through
go()+ URL update — never purely via React setState - Main TabBar tabs use separate pathname routes (
/,/contacts,/me), not query params - Tab/subtab switching uses
mode: 'replace'; modals/drawers usemode: 'push'(closed viaback()) - Dialogs / popups are URL-driven by default (matching Android's DialogFragment / Navigation dialog destination), unless the user explicitly asks otherwise:
- Push them into the history stack via
searchParams(e.g.setSearchParams(p => { p.set('myDialog', 'open'); return p; })), and derive dialog visibility fromsearchParams.get('myDialog') === 'open' - Close dialogs uniformly with
navigate(-1)to pop the history entry; the system back key automatically pops the top of the stack to close the dialog — no extra handling needed - Never control dialog visibility with
useState— the back key cannot see React local state and will pass through the dialog straight to the previous page - Do not import
BackDispatcherdirectly in the App layer — it's an OS-internal module; Apps gain back-key support indirectly via the URL + navigation stack
- Push them into the history stack via
- Business pages must never use
useNavigate()/navigate()directly — only the app'sgo()/back() - New route paths must be registered in the app's
<Routes>in<AppName>App.tsx
Adding a New App
Adding an App requires no changes to any OS-layer file. The OS auto-discovers via import.meta.glob. Third-party Apps go under apps/, system apps under system/. You only need:
apps/<AppDir>/manifest.tsorsystem/<AppDir>/manifest.ts— mustexport const manifest: AppManifest, declaringid,displayName,displayNameEn, icon, theme, etc.apps/<AppDir>/<Name>App.tsxorsystem/<AppDir>/<Name>App.tsx— entry component; filename must match*App.tsx, and mustexport defaultapps/<AppDir>/state.ts/system/<AppDir>/state.ts(optional) — Zustand store, auto-registered viaimport.meta.glob(['./apps/*/state.ts', './system/*/state.ts'])
Convention details:
manifest.idis theappId(e.g.'wechat') and also the localStorage keydisplayNameEnis auto-injected into the OS i18n dictionary (patchAppNames); no need to editos/i18n/en.ts- The
aliasesarray is auto-injected into the system-app alias map (e.g.['通讯录', '联系人']); no need to edit OS-layer files - The directory name (e.g.
Wechat) does not have to match theappId(e.g.'wechat') — the OS builds the mapping automatically from the manifest path
DOM Tagging
- All navigation triggers must produce
data-trigger+data-trigger-typeattributes via gesture hooks - All action triggers must produce
data-action+data-action-typeattributes - Transition/Action IDs must be string literals at bind sites (no dynamic concatenation/variables)
- Return/close buttons must use
bindBack()(system.back), not custom transitions - Only tag controls that actually do something — no tags on unimplemented placeholders
- Scrollable containers need
data-scroll-container+data-scroll-directionattributes matchingscrollContainersdeclarations
State and Data
The full state-and-data-layer spec lives in
docs/platform/state/model.md(settings naming, nested structure, data-layering criteria, store action patterns, and bench_env path conventions are all there).
- Config-first: constants in
constants.ts, default data indata/defaults.json, unified export viadata/index.tsas<APPNAME>_CONFIG - localStorage key must exactly match
manifest.id(i.e.appId) - No form of
new Date(...)or bareDate.now()is allowed — go throughTimeService:TimeService.now()/TimeService.getDate()— simulated time: on-screen clocks, data timestamps, benchmark state checksTimeService.realNow()— real wall-clock time: debouncing, animations, gesture detection, cache TTLs, and anywhere you're measuring real physical elapsed timeTimeService.fromTimestamp(ts)— replacesnew Date(timestamp)TimeService.fromLocalParts(year, month, day, ...)— replacesnew Date(year, month, day, ...)TimeService.parseToTimestamp(str)— parses a date string into a timestamp (pair it withfromTimestampto replacenew Date(dateString))
- Use
LocationServiceinstead ofnavigator.geolocation - Use
NetworkService(netJson/netFetch) for HTTP requests to avoid CORS - Do not define query-style getters inside store actions (
isLiked,isFollowing,getXxxById, etc.), and do not have components subscribe to store function references — Zustand function references are stable after creation,Object.isis always true, so the component never re-renders. The right approach: subscribe to data directly in the component (s.likedPostIds) and derive booleans via.includes()/Set.has(); or usememoSelectorto build a derived selector (e.g.selectLikedSongIdsreturning aSet). Wrapping a getter inuseShallow+useMemodoes not work either.
UI
- Every page must reserve status bar space at top with
pt-10 - Pages should explicitly declare
data-status-bar-foreground="dark|light"on the outermost page container when the chrome foreground is not the default dark text; the OS no longer does DOM-based auto-detection fallback - When bottom gesture bar foreground differs from the status bar, explicitly declare
data-navigation-bar-foreground="dark|light"; GestureBar reads declarative/manifest signals only - Keyboard-attached UI (chat input bars, send buttons) needs
data-keep-keyboard="true" - OS implements
adjustResize: keyboard shrinks the Activity container automatically. Form pages need no extra handling - Drag / swipe / slider / any follow-the-finger continuous interaction must use
PointerEvent(onPointerDown / onPointerMove / onPointerUp / onPointerCancel, withsetPointerCapturewhere needed); do not maintain paralleltouch*andmouse*logic, and do not usetouchmove + clickas a fallback for mouse dragging - Chat pages and bottom action bars must not use
position: fixed— use a flex layout (flex-shrink-0) and let adjustResize handle it.position: fixed + bottom: keyboardHeightcauses the keyboard to cover the input box in Apps that usedesignViewportWidth(CSS zoom), because zoom scales CSS pixels and offsets fixed positioning - Hide elements when the keyboard is open: add the
data-hide-on-keyboardattribute to the element; the OS hides it automatically (viadata-keyboard-activeon thedata-adjust-resizecontainer plus a global CSSdisplay:nonerule). Typical case: a bottom TabBar should not be pushed up by the keyboard — adding this attribute hides it automatically
Validation
After modifying navigation declarations or adding pages, always run:
node scripts/build_nav_artifacts.mjs <AppName>
If the output has ERROR or WARN, include the specific IDs and file locations — not just summary counts.
App File Architecture — Strict Boundaries
Each file has one responsibility. Violating boundaries makes maintenance painful and the codebase progressively messier. The rules below are enforced.
constants.ts — Structural configuration
Constants of the following kinds belong here:
- Tab definitions (id, route, label, icon component ref)
- Service / feature catalogs (id, name, icon, color) — fixed app structure, not user-editable
- Layout parameters (grid columns count, visible item count)
- Feature flags
Do not include:
- User data (account info, messages, bill records) →
data/defaults.json - Raw Lucide icon names (e.g.
"CreditCard","Bus") → must use theIc*alias ("IcCard","IcBus")
data/defaults.json — Replaceable initial state
Must contain:
- User info (name, avatar, phone, balance)
- Content data (chat history, bill stream, posts, history)
- User-configurable layout (service-ID list shown on the home page, ordering)
- User settings (language, theme, notification prefs)
Must not contain:
- Static attributes of services / features (icon, color, label) → these are fixed; they belong in
constants.ts - Raw icon-name strings — if they must appear (data-driven rendering), use the
Ic*prefix
res/colors.ts — Special colors (optional)
colors.ts is only needed in these cases:
- Special colors that Tailwind cannot express (brand colors, gradients, etc.)
- Component colors that need dark-mode awareness
Don't bother extracting:
- Standard Tailwind colors → just use
text-gray-800 bg-white - One-off colors → just inline
bg-[#FF7D00]
res/dimens.ts — Key dimensions (optional)
Only dimensions reused in multiple places (e.g. list-item height, avatar size) should be extracted into dimens.ts.
Don't bother extracting:
- One-off sizes → inline a Tailwind class or
style - Icon size → hard-code
size={22} - Spacing / radii / font sizes → use Tailwind classes like
p-4 rounded-lg text-sm
⚠️ JS pixel calculations must use CSS vars, not Tailwind rem classes
When JS does pixel math against an element's height (e.g. scrollTop = index * itemHeight), that element's height must use a CSS var (h-(--app-xxx)) or an arbitrary pixel value (h-[Npx]). Do not use rem-based classes like h-10 / h-14 — the browser default font size is not 16px in this environment, so rem-derived heights and JS-hardcoded pixel values accumulate drift.
res/icons.tsx — Icon definitions
Rules:
- All icon aliases start with the
Icprefix (IcCard,IcBus,IcNavBack) ICON_REGISTRYkeys must match the export names exactly (allIc-prefixed)- Do not add raw Lucide names to
ICON_REGISTRYas a workaround — fix the data layer instead (change the strings inconstants.ts/defaults.json) - Only import the icons the app actually uses
Icon usage
| Scenario | Correct usage |
|---|---|
| Fixed icon in JSX | <IcCard size={22} /> |
| Data-driven (from map/JSON) | <IconRenderer name={item.icon} size={22} /> |
| Icon name inside a data file | "IcCard" (must use the Ic* prefix) |
The full App module contract (resource spec, icons, theming, cross-cutting rules) is in
docs/platform/app/module-contract.md.
Screenshot-driven development workflow
This section applies only when the user supplies a screenshot and asks you to replicate it or to build a new App page — e.g. "implement this page from the screenshot", "replicate page X from app Y", or just a screenshot with "build this". For everything else, follow the general rules above; do not force this workflow onto unrelated tasks.
0. Missing-information handling (do this first)
If the screenshot / requirements are insufficient to infer any of the following, ask before writing code — do not extrapolate or invent requirements:
- Target
AppNameandroutePath(pathname template) - Whether there are discrete UI states like tab / modal / menu / select (must land in
uiStates) - Entries that need parameter differentiation (Tab target value, list-item id, etc.; determines
data-trigger-params/data-action-params) - Whether the entry is a transition (changes URL) or an action (does not change URL)
- pathname naming must align with existing repo conventions ("我" / "Me" is always
/me, "探索" / "Explore" is/explore, etc.); when the screenshot only has Chinese copy, agree on the English token with the user before writing
1. Reading the screenshot (before touching code)
After receiving a screenshot, the first step is to describe its contents in detail in words to confirm you understand it correctly — only then start implementing:
- Describe the layout structure (top bar / body / bottom TabBar / FAB, etc.)
- Describe what each region does (list, form, chat, settings, …)
- Describe the interactive controls (buttons / inputs / switches / list items) and their expected behaviour (navigate, open modal, change state)
- Identify elements that need parameterising (each list item's id, each tab's target)
When there are multiple screenshots, first describe how they relate (different states of the same page? adjacent pages? a popup?), then decide the uiStates / subroute split.
The implemented page should match the screenshot (layout, styling, visual hierarchy); business data (contacts, message contents, orders) does not need word-for-word fidelity, but the presentation logic must match.
2. Implementation order
Land code in this order to avoid declaration / source drift:
navigation.declaration.ts— declare the new routes,uiStates, transitions, actions<Routes>in<AppName>App.tsx— register the route componentsdata/defaults.json/state.ts— prepare the replaceable default data / runtime statepages/— implement the UI; use the app's owngo()/back()and the gesture hooks- Run
node scripts/build_nav_artifacts.mjs <AppName>once and confirm no ERROR / WARN - If you modified an existing App, confirm that the state paths referenced by
bench_envare not broken
For the syntactic rules (uiStates required, transitions[].to required, from:'*' constraints, .switch + cases, ID literal binding, the { id: '...' } configuration constraints for shared components, when data-trigger-params is required, etc.) see docs/platform/navigation/declaration.md and docs/platform/navigation/actions.md.
3. Required output format
Your reply to the user must include:
- Change summary — which elements and interactions from the screenshot you implemented
- Files touched — one sentence per file describing what changed
- New / modified IDs — the list of transitionId / actionId
- Self-check verdict — whether
build_nav_artifacts.mjspassed; if there are WARN / ERROR, list specific IDs + file:line (the top 5–10 is enough) and explain how to fix them
Do not just paste summary counts; ERROR / WARN must come with details.