Client And Threads
The @pai/client facade, native message state, thread, and run API.
Package: @pai/client
@pai/client exposes one application transcript: ThreadState.messages as
PaiMessage[]. Runs, queued work, usage, and thread attributes are normalized
beside those messages. There is no second render-item or run-container message
grammar.
createAgentClient
function createAgentClient<TContract extends AgentContract = AgentContract>(
transport: AgentClientTransport<TContract>,
): AgentClient<TContract>;createAgentClient() builds the shared high-level facade over a transport. The
agent contract types application message data, static tool inputs and outputs,
client-readable metadata, commands, and client request data.
import { createHttpAgentTransport } from "@pai/client-http";
import { createAgentClient } from "@pai/client";
const assistant = createAgentClient<AssistantAgentContract>(
createHttpAgentTransport({
url: "http://localhost:3001/api/pai/main",
}),
);HTTP applications usually use createPaiHttpClient() from @pai/client-http
when the server exposes a multi-agent PAI registry.
AgentClientTransport
type AgentClientTransport<TContract extends AgentContract = AgentContract> = {
getManifest(): Promise<AgentManifest<TContract>>;
listThreads(options?: ThreadListOptions): Promise<ThreadListResult>;
renameThread(input: ThreadRenameInput): Promise<ThreadRenameResult>;
deleteThread(input: { threadId: ThreadId }): Promise<void>;
getThreadState(
input: GetThreadStateInput,
): Promise<PaiThreadTransportState<TContract> | null>;
send(input: ThreadSendInput<TContract>): Promise<ThreadSendResult<TContract>>;
regenerate(
input: ThreadRegenerateInput<TContract>,
): Promise<ThreadRegenerateResult<TContract>>;
stopThread(input: StopThreadInput): Promise<ThreadMutationResult<TContract>>;
stopRun(input: StopRunInput): Promise<ThreadMutationResult<TContract>>;
respondToAction(
input: RespondToActionInput<TContract>,
): Promise<RespondToActionResult<TContract>>;
writeToolData(
input: WriteToolDataInput<TContract>,
): Promise<WriteToolDataResult<TContract>>;
recallQueuedItem(
input: RecallQueuedItemInput,
): Promise<RecallQueuedItemResult<TContract>>;
executeCommand<TName extends CommandName<TContract>>(
input: ExecuteCommandInput<TContract, TName>,
): Promise<CommandOutput<TContract, TName>>;
getRun(input: GetRunInput): Promise<RunStateDTO<TContract> | null>;
uploadFile(input: FileUploadInput): Promise<UploadedFile>;
createFileUrl(input: ClientCreateFileUrlInput): Promise<FileUrlResult | null>;
readFile(input: ClientReadFileInput): Promise<StoredFileBody>;
watchThread?(
input: ThreadWatchTransportInput,
): AsyncIterable<PaiThreadTransportEvent<TContract>>;
watchThreadHeads?(
input: ThreadHeadWatchInput,
): AsyncIterable<ThreadHeadWatchEvent>;
close?(): Promise<void>;
};This is the lower-level delivery SPI for packages such as @pai/client-http
and the direct runtime transport. Its native wire state and event types come
from @pai/protocol/internal; they are inputs to PAI's private reducer, not an
application observation API.
Transport generation events carry exact AI SDK UIMessageChunk values inside
PAI's thread, run, message, version, and sequence envelope. The client folds
those chunks with a long-lived native reducer, projects reserved PAI data onto
the resulting parts, and exposes only ThreadState from watch().
If watchThread() is absent, the facade polls snapshots. If a native stream has
a sequence gap, changes thread incarnation, or cannot be reduced safely, the
facade repairs from an authoritative snapshot. Transport choice changes
latency, not the public state shape.
Both transport watch inputs include an optional
onConnectionEstablished() callback. Invoke it after the subscription is
usable, even if the stream is still quiet. The client uses this boundary to
report connection state and to reconcile delta-only thread-head watches.
AgentClient
type AgentClient<TContract extends AgentContract = AgentContract> = {
getManifest(): Promise<AgentManifest<TContract>>;
threads: ThreadCollectionClient<TContract>;
files: {
upload(input: FileUploadInput): Promise<UploadedFile>;
url(input: ClientCreateFileUrlInput): Promise<FileUrlResult | null>;
read(input: ClientReadFileInput): Promise<StoredFileBody>;
};
thread(
threadId: ThreadId,
options?: ThreadHandleOptions<TContract>,
): Thread<TContract>;
newThreadId(): ThreadId;
close?(): Promise<void>;
};client.thread(threadId) creates a local handle synchronously. It does not
assert that the durable thread already exists. The first accepted send can
create it, and refresh() returns an empty unrealized state until then.
Use client.threads.find(threadId) when existence should be checked through
I/O and a missing thread should return null.
ThreadCollectionClient
type ThreadCollectionClient<TContract extends AgentContract = AgentContract> = {
list(options?: ThreadListOptions): Promise<ThreadListResult>;
watchHeads?(
options?: ThreadHeadsWatchOptions,
): AsyncIterable<ThreadHeadWatchEvent>;
rename(threadId: ThreadId, title: string): Promise<ThreadRenameResult>;
delete(threadId: ThreadId): Promise<void>;
find(
threadId: ThreadId,
options?: ThreadHandleOptions<TContract>,
): Promise<Thread<TContract> | null>;
};
type ThreadListOptions = {
parentThreadId?: ThreadId;
category?: string;
pageToken?: PageToken;
limit?: number;
};
type ThreadListResult = {
items: ThreadSummary[];
nextPageToken?: PageToken;
};Thread lists are scoped to the resolved runtime identity and agent. Trusted runtime clients can additionally list hidden threads and filter by agent.
Deletion removes the complete durable thread aggregate and queued work. Cancellation of already-started model, tool, or external-service work remains cooperative. Await deletion before deliberately reusing the same thread ID so thread-scoped cleanup can settle.
ThreadHandleOptions
type ThreadHandleOptions<
TContract extends AgentContract = AgentContract,
> = {
clientData?: JsonCompatible<TContract["clientData"]>;
clientTools?: ClientToolDefinitionSnapshot[];
requestContext?(): {
clientData?: JsonCompatible<TContract["clientData"]>;
clientTools?: ClientToolDefinitionSnapshot[];
};
/** Receives application-authored native data parts only. */
onData?(part: DataUIPart<TContract["uiData"]>): void;
};Static values apply to later sends, regeneration, actions, and ToolData writes.
requestContext() is evaluated for each request and can provide current
session data or mounted client-tool snapshots. An explicit request option wins
over the dynamic and static values.
onData receives application data-${name} parts inferred from the agent's
UI-data schemas. Framework-reserved data-pai-* parts are consumed by PAI and
never reach this callback.
ThreadState
type ThreadState<TContract extends AgentContract = AgentContract> = {
thread: PaiThreadHead<TContract>;
messages: PaiMessage<TContract>[];
runs: RunState<TContract>[];
activeRunId: RunId | null;
queue: ThreadQueueView<TContract>;
usage: UsageSummary;
capabilities: ThreadCapabilityView;
error: ThreadErrorRecord | null;
};
type PaiThreadHead<TContract extends AgentContract = AgentContract> = {
threadId: ThreadId;
/** Null only before this thread ID has a persisted incarnation. */
version: ThreadVersion | null;
status: "idle" | "running" | "waiting";
title?: ThreadTitleRecord;
metadata: Partial<ThreadMetadataOf<TContract>>;
};
type ThreadCapabilityView = {
canStop: boolean;
canRespondToActions: boolean;
};messages is the canonical render order. runs is a normalized control
collection; group messages by message.metadata.pai.runId only when a UI wants
turn-like containers. Queued inputs stay in queue.items until admission.
activeRunId identifies the run that currently owns execution. It is null
while a run waits for external input even though that waiting run remains in
state.runs.
thread.metadata is partial because an unrealized handle exists before server
defaults and schema transforms have produced an authoritative snapshot.
Server-only thread metadata is never projected here.
PaiMessage
type PaiMessage<TContract extends AgentContract = AgentContract> =
| PaiCanonicalMessage<TContract>
| PaiPendingInputMessage<TContract>;PaiMessage is the only ordinary consumer message type. It retains the AI SDK
UIMessage layout:
type PaiCanonicalMessageShape<TContract extends AgentContract> = {
id: string;
role: "system" | "user" | "assistant";
metadata: MessageMetadataOf<TContract> & {
pai: PaiMessageFacts;
};
parts: PaiMessagePart<TContract>[];
};That metadata shape describes canonical server messages. An optimistic
PaiPendingInputMessage<TContract> instead carries the sparse metadata supplied
to send; schema defaults and transforms appear only when the canonical message
with the same ID replaces it. Narrow the union with the exported guard:
if (isOptimisticInputMessage(message)) {
// Declared application metadata fields may still be absent.
} else {
// Canonical metadata is the complete schema output.
}Application metadata remains at the top level. metadata.pai is reserved for
PAI provenance and lifecycle facts:
type PaiMessageFacts =
| {
producer: "send" | "trigger";
runId: RunId;
relation: "input";
status: "committed";
createdAt: string;
updatedAt: string;
}
| {
producer: "model";
runId: RunId;
relation: "output";
status: "open" | "committed";
createdAt: string;
updatedAt: string;
usage?: UsageSummary;
diagnostics?: MessageDiagnosticsRecord;
}
| {
producer: "lifecycle";
runId: RunId;
relation: "context" | "output";
status: "committed";
createdAt: string;
updatedAt: string;
}
| {
producer: "admin";
status: "committed";
createdAt: string;
updatedAt: string;
};
type PendingPaiFactsShape = {
/** Client-only optimistic input before send admission settles. */
producer: "send";
status: "pending";
createdAt: string;
updatedAt: string;
runId?: never;
relation?: never;
};The actual exported union correlates producer, role, relation, and status more narrowly than this abbreviated overview. A client-only pending input has no run ID yet. The canonical server message with the same message ID reconciles it in place after admission.
PaiMessagePart
PaiMessagePart is the exported union of ordinary AI SDK UI-message parts,
contract-typed application data parts, PAI-enriched static and dynamic tool
parts, PaiReasoningPart, and PaiAttachmentPart. Standard AI SDK
discriminants remain native: text, reasoning, source-url,
source-document, file, dynamic-tool, tool-${name}, and application
data-${name} parts. Use the exported guards where a pattern needs type
narrowing:
import {
isPaiAttachmentPart,
isPaiDataPart,
isPaiToolPart,
} from "@pai/client";
for (const message of state.messages) {
for (const part of message.parts) {
if (part.type === "text") renderText(part.text);
else if (isPaiToolPart(part)) renderTool(message, part);
else if (isPaiAttachmentPart(part)) renderAttachment(part);
else if (isPaiDataPart(part)) renderApplicationData(part);
}
}PAI consumes its reserved data parts during projection. Their public effects
are joined onto the native owner: ToolData and suspension episodes enrich a
tool part, a generated summary enriches a reasoning part, and a managed file
reference becomes PaiAttachmentPart.
Tool parts
Static tool parts use type: "tool-${name}"; dynamic provider or request-time
tools use type: "dynamic-tool" plus toolName. Both keep the AI SDK's native
state-discriminated fields:
switch (part.state) {
case "input-streaming":
renderPartialInput(part.input);
break;
case "input-available":
renderInput(part.input);
break;
case "approval-requested":
case "approval-responded":
renderApproval(part.approval);
break;
case "output-available":
renderOutput(part.output);
break;
case "output-error":
renderError(part.errorText);
break;
case "output-denied":
renderDenied(part.approval.reason);
break;
}PAI does not serialize custom running, waiting, or cancelled states into
the native tool union. React derives those presentation states from the native
part plus run and action sidecars.
For a contract-typed static tool named TName, the projected PAI fields are
equivalent to:
type PaiStaticToolProjection<
TContract extends AgentContract,
TName extends ToolName<TContract> & string,
> = {
data: PaiToolDataView<TContract, TName>;
suspensions: readonly PaiSuspensionView<TContract, TName>[];
pendingSuspension: Extract<
PaiSuspensionView<TContract, TName>,
{ state: "pending" }
> | null;
};For a static tool, data, suspension input, and submitted resume payloads are
typed from the agent contract. Dynamic tools expose the same fields with
JSON-safe, contract-erased values. The native toolCallId is the invocation
identity; PAI does not invent another universal tool-part identifier.
Reasoning and attachments
type PaiReasoningPart = ReasoningUIPart & {
summary?: string;
};
type PaiAttachmentPart = {
type: "attachment";
id: string;
fileId: string;
mediaType: string;
filename?: string;
byteSize?: number;
metadata?: JsonObject;
};Reasoning and other standard content retain native provider metadata. An attachment is a provider-neutral snapshot of a file reference managed by PAI; request a view or download URL separately when rendering it.
RunState
type RunStateBase<TContract extends AgentContract = AgentContract> = {
threadId: ThreadId;
runId: RunId;
/** Absent only when queued input was cancelled before admission. */
inputMessageIds?: [MessageId, ...MessageId[]];
initiator: "send" | "trigger" | "regenerate";
admission:
| { mode: "direct" }
| { mode: "queue" | "steer"; queueItemId: QueueItemId };
usage: UsageSummary;
metadata: RunMetadataOf<TContract>;
createdAt: string;
};
type RunLifecycleStatus =
| "queued"
| "running"
| "waiting"
| "completed"
| "failed"
| "cancelled";The exported RunState is a status-discriminated union. running and
waiting include startedAt; terminal states include completedAt;
completed includes finishReason; failed includes a persisted error.
Regenerated runs are always direct, while queue and steer admission apply only
to send or trigger runs.
RunState is client-safe. metadata is typed from the agent's public run
schema; private run metadata remains available only to lifecycle and trusted
runtime code.
Thread
type Thread<TContract extends AgentContract = AgentContract> = {
readonly threadId: ThreadId;
readonly commands: ThreadCommands<TContract>;
subscribeData(
listener: (part: DataUIPart<TContract["uiData"]>) => void,
): () => void;
run(runId: RunId): RunHandle<TContract>;
refresh(): Promise<ThreadState<TContract>>;
getState(): ThreadState<TContract>;
rename(title: string): Promise<ThreadState<TContract>>;
send(
input: ClientSendInput<TContract>,
options?: SendOptions<TContract>,
): Promise<RunHandle<TContract>>;
regenerate(
input: ClientThreadRegenerateInput<TContract>,
options?: ThreadRegenerateOptions<TContract>,
): Promise<RunHandle<TContract>>;
retry(
options?: ThreadRegenerateOptions<TContract>,
): Promise<RunHandle<TContract>>;
stop(input?: ThreadStopOptions): Promise<ActionResult<TContract>>;
writeToolData(
input: Omit<
WriteToolDataInput<TContract>,
"threadId" | "clientData" | "clientTools"
>,
): Promise<ActionResult<TContract>>;
recallQueuedItem(
input: Omit<RecallQueuedItemInput, "threadId">,
): Promise<ActionResult<TContract>>;
getPendingAction(
message: PaiMessage<TContract>,
part: PaiStaticToolPart<TContract> | PaiDynamicToolPart,
): PendingActionView<TContract> | null;
watch(options?: {
signal?: AbortSignal;
intervalMs?: number;
}): AsyncIterable<ThreadState<TContract>>;
};refresh() fetches an authoritative snapshot. getState() returns the latest
locally reduced public state. watch() yields that same shape and never exposes
raw transport events.
subscribeData(listener) observes future application-authored native
data-${name} parts on this thread handle and returns an unsubscribe function.
Like ThreadHandleOptions.onData, it excludes reserved data-pai-* parts and
uses the agent's closed UI-data registry for payload typing. Live reconnect and
generation replay do not redeliver a data part already observed by the same
thread handle; refreshing a retained snapshot does not invoke data listeners.
run(runId) creates an exact run handle without I/O. It does not claim that the
run exists; run.getState() returns null for an unknown identity.
Sending And Regeneration
type ClientSendInput<
TContract extends AgentContract = AgentContract,
> =
| string
| UserMessageInput<TContract>
| {
text?: string;
attachments?: SendAttachmentInput[];
metadata?: Exclude<UserMessageInput<TContract>["metadata"], undefined>;
parts?: never;
};
/** Illustrative private helper used by the signature below. */
type IsUnion<TValue, TWhole = TValue> = TValue extends TWhole
? [TWhole] extends [TValue]
? false
: true
: never;
type UserMessageInput<
TContract extends AgentContract = AgentContract,
> = {
parts: UserMessagePartInput[];
metadata?: IsUnion<TContract> extends true
? never
: MessageMetadataInput<MessageMetadataOf<TContract>>;
};
type UserMessagePartInput =
| { type: "text"; text: string }
| {
type: "file";
url: string;
mediaType: string;
filename?: string;
providerMetadata?: ProviderMetadataRecord;
}
| { type: "attachment"; fileId: string };thread.send("hello") is shorthand for one native text input. The client adds
an optimistic pending message immediately and reconciles it using the final
message ID. With a concrete agent contract, both structured and shorthand
metadata inputs accept only declared fields and their input values. Declared
fields are optional because server defaults or transforms may complete them.
An erased AgentContract client remains open to arbitrary strict-JSON metadata.
When a client is bound to an unresolved union of agent contracts, text and
metadata-free structured inputs remain available, but any metadata requires
narrowing the selected contract first. This conservative rule also covers keys
shared by every branch because separate runtime agent and input values cannot
preserve that correlation.
type SendOptions<TContract extends AgentContract = AgentContract> = {
queue?: SendQueueOptions;
clientData?: JsonCompatible<TContract["clientData"]>;
clientTools?: ClientToolDefinitionSnapshot[];
/** Final user-message identity; generated by the client when omitted. */
messageId?: MessageId;
};
type ClientThreadRegenerateInput<
TContract extends AgentContract = AgentContract,
> = {
messageId: MessageId;
replacement?: ClientSendInput<TContract>;
};Regeneration rewinds from an admitted user message. run.regenerate() is the
safer run-centered form because it preserves the complete ordered input set for
a merged queued turn.
PendingActionView
Pending action commands are controller sidecars. They are not stored on the
native tool part and are not a public collection on ThreadState.
const state = await run.waitUntilBlocked();
for (const message of state.messages) {
for (const part of message.parts) {
if (!isPaiToolPart(part)) continue;
const action = thread.getPendingAction(message, part);
if (action) await action.submit({ approved: true });
}
}getPendingAction() matches the exact message identity, tool-call identity,
and current suspension identity. It returns null for a stale message or part.
type PendingActionView<TContract extends AgentContract = AgentContract> = {
name: string;
input: unknown;
origin: {
kind: "tool";
threadId: ThreadId;
toolName:
| ToolName<TContract>
| (string & Record<never, never>);
suspendName?: string;
messageId: MessageId;
toolCallId: ToolCallId;
runId: RunId;
};
ref: PendingActionRef<TContract>;
submit(resume: unknown): Promise<ActionResult<TContract>>;
cancel(input?: { reason?: string }): Promise<ActionResult<TContract>>;
fail(error: unknown): Promise<ActionResult<TContract>>;
};
type PendingActionRef<TContract extends AgentContract = AgentContract> =
PendingActionView<TContract>["origin"] & {
/** Suspension data identity, or toolCallId for client/manual output. */
actionId: string;
name: string;
};The low-level client keeps action payloads as unknown at this transport-safe
boundary. @pai/react narrows renderer action input and submit() from the
tool contract.
For an external-output client tool, actionId equals toolCallId and the
action name is ${toolName}.output. A named suspension uses its durable
suspension data identity.
cancel() means the action cannot receive a valid domain response. If denial
is valid domain data, submit a payload such as { approved: false } instead.
ActionResult
type ActionResult<TContract extends AgentContract = AgentContract> = {
run?: RunHandleLike<TContract>;
state: ThreadState<TContract>;
};The optional run is present when the command created or resumed executable work. The state is the latest projected state after the mutation settles.
Stopping
type ThreadStopContinuation = "none" | "steer" | "all";
type ThreadStopOptions = {
reason?: string;
/** Defaults to "none". */
continueWith?: ThreadStopContinuation;
};
type RunStopOptions = {
reason?: string;
};thread.stop() cancels active work and applies the selected policy to queued
work. run.stop() targets only that exact running or waiting run and leaves
sibling queued work unchanged. Cancellation is cooperative.
Queue
type ThreadQueueView<TContract extends AgentContract = AgentContract> = {
items: QueuedItemView<TContract>[];
canRecall: boolean;
};
type QueuedItemView<TContract extends AgentContract = AgentContract> = {
queueItemId: QueueItemId;
runId: RunId;
mode: "queue" | "steer";
messages: [PaiQueuedMessage<TContract>, ...PaiQueuedMessage<TContract>[]];
createdAt: string;
recall(): Promise<ActionResult<TContract>>;
};Queued messages use the same projected native message shape as admitted input,
but they do not enter state.messages until admission. Calling recall()
removes the queued item and cancels its queued run without creating transcript
history.
await thread.send("Handle this later", {
queue: { mode: "queue", merge: "none" },
});
await thread.send("Correct the current direction", {
queue: { mode: "steer", merge: "last" },
});Omitting queue keeps the default busy-thread behavior: reject with
ThreadBusyError.
UsageSummary
UsageSummary is shared by thread, run, and model-message step usage:
type UsageSummary = {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
inputTokenDetails: {
noCacheTokens?: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
};
outputTokenDetails: {
textTokens?: number;
reasoningTokens?: number;
};
};Step usage is available on model-produced
message.metadata.pai.usage. Run usage is in state.runs; state.usage is the
thread aggregate. Usage counts provider calls, not unique transcript tokens.
The package exports addUsage, emptyUsage, and hasUsage for safe
aggregation and presence checks.
Files And Attachments
type FileUploadInput = {
body: Uint8Array | Blob | ArrayBuffer | ReadableStream<Uint8Array>;
mediaType: string;
filename?: string;
threadId?: ThreadId;
byteSize?: number;
metadata?: Record<string, unknown>;
};
type UploadedFile = {
fileId: string;
mediaType: string;
filename?: string;
byteSize?: number;
metadata?: Record<string, unknown>;
};
type ClientCreateFileUrlInput = {
fileId: string;
intent?: "view" | "download";
ttlSeconds?: number;
};Upload first, then send the returned file reference as an attachment. Direct
client.files.upload() is thread-agnostic unless threadId is supplied.
Framework composers can fill that association automatically.
Attachment bytes remain in the configured file provider. The transcript keeps
the immutable provider-neutral PaiAttachmentPart, and the runtime expands it
into provider input only when assembling model context.
Runtime/Admin Extensions
Trusted runtime clients add server-only message, metadata, run, command, and
trigger operations. Their message API returns LifecycleMessage, the same
native-first canonical message plus visibility and private metadata:
type RuntimeThreadMessageClient<
TContract extends AgentContract = AgentContract,
> = {
getMany(messageIds: MessageId[]): Promise<LifecycleMessage<TContract>[]>;
append(
input: AppendAdminMessageInput<TContract>,
): Promise<LifecycleMessage<TContract>>;
updateVisibility(
messageIds: MessageId[],
input: { visibility: LifecycleMessageVisibility },
): Promise<void>;
};See the runtime reference for the complete trusted surface. Ordinary browser clients cannot append arbitrary messages, update visibility, or write private metadata.
Client Tool Snapshots
type ClientToolDefinitionSnapshot = {
name: string;
description: string;
inputSchema: JsonValue;
outputSchema: JsonValue;
dataSchemas?: Record<string, JsonValue>;
};Low-level clients can pass serialized frontend tool definitions through thread
request context or send options. Executable functions and renderers never cross
this boundary. React clients normally use provider/session clientTools or
component-scoped useClientTool() registrations.
RunHandle
type RunHandle<TContract extends AgentContract = AgentContract> = {
readonly runId: RunId;
getState(): Promise<RunState<TContract> | null>;
watch(options?: {
stopOn?: "idle" | "blocked";
signal?: AbortSignal;
intervalMs?: number;
}): AsyncIterable<ThreadState<TContract>>;
waitUntilStatus(status: RunLifecycleStatus): Promise<RunState<TContract>>;
waitUntil(status: RunLifecycleStatus): Promise<ThreadState<TContract>>;
stop(input?: RunStopOptions): Promise<ActionResult<TContract>>;
regenerate(
options?: ThreadRegenerateOptions<TContract>,
): Promise<RunHandle<TContract>>;
waitUntilBlocked(): Promise<ThreadState<TContract>>;
waitUntilIdle(): Promise<ThreadState<TContract>>;
};waitUntilStatus() returns the run record. The other wait helpers return the
latest owning ThreadState, which is usually what rendering and action code
needs.
run.watch() yields the same state as thread.watch() and stops at the chosen
boundary. idle means completed, failed, or cancelled; blocked also
includes waiting.
waitUntilBlocked() follows queued and running work until it waits or becomes
terminal. waitUntilIdle() continues through waiting until the run becomes
terminal.