@jskit-ai/assistant-runtime 0.2.1 → 0.2.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 +2 -1
- package/fixtures/canonical-conversation/vite.config.mjs +22 -2
- package/locales/en.json +2 -1
- package/package.json +8 -8
- package/src/client/composables/useAssistantConversation.js +67 -17
- package/src/client/support/subscribeAssistantConversation.js +18 -5
- package/src/server/actionIds.js +1 -0
- package/src/server/actions.js +15 -0
- package/src/server/registerRoutes.js +19 -0
- package/src/server/services/chatService.js +108 -74
- package/test/actionDefinitions.test.js +2 -1
- package/test/assistantConversationBinding.browser.test.js +240 -13
- package/test/assistantConversationOperations.test.js +66 -9
- package/test/assistantConversationSubscriptions.test.js +155 -25
- package/test/chatServiceLifecycle.test.js +48 -2
- package/test/featureRuntime.test.js +3 -3
|
@@ -215,6 +215,34 @@ function createChatService({
|
|
|
215
215
|
return { conversationId: response.meta?.conversationId, messageId: request.messageId, status: record.status };
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
// Turns on one conversation share its runtime entry. The last of them to
|
|
219
|
+
// settle disposes it, so a finished chat keeps no runtime state; a turn that
|
|
220
|
+
// starts while that disposal runs waits for it and opens the entry again.
|
|
221
|
+
const conversationTurns = new Map();
|
|
222
|
+
async function openConversationTurn(conversationId, open) {
|
|
223
|
+
while (conversationTurns.get(conversationId)?.releasing) await conversationTurns.get(conversationId).releasing;
|
|
224
|
+
const turns = conversationTurns.get(conversationId) || { count: 0, handle: null, releasing: null };
|
|
225
|
+
conversationTurns.set(conversationId, turns);
|
|
226
|
+
turns.count += 1;
|
|
227
|
+
try {
|
|
228
|
+
turns.handle = await open();
|
|
229
|
+
return turns.handle;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
await releaseConversationTurn(conversationId);
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
async function releaseConversationTurn(conversationId) {
|
|
236
|
+
const turns = conversationTurns.get(conversationId);
|
|
237
|
+
turns.count -= 1;
|
|
238
|
+
if (turns.count > 0) return;
|
|
239
|
+
// A failed disposal keeps the entry, as before: the next turn reuses it and
|
|
240
|
+
// runtime close retries its save.
|
|
241
|
+
turns.releasing = Promise.resolve(turns.handle?.dispose()).catch(() => {})
|
|
242
|
+
.finally(() => conversationTurns.delete(conversationId));
|
|
243
|
+
await turns.releasing;
|
|
244
|
+
}
|
|
245
|
+
|
|
218
246
|
async function streamChat(payload = {}, options = {}) {
|
|
219
247
|
const assistantSurface = requireAssistantSurface(appConfig, payload?.targetSurfaceId);
|
|
220
248
|
const context = normalizeObject(options.context);
|
|
@@ -320,85 +348,91 @@ function createChatService({
|
|
|
320
348
|
const runtimeContext = { ...assistantContext, assistantRequest: {
|
|
321
349
|
conversation, workspace, history: source.history, resolvedAttachments
|
|
322
350
|
} };
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
let turnId = null;
|
|
331
|
-
let displayedId = null;
|
|
332
|
-
let displayedText = "";
|
|
333
|
-
const release = await handle.subscribe(event => {
|
|
334
|
-
if (event.type === "accepted" && event.messageId === source.messageId) turnId = event.turnId;
|
|
335
|
-
if (!turnId || event.turnId !== turnId) return;
|
|
336
|
-
if (event.type === "message" && ["assistant", "commentary"].includes(event.role)) {
|
|
337
|
-
if (displayedId !== event.messageId) {
|
|
338
|
-
if (displayedText) streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text: "", status: "streaming" });
|
|
339
|
-
displayedId = event.messageId;
|
|
340
|
-
displayedText = "";
|
|
341
|
-
}
|
|
342
|
-
if (event.text === displayedText) return;
|
|
343
|
-
if (event.text.startsWith(displayedText)) streamWriter.sendAssistantDelta({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_DELTA, delta: event.text.slice(displayedText.length) });
|
|
344
|
-
else streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text: event.text, status: "streaming" });
|
|
345
|
-
displayedText = event.text;
|
|
346
|
-
} else if (event.type === "tool") {
|
|
347
|
-
const call = event.call;
|
|
348
|
-
if (call.status === "running") streamWriter.sendToolCall({ type: ASSISTANT_STREAM_EVENT_TYPES.TOOL_CALL,
|
|
349
|
-
toolCallId: call.id, name: call.name, arguments: call.arguments });
|
|
350
|
-
else if (call.result) streamWriter.sendToolResult({ type: ASSISTANT_STREAM_EVENT_TYPES.TOOL_RESULT,
|
|
351
|
-
toolCallId: call.id, name: call.name, ok: call.result.ok === true,
|
|
352
|
-
...(call.result.ok ? { result: call.result.result } : { error: call.result.error }) });
|
|
351
|
+
const handle = await openConversationTurn(conversationId, async () => {
|
|
352
|
+
try { return await conversationRuntime.open({ id: conversationId, context: runtimeContext, configuration }); }
|
|
353
|
+
catch (error) {
|
|
354
|
+
if (error.code !== "conversation_configuration_mismatch") throw error;
|
|
355
|
+
const opened = await conversationRuntime.open({ id: conversationId, context: runtimeContext });
|
|
356
|
+
await opened.configure(configuration);
|
|
357
|
+
return opened;
|
|
353
358
|
}
|
|
354
359
|
});
|
|
355
|
-
let cancellation;
|
|
356
|
-
const cancel = () => { cancellation ||= handle.cancel(); cancellation.catch(() => {}); };
|
|
357
|
-
let streamed = false;
|
|
358
360
|
try {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
361
|
+
let turnId = null;
|
|
362
|
+
let displayedId = null;
|
|
363
|
+
let displayedText = "";
|
|
364
|
+
const release = await handle.subscribe(event => {
|
|
365
|
+
if (event.type === "accepted" && event.messageId === source.messageId) turnId = event.turnId;
|
|
366
|
+
if (!turnId || event.turnId !== turnId) return;
|
|
367
|
+
if (event.type === "message" && ["assistant", "commentary"].includes(event.role)) {
|
|
368
|
+
if (displayedId !== event.messageId) {
|
|
369
|
+
if (displayedText) streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text: "", status: "streaming" });
|
|
370
|
+
displayedId = event.messageId;
|
|
371
|
+
displayedText = "";
|
|
372
|
+
}
|
|
373
|
+
if (event.text === displayedText) return;
|
|
374
|
+
if (event.text.startsWith(displayedText)) streamWriter.sendAssistantDelta({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_DELTA, delta: event.text.slice(displayedText.length) });
|
|
375
|
+
else streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text: event.text, status: "streaming" });
|
|
376
|
+
displayedText = event.text;
|
|
377
|
+
} else if (event.type === "tool") {
|
|
378
|
+
const call = event.call;
|
|
379
|
+
if (call.status === "running") streamWriter.sendToolCall({ type: ASSISTANT_STREAM_EVENT_TYPES.TOOL_CALL,
|
|
380
|
+
toolCallId: call.id, name: call.name, arguments: call.arguments });
|
|
381
|
+
else if (call.result) streamWriter.sendToolResult({ type: ASSISTANT_STREAM_EVENT_TYPES.TOOL_RESULT,
|
|
382
|
+
toolCallId: call.id, name: call.name, ok: call.result.ok === true,
|
|
383
|
+
...(call.result.ok ? { result: call.result.result } : { error: call.result.error }) });
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
let cancellation;
|
|
387
|
+
const cancel = () => { cancellation ||= handle.cancel(); cancellation.catch(() => {}); };
|
|
388
|
+
let streamed = false;
|
|
389
|
+
try {
|
|
390
|
+
const meta = { type: ASSISTANT_STREAM_EVENT_TYPES.META, messageId: source.messageId, conversationId,
|
|
391
|
+
provider: aiClient.provider, model: aiClient.defaultModel };
|
|
392
|
+
await turnRequests.update(claim, { meta });
|
|
393
|
+
streamWriter.sendMeta(meta);
|
|
394
|
+
streamed = true;
|
|
395
|
+
options.abortSignal?.addEventListener("abort", cancel, { once: true });
|
|
396
|
+
options.abortSignal?.throwIfAborted();
|
|
397
|
+
const receipt = await handle.send({ messageId: source.messageId, text: source.input,
|
|
398
|
+
...(source.attachmentIds !== undefined ? { attachmentIds: source.attachmentIds } : {}) });
|
|
399
|
+
turnId = receipt.turnId;
|
|
400
|
+
const snapshot = await handle.wait();
|
|
401
|
+
const turn = snapshot.conversationLog.find(entry => entry.turnId === turnId);
|
|
402
|
+
const status = turn?.metadata?.runtime?.status;
|
|
403
|
+
if (status !== "complete") {
|
|
404
|
+
const reason = turn?.metadata?.runtime?.error || snapshot.error;
|
|
405
|
+
const error = reason ? new Error(reason) : Object.assign(new Error("Assistant request failed."), {
|
|
406
|
+
messageCode: "assistant.runtime.request.failed", messageParams: {} });
|
|
407
|
+
if (status === "cancelled") error.name = "AbortError";
|
|
408
|
+
throw error;
|
|
409
|
+
}
|
|
410
|
+
const text = normalizeText(turn.assistant?.text);
|
|
411
|
+
if (!text) throw new AppError(502, "Assistant returned no output.", { code: "assistant.runtime.request.noOutput" });
|
|
412
|
+
await transcriptService.completeConversation(assistantSurface, conversationId,
|
|
413
|
+
{ status: "completed", metadata: turn.metadata?.completion || {} }, { context: assistantContext, workspace });
|
|
414
|
+
streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text });
|
|
415
|
+
streamWriter.sendDone({ type: ASSISTANT_STREAM_EVENT_TYPES.DONE, messageId: source.messageId, status: "completed" });
|
|
416
|
+
return { conversationId, messageId: source.messageId, status: "completed" };
|
|
417
|
+
} catch (error) {
|
|
418
|
+
const aborted = isAbortError(error) || options.abortSignal?.aborted;
|
|
419
|
+
const status = aborted ? "aborted" : "failed";
|
|
420
|
+
if (!streamed) throw error;
|
|
421
|
+
await transcriptService.completeConversation(assistantSurface, conversationId, { status }, { context: assistantContext, workspace });
|
|
422
|
+
streamWriter.sendError({ type: ASSISTANT_STREAM_EVENT_TYPES.ERROR, messageId: source.messageId,
|
|
423
|
+
code: aborted ? "assistant_stream_aborted" : String(error?.code || "assistant_stream_failed"),
|
|
424
|
+
message: aborted ? "Assistant request was cancelled." : String(error?.message || "Assistant request failed."),
|
|
425
|
+
...streamErrorMessageFields(error, aborted),
|
|
426
|
+
status: aborted ? 499 : errorStatus(error) });
|
|
427
|
+
streamWriter.sendDone({ type: ASSISTANT_STREAM_EVENT_TYPES.DONE, messageId: source.messageId, status });
|
|
428
|
+
return { conversationId, messageId: source.messageId, status };
|
|
429
|
+
} finally {
|
|
430
|
+
options.abortSignal?.removeEventListener("abort", cancel);
|
|
431
|
+
release();
|
|
432
|
+
await cancellation;
|
|
378
433
|
}
|
|
379
|
-
const text = normalizeText(turn.assistant?.text);
|
|
380
|
-
if (!text) throw new AppError(502, "Assistant returned no output.", { code: "assistant.runtime.request.noOutput" });
|
|
381
|
-
await transcriptService.completeConversation(assistantSurface, conversationId,
|
|
382
|
-
{ status: "completed", metadata: turn.metadata?.completion || {} }, { context: assistantContext, workspace });
|
|
383
|
-
streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text });
|
|
384
|
-
streamWriter.sendDone({ type: ASSISTANT_STREAM_EVENT_TYPES.DONE, messageId: source.messageId, status: "completed" });
|
|
385
|
-
return { conversationId, messageId: source.messageId, status: "completed" };
|
|
386
|
-
} catch (error) {
|
|
387
|
-
const aborted = isAbortError(error) || options.abortSignal?.aborted;
|
|
388
|
-
const status = aborted ? "aborted" : "failed";
|
|
389
|
-
if (!streamed) throw error;
|
|
390
|
-
await transcriptService.completeConversation(assistantSurface, conversationId, { status }, { context: assistantContext, workspace });
|
|
391
|
-
streamWriter.sendError({ type: ASSISTANT_STREAM_EVENT_TYPES.ERROR, messageId: source.messageId,
|
|
392
|
-
code: aborted ? "assistant_stream_aborted" : String(error?.code || "assistant_stream_failed"),
|
|
393
|
-
message: aborted ? "Assistant request was cancelled." : String(error?.message || "Assistant request failed."),
|
|
394
|
-
...streamErrorMessageFields(error, aborted),
|
|
395
|
-
status: aborted ? 499 : errorStatus(error) });
|
|
396
|
-
streamWriter.sendDone({ type: ASSISTANT_STREAM_EVENT_TYPES.DONE, messageId: source.messageId, status });
|
|
397
|
-
return { conversationId, messageId: source.messageId, status };
|
|
398
434
|
} finally {
|
|
399
|
-
|
|
400
|
-
release();
|
|
401
|
-
await cancellation;
|
|
435
|
+
await releaseConversationTurn(conversationId);
|
|
402
436
|
}
|
|
403
437
|
}
|
|
404
438
|
}
|
|
@@ -117,7 +117,8 @@ test("host access wraps only canonical conversation definitions and receives the
|
|
|
117
117
|
});
|
|
118
118
|
assert.deepEqual(wrapped.map(({ id }) => id), [
|
|
119
119
|
actionIds.conversationRead, actionIds.conversationSend, actionIds.conversationCancel, subscribeActionId,
|
|
120
|
-
actionIds.conversationInspectDelivery, actionIds.
|
|
120
|
+
actionIds.conversationInspectDelivery, actionIds.conversationCancelDelivery, actionIds.conversationGoalRead,
|
|
121
|
+
actionIds.conversationGoalUpdate
|
|
121
122
|
]);
|
|
122
123
|
assert.equal(findDefinition(definitions, actionIds.conversationSubscribe), undefined);
|
|
123
124
|
for (const definition of wrapped) {
|
|
@@ -33,7 +33,14 @@ async function fixture(t, width = 1280, { acceptedDraft = false, draftWhileLoadi
|
|
|
33
33
|
const state = async () => (await page.request.get(`${vite.baseURL}/fixture/state`)).json();
|
|
34
34
|
const sent = async () => (await state()).requests.filter(request => request.suffix === "/messages");
|
|
35
35
|
const notify = id => page.evaluate(id => window.conversationFixture.socket.notify(id), id);
|
|
36
|
-
|
|
36
|
+
// An unknown delivery check rereads the conversation. A receipt confirmed
|
|
37
|
+
// before the server serves that read would settle it from canonical history.
|
|
38
|
+
const inspectionReread = id => expect.poll(async () => {
|
|
39
|
+
const { requests } = await state();
|
|
40
|
+
const inspection = requests.findLastIndex(request => request.id === id && request.suffix.endsWith("/inspect"));
|
|
41
|
+
return inspection >= 0 && requests.slice(inspection + 1).some(request => request.method === "GET" && request.id === id && !request.suffix);
|
|
42
|
+
}).toBe(true);
|
|
43
|
+
return { page, primary, input, command, state, sent, notify, inspectionReread };
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
test("inspecting a restored no-admission request releases Send while preserving newer draft and uploads", options, async t => {
|
|
@@ -180,6 +187,142 @@ test("optional goal controls follow declared command semantics and budgets witho
|
|
|
180
187
|
assert.equal((await f.sent()).length, 0, "Goal commands use the existing goal operation, not ordinary send");
|
|
181
188
|
});
|
|
182
189
|
|
|
190
|
+
test("a paused goal stays cancellable while a message is sending or its delivery is unknown", options, async t => {
|
|
191
|
+
const f = await fixture(t);
|
|
192
|
+
await f.command("goals", { id: "chat:1", budgets: false, commands: {
|
|
193
|
+
set: { delivery: "message", interruptsTurn: false }, resume: { delivery: "message", interruptsTurn: false },
|
|
194
|
+
pause: { delivery: "control", interruptsTurn: true }, cancel: { delivery: "message", interruptsTurn: true }
|
|
195
|
+
} });
|
|
196
|
+
await f.notify("chat:1");
|
|
197
|
+
const goals = async () => (await f.state()).requests.filter(request => request.suffix === "/goal" && request.method === "POST").map(request => request.input);
|
|
198
|
+
const pausedGoal = async objective => {
|
|
199
|
+
await f.primary.getByRole("button", { name: "Set goal", exact: true }).click();
|
|
200
|
+
await f.page.getByRole("textbox", { name: "Goal objective" }).fill(objective);
|
|
201
|
+
await f.page.getByRole("button", { name: "Start goal", exact: true }).click();
|
|
202
|
+
await f.primary.getByRole("button", { name: "Goal running", exact: true }).click();
|
|
203
|
+
await f.page.getByRole("button", { name: "Pause goal", exact: true }).click();
|
|
204
|
+
await expect(f.primary.getByRole("button", { name: "Goal paused", exact: true })).toBeVisible();
|
|
205
|
+
await f.page.keyboard.press("Escape");
|
|
206
|
+
await expect(f.page.getByRole("button", { name: "Resume goal", exact: true })).toHaveCount(0);
|
|
207
|
+
};
|
|
208
|
+
const cancelPausedGoal = async () => {
|
|
209
|
+
await f.primary.getByRole("button", { name: "Goal paused", exact: true }).click();
|
|
210
|
+
await expect(f.page.getByRole("button", { name: "Resume goal", exact: true })).toBeDisabled();
|
|
211
|
+
const cancel = f.page.getByRole("button", { name: "Cancel goal", exact: true });
|
|
212
|
+
await expect(cancel).toBeEnabled();
|
|
213
|
+
await cancel.click();
|
|
214
|
+
await expect(f.primary.getByRole("button", { name: "Set goal", exact: true })).toBeVisible();
|
|
215
|
+
await f.page.keyboard.press("Escape");
|
|
216
|
+
await expect(f.page.getByRole("textbox", { name: "Goal objective" })).toHaveCount(0);
|
|
217
|
+
const sent = await goals();
|
|
218
|
+
assert.equal(sent.at(-1).action, "cancel");
|
|
219
|
+
assert.ok(sent.at(-1).messageId, "A message-delivered Cancel keeps its receipt identity");
|
|
220
|
+
assert.equal(sent.at(-1).expectedGoalId, sent.at(-2).expectedGoalId);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
await pausedGoal("Finish while a message is sending");
|
|
224
|
+
await f.command("mode", { mode: "hold" });
|
|
225
|
+
await f.input.fill("Still sending");
|
|
226
|
+
await f.input.press("Enter");
|
|
227
|
+
await expect.poll(async () => (await f.state()).held).toBe(1);
|
|
228
|
+
await f.command("mode", { mode: "accepted" });
|
|
229
|
+
await cancelPausedGoal();
|
|
230
|
+
assert.equal((await f.state()).held, 1, "Cancel did not wait for the message still sending");
|
|
231
|
+
await f.command("accept");
|
|
232
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().delivery.state.sending)).toBe(false);
|
|
233
|
+
|
|
234
|
+
await pausedGoal("Finish while a delivery is unknown");
|
|
235
|
+
await f.command("mode", { mode: "uncertain" });
|
|
236
|
+
await f.input.fill("Deslop");
|
|
237
|
+
await f.input.press("Enter");
|
|
238
|
+
await expect(f.primary.getByRole("button", { name: "Retry delivery check", exact: true })).toBeVisible();
|
|
239
|
+
await f.command("mode", { mode: "accepted" });
|
|
240
|
+
await cancelPausedGoal();
|
|
241
|
+
assert.deepEqual((await goals()).map(command => command.action), ["set", "pause", "cancel", "set", "pause", "cancel"]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("Cancel ends a running delivery check, keeps the message until the server agrees and retries after the server was unreachable", options, async t => {
|
|
245
|
+
const f = await fixture(t);
|
|
246
|
+
await f.command("mode", { mode: "uncertain" });
|
|
247
|
+
await f.input.fill("Say OK and just that");
|
|
248
|
+
await f.input.press("Enter");
|
|
249
|
+
const check = f.primary.getByRole("button", { name: "Retry delivery check", exact: true });
|
|
250
|
+
await expect(check).toBeVisible();
|
|
251
|
+
const [original] = await f.sent();
|
|
252
|
+
const message = f.primary.getByText("Say OK and just that", { exact: true });
|
|
253
|
+
const cancelling = f.primary.getByRole("status").filter({ hasText: "Cancelling…" });
|
|
254
|
+
const cancel = f.primary.getByRole("button", { name: "Cancel", exact: true });
|
|
255
|
+
const cancels = async () => (await f.state()).requests.filter(request => request.suffix.endsWith("/cancel")).length;
|
|
256
|
+
|
|
257
|
+
// The server cannot be reached: the message stays, with Cancel again.
|
|
258
|
+
await f.page.route("**/deliveries/*/cancel", route => route.abort("internetdisconnected"));
|
|
259
|
+
const result = await f.page.evaluate(async messageId => {
|
|
260
|
+
const runtime = window.conversationFixture.current();
|
|
261
|
+
const checking = runtime.inspectDelivery(messageId);
|
|
262
|
+
const wasChecking = runtime.delivery.find(messageId)?.checking === true;
|
|
263
|
+
const cancelled = runtime.cancelMessage(messageId);
|
|
264
|
+
const whileWaiting = runtime.delivery.find(messageId)?.cancelling === true;
|
|
265
|
+
return { wasChecking, whileWaiting, receipt: await checking, cancelled: await cancelled };
|
|
266
|
+
}, original.input.messageId);
|
|
267
|
+
assert.deepEqual(result, { wasChecking: true, whileWaiting: true, receipt: false, cancelled: false });
|
|
268
|
+
await expect(f.primary.getByText("Couldn't reach the server — Cancel again", { exact: true })).toBeVisible();
|
|
269
|
+
await expect(message).toBeVisible();
|
|
270
|
+
await expect(cancel).toBeEnabled();
|
|
271
|
+
await f.page.unroute("**/deliveries/*/cancel");
|
|
272
|
+
|
|
273
|
+
// Reconnecting retries the cancel; the message stays, cancelling, until the
|
|
274
|
+
// server answers, and an unconfirmed snapshot meanwhile does not change that.
|
|
275
|
+
await f.command("hold-cancels", { hold: true });
|
|
276
|
+
await f.notify("chat:1");
|
|
277
|
+
await expect(cancelling).toBeVisible();
|
|
278
|
+
await expect.poll(cancels).toBe(1);
|
|
279
|
+
await expect(message).toBeVisible();
|
|
280
|
+
assert.equal((await f.state()).states["chat:1"].pendingRequest.messageId, original.input.messageId);
|
|
281
|
+
await f.command("answer-cancels");
|
|
282
|
+
await expect(message).toHaveCount(0);
|
|
283
|
+
await expect(check).toHaveCount(0);
|
|
284
|
+
|
|
285
|
+
// The server dropped the request, so neither a snapshot nor a reload brings it back.
|
|
286
|
+
assert.equal((await f.state()).states["chat:1"].pendingRequest, null);
|
|
287
|
+
await f.notify("chat:1");
|
|
288
|
+
await f.page.reload();
|
|
289
|
+
await expect(f.input).toBeEnabled();
|
|
290
|
+
await expect(message).toHaveCount(0);
|
|
291
|
+
assert.equal(await cancels(), 1);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("a Cancel the server never received is retried after a page reload", options, async t => {
|
|
295
|
+
const f = await fixture(t);
|
|
296
|
+
const attach = async () => {
|
|
297
|
+
await f.page.evaluate(() => {
|
|
298
|
+
window.savedBinding = window.conversationFixture.acquireConversation({
|
|
299
|
+
conversationId: "chat:2", draftStorage: { storage: sessionStorage, key: "unfinished-cancel" }
|
|
300
|
+
});
|
|
301
|
+
window.conversationFixture.target("chat:2");
|
|
302
|
+
});
|
|
303
|
+
await expect.poll(() => f.page.evaluate(() => window.savedBinding.runtime.value?.available.value)).toBe(true);
|
|
304
|
+
};
|
|
305
|
+
await attach();
|
|
306
|
+
await f.command("mode", { mode: "uncertain" });
|
|
307
|
+
await f.input.fill("Cancel me across a reload");
|
|
308
|
+
await f.input.press("Enter");
|
|
309
|
+
await expect(f.primary.getByRole("button", { name: "Retry delivery check", exact: true })).toBeVisible();
|
|
310
|
+
await f.page.route("**/deliveries/*/cancel", route => route.abort("internetdisconnected"));
|
|
311
|
+
await f.primary.getByRole("button", { name: "Cancel", exact: true }).click();
|
|
312
|
+
await expect(f.primary.getByText("Couldn't reach the server — Cancel again", { exact: true })).toBeVisible();
|
|
313
|
+
await f.page.unroute("**/deliveries/*/cancel");
|
|
314
|
+
await f.command("hold-cancels", { hold: true });
|
|
315
|
+
await f.page.reload();
|
|
316
|
+
await attach();
|
|
317
|
+
const message = f.primary.getByText("Cancel me across a reload", { exact: true });
|
|
318
|
+
await expect(f.primary.getByRole("status").filter({ hasText: "Cancelling…" })).toBeVisible();
|
|
319
|
+
await expect(message).toBeVisible();
|
|
320
|
+
await f.command("answer-cancels");
|
|
321
|
+
await expect(message).toHaveCount(0);
|
|
322
|
+
const cancels = (await f.state()).requests.filter(request => request.suffix.endsWith("/cancel"));
|
|
323
|
+
assert.deepEqual(cancels.map(request => request.id), ["chat:2"]);
|
|
324
|
+
});
|
|
325
|
+
|
|
183
326
|
test("the first native goal preserves absent identity and later controls capture its actual thread", options, async t => {
|
|
184
327
|
const f = await fixture(t);
|
|
185
328
|
await f.input.fill("Keep this ordinary draft");
|
|
@@ -413,6 +556,7 @@ test("explicit delivery inspection acknowledges uncertain attachments without cl
|
|
|
413
556
|
await check.click();
|
|
414
557
|
await expect(check).toBeVisible();
|
|
415
558
|
assert.deepEqual((await attachments()).acknowledged, [], "An unknown receipt does not accept the upload");
|
|
559
|
+
await f.inspectionReread("chat:1");
|
|
416
560
|
await f.command("confirm", { id: "chat:1" });
|
|
417
561
|
await check.click();
|
|
418
562
|
await expect(check).toHaveCount(0);
|
|
@@ -639,7 +783,9 @@ test("canonical receipts settle a held response and uncertain delivery stays ins
|
|
|
639
783
|
const check = f.primary.getByRole("button", { name: "Retry delivery check", exact: true });
|
|
640
784
|
await expect(check).toBeVisible();
|
|
641
785
|
await expect(f.primary.getByText("Keep this exact uncertain request", { exact: true })).toHaveCount(1);
|
|
642
|
-
|
|
786
|
+
// Never resent or edited while unknown; only the server's Cancel is offered.
|
|
787
|
+
await expect(f.primary.getByRole("button", { name: /^(Retry|Edit)$/u })).toHaveCount(0);
|
|
788
|
+
await expect(f.primary.getByRole("button", { name: "Cancel", exact: true })).toBeEnabled();
|
|
643
789
|
const checkBox = await check.boundingBox();
|
|
644
790
|
assert.ok(checkBox.width >= 48 && checkBox.height >= 48);
|
|
645
791
|
await f.input.fill("Preserve the newer draft");
|
|
@@ -731,7 +877,17 @@ for (const status of ["uncertain", "pending", "accepted", "failed", "unrecognise
|
|
|
731
877
|
assert.equal(restored.id, saved.messages[0].id);
|
|
732
878
|
assert.deepEqual(restored.payload, saved.messages[0].payload);
|
|
733
879
|
assert.equal((await f.sent()).length, 1, "restoration never dispatches a saved request");
|
|
734
|
-
|
|
880
|
+
// The server is ready, so a different authored draft may proceed; only the
|
|
881
|
+
// restored uncertain words themselves stay blocked until inspection settles them.
|
|
882
|
+
assert.equal(await f.page.evaluate(() => window.savedBinding.runtime.value.canSubmit.value), true);
|
|
883
|
+
if (unconfirmed) {
|
|
884
|
+
assert.equal(await f.page.evaluate(() => {
|
|
885
|
+
const runtime = window.savedBinding.runtime.value;
|
|
886
|
+
runtime.draft.value = "Exact original saved words";
|
|
887
|
+
return runtime.canSubmit.value;
|
|
888
|
+
}), false, "the uncertain request's own words cannot be sent again");
|
|
889
|
+
await f.page.evaluate(draft => { window.savedBinding.runtime.value.draft.value = draft; }, saved.draft);
|
|
890
|
+
}
|
|
735
891
|
if (unconfirmed) {
|
|
736
892
|
const check = f.primary.getByRole("button", { name: "Retry delivery check", exact: true });
|
|
737
893
|
try { await expect(check).toBeEnabled(); }
|
|
@@ -746,9 +902,9 @@ for (const status of ["uncertain", "pending", "accepted", "failed", "unrecognise
|
|
|
746
902
|
await f.page.screenshot({ path: `/tmp/jskit-saved-delivery-reload-${status}-20261006.png` });
|
|
747
903
|
throw failure;
|
|
748
904
|
}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
}
|
|
905
|
+
// Never resent or edited while unknown; only the server's Cancel is offered.
|
|
906
|
+
await expect(f.primary.getByRole("button", { name: /^(Retry|Edit)$/u })).toHaveCount(0);
|
|
907
|
+
await expect(f.primary.getByRole("button", { name: "Cancel", exact: true })).toBeEnabled();
|
|
752
908
|
// The fixture's original inspector matches any same-ID row. Keep the
|
|
753
909
|
// nonreceipt projection proof above, then inspect genuine absence.
|
|
754
910
|
await f.command("history", { id: "chat:2", turns: [], limit: 20 });
|
|
@@ -833,7 +989,7 @@ test("a finished-turn error without a definite rejection status remains uncertai
|
|
|
833
989
|
assert.equal((await f.sent()).length, 1);
|
|
834
990
|
});
|
|
835
991
|
|
|
836
|
-
for (const code of ["ACTION_VALIDATION_FAILED", "conversation_not_steerable"]) {
|
|
992
|
+
for (const code of ["ACTION_VALIDATION_FAILED", "conversation_not_steerable", "conversation_message_cancelled"]) {
|
|
837
993
|
test(`an explicit pre-admission rejection retains original retry identity and the newer draft: ${code}`, options, async t => {
|
|
838
994
|
const f = await fixture(t);
|
|
839
995
|
await f.command("mode", { mode: "rejected" });
|
|
@@ -842,6 +998,8 @@ test(`an explicit pre-admission rejection retains original retry identity and th
|
|
|
842
998
|
await f.command("question", { id: "chat:1", text: "An earlier answer is still finishing.", pending: true });
|
|
843
999
|
await f.notify("chat:1");
|
|
844
1000
|
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().steerable.value)).toBe(true);
|
|
1001
|
+
}
|
|
1002
|
+
if (code !== "ACTION_VALIDATION_FAILED") {
|
|
845
1003
|
await f.page.route("**/messages", async route => {
|
|
846
1004
|
const response = await route.fetch();
|
|
847
1005
|
await route.fulfill(response.status() === 400
|
|
@@ -887,7 +1045,8 @@ test(`an explicit pre-admission rejection retains original retry identity and th
|
|
|
887
1045
|
await f.input.fill("Keep my newer draft");
|
|
888
1046
|
await f.primary.getByRole("button", { name: action, exact: true }).click();
|
|
889
1047
|
await expect(f.primary.getByText("Failed: The message was rejected before admission.", { exact: true })).toHaveCount(0);
|
|
890
|
-
|
|
1048
|
+
// Cancel removes the message once the server agrees.
|
|
1049
|
+
await expect.poll(() => f.page.evaluate(id => window.conversationFixture.current().delivery.find(id), request.input.messageId)).toBe(null);
|
|
891
1050
|
await expect(f.input).toHaveValue(action === "Edit" ? `${text}\n\nKeep my newer draft` : "Keep my newer draft");
|
|
892
1051
|
}
|
|
893
1052
|
assert.equal((await f.sent()).length, 4, "Cancel and Edit do not dispatch another prompt");
|
|
@@ -1557,13 +1716,18 @@ test("a voice preview cannot acknowledge sending or uncertain delivery and dedup
|
|
|
1557
1716
|
const second = (await f.sent())[1].input.messageId;
|
|
1558
1717
|
await preview(second, "An uncertain preview is not an acknowledgement.");
|
|
1559
1718
|
await expect(f.primary.getByText("An uncertain preview is not an acknowledgement.", { exact: true })).toHaveCount(0);
|
|
1560
|
-
|
|
1719
|
+
// The uncertain notice stays attached to the user's own bubble, not a system row.
|
|
1720
|
+
const uncertainBubble = f.primary.locator(".assistant-transcript__message--user").filter({ hasText: "Keep this uncertain request." });
|
|
1721
|
+
await expect(uncertainBubble.locator(".assistant-transcript__optimistic-failure")).toBeVisible();
|
|
1722
|
+
await expect(uncertainBubble.getByRole("button", { name: "Retry delivery check", exact: true })).toBeVisible();
|
|
1723
|
+
await expect(f.primary.locator(".assistant-transcript__system")).toHaveCount(0);
|
|
1561
1724
|
assert.deepEqual((await delivery()).messages, [{ id: second, status: "uncertain" }]);
|
|
1562
1725
|
assert.equal((await delivery()).history.length, 1);
|
|
1563
1726
|
await check.click();
|
|
1564
1727
|
await expect(check).toBeEnabled();
|
|
1565
1728
|
assert.deepEqual((await delivery()).messages, [{ id: second, status: "uncertain" }]);
|
|
1566
1729
|
await expect(f.input).toHaveValue("Keep this uncertain request.");
|
|
1730
|
+
await f.inspectionReread("chat:1");
|
|
1567
1731
|
await f.command("confirm", { id: "chat:1" });
|
|
1568
1732
|
await check.click();
|
|
1569
1733
|
await expect(check).toHaveCount(0);
|
|
@@ -2311,6 +2475,50 @@ test("prepared binding preserves retry identity, newer composer input and inspec
|
|
|
2311
2475
|
assert.deepEqual(await f.page.evaluate(() => window.taskClearedFiles), [["original-file"]]);
|
|
2312
2476
|
});
|
|
2313
2477
|
|
|
2478
|
+
test("prepared Retry sends an uncertain message again only after the server proves it was never sent", options, async t => {
|
|
2479
|
+
const f = await fixture(t);
|
|
2480
|
+
await f.page.evaluate(() => {
|
|
2481
|
+
window.provenBinding = window.conversationFixture.acquireConversation({ conversationId: "chat:3", active: false });
|
|
2482
|
+
window.provenReader = window.conversationFixture.acquireConversation({ conversationId: "chat:3" });
|
|
2483
|
+
});
|
|
2484
|
+
await expect.poll(() => f.page.evaluate(() => window.provenReader.runtime.value.available.value)).toBe(true);
|
|
2485
|
+
await f.command("mode", { mode: "uncertain" });
|
|
2486
|
+
const uncertain = await f.page.evaluate(() => window.provenBinding.submitPrepared({ message: "Send me once." },
|
|
2487
|
+
{ messageId: "proven-retry", prepare: captured => captured.request }));
|
|
2488
|
+
assert.equal(uncertain.status, "uncertain");
|
|
2489
|
+
assert.equal((await f.sent()).length, 1);
|
|
2490
|
+
// An unknown receipt still never resends.
|
|
2491
|
+
const unknown = await f.page.evaluate(() => window.provenBinding.submitPrepared({ message: "Do not recapture." },
|
|
2492
|
+
{ retryMessageId: "proven-retry", prepare: captured => captured.request }));
|
|
2493
|
+
assert.equal(unknown.status, "uncertain");
|
|
2494
|
+
assert.equal((await f.sent()).length, 1);
|
|
2495
|
+
await f.command("mode", { mode: "not-sent" });
|
|
2496
|
+
// Hold the resend until a read after the not-sent proof has arrived: the
|
|
2497
|
+
// proven request is saved as unattempted, so it no longer reads as unconfirmed.
|
|
2498
|
+
const sendGate = Promise.withResolvers();
|
|
2499
|
+
t.after(() => sendGate.resolve());
|
|
2500
|
+
let sendHeld = false;
|
|
2501
|
+
await f.page.route("**/messages", async route => {
|
|
2502
|
+
sendHeld = true;
|
|
2503
|
+
await sendGate.promise;
|
|
2504
|
+
await route.continue().catch(() => {});
|
|
2505
|
+
});
|
|
2506
|
+
const retrying = f.page.evaluate(() => window.provenBinding.submitPrepared({ message: "Do not recapture." },
|
|
2507
|
+
{ retryMessageId: "proven-retry", prepare: captured => captured.request }));
|
|
2508
|
+
await expect.poll(() => sendHeld).toBe(true);
|
|
2509
|
+
await f.page.evaluate(() => window.provenBinding.runtime.value.reload());
|
|
2510
|
+
sendGate.resolve();
|
|
2511
|
+
const retried = await retrying;
|
|
2512
|
+
await f.page.unroute("**/messages");
|
|
2513
|
+
// Acceptance is the HTTP receipt or the canonical row, whichever arrives first.
|
|
2514
|
+
assert.ok(retried && retried.ok !== false, JSON.stringify(retried));
|
|
2515
|
+
const sent = await f.sent();
|
|
2516
|
+
assert.equal(sent.length, 2);
|
|
2517
|
+
assert.equal(sent[1].input.messageId, "proven-retry");
|
|
2518
|
+
assert.equal(sent[1].input.text, "Send me once.");
|
|
2519
|
+
assert.equal(await f.page.evaluate(() => window.provenBinding.runtime.value.delivery.find("proven-retry")?.status ?? "accepted"), "accepted");
|
|
2520
|
+
});
|
|
2521
|
+
|
|
2314
2522
|
test("acceptance settles a voice-retained conversation after its text view retargets without clearing the next composer", options, async t => {
|
|
2315
2523
|
const f = await fixture(t, 1280, { acceptedDraft: true });
|
|
2316
2524
|
await f.page.evaluate(() => window.conversationFixture.attachments(true));
|
|
@@ -2366,7 +2574,8 @@ test("transient subscription errors keep loaded history and recover through the
|
|
|
2366
2574
|
user: { messageId: `question-${index}`, role: "user", text: `Private saved question ${index}. ` + "Keep the original discussion. ".repeat(8) },
|
|
2367
2575
|
assistant: { messageId: `answer-${index}`, role: "assistant", text: `Private saved answer ${index}. ` + "Read the saved answer without losing your place. ".repeat(8) }
|
|
2368
2576
|
}));
|
|
2369
|
-
|
|
2577
|
+
// History pages count real messages: load all 30 turns' questions and answers.
|
|
2578
|
+
await f.command("history", { id: "chat:1", turns, limit: turns.length * 2 });
|
|
2370
2579
|
await f.notify("chat:1");
|
|
2371
2580
|
const body = f.primary.locator(".assistant-transcript__body");
|
|
2372
2581
|
await expect(body.locator(".assistant-transcript__turn")).toHaveCount(turns.length * 2);
|
|
@@ -2472,7 +2681,25 @@ for (const width of [390, 768, 1280]) {
|
|
|
2472
2681
|
await body.hover();
|
|
2473
2682
|
await f.page.mouse.wheel(0, -300);
|
|
2474
2683
|
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBeLessThan(previousScroll);
|
|
2475
|
-
|
|
2684
|
+
// The reader looks at question 13. The refresh removes saved turns 7 and 10
|
|
2685
|
+
// and shortens answer 11 above it, so the reader's rows must stay where they
|
|
2686
|
+
// are on screen, whatever scrollTop that takes at this width.
|
|
2687
|
+
const reader = body.locator(".assistant-transcript__turn").filter({ hasText: "Atomic saved question 13." });
|
|
2688
|
+
await reader.evaluate(row => {
|
|
2689
|
+
const scroller = row.closest(".assistant-transcript__body");
|
|
2690
|
+
window.atomicHistoryBody = scroller;
|
|
2691
|
+
scroller.scrollTop += row.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
|
2692
|
+
});
|
|
2693
|
+
// Rows near the new position replace their size estimates on later frames.
|
|
2694
|
+
const readerTop = () => reader.evaluate(async row => {
|
|
2695
|
+
const scroller = row.closest(".assistant-transcript__body");
|
|
2696
|
+
const top = () => row.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
|
2697
|
+
const before = [top(), scroller.scrollTop];
|
|
2698
|
+
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
2699
|
+
return before[0] === top() && before[1] === scroller.scrollTop ? Math.round(top()) : NaN;
|
|
2700
|
+
});
|
|
2701
|
+
await expect.poll(readerTop).not.toBeNaN();
|
|
2702
|
+
const readerOffset = await readerTop();
|
|
2476
2703
|
await f.input.fill("Unsent while history refreshes");
|
|
2477
2704
|
await f.input.evaluate(element => { element.focus({ preventScroll: true }); element.setSelectionRange(3, 8); });
|
|
2478
2705
|
const previousIds = turns.slice(6).map(turn => turn.turnId);
|
|
@@ -2494,7 +2721,7 @@ for (const width of [390, 768, 1280]) {
|
|
|
2494
2721
|
await expect.poll(() => Boolean(release)).toBe(true);
|
|
2495
2722
|
assert.deepEqual(await f.page.evaluate(() => window.conversationFixture.current().turns.value.map(turn => turn.turnId)), previousIds);
|
|
2496
2723
|
assert.equal(await body.evaluate(element => element === window.atomicHistoryBody), true);
|
|
2497
|
-
await expect.poll(() =>
|
|
2724
|
+
await expect.poll(async () => Math.abs(await readerTop() - readerOffset)).toBeLessThanOrEqual(1);
|
|
2498
2725
|
await expect(f.input).toHaveValue("Unsent while history refreshes");
|
|
2499
2726
|
await expect(f.input).toBeFocused();
|
|
2500
2727
|
assert.deepEqual(await f.input.evaluate(element => [element.selectionStart, element.selectionEnd]), [3, 8]);
|
|
@@ -2505,7 +2732,7 @@ for (const width of [390, 768, 1280]) {
|
|
|
2505
2732
|
await expect(f.primary.getByText("Authoritatively replaced saved answer", { exact: true })).toHaveCount(1);
|
|
2506
2733
|
await expect(f.primary.getByText(/Atomic saved question (7|10)\./)).toHaveCount(0);
|
|
2507
2734
|
assert.equal(await body.evaluate(element => element === window.atomicHistoryBody), true);
|
|
2508
|
-
await expect.poll(() =>
|
|
2735
|
+
await expect.poll(async () => Math.abs(await readerTop() - readerOffset)).toBeLessThanOrEqual(1);
|
|
2509
2736
|
await expect(f.input).toHaveValue("Unsent while history refreshes");
|
|
2510
2737
|
await expect(f.input).toBeFocused();
|
|
2511
2738
|
assert.deepEqual(await f.input.evaluate(element => [element.selectionStart, element.selectionEnd]), [3, 8]);
|