GUIAgent: a paused run loop cannot be released by stop(), by the documented abort signal, or (after a second pause()) by resume()

Author: sxh313Created Sep 23, 2026Updated Sep 23, 2026

Description

GUIAgent's pause/resume API has exactly one release hook: resume() is the only thing that ever resolves resumePromise, and the run loop checks the two cancellation flags (isStopped, signal.aborted) only after it has awaited that promise. So once the loop is parked, stop() and the abort signal that the SDK README documents as the way to cancel do nothing at all — run() never settles. A redundant pause() makes it worse: it replaces resumePromise, orphaning the promise the loop is actually waiting on, after which even resume() cannot release the loop.

On main (c2ad42e3), packages/ui-tars/sdk/src/GUIAgent.ts:133-160 — park, then look for cancellation:

typescript
        if (this.isPaused && this.resumePromise) {
          data.status = StatusEnum.PAUSE;
          await onData?.({
            data: {
              ...data,
              conversations: [],
            },
          });
          await this.resumePromise;
          data.status = StatusEnum.RUNNING;
          await onData?.({
            data: {
              ...data,
              conversations: [],
            },
          });
        }

        if (
          this.isStopped ||
          (data.status !== StatusEnum.RUNNING &&
            data.status !== StatusEnum.PAUSE) ||
          signal?.aborted
        ) {
          // check if stop or aborted
          signal?.aborted && (data.status = StatusEnum.USER_STOPPED);
          break;
        }

and the three writers at :495-513:

typescript
  public pause() {
    this.isPaused = true;
    this.resumePromise = new Promise((resolve) => {
      this.resolveResume = resolve;
    });
  }

  public resume() {
    if (this.resolveResume) {
      this.resolveResume();
      this.resumePromise = null;
      this.resolveResume = null;
    }
    this.isPaused = false;
  }

  public stop() {
    this.isStopped = true;
  }

resume() is written to survive misuse — if (this.resolveResume) handles "nothing is paused", and calling it twice is harmless — which reads as an intent that this pair be safe to call out of order and repeatedly. pause() and stop() do not hold up their half of that: pause() unconditionally overwrites the promise the loop may already be awaiting, and stop() sets a flag that a parked loop will never read.

1. stop() while paused never stops

Park the loop with pause(), then call stop(). isStopped is only read at :151-160, which the loop cannot reach while it is sitting on await this.resumePromise at :141, so run() stays pending forever. The loop's terminal event is never emitted either, and because data.status never becomes USER_STOPPED, the operator's user_stop cleanup at :459-473 — packages/ui-tars/operators/nut-js/src/index.ts:318, packages/ui-tars/operators/browser-operator/src/browser-operator.ts:294 and apps/ui-tars/src/main/remote/operators.ts:349 all implement that branch — is skipped.

2. Aborting while paused never aborts

packages/ui-tars/sdk/README.md:140 states it without qualification: "You can abort the agent by passing a AbortSignal to the GUIAgent signal option", with the process.on('SIGINT', () => abortController.abort()) example at :151-153, and the options list repeats it at :166 ("AbortController signal for canceling operations"). Same shape as above: an abort() that lands while the loop is parked is never observed, so the documented cancellation path stops being a cancellation path the moment the agent is paused.

The desktop app already depends on this ordering without it being stated anywhere. stopRun (apps/ui-tars/src/main/ipcRoutes/agent.ts:75-89) does this at :81-85:

typescript
    abortController?.abort();
    const guiAgent = GUIAgentManager.getInstance().getAgent();
    if (guiAgent instanceof GUIAgent) {
      guiAgent.resume();
      guiAgent.stop();
    }

i.e. it has to call guiAgent.resume() (:84) to wake the loop before abort()/stop() can take effect. A consumer that follows the README and only aborts — or that only calls stop() — gets a run() that never returns.

3. A second pause() while parked disables resume() too

pause() called while the loop is parked points resumePromise at a new promise; the loop is still awaiting the old one, and resume() can only resolve the new one, so nothing can release the loop afterwards. pauseRun at apps/ui-tars/src/main/ipcRoutes/agent.ts:61-67 has no state guard at all — unlike runAgent at :45-49, which returns early when a run is already active — so the IPC surface will happily pause an already-paused agent. To be clear about reachability: I did not find a path in the current desktop UI that double-pauses, because the widget page's local isLoading / isPaused state machine (apps/ui-tars/src/renderer/src/pages/widget/index.tsx:119-139) filters the second click. This one is about the contract of the exported method, and the fix is the mirror image of the guard resume() already has.

Reproduction

The PR adds packages/ui-tars/sdk/tests/GUIAgent-pause.test.ts: a scripted model (CLICK, then FINISHED) plus an operator whose screenshot/execute are spies, no network. Each case calls pause(), waits for the PAUSE status event and then one macrotask tick, so the call under test always lands inside the parked window, and asserts that run() settles within 3 s instead of being reported as still pending.

Measured locally on main @ c2ad42e3, in packages/ui-tars/sdk:

CI=true ./node_modules/.bin/vitest run --environment node tests/GUIAgent-pause.test.ts
→ 3 failed | 1 passed (4)

with all three failures reading expected 'pending' to be 'settled' / Object.is equality, i.e. run() had not returned. The passing case is "a single pause(), then resume()", which is there as the control: it shows the loop does park and that releasing it does finish the run, so the three red cases are the release paths failing, not the harness.

After the fix: 4 passed (--environment node 107 ms, --environment happy-dom 166 ms — the package's test script runs both).

Proposed fix

  • stop() releases a parked loop in addition to setting isStopped.
  • run() registers an abort listener for its own signal that releases the parked loop too, and removes it in finally, so the documented cancellation path works while paused.
  • pause() returns early when already paused, so it cannot orphan the awaited promise.
  • When the loop breaks because it was stopped, report StatusEnum.USER_STOPPED instead of leaving RUNNING, so the terminal onData event says the session is over and the operator's user_stop cleanup runs.

Test results after the fix

  • Package suite, same file set, --environment node: main 5 failed | 34 passed (39) → branch 2 failed | 37 passed (39).
  • The 2 that remain are pre-existing and unrelated: GUIAgent > normal run and GUIAgent > Custom Action Spaces in Custom Operator hit vitest's default 5 s timeout on this machine (they encode a 1920x1080 image through Jimp once per loop, ~4-5 s each). They fail identically with GUIAgent.ts reverted to main, and the third case in that file, should handle abort correctly, passes before and after. I did not touch that file.
  • tsc --noEmit -p tsconfig.json in the package: exit 0. eslint on both changed files plus the existing tests/GUIAgent.test.ts: exit 0. prettier --check (root .prettierrc.mjs): clean. secretlint: clean.

Scope

  • resume(), and when the PAUSE / RUNNING events are emitted, are unchanged.
  • The status change is deliberately narrow: it only applies when stop() breaks a loop whose status is still RUNNING, so a run whose operator already returned END / CALL_USER keeps that status, and the existing signal?.aborted line is untouched. The desktop app's stop path aborts first, so it reports what it reports today.
  • Cancellation while a long model.invoke() is in flight is a different mechanism (the openai abort + asyncRetry bail) and is not changed here.

Source: bytedance/UI-TARS-desktop