qz-browser-agent 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/package.json +1 -1
- package/src/catalog.json +6 -6
- package/src/cli.mjs +5 -3
- package/src/commands.mjs +7 -5
- package/src/task-stream.mjs +63 -61
- package/src/tools.mjs +5 -3
package/README.md
CHANGED
|
@@ -94,7 +94,7 @@ CLI and MCP share the same browser connection. The MCP tool list is in the
|
|
|
94
94
|
| `qz send TASK_ID "MESSAGE"` | Send a follow-up, or `--steer` the current run |
|
|
95
95
|
| `qz tasks` | List tasks |
|
|
96
96
|
| `qz show TASK_ID` | Read a task's status, response, or pending question |
|
|
97
|
-
| `qz watch TASK_ID` | Follow a
|
|
97
|
+
| `qz watch TASK_ID` | Follow a task until its work and queued messages finish |
|
|
98
98
|
| `qz submit` / `approve` / `reject` | Answer a question or permission request |
|
|
99
99
|
| `qz cancel TASK_ID` | Stop a task |
|
|
100
100
|
| `qz data` | List, export, or delete data tables |
|
|
@@ -107,6 +107,12 @@ CLI and MCP share the same browser connection. The MCP tool list is in the
|
|
|
107
107
|
With several browser profiles connected, choose one with `--client NAME|UUID` (or `-c`).
|
|
108
108
|
Run `qz COMMAND --help` for options. Interactive terminals show readable text; pipes receive JSON.
|
|
109
109
|
|
|
110
|
+
`watch`, `--wait`, and `--stream` follow the same task across runs until its current work and
|
|
111
|
+
accepted follow-ups and steering messages have completed and its queues are empty. Questions
|
|
112
|
+
and permission requests keep observation open. Task failure or stopping ends observation.
|
|
113
|
+
Timeout or Ctrl-C ends observation while the browser task continues; execute the returned
|
|
114
|
+
`watch` command with the same task ID and browser UUID to resume. Observation never resends messages.
|
|
115
|
+
|
|
110
116
|
## Privacy and local access
|
|
111
117
|
|
|
112
118
|
The service accepts connections only on your computer, and ordinary websites cannot use its MCP
|
package/package.json
CHANGED
package/src/catalog.json
CHANGED
|
@@ -62,10 +62,10 @@
|
|
|
62
62
|
},
|
|
63
63
|
{
|
|
64
64
|
"name": "qingzhou_create_session",
|
|
65
|
-
"description": "Create a task with an optional first message. Set wait=true to
|
|
65
|
+
"description": "Create a task with an optional first message. Set wait=true to follow the same task across runs until its current work and accepted follow-ups and steering messages have completed and its queues are empty. stream=true adds progress events. User-input waits keep observation open. Task failure or stopping ends observation. Timeout or cancellation ends observation while the browser task continues; use the returned watch recovery call without resubmitting.",
|
|
66
66
|
"inputSchema": {
|
|
67
67
|
"properties": {
|
|
68
|
-
"wait": {"type": "boolean", "default": false, "description": "
|
|
68
|
+
"wait": {"type": "boolean", "default": false, "description": "Follow the same task across runs until its work completes and its accepted follow-up and steering queues are empty. User-input waits keep observation open; task failure or stopping ends it."},
|
|
69
69
|
"stream": {"type": "boolean", "default": false, "description": "Implies wait. Send task text, status, and input events through MCP progress notifications when a progressToken is supplied."},
|
|
70
70
|
"waitTimeoutSeconds": {"type": ["number", "null"], "exclusiveMinimum": 0, "maximum": 86400, "default": null, "description": "Optional total observation deadline; null waits indefinitely. timeoutSeconds bounds each browser request."},
|
|
71
71
|
"title": {"default": "", "title": "Title", "type": "string"},
|
|
@@ -80,10 +80,10 @@
|
|
|
80
80
|
},
|
|
81
81
|
{
|
|
82
82
|
"name": "qingzhou_send_message",
|
|
83
|
-
"description": "Send a message to the specified task. By default, a busy task queues a follow-up for its next run. Set steer=true to guide its current run at the next safe boundary. An idle task starts with either mode. Returns deliveryMode, disposition, and clientMessageId
|
|
83
|
+
"description": "Send a message to the specified task. By default, a busy task queues a follow-up for its next run. Set steer=true to guide its current run at the next safe boundary. An idle task starts with either mode. Returns deliveryMode, disposition, and clientMessageId to confirm acceptance. Set wait=true to follow the same task across runs until its work completes and its follow-up and steering queues are empty. A queued message removed before execution no longer blocks completion. stream=true emits text from the submitted message onward through MCP progress notifications. Timeout or cancellation ends observation; use the returned watch recovery call without resubmitting.",
|
|
84
84
|
"inputSchema": {
|
|
85
85
|
"properties": {
|
|
86
|
-
"wait": {"type": "boolean", "default": false, "description": "
|
|
86
|
+
"wait": {"type": "boolean", "default": false, "description": "Follow the same task across runs until its work completes and its accepted follow-up and steering queues are empty. User-input waits keep observation open; task failure or stopping ends it."},
|
|
87
87
|
"stream": {"type": "boolean", "default": false, "description": "Implies wait. Send task text, status, and input events through MCP progress notifications when a progressToken is supplied."},
|
|
88
88
|
"waitTimeoutSeconds": {"type": ["number", "null"], "exclusiveMinimum": 0, "maximum": 86400, "default": null, "description": "Optional total observation deadline; null waits indefinitely. timeoutSeconds bounds each browser request."},
|
|
89
89
|
"text": {"title": "Text", "type": "string"},
|
|
@@ -150,7 +150,7 @@
|
|
|
150
150
|
"description": "Respond using the pending input's responseTemplate: fill answers keyed by field IDs with option values for choices (arrays for multi_choice), then submit; approve a completed action or application consent, or reject. Show location disclosures and obtain explicit user consent before approving with consentVersion. Native Chrome permissions require the user's click in qz. Invalid or stale replies return the current input for correction and keep the request pending.",
|
|
151
151
|
"inputSchema": {
|
|
152
152
|
"properties": {
|
|
153
|
-
"wait": {"type": "boolean", "default": false, "description": "
|
|
153
|
+
"wait": {"type": "boolean", "default": false, "description": "Follow the same task across runs until its work completes and its accepted follow-up and steering queues are empty. User-input waits keep observation open; task failure or stopping ends it."},
|
|
154
154
|
"stream": {"type": "boolean", "default": false, "description": "Implies wait. Send task text, status, and input events through MCP progress notifications when a progressToken is supplied."},
|
|
155
155
|
"waitTimeoutSeconds": {"type": ["number", "null"], "exclusiveMinimum": 0, "maximum": 86400, "default": null, "description": "Optional total observation deadline; null waits indefinitely. timeoutSeconds bounds each browser request."},
|
|
156
156
|
"sessionId": {"type": "string", "minLength": 1},
|
|
@@ -357,7 +357,7 @@
|
|
|
357
357
|
},
|
|
358
358
|
{
|
|
359
359
|
"name": "qingzhou_watch_task",
|
|
360
|
-
"description": "Observe an existing task until
|
|
360
|
+
"description": "Observe an existing task across runs until its current work and accepted follow-ups and steering messages have completed and its queues are empty, without resubmitting anything. Questions and Chrome permission requests keep observation open; MCP progress notifications include their full input and responseTemplate. stream=true includes text updates. Task failure or stopping ends observation. Timeout or cancellation ends observation while the browser task continues; resume with the returned recovery call using the same sessionId and clientId.",
|
|
361
361
|
"inputSchema": {
|
|
362
362
|
"type": "object",
|
|
363
363
|
"properties": {
|
package/src/cli.mjs
CHANGED
|
@@ -213,14 +213,14 @@ async function dispatchCommand(parsed, dependencies) {
|
|
|
213
213
|
throw error;
|
|
214
214
|
}
|
|
215
215
|
};
|
|
216
|
-
const observe = async (sessionId, { runId = "", expectedMessageId = "",
|
|
216
|
+
const observe = async (sessionId, { runId = "", expectedMessageId = "", messageWasQueued = false, ignoreWaitId = "", allowIdle = false } = {}) => {
|
|
217
217
|
try {
|
|
218
218
|
const remaining = waitTimeoutSeconds == null ? null : waitTimeoutSeconds - (Date.now() - startedAt) / 1000;
|
|
219
219
|
if (remaining !== null && remaining <= 0) throw new DOMException("Stopped watching after the configured --timeout. The browser task may still be running.", "TimeoutError");
|
|
220
220
|
return await waitForTask({
|
|
221
221
|
sessionId, clientId: common.clientId, timeoutSeconds: remaining, signal: dependencies.signal,
|
|
222
222
|
requestTimeoutSeconds: timeoutSeconds,
|
|
223
|
-
expectedRunId: runId, expectedMessageId,
|
|
223
|
+
expectedRunId: runId, expectedMessageId, messageWasQueued, ignoreWaitId, allowIdle,
|
|
224
224
|
onUpdate: dependencies.onTaskUpdate,
|
|
225
225
|
getSnapshot: ({ timeoutSeconds: seconds, signal }) => readSnapshot(sessionId, seconds, signal),
|
|
226
226
|
});
|
|
@@ -245,7 +245,9 @@ async function dispatchCommand(parsed, dependencies) {
|
|
|
245
245
|
sessionId: id, hint: "Continue watching the same task:", command: recoveryCommand("watch", id, clientId, true),
|
|
246
246
|
});
|
|
247
247
|
}
|
|
248
|
-
result = { ...result, snapshot: await observe(id, {
|
|
248
|
+
result = { ...result, snapshot: await observe(id, {
|
|
249
|
+
expectedMessageId: result.clientMessageId, messageWasQueued: result.disposition === "queued",
|
|
250
|
+
}) };
|
|
249
251
|
}
|
|
250
252
|
return { result };
|
|
251
253
|
}
|
package/src/commands.mjs
CHANGED
|
@@ -14,7 +14,7 @@ export const COMMANDS = {
|
|
|
14
14
|
create: { options: [...waiting, "title", "parent"], summary: "Start a task",
|
|
15
15
|
help: ['qz create "PROMPT" [--title TITLE] [--wait | --stream]', "Start a browser task. The title is optional.",
|
|
16
16
|
"--title, -t Set the task title. --parent TASK_ID creates a child task.",
|
|
17
|
-
"--wait
|
|
17
|
+
"--wait Follow the task across runs until its work completes and accepted message queues are empty; user-input pauses keep waiting.", "--stream Show responses as they arrive; also wait.",
|
|
18
18
|
"Use - as the prompt to read stdin.",
|
|
19
19
|
'qz create "Compare these two pages." --stream', 'qz create "Summarize this page." --title Research --wait'],
|
|
20
20
|
},
|
|
@@ -22,7 +22,8 @@ export const COMMANDS = {
|
|
|
22
22
|
help: ['qz send TASK_ID "MESSAGE" [--steer] [--wait | --stream]', "Send a follow-up or steer a specific task.",
|
|
23
23
|
"A busy task queues a follow-up for its next run. --steer delivers the message to the current run at its next safe boundary.",
|
|
24
24
|
"Both start a new run when the task is idle. Pending questions and permissions still require a response.",
|
|
25
|
-
"Use - as the message to read stdin. --wait follows the
|
|
25
|
+
"Use - as the message to read stdin. --wait follows the task until its work completes and queues are empty; removed queued messages no longer block completion.",
|
|
26
|
+
"--stream also shows responses from this message onward, including later runs.",
|
|
26
27
|
'qz send TASK_ID "Add the source links." --stream', 'qz send TASK_ID "Focus on open-source options." --steer --stream'],
|
|
27
28
|
},
|
|
28
29
|
tasks: { options: [...request, "limit"], summary: "List tasks",
|
|
@@ -32,19 +33,20 @@ export const COMMANDS = {
|
|
|
32
33
|
help: ["qz show TASK_ID", "Show task status, the latest response, and any pending form or permission request.", "Pending input includes field IDs, option values, and a submission template. Use --json for the complete snapshot.", "qz show TASK_ID --json"],
|
|
33
34
|
},
|
|
34
35
|
watch: { options: [...request, "stream"], summary: "Wait for a task",
|
|
35
|
-
help: ["qz watch TASK_ID [--stream]", "
|
|
36
|
+
help: ["qz watch TASK_ID [--stream]", "Follow an existing task across runs until its work completes and accepted message queues are empty; user-input pauses keep waiting.", "--stream Show assistant responses as they arrive.", "Task failure or stopping ends observation. Timeout or Ctrl-C ends watching without cancelling the task.", "Resume with the returned watch command for the same task and browser; observation never resends messages."],
|
|
36
37
|
},
|
|
37
38
|
submit: { options: [...waiting, "wait-id"], summary: "Answer a form",
|
|
38
39
|
help: ["qz submit TASK_ID JSON [--wait | --stream]", "Submit answers to the current form. Use - to read JSON from stdin.",
|
|
39
40
|
"--wait-id ID Bind the response to a previously read wait. Otherwise qz reads the current wait before submitting.",
|
|
41
|
+
"--wait and --stream follow the task across runs until its work completes and accepted message queues are empty.",
|
|
40
42
|
'qz submit TASK_ID \'{"selectedIds":["option-a"]}\' --stream'],
|
|
41
43
|
},
|
|
42
44
|
approve: { options: [...waiting, "wait-id"], summary: "Continue a waiting task",
|
|
43
45
|
help: ["qz approve TASK_ID [--wait | --stream]", "Continue after completing a requested action.",
|
|
44
|
-
"Review app consent notices with qz show before approving. Native browser permissions require a click in the qz side panel.", "--wait-id ID Respond only to this wait."],
|
|
46
|
+
"Review app consent notices with qz show before approving. Native browser permissions require a click in the qz side panel.", "--wait-id ID Respond only to this wait.", "--wait and --stream follow the task across runs until its work completes and accepted message queues are empty."],
|
|
45
47
|
},
|
|
46
48
|
reject: { options: [...waiting, "wait-id"], summary: "Decline a pending request",
|
|
47
|
-
help: ["qz reject TASK_ID [--wait-id ID] [--wait | --stream]", "Decline the pending request and
|
|
49
|
+
help: ["qz reject TASK_ID [--wait-id ID] [--wait | --stream]", "Decline the pending request. --wait and --stream follow the task across runs until its work completes and accepted message queues are empty. Use qz cancel TASK_ID to stop the whole task."],
|
|
48
50
|
},
|
|
49
51
|
cancel: { options: request, summary: "Stop a task",
|
|
50
52
|
help: ["qz cancel TASK_ID", "Stop a task's active run.", "qz stop stops the local service instead."],
|
package/src/task-stream.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { setTimeout as delay } from "node:timers/promises";
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const FAILURE_STATUSES = new Set(["error", "failed", "aborted", "cancelled", "interrupted"]);
|
|
4
4
|
const ACTIVE_STATUSES = new Set(["pending", "running", "waiting_user", "paused"]);
|
|
5
5
|
const INPUT_STATUSES = new Set(["waiting_user", "paused"]);
|
|
6
|
+
const QUEUE_FIELDS = ["pendingFollowUps", "pendingFollowUpMessages", "pendingSteerings", "pendingSteeringMessages"];
|
|
6
7
|
const record = value => value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
7
8
|
|
|
8
9
|
export const TASK_NOT_FOUND_MESSAGE = "The task was not found in the selected browser. It may have been deleted, the task ID may be incorrect, or a different browser may be selected.";
|
|
@@ -13,6 +14,11 @@ function taskStatus(snapshot) {
|
|
|
13
14
|
return status.toLowerCase() === "canceled" ? "cancelled" : status.toLowerCase();
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
function hasPendingWork(runtime) {
|
|
18
|
+
return runtime.running === true || runtime.pendingStart === true
|
|
19
|
+
|| QUEUE_FIELDS.some(key => Array.isArray(runtime[key]) && runtime[key].length > 0);
|
|
20
|
+
}
|
|
21
|
+
|
|
16
22
|
function assistantText(content) {
|
|
17
23
|
if (typeof content === "string") return content;
|
|
18
24
|
return Array.isArray(content) ? content.filter(part => part?.type === "text")
|
|
@@ -37,12 +43,13 @@ function abortable(operation, signal) {
|
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
/** Observe snapshots only: timeout, interruption, and polling failures never cancel or restart a browser task.
|
|
46
|
+
* Follow one Session across runs until execution and its accepted queues are finished.
|
|
40
47
|
* User input pauses execution, not observation; input events expose each pending request.
|
|
41
48
|
* Total observation is unlimited by default; each snapshot request has its own finite deadline.
|
|
42
49
|
* getSnapshot must request compact:false and tail:false so transcript identities and streaming text survive.
|
|
43
50
|
*/
|
|
44
51
|
export async function waitForTask({ sessionId, clientId, timeoutSeconds = null, requestTimeoutSeconds = 180, signal, getSnapshot, onUpdate,
|
|
45
|
-
pollIntervalMs = 750, expectedRunId = "", expectedMessageId = "",
|
|
52
|
+
pollIntervalMs = 750, expectedRunId = "", expectedMessageId = "", messageWasQueued = false, ignoreWaitId = "", allowIdle = false } = {}) {
|
|
46
53
|
if (!String(sessionId || "").trim()) throw new TypeError("sessionId is required");
|
|
47
54
|
if (typeof getSnapshot !== "function") throw new TypeError("getSnapshot is required");
|
|
48
55
|
if (timeoutSeconds !== null && (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0)) throw new TypeError("timeoutSeconds must be positive or null");
|
|
@@ -86,12 +93,11 @@ export async function waitForTask({ sessionId, clientId, timeoutSeconds = null,
|
|
|
86
93
|
}
|
|
87
94
|
};
|
|
88
95
|
const messages = new Map();
|
|
89
|
-
let
|
|
90
|
-
let
|
|
96
|
+
let firstRunId = String(expectedRunId || "");
|
|
97
|
+
let trackingReady = false;
|
|
91
98
|
let observedActivity = false;
|
|
92
99
|
let awaitingMessage = Boolean(expectedMessageId);
|
|
93
|
-
let
|
|
94
|
-
let awaitingResume = Boolean(ignoreWaitId);
|
|
100
|
+
let observedQueuedMessage = messageWasQueued;
|
|
95
101
|
let lastStatus = "";
|
|
96
102
|
let lastInput = "";
|
|
97
103
|
const emit = async update => {
|
|
@@ -137,67 +143,63 @@ export async function waitForTask({ sessionId, clientId, timeoutSeconds = null,
|
|
|
137
143
|
const transcript = Array.isArray(snapshot.transcript) ? snapshot.transcript : Array.isArray(snapshot.tail) ? snapshot.tail : [];
|
|
138
144
|
const submittedMessageIndex = expectedMessageId
|
|
139
145
|
? transcript.findIndex(entry => entry?.type === "user" && entry.clientMessageId === expectedMessageId) : -1;
|
|
140
|
-
if (awaitingMessage)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
} else {
|
|
147
|
-
// A queued message may wait behind an earlier run's question or permission.
|
|
148
|
-
// Keep that input actionable, but never mistake its answer for this message's result.
|
|
149
|
-
if (status !== "completed") await emitStatus(status, runId, runtime);
|
|
150
|
-
await emitInput(status, runId, runtime, canonicalInput);
|
|
151
|
-
if (STOP_STATUSES.has(status) && status !== "completed") return snapshot;
|
|
152
|
-
await pollingDelay();
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
146
|
+
if (awaitingMessage && QUEUE_FIELDS.some(key => Array.isArray(runtime[key])
|
|
147
|
+
&& runtime[key].some(message => message?.clientMessageId === expectedMessageId))) observedQueuedMessage = true;
|
|
148
|
+
const submittedMessage = transcript[submittedMessageIndex];
|
|
149
|
+
if (awaitingMessage && submittedMessage?.runId) {
|
|
150
|
+
firstRunId = String(submittedMessage.runId);
|
|
151
|
+
awaitingMessage = false;
|
|
155
152
|
}
|
|
156
|
-
const previous = previousRunId && (!runId || runId === previousRunId);
|
|
157
|
-
const awaitedRunId = messageRunId || expectedRunId;
|
|
158
|
-
const awaitingExpected = awaitedRunId && runId !== awaitedRunId;
|
|
159
153
|
const targetIndex = submittedMessageIndex >= 0 ? submittedMessageIndex
|
|
160
|
-
:
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
const passedTarget =
|
|
154
|
+
: firstRunId ? transcript.findIndex(entry => entry?.runId === firstRunId) : -1;
|
|
155
|
+
// The runtime can lag the transcript or skip whole runs between polls. Admit the
|
|
156
|
+
// target run or a proven successor once, then follow the Session without a run fence.
|
|
157
|
+
const passedTarget = runId && targetIndex >= 0
|
|
164
158
|
&& transcript.slice(targetIndex + 1).some(entry => entry?.type === "user" && entry.runId === runId);
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
159
|
+
// Resolving a wait and persisting its updated snapshot are separate operations.
|
|
160
|
+
// Ignore that exact old wait even while its runtime lags the acknowledged run.
|
|
161
|
+
const oldWait = !trackingReady && ignoreWaitId && status === "waiting_user" && String(canonicalInput?.waitId || runtime.waitId || "") === ignoreWaitId;
|
|
162
|
+
if (oldWait) {
|
|
163
|
+
await pollingDelay();
|
|
164
|
+
continue;
|
|
169
165
|
}
|
|
170
|
-
if (!
|
|
171
|
-
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
awaitingResume = false;
|
|
180
|
-
if (ACTIVE_STATUSES.has(status)) observedActivity = true;
|
|
181
|
-
await emitStatus(status, runId, runtime);
|
|
182
|
-
for (const [index, entry] of transcript.entries()) {
|
|
183
|
-
// Steering can continue the same run. Only stream responses after this submitted message.
|
|
184
|
-
if (expectedMessageId && (submittedMessageIndex < 0 || index <= submittedMessageIndex)) continue;
|
|
185
|
-
if (entry?.type !== "assistant" || (entry.runId && selectedRunId && entry.runId !== selectedRunId)) continue;
|
|
186
|
-
const messageId = messageIdentity(entry, index, selectedRunId);
|
|
187
|
-
const fullText = assistantText(entry.content);
|
|
188
|
-
const seen = messages.has(messageId);
|
|
189
|
-
const previousText = messages.get(messageId) || "";
|
|
190
|
-
if (fullText === previousText) continue;
|
|
191
|
-
messages.set(messageId, fullText);
|
|
192
|
-
const append = fullText.startsWith(previousText);
|
|
193
|
-
await emit({ type: append ? "text" : "replace", sessionId, runId: String(entry.runId || runId), messageId,
|
|
194
|
-
text: append ? fullText.slice(previousText.length) : fullText, fullText,
|
|
195
|
-
newMessage: !seen, streaming: entry.streaming === true });
|
|
196
|
-
}
|
|
166
|
+
if (!trackingReady && (awaitingMessage || (firstRunId && runId !== firstRunId && !passedTarget))) {
|
|
167
|
+
// Queue acceptance precedes these reads; pending precedes dequeue. A settled
|
|
168
|
+
// task with no trace of an accepted queued message means it was removed.
|
|
169
|
+
const removedMessage = awaitingMessage && observedQueuedMessage && submittedMessageIndex < 0
|
|
170
|
+
&& status === "completed" && !hasPendingWork(runtime);
|
|
171
|
+
// A queued message can wait behind another run's question. Keep it actionable,
|
|
172
|
+
// but do not emit its earlier answer or accept its successful completion.
|
|
173
|
+
if (status !== "completed" || removedMessage) await emitStatus(status, runId, runtime);
|
|
197
174
|
await emitInput(status, runId, runtime, canonicalInput);
|
|
198
|
-
|
|
199
|
-
|
|
175
|
+
if (FAILURE_STATUSES.has(status) || removedMessage) return snapshot;
|
|
176
|
+
await pollingDelay();
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
trackingReady = true;
|
|
180
|
+
if (!firstRunId && runId) firstRunId = runId;
|
|
181
|
+
if (ACTIVE_STATUSES.has(status)) observedActivity = true;
|
|
182
|
+
await emitStatus(status, runId, runtime);
|
|
183
|
+
for (const [index, entry] of transcript.entries()) {
|
|
184
|
+
// Steering can continue the same run. Only stream responses after this submitted message.
|
|
185
|
+
if (expectedMessageId && (submittedMessageIndex < 0 || index <= submittedMessageIndex)) continue;
|
|
186
|
+
if (entry?.type !== "assistant") continue;
|
|
187
|
+
if (!expectedMessageId && (targetIndex >= 0 ? index < targetIndex : entry.runId && entry.runId !== runId)) continue;
|
|
188
|
+
const messageId = messageIdentity(entry, index, firstRunId);
|
|
189
|
+
const fullText = assistantText(entry.content);
|
|
190
|
+
const seen = messages.has(messageId);
|
|
191
|
+
const previousText = messages.get(messageId) || "";
|
|
192
|
+
if (fullText === previousText) continue;
|
|
193
|
+
messages.set(messageId, fullText);
|
|
194
|
+
const append = fullText.startsWith(previousText);
|
|
195
|
+
await emit({ type: append ? "text" : "replace", sessionId, runId: String(entry.runId || runId), messageId,
|
|
196
|
+
text: append ? fullText.slice(previousText.length) : fullText, fullText,
|
|
197
|
+
newMessage: !seen, streaming: entry.streaming === true });
|
|
200
198
|
}
|
|
199
|
+
await emitInput(status, runId, runtime, canonicalInput);
|
|
200
|
+
// A newly created session can report idle before its first run starts.
|
|
201
|
+
if (FAILURE_STATUSES.has(status) || (!hasPendingWork(runtime)
|
|
202
|
+
&& (status === "completed" || (status === "idle" && (observedActivity || allowIdle))))) return snapshot;
|
|
201
203
|
await pollingDelay();
|
|
202
204
|
}
|
|
203
205
|
} catch (error) {
|
package/src/tools.mjs
CHANGED
|
@@ -78,7 +78,7 @@ async function executeBrowserTool(state, name, args, { signal, connectionSignal,
|
|
|
78
78
|
if (remaining !== null && remaining <= 0) throw new DOMException("Stopped watching after waitTimeoutSeconds. The browser task may still be running.", "TimeoutError");
|
|
79
79
|
return await waitForTask({ sessionId, clientId: args.clientId, signal, timeoutSeconds: remaining,
|
|
80
80
|
requestTimeoutSeconds: args.timeoutSeconds || state.requestTimeoutSeconds || 180,
|
|
81
|
-
expectedRunId: tracking.runId, expectedMessageId: tracking.clientMessageId,
|
|
81
|
+
expectedRunId: tracking.runId, expectedMessageId: tracking.clientMessageId, messageWasQueued: tracking.disposition === "queued",
|
|
82
82
|
ignoreWaitId: tracking.ignoreWaitId, allowIdle: tracking.allowIdle,
|
|
83
83
|
onUpdate: onTaskUpdate && (event => args.stream || !["text", "replace"].includes(event.type) ? onTaskUpdate(event) : undefined),
|
|
84
84
|
getSnapshot: async ({ signal: requestSignal, timeoutSeconds }) => bindInputClient("qingzhou_get_session_snapshot",
|
|
@@ -140,9 +140,11 @@ async function executeBrowserTool(state, name, args, { signal, connectionSignal,
|
|
|
140
140
|
result = { ...result, snapshot: await watch(sessionId, { allowIdle: !args.firstMessage.trim() }) };
|
|
141
141
|
} else if (name === "qingzhou_send_message" && result?.accepted === true) {
|
|
142
142
|
if (typeof result.clientMessageId !== "string" || !result.clientMessageId.trim()) {
|
|
143
|
-
throw new BridgeError("invalid_response", "The message was accepted without a tracking ID.
|
|
143
|
+
throw Object.assign(new BridgeError("invalid_response", "The message was accepted without a tracking ID. Continue watching the same task before sending it again."), {
|
|
144
|
+
details: { sessionId, clientId, recovery: { tool: "qingzhou_watch_task", arguments: { sessionId, clientId } } },
|
|
145
|
+
});
|
|
144
146
|
}
|
|
145
|
-
result = { ...result, snapshot: await watch(sessionId, { clientMessageId: result.clientMessageId }) };
|
|
147
|
+
result = { ...result, snapshot: await watch(sessionId, { clientMessageId: result.clientMessageId, disposition: result.disposition }) };
|
|
146
148
|
} else if (name === "qingzhou_respond_to_user_input" && (result?.accepted === true || result?.requiresUserAction)) {
|
|
147
149
|
const snapshot = await watch(sessionId, { runId: result.runId || "", ignoreWaitId: result.requiresUserAction ? "" : args.waitId });
|
|
148
150
|
result = result.requiresUserAction ? { sessionId, waitId: args.waitId, snapshot } : { ...result, snapshot };
|