GUIAgent: a paused run loop cannot be released by stop(), by the documented abort signal, or (after a second pause()) by resume()
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:
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:
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:
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 settingisStopped.run()registers an abort listener for its own signal that releases the parked loop too, and removes it infinally, 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_STOPPEDinstead of leavingRUNNING, so the terminalonDataevent says the session is over and the operator'suser_stopcleanup runs.
Test results after the fix
- Package suite, same file set,
--environment node:main5 failed | 34 passed (39)→ branch2 failed | 37 passed (39). - The 2 that remain are pre-existing and unrelated:
GUIAgent > normal runandGUIAgent > Custom Action Spaces in Custom Operatorhit 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 withGUIAgent.tsreverted tomain, 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.jsonin the package: exit 0.eslinton both changed files plus the existingtests/GUIAgent.test.ts: exit 0.prettier --check(root.prettierrc.mjs): clean.secretlint: clean.
Scope
resume(), and when thePAUSE/RUNNINGevents are emitted, are unchanged.- The status change is deliberately narrow: it only applies when
stop()breaks a loop whose status is stillRUNNING, so a run whose operator already returnedEND/CALL_USERkeeps that status, and the existingsignal?.abortedline 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 +asyncRetrybail) and is not changed here.
Source: bytedance/UI-TARS-desktop