@jskit-ai/assistant-runtime 0.1.218 → 0.1.220
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 +29 -0
- package/package.json +2 -2
- package/src/client/composables/useAssistantConversation.js +34 -8
- package/src/server/AssistantProvider.js +2 -1
- package/src/server/actions.js +3 -0
- package/src/server/registerConversationSubscriptions.js +30 -5
- package/test/assistantConversationBinding.browser.test.js +159 -3
- package/test/assistantConversationSubscriptions.test.js +128 -4
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
|
|
2
|
+
With the existing realtime diagnostic option (`JSKIT_REALTIME_DEBUG=1`, or
|
|
3
|
+
`config.realtime.debug=true`), conversation subscriptions emit one `pending`
|
|
4
|
+
warning if admission/snapshot reading has not returned within the client's
|
|
5
|
+
10-second acknowledgement budget. The warning names the current stage and the
|
|
6
|
+
server-generated epoch; a conversation identity is included only after its
|
|
7
|
+
successful authorized open. Observer release also records the current stage and
|
|
8
|
+
reason. While diagnostics are enabled, every stage transition is recorded at
|
|
9
|
+
warning level with its duration and total elapsed time, so warning-only logs
|
|
10
|
+
retain the earlier admission costs as well as the step pending at timeout.
|
|
11
|
+
Timers are removed on acknowledgement, failure, replacement, unsubscribe
|
|
12
|
+
and connection close. Diagnostics contain no request body or conversation text,
|
|
13
|
+
and do not change authorization, subscription deadlines or model work.
|
|
14
|
+
|
|
15
|
+
Released subscriptions stop setup at the next completed phase. The existing
|
|
16
|
+
socket owner supplies its current subscription identity to the subscribe action;
|
|
17
|
+
an obsolete open cannot attach a new observer, and a late observer is detached
|
|
18
|
+
without starting a snapshot. Reads already in progress may finish, but cannot
|
|
19
|
+
acknowledge into a replacement view. Reload performs fresh authorization, and
|
|
20
|
+
releasing a view never stops its model turn.
|
|
21
|
+
|
|
22
|
+
Prepared application submissions may explicitly enable `queueWhileSending` to
|
|
23
|
+
reuse the binding's serial delivery queue while application preparation is still
|
|
24
|
+
running. Each queued request retains its captured payload and receipt identity.
|
|
25
|
+
Stop and conversation retirement cancel queued preparation through the existing
|
|
26
|
+
owner. A typed, definite ended-turn rejection may become one ordinary Send with
|
|
27
|
+
the same identity, without repeating preparation; uncertain delivery never
|
|
28
|
+
permits replay. With the option absent or false, prepared submissions retain
|
|
29
|
+
their existing non-queueing behavior.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/assistant-runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.220",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"./server": "./src/server/index.js"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@jskit-ai/assistant-core": "0.1.
|
|
15
|
+
"@jskit-ai/assistant-core": "0.1.227",
|
|
16
16
|
"json-rest-schema": "^1.0.17"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
@@ -390,7 +390,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
390
390
|
throw new TypeError("Prepared submission requires an authored conversation request.");
|
|
391
391
|
}
|
|
392
392
|
if (!current.value || accessDenied.value || retry && retry.status !== "failed") return Promise.resolve(false);
|
|
393
|
-
return submitRequest(captured, { messageId, onAccepted, prepare });
|
|
393
|
+
return submitRequest(captured, { messageId, onAccepted, prepare, queue: queueWhileSending === true });
|
|
394
394
|
}
|
|
395
395
|
async function submitDraft(payload, { messageId = crypto.randomUUID(), submissionKind = steerable.value ? "steer" : "send",
|
|
396
396
|
questionText = "", onAccepted } = {}) {
|
|
@@ -463,7 +463,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
463
463
|
return { ok: false, error: "This conversation no longer accepts attachments." };
|
|
464
464
|
}
|
|
465
465
|
}
|
|
466
|
-
|
|
466
|
+
let request = prepare ? await prepare(submission, { signal: controller.signal }) : submission.request;
|
|
467
467
|
if (prepare) {
|
|
468
468
|
if (!current.value || accessDenied.value || controller.signal.aborted || request === false) return false;
|
|
469
469
|
if (!request || typeof request !== "object" || Array.isArray(request)) {
|
|
@@ -471,9 +471,31 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
471
471
|
}
|
|
472
472
|
}
|
|
473
473
|
pending.dispatched = true;
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
474
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
475
|
+
try {
|
|
476
|
+
return await (submission.goalRequest
|
|
477
|
+
? api.updateConversationGoal(identity.conversationId, { ...submission.goalRequest, messageId }, { signal: controller.signal })
|
|
478
|
+
: api.sendConversationMessage(identity.conversationId, { ...request, messageId }, { signal: controller.signal }));
|
|
479
|
+
} catch (failure) {
|
|
480
|
+
// The server identifies this exact pre-admission race. Keep the
|
|
481
|
+
// same queued words/attachments/identity and try ordinary Send
|
|
482
|
+
// once. A timeout, generic rejection or uncertain send never
|
|
483
|
+
// grants another dispatch.
|
|
484
|
+
if (attempt !== 0 || submission.goalRequest || request.steer !== true ||
|
|
485
|
+
failure.code !== "conversation_steer_turn_finished" || failure.status !== 409 ||
|
|
486
|
+
failure.details?.delivery && (failure.details.delivery.status !== "not-sent" ||
|
|
487
|
+
failure.details.delivery.messageId !== messageId)) throw failure;
|
|
488
|
+
const retained = delivery.find(messageId);
|
|
489
|
+
if (retained?.status === "accepted" || turns.value.some(turn => turn.user?.messageId === messageId && turn.user.receipt !== false)) return { ok: true };
|
|
490
|
+
if (!current.value || accessDenied.value || controller.signal.aborted || !retained || retained.status !== "pending") return false;
|
|
491
|
+
const { steer: _finishedTurn, ...nextRequest } = request;
|
|
492
|
+
request = nextRequest;
|
|
493
|
+
// Retain the new dispatch intent if Send itself fails; Retry
|
|
494
|
+
// must not loop back through the obsolete steering request.
|
|
495
|
+
retained.payload.request = nextRequest;
|
|
496
|
+
retained.payload.submissionKind = "send";
|
|
497
|
+
}
|
|
498
|
+
}
|
|
477
499
|
}
|
|
478
500
|
catch (failure) {
|
|
479
501
|
if (controller.signal.aborted) return false;
|
|
@@ -482,7 +504,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
482
504
|
const rejection = failure.details?.delivery;
|
|
483
505
|
const notSent = Number.isInteger(failure.status) && failure.status >= 400 && failure.status < 500 &&
|
|
484
506
|
rejection && !Array.isArray(rejection) && rejection.status === "not-sent" && rejection.messageId === messageId;
|
|
485
|
-
if (notSent || SEND_REJECTIONS.has(failure.code)) return { ok: false, error: failure.message,
|
|
507
|
+
if (notSent || SEND_REJECTIONS.has(failure.code) || failure.code === "conversation_steer_turn_finished" && failure.status === 409 && !failure.details?.delivery) return { ok: false, error: failure.message,
|
|
486
508
|
...(prepare && failure.code ? { code: failure.code } : {}) };
|
|
487
509
|
throw failure;
|
|
488
510
|
} finally {
|
|
@@ -650,12 +672,13 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
650
672
|
loading,
|
|
651
673
|
available: Boolean(goalAvailable),
|
|
652
674
|
action: goalAction.value,
|
|
675
|
+
working: snapshot.value?.status === "working",
|
|
653
676
|
tokenBudgetSupported: capabilities.goalBudgets === true,
|
|
654
677
|
goal: goalAvailable && view?.goal ? { ...view.goal, elapsedSeconds: view.goal.timeUsedSeconds,
|
|
655
678
|
sampledAt: Date.parse(view.goal.updatedAt) } : null,
|
|
656
679
|
pending: Boolean(goalAction.value) || !goalAvailable || !available.value || delivery.state.sending ||
|
|
657
680
|
delivery.state.messages.some(message => message.status === "uncertain"),
|
|
658
|
-
error: goalError.value || goalLoadError.value,
|
|
681
|
+
error: goalError.value || goalLoadError.value || view?.error || "",
|
|
659
682
|
pauseInterruptsTurn: commands.pause?.interruptsTurn === true,
|
|
660
683
|
cancelInterruptsTurn: commands.cancel?.interruptsTurn === true,
|
|
661
684
|
...Object.fromEntries(["set", "resume", "pause", "cancel"].filter(action =>
|
|
@@ -680,7 +703,10 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
680
703
|
try {
|
|
681
704
|
const receipt = await api.inspectConversationDelivery(identity.conversationId, messageId);
|
|
682
705
|
if (!current.value) return false;
|
|
683
|
-
if (receipt.status === "accepted")
|
|
706
|
+
if (receipt.status === "accepted") {
|
|
707
|
+
delivery.accept(messageId);
|
|
708
|
+
if (receipt.messageId === messageId && message?.payload.goalRequest) goalError.value = "";
|
|
709
|
+
}
|
|
684
710
|
else if (receipt.status === "not-sent" && receipt.messageId === messageId) delivery.rejectUnsent(messageId, receipt.error);
|
|
685
711
|
else if (receipt.status === "unknown" && message?.status === "uncertain") {
|
|
686
712
|
message.error = "Delivery is still unknown. Retry to check again.";
|
|
@@ -98,11 +98,12 @@ const AssistantFeature = defineFeature({
|
|
|
98
98
|
config
|
|
99
99
|
});
|
|
100
100
|
},
|
|
101
|
-
boot({ conversationRuntime, conversationAccess, realtime, events, config, workspaces, logger }, { outputs }) {
|
|
101
|
+
boot({ conversationRuntime, conversationAccess, realtime, events, config, env, workspaces, logger }, { outputs }) {
|
|
102
102
|
if (!conversationRuntime || !realtime) return;
|
|
103
103
|
const state = subscriptionLifecycle.get(outputs.assistant);
|
|
104
104
|
state.release = registerConversationSubscriptions({
|
|
105
105
|
realtime, events, actions: state.actions, config, logger, workspaceScopeSupport: workspaces?.scope || null,
|
|
106
|
+
diagnostics: ["1", "true", "yes", "on"].includes(String(env?.JSKIT_REALTIME_DEBUG ?? config?.realtime?.debug ?? "").trim().toLowerCase()),
|
|
106
107
|
...(conversationAccess ? {
|
|
107
108
|
subscribeActionId: conversationAccess.subscribeActionId,
|
|
108
109
|
requestPolicy: conversationAccess.requestPolicy
|
package/src/server/actions.js
CHANGED
|
@@ -201,12 +201,15 @@ function createConversationActions({ conversationRuntime, conversationDataSchema
|
|
|
201
201
|
if (typeof deps?.onEvent !== "function" || typeof deps?.onRelease !== "function") {
|
|
202
202
|
throw new TypeError("Conversation subscriptions require their server-owned event and cleanup callbacks.");
|
|
203
203
|
}
|
|
204
|
+
if (deps.isCurrent?.() === false) return null;
|
|
204
205
|
deps.onStage?.("conversation-open");
|
|
205
206
|
const conversation = await openConversation(input, context);
|
|
207
|
+
if (deps.isCurrent?.() === false) return null;
|
|
206
208
|
deps.onStage?.("observer-attach");
|
|
207
209
|
const release = await conversation.subscribe(deps.onEvent);
|
|
208
210
|
try {
|
|
209
211
|
deps.onRelease(release);
|
|
212
|
+
if (deps.isCurrent?.() === false) return null;
|
|
210
213
|
deps.onStage?.("snapshot-read");
|
|
211
214
|
return await conversation.read();
|
|
212
215
|
} catch (error) {
|
|
@@ -31,7 +31,7 @@ function subscriptionError(error) {
|
|
|
31
31
|
// A host may select its fixed action/request policy at composition; clients
|
|
32
32
|
// never choose either. The selected action remains the authorization boundary.
|
|
33
33
|
function registerConversationSubscriptions({ realtime, events, actions, config = {}, logger = null, workspaceScopeSupport = null,
|
|
34
|
-
subscribeActionId = actionIds.conversationSubscribe, requestPolicy = "authenticated" } = {}) {
|
|
34
|
+
subscribeActionId = actionIds.conversationSubscribe, requestPolicy = "authenticated", diagnostics = false } = {}) {
|
|
35
35
|
if (typeof realtime?.onConnection !== "function" || typeof events?.publish !== "function") {
|
|
36
36
|
throw new TypeError("Conversation subscriptions require the shared realtime connection and event runtime.");
|
|
37
37
|
}
|
|
@@ -50,10 +50,11 @@ function registerConversationSubscriptions({ realtime, events, actions, config =
|
|
|
50
50
|
const subscriptions = new Map();
|
|
51
51
|
let closed = false;
|
|
52
52
|
|
|
53
|
-
function release(subscriptionId) {
|
|
53
|
+
function release(subscriptionId, reason = "unsubscribe") {
|
|
54
54
|
const subscription = subscriptions.get(subscriptionId);
|
|
55
55
|
if (!subscription) return;
|
|
56
56
|
subscriptions.delete(subscriptionId);
|
|
57
|
+
subscription.diagnosticRelease?.(reason);
|
|
57
58
|
clearTimeout(subscription.timer);
|
|
58
59
|
subscription.pending.clear();
|
|
59
60
|
subscription.release?.();
|
|
@@ -67,12 +68,25 @@ function registerConversationSubscriptions({ realtime, events, actions, config =
|
|
|
67
68
|
let stageStartedAt = startedAt;
|
|
68
69
|
let stage = "request-admission";
|
|
69
70
|
let observedConversationId;
|
|
71
|
+
let diagnosticTimer;
|
|
72
|
+
function diagnostic(stageName, fields = {}) {
|
|
73
|
+
if (!diagnostics) return;
|
|
74
|
+
const now = performance.now();
|
|
75
|
+
try {
|
|
76
|
+
logger?.warn?.({ event: "assistant.conversation.subscription", subscriptionEpoch: subscription?.epoch,
|
|
77
|
+
...(typeof observedConversationId === "string" ? { conversationId: observedConversationId } : {}),
|
|
78
|
+
stage: stageName, previousStage: stage, durationMs: now - stageStartedAt,
|
|
79
|
+
elapsedMs: now - startedAt, ...fields }, "Assistant conversation subscription diagnostic.");
|
|
80
|
+
} catch { /* Diagnostics never change admission or cleanup. */ }
|
|
81
|
+
}
|
|
70
82
|
function onStage(next, fields = {}) {
|
|
71
83
|
const now = performance.now();
|
|
72
84
|
// Only a successfully opened, authorized conversation may enter diagnostics.
|
|
73
85
|
if (next === "observer-attach") observedConversationId = input.conversationId;
|
|
74
86
|
try {
|
|
75
|
-
|
|
87
|
+
const slowOutcome = now - startedAt >= 5_000 && ["acknowledgement", "abandoned"].includes(next);
|
|
88
|
+
const level = diagnostics || next === "failed" || slowOutcome ? "warn" : "info";
|
|
89
|
+
logger?.[level]?.({ event: "assistant.conversation.subscription", subscriptionEpoch: subscription?.epoch,
|
|
76
90
|
...(typeof observedConversationId === "string" ? { conversationId: observedConversationId } : {}),
|
|
77
91
|
stage: next, previousStage: stage,
|
|
78
92
|
durationMs: now - stageStartedAt, elapsedMs: now - startedAt, ...fields },
|
|
@@ -83,9 +97,17 @@ function registerConversationSubscriptions({ realtime, events, actions, config =
|
|
|
83
97
|
}
|
|
84
98
|
try {
|
|
85
99
|
subscriptionId = requireSubscriptionId(input);
|
|
86
|
-
release(subscriptionId);
|
|
100
|
+
release(subscriptionId, "replacement");
|
|
87
101
|
subscription = { pending: new Map(), revision: 0, epoch: randomUUID(), queue: Promise.resolve() };
|
|
88
102
|
subscriptions.set(subscriptionId, subscription);
|
|
103
|
+
if (diagnostics) {
|
|
104
|
+
subscription.diagnosticRelease = reason => {
|
|
105
|
+
clearTimeout(diagnosticTimer);
|
|
106
|
+
diagnostic("released", { reason });
|
|
107
|
+
};
|
|
108
|
+
diagnosticTimer = setTimeout(() => diagnostic("pending", { acknowledgementBudgetMs: 10_000 }), 10_000);
|
|
109
|
+
diagnosticTimer.unref?.();
|
|
110
|
+
}
|
|
89
111
|
const current = () => !closed && socket.connected && subscriptions.get(subscriptionId) === subscription;
|
|
90
112
|
onStage("authentication");
|
|
91
113
|
const request = await (requestPolicy === "host" ? readRequest() : authenticate());
|
|
@@ -148,6 +170,7 @@ function registerConversationSubscriptions({ realtime, events, actions, config =
|
|
|
148
170
|
deps: {
|
|
149
171
|
onEvent,
|
|
150
172
|
onStage,
|
|
173
|
+
isCurrent: current,
|
|
151
174
|
onRelease(detach) {
|
|
152
175
|
if (!current()) detach();
|
|
153
176
|
else subscription.release = detach;
|
|
@@ -167,6 +190,8 @@ function registerConversationSubscriptions({ realtime, events, actions, config =
|
|
|
167
190
|
status: Number(error?.status || error?.statusCode || 500) });
|
|
168
191
|
if (subscriptions.get(subscriptionId) === subscription) release(subscriptionId);
|
|
169
192
|
if (!closed && socket.connected) acknowledge(subscriptionError(error));
|
|
193
|
+
} finally {
|
|
194
|
+
clearTimeout(diagnosticTimer);
|
|
170
195
|
}
|
|
171
196
|
}
|
|
172
197
|
|
|
@@ -184,7 +209,7 @@ function registerConversationSubscriptions({ realtime, events, actions, config =
|
|
|
184
209
|
closed = true;
|
|
185
210
|
socket.off(ASSISTANT_CONVERSATION_SUBSCRIBE, subscribe);
|
|
186
211
|
socket.off(ASSISTANT_CONVERSATION_UNSUBSCRIBE, unsubscribe);
|
|
187
|
-
for (const subscriptionId of subscriptions.keys()) release(subscriptionId);
|
|
212
|
+
for (const subscriptionId of subscriptions.keys()) release(subscriptionId, "connection-closed");
|
|
188
213
|
};
|
|
189
214
|
});
|
|
190
215
|
}
|
|
@@ -147,6 +147,8 @@ test("optional goal controls follow declared command semantics and budgets witho
|
|
|
147
147
|
await expect(start).toBeEnabled();
|
|
148
148
|
await start.click();
|
|
149
149
|
await expect(f.primary.getByRole("button", { name: "Goal running", exact: true })).toBeVisible();
|
|
150
|
+
await expect(f.primary.getByRole("button", { name: "Goal running", exact: true })).toHaveAttribute("aria-expanded", "false");
|
|
151
|
+
await f.primary.getByRole("button", { name: "Goal running", exact: true }).click();
|
|
150
152
|
const pause = f.page.getByRole("button", { name: "Pause goal", exact: true });
|
|
151
153
|
await expect(pause).toBeEnabled();
|
|
152
154
|
await expect(f.page.getByText(budgets
|
|
@@ -191,6 +193,8 @@ test("the first native goal preserves absent identity and later controls capture
|
|
|
191
193
|
await expect(start).toBeEnabled();
|
|
192
194
|
await start.click();
|
|
193
195
|
await expect(f.primary.getByRole("button", { name: "Goal running", exact: true })).toBeVisible();
|
|
196
|
+
await expect(f.primary.getByRole("button", { name: "Goal running", exact: true })).toHaveAttribute("aria-expanded", "false");
|
|
197
|
+
await f.primary.getByRole("button", { name: "Goal running", exact: true }).click();
|
|
194
198
|
const pause = f.page.getByRole("button", { name: "Pause goal", exact: true });
|
|
195
199
|
await expect(pause).toBeEnabled();
|
|
196
200
|
await pause.click();
|
|
@@ -225,6 +229,7 @@ test("a lost goal receipt uses the same inspection-only ledger and never starts
|
|
|
225
229
|
const check = f.primary.getByRole("button", { name: "Retry delivery check", exact: true });
|
|
226
230
|
await expect(check).toBeVisible();
|
|
227
231
|
await expect(f.primary.getByText("Finish the uncertain goal", { exact: true })).toHaveCount(1);
|
|
232
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().goalState.value.error)).toBe("The goal's receipt was lost.");
|
|
228
233
|
const requests = async () => (await f.state()).requests.filter(request => request.suffix === "/goal" && request.method === "POST");
|
|
229
234
|
const [original] = await requests();
|
|
230
235
|
assert.ok(original.input.messageId);
|
|
@@ -235,6 +240,7 @@ test("a lost goal receipt uses the same inspection-only ledger and never starts
|
|
|
235
240
|
await f.command("confirm", { id: "chat:1" });
|
|
236
241
|
await check.click();
|
|
237
242
|
await expect(check).toHaveCount(0);
|
|
243
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().goalState.value.error)).toBe("");
|
|
238
244
|
await expect(f.primary.getByRole("button", { name: "Goal running", exact: true })).toBeVisible();
|
|
239
245
|
await expect(f.primary.getByText("Finish the uncertain goal", { exact: true })).toHaveCount(1);
|
|
240
246
|
assert.equal((await requests()).length, 1);
|
|
@@ -319,7 +325,7 @@ test("a supplied goal view keeps its pinned target and refreshes through the ret
|
|
|
319
325
|
assert.equal(commands.length, 1, "Retired viewer controls cannot target the next viewer's goal");
|
|
320
326
|
});
|
|
321
327
|
|
|
322
|
-
test(
|
|
328
|
+
for (const rejection of ["conversation_goal_changed", "conversation_invalid_goal"]) test(`a rejected message goal retry keeps its original UUID and exact read target (${rejection})`, options, async t => {
|
|
323
329
|
const f = await fixture(t);
|
|
324
330
|
const commands = [];
|
|
325
331
|
const view = { status: "available", goal: null, target: { segmentId: null,
|
|
@@ -328,8 +334,8 @@ test("a rejected message goal retry keeps its original UUID and exact read targe
|
|
|
328
334
|
if (route.request().method() === "GET") return route.fulfill({ json: view });
|
|
329
335
|
const input = route.request().postDataJSON();
|
|
330
336
|
commands.push(input);
|
|
331
|
-
if (commands.length === 1) return route.fulfill({ status: 409,
|
|
332
|
-
json: { code:
|
|
337
|
+
if (commands.length === 1) return route.fulfill({ status: rejection === "conversation_invalid_goal" ? 400 : 409,
|
|
338
|
+
json: { code: rejection, error: "Goal rejected before admission." } });
|
|
333
339
|
return route.fulfill({ json: { status: "accepted", messageId: input.messageId, turnId: "original-goal-receipt" } });
|
|
334
340
|
});
|
|
335
341
|
await f.page.evaluate(() => window.conversationFixture.eventReaders([{ id: "goal-reader", goal: true }]));
|
|
@@ -337,6 +343,8 @@ test("a rejected message goal retry keeps its original UUID and exact read targe
|
|
|
337
343
|
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().goalState.value?.pending)).toBe(false);
|
|
338
344
|
assert.equal(await f.page.evaluate(() => window.conversationFixture.current().changeGoal("set", { objective: "Original objective" })), false);
|
|
339
345
|
assert.equal(commands.length, 1);
|
|
346
|
+
assert.equal(await f.page.evaluate(id => window.conversationFixture.current().delivery.find(id)?.status,
|
|
347
|
+
commands[0].messageId), "failed", "pre-dispatch validation is not an uncertain delivery");
|
|
340
348
|
assert.ok(commands[0].messageId);
|
|
341
349
|
assert.equal(commands[0].expectedSegmentId, null);
|
|
342
350
|
assert.equal(commands[0].expectedGoalId, null);
|
|
@@ -764,6 +772,67 @@ for (const status of ["uncertain", "pending", "accepted", "failed", "unrecognise
|
|
|
764
772
|
});
|
|
765
773
|
}
|
|
766
774
|
|
|
775
|
+
for (const ordinaryFails of [false, true]) {
|
|
776
|
+
test(`a finished-turn steer becomes one ordinary Send with its original identity (Send fails: ${ordinaryFails})`, options, async t => {
|
|
777
|
+
const f = await fixture(t);
|
|
778
|
+
await f.command("mode", { mode: "rejected" });
|
|
779
|
+
await f.command("steering", { id: "chat:1", enabled: true });
|
|
780
|
+
await f.command("question", { id: "chat:1", text: "Earlier work", pending: true });
|
|
781
|
+
await f.notify("chat:1");
|
|
782
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().steerable.value)).toBe(true);
|
|
783
|
+
let calls = 0;
|
|
784
|
+
await f.page.route("**/messages", async route => {
|
|
785
|
+
calls += 1;
|
|
786
|
+
const response = await route.fetch();
|
|
787
|
+
if (calls === 1) {
|
|
788
|
+
if (!ordinaryFails) await f.command("mode", { mode: "accepted" });
|
|
789
|
+
await route.fulfill({ response, status: 409, json: {
|
|
790
|
+
error: "The turn finished before dispatch.", code: "conversation_steer_turn_finished"
|
|
791
|
+
} });
|
|
792
|
+
} else await route.fulfill({ response });
|
|
793
|
+
});
|
|
794
|
+
await f.input.fill("Keep exactly these words");
|
|
795
|
+
await f.input.press("Enter");
|
|
796
|
+
await expect.poll(async () => (await f.sent()).length).toBe(2);
|
|
797
|
+
const [steer, send] = await f.sent();
|
|
798
|
+
assert.equal(steer.input.steer, true);
|
|
799
|
+
const { steer: _oldIntent, ...ordinary } = steer.input;
|
|
800
|
+
assert.deepEqual(send.input, ordinary);
|
|
801
|
+
if (ordinaryFails) {
|
|
802
|
+
await expect(f.primary.getByText("Failed: The message was rejected before admission.", { exact: true })).toBeVisible();
|
|
803
|
+
assert.equal(await f.page.evaluate(id => window.conversationFixture.current().delivery.find(id).payload.request.steer,
|
|
804
|
+
steer.input.messageId), undefined);
|
|
805
|
+
await f.input.fill("Keep my newer draft");
|
|
806
|
+
await f.command("mode", { mode: "accepted" });
|
|
807
|
+
await f.primary.getByRole("button", { name: "Retry", exact: true }).click();
|
|
808
|
+
await expect.poll(async () => (await f.sent()).length).toBe(3);
|
|
809
|
+
assert.deepEqual((await f.sent())[2].input, ordinary);
|
|
810
|
+
await expect(f.input).toHaveValue("Keep my newer draft");
|
|
811
|
+
}
|
|
812
|
+
await expect(f.primary.getByRole("button", { name: "Stop", exact: true })).toBeVisible();
|
|
813
|
+
await expect(f.primary.getByText("Keep exactly these words", { exact: true })).toHaveCount(1);
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
test("a finished-turn error without a definite rejection status remains uncertain and never auto-resends", options, async t => {
|
|
818
|
+
const f = await fixture(t);
|
|
819
|
+
await f.command("mode", { mode: "rejected" });
|
|
820
|
+
await f.command("steering", { id: "chat:1", enabled: true });
|
|
821
|
+
await f.command("question", { id: "chat:1", text: "Earlier work", pending: true });
|
|
822
|
+
await f.notify("chat:1");
|
|
823
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().steerable.value)).toBe(true);
|
|
824
|
+
await f.page.route("**/messages", async route => {
|
|
825
|
+
const response = await route.fetch();
|
|
826
|
+
await route.fulfill({ response, status: 503, json: { error: "Delivery unknown", code: "conversation_steer_turn_finished" } });
|
|
827
|
+
});
|
|
828
|
+
await f.input.fill("Retain uncertain words");
|
|
829
|
+
await f.input.press("Enter");
|
|
830
|
+
await expect.poll(async () => (await f.sent()).length).toBe(1);
|
|
831
|
+
const [request] = await f.sent();
|
|
832
|
+
await expect.poll(() => f.page.evaluate(id => window.conversationFixture.current().delivery.find(id).status, request.input.messageId)).toBe("uncertain");
|
|
833
|
+
assert.equal((await f.sent()).length, 1);
|
|
834
|
+
});
|
|
835
|
+
|
|
767
836
|
for (const code of ["ACTION_VALIDATION_FAILED", "conversation_not_steerable"]) {
|
|
768
837
|
test(`an explicit pre-admission rejection retains original retry identity and the newer draft: ${code}`, options, async t => {
|
|
769
838
|
const f = await fixture(t);
|
|
@@ -1810,6 +1879,65 @@ test("dynamic acquisition keeps the original shared readers and releases only it
|
|
|
1810
1879
|
assert.equal((await f.sent()).length, 0);
|
|
1811
1880
|
});
|
|
1812
1881
|
|
|
1882
|
+
for (const stop of [false, true]) {
|
|
1883
|
+
test(`prepared application queue retains followers until admission and respects Stop (${stop})`, options, async t => {
|
|
1884
|
+
const f = await fixture(t);
|
|
1885
|
+
await f.command("mode", { mode: "hold" });
|
|
1886
|
+
await f.page.evaluate(() => {
|
|
1887
|
+
window.preparedQueue = window.conversationFixture.acquireConversation({ conversationId: "chat:2", queueWhileSending: true });
|
|
1888
|
+
window.preparedCalls = [];
|
|
1889
|
+
const send = (id, text) => window.preparedQueue.runtime.value.submitPrepared({ message: text, request: { text } }, {
|
|
1890
|
+
messageId: id, prepare(submission) { window.preparedCalls.push(id); return submission.request; }
|
|
1891
|
+
});
|
|
1892
|
+
window.preparedFirst = send("prepared-first", "First authored message");
|
|
1893
|
+
window.preparedSecond = send("prepared-second", "Second authored message");
|
|
1894
|
+
});
|
|
1895
|
+
await expect.poll(async () => (await f.state()).held).toBe(1);
|
|
1896
|
+
assert.deepEqual(await f.page.evaluate(() => window.preparedCalls), ["prepared-first"]);
|
|
1897
|
+
assert.equal(await f.page.evaluate(() => window.preparedQueue.runtime.value.delivery.state.messages.length), 2);
|
|
1898
|
+
if (stop) await f.page.evaluate(() => window.preparedQueue.runtime.value.cancel());
|
|
1899
|
+
await f.command("mode", { mode: "accepted" });
|
|
1900
|
+
await f.command("accept");
|
|
1901
|
+
await f.page.evaluate(() => window.preparedFirst);
|
|
1902
|
+
const result = await f.page.evaluate(() => window.preparedSecond);
|
|
1903
|
+
assert.equal((await f.sent()).length, stop ? 1 : 2);
|
|
1904
|
+
assert.deepEqual(await f.page.evaluate(() => window.preparedCalls), stop ? ["prepared-first"] : ["prepared-first", "prepared-second"]);
|
|
1905
|
+
if (stop) assert.equal(result, false);
|
|
1906
|
+
else assert.equal(result.status, "accepted");
|
|
1907
|
+
});
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
test("prepared steering uses the definite finished-turn fallback without repeating preparation", options, async t => {
|
|
1911
|
+
const f = await fixture(t);
|
|
1912
|
+
await f.command("mode", { mode: "rejected" });
|
|
1913
|
+
let calls = 0;
|
|
1914
|
+
await f.page.route("**/messages", async route => {
|
|
1915
|
+
const response = await route.fetch();
|
|
1916
|
+
if (++calls === 1) {
|
|
1917
|
+
await f.command("mode", { mode: "accepted" });
|
|
1918
|
+
const input = route.request().postDataJSON();
|
|
1919
|
+
await route.fulfill({ response, status: 409, json: {
|
|
1920
|
+
error: "The turn finished before dispatch.", code: "conversation_steer_turn_finished",
|
|
1921
|
+
details: { delivery: { status: "not-sent", messageId: input.messageId } }
|
|
1922
|
+
} });
|
|
1923
|
+
} else await route.fulfill({ response });
|
|
1924
|
+
});
|
|
1925
|
+
const result = await f.page.evaluate(async () => {
|
|
1926
|
+
const binding = window.conversationFixture.acquireConversation({ conversationId: "chat:2", queueWhileSending: true });
|
|
1927
|
+
window.prepareCount = 0;
|
|
1928
|
+
return binding.runtime.value.submitPrepared({ message: "Keep these words", request: { text: "Keep these words", steer: true } }, {
|
|
1929
|
+
messageId: "prepared-finished", prepare(submission) { window.prepareCount += 1; return submission.request; }
|
|
1930
|
+
});
|
|
1931
|
+
});
|
|
1932
|
+
assert.equal(result.status, "accepted");
|
|
1933
|
+
assert.equal(await f.page.evaluate(() => window.prepareCount), 1);
|
|
1934
|
+
const sent = await f.sent();
|
|
1935
|
+
assert.equal(sent.length, 2);
|
|
1936
|
+
assert.equal(sent[0].input.steer, true);
|
|
1937
|
+
const { steer: _ended, ...ordinary } = sent[0].input;
|
|
1938
|
+
assert.deepEqual(sent[1].input, ordinary);
|
|
1939
|
+
});
|
|
1940
|
+
|
|
1813
1941
|
test("prepared submission keeps optimism before application creation and uses the same captured receipt owner", options, async t => {
|
|
1814
1942
|
const f = await fixture(t);
|
|
1815
1943
|
await f.command("mode", { mode: "hold" });
|
|
@@ -3085,3 +3213,31 @@ test("initial goal loading shows no goal commands until the empty goal is confir
|
|
|
3085
3213
|
await expect(f.page.getByRole("button", { name: "Resume goal", exact: true })).toHaveCount(0);
|
|
3086
3214
|
} finally { waiting.resolve(); }
|
|
3087
3215
|
});
|
|
3216
|
+
|
|
3217
|
+
|
|
3218
|
+
test("host goal recovery errors remain visible while Resume uses the current goal target", options, async t => {
|
|
3219
|
+
const f = await fixture(t);
|
|
3220
|
+
const view = { status: "available", error: "Goal recovery stopped because its AI account changed. Resume with the intended account.",
|
|
3221
|
+
goal: { id: "retained-goal", objective: "Finish the agreed work", status: "paused" },
|
|
3222
|
+
target: { segmentId: "retained-native", capabilities: { goals: true,
|
|
3223
|
+
goalCommands: { resume: { delivery: "control", interruptsTurn: false } } } } };
|
|
3224
|
+
const commands = [];
|
|
3225
|
+
await f.page.route("**/conversations/*/goal", async route => {
|
|
3226
|
+
if (route.request().method() === "GET") return route.fulfill({ json: view });
|
|
3227
|
+
commands.push(route.request().postDataJSON());
|
|
3228
|
+
view.error = "";
|
|
3229
|
+
view.goal.status = "active";
|
|
3230
|
+
return route.fulfill({ json: { ok: true } });
|
|
3231
|
+
});
|
|
3232
|
+
await f.page.evaluate(() => window.conversationFixture.eventReaders([{ id: "goal-reader", goal: true }]));
|
|
3233
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().goalState.value?.pending)).toBe(false);
|
|
3234
|
+
await f.primary.getByRole("button", { name: "Goal paused", exact: true }).click();
|
|
3235
|
+
await expect(f.page.getByRole("alert").filter({ hasText: "Goal recovery stopped" })).toBeVisible();
|
|
3236
|
+
const resume = f.page.getByRole("button", { name: "Resume goal", exact: true });
|
|
3237
|
+
await expect(resume).toBeEnabled();
|
|
3238
|
+
await resume.click();
|
|
3239
|
+
await expect.poll(() => commands.length).toBe(1);
|
|
3240
|
+
assert.deepEqual(commands[0], { action: "resume", expectedSegmentId: "retained-native", expectedGoalId: "retained-goal" });
|
|
3241
|
+
await expect.poll(() => f.page.evaluate(() => window.conversationFixture.current().goalState.value.error)).toBe("");
|
|
3242
|
+
assert.deepEqual(await f.sent(), []);
|
|
3243
|
+
});
|
|
@@ -42,7 +42,7 @@ function requestSubscription(socket, input) {
|
|
|
42
42
|
});
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
async function fixture(t) {
|
|
45
|
+
async function fixture(t, { diagnostics = false, onSetup } = {}) {
|
|
46
46
|
const directory = await mkdtemp(join(tmpdir(), "assistant-subscriptions-"));
|
|
47
47
|
const changes = new EventEmitter();
|
|
48
48
|
const notify = () => changes.emit("change");
|
|
@@ -86,13 +86,16 @@ async function fixture(t) {
|
|
|
86
86
|
conversations.set(id, conversation);
|
|
87
87
|
}
|
|
88
88
|
let active = 0;
|
|
89
|
+
let snapshotReads = 0;
|
|
89
90
|
const actions = createActionCatalogue();
|
|
90
91
|
const contributor = createAuthActionContextContributor();
|
|
91
92
|
actions.registerContextContributor({ id: contributor.contributorId, contribute: contributor.contribute });
|
|
92
93
|
actions.register({ contributorId: "test.assistant", domain: "assistant", actions: createAssistantActions({ config,
|
|
93
94
|
conversationRuntime: { async open(input) {
|
|
95
|
+
await onSetup?.("conversation-open");
|
|
94
96
|
const conversation = await runtime.open(input);
|
|
95
97
|
return { ...conversation, async read() {
|
|
98
|
+
snapshotReads += 1;
|
|
96
99
|
const barrier = nextReadBarriers.get(input.id);
|
|
97
100
|
if (barrier) {
|
|
98
101
|
nextReadBarriers.delete(input.id);
|
|
@@ -106,6 +109,7 @@ async function fixture(t) {
|
|
|
106
109
|
let released = false;
|
|
107
110
|
active += 1;
|
|
108
111
|
notify();
|
|
112
|
+
await onSetup?.("observer-attach");
|
|
109
113
|
return () => {
|
|
110
114
|
if (released) return;
|
|
111
115
|
released = true;
|
|
@@ -141,8 +145,11 @@ async function fixture(t) {
|
|
|
141
145
|
serverSockets.add(socket);
|
|
142
146
|
return () => { serverSockets.delete(socket); notify(); };
|
|
143
147
|
});
|
|
144
|
-
const stopSubscriptions = registerConversationSubscriptions({ realtime, events, actions, config,
|
|
145
|
-
logger: {
|
|
148
|
+
const stopSubscriptions = registerConversationSubscriptions({ realtime, events, actions, config, diagnostics,
|
|
149
|
+
logger: {
|
|
150
|
+
info(record) { subscriptionTimings.push({ ...record, level: "info" }); notify(); },
|
|
151
|
+
warn(record) { subscriptionTimings.push({ ...record, level: "warn" }); notify(); }
|
|
152
|
+
} });
|
|
146
153
|
const published = [];
|
|
147
154
|
events.register({ id: "test.assistant.published", handle(event) { published.push(event); notify(); } });
|
|
148
155
|
http.listen(0, "127.0.0.1");
|
|
@@ -204,7 +211,49 @@ async function fixture(t) {
|
|
|
204
211
|
}
|
|
205
212
|
return { ids, connect, view, changes, sessions, revoked, fetches, conversations, runtimeEvents,
|
|
206
213
|
realtime, transport, serverSockets, published, events, stopSubscriptions, authRequests, subscriptionTimings,
|
|
207
|
-
holdNextRead, active: () => active };
|
|
214
|
+
holdNextRead, active: () => active, snapshotReads: () => snapshotReads };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const phase of ["conversation-open", "observer-attach"]) {
|
|
218
|
+
test(`released during ${phase} skips obsolete snapshot and preserves replacement observation`, async t => {
|
|
219
|
+
const entered = Promise.withResolvers();
|
|
220
|
+
const gate = Promise.withResolvers();
|
|
221
|
+
t.after(() => gate.resolve());
|
|
222
|
+
let held = false;
|
|
223
|
+
const f = await fixture(t, { diagnostics: true, async onSetup(stage) {
|
|
224
|
+
if (stage !== phase || held) return;
|
|
225
|
+
held = true;
|
|
226
|
+
entered.resolve();
|
|
227
|
+
await gate.promise;
|
|
228
|
+
} });
|
|
229
|
+
const socket = await f.connect();
|
|
230
|
+
const id = f.ids[0];
|
|
231
|
+
await f.conversations.get(id).send({ messageId: "survives-observer-release", text: "Keep working" });
|
|
232
|
+
await until(f.changes, () => f.fetches.length === 1);
|
|
233
|
+
const oldView = f.view(socket, id);
|
|
234
|
+
await entered.promise;
|
|
235
|
+
const epoch = f.subscriptionTimings.at(-1).subscriptionEpoch;
|
|
236
|
+
oldView.dispose();
|
|
237
|
+
await until(f.changes, () => f.subscriptionTimings.some(record =>
|
|
238
|
+
record.subscriptionEpoch === epoch && record.stage === "released"));
|
|
239
|
+
const replacement = f.view(socket, id);
|
|
240
|
+
await until(f.changes, () => replacement.states.length === 1);
|
|
241
|
+
assert.equal(f.snapshotReads(), 1);
|
|
242
|
+
gate.resolve();
|
|
243
|
+
await until(f.changes, () => f.subscriptionTimings.some(record =>
|
|
244
|
+
record.subscriptionEpoch === epoch && record.stage === "abandoned"));
|
|
245
|
+
assert.equal(f.snapshotReads(), 1, "Released setup cannot start a new snapshot");
|
|
246
|
+
assert.equal(f.subscriptionTimings.some(record =>
|
|
247
|
+
record.subscriptionEpoch === epoch && record.stage === "snapshot-read"), false);
|
|
248
|
+
assert.equal(f.active(), 1, "Late cleanup preserves the replacement observer");
|
|
249
|
+
assert.equal(oldView.states.length, 0);
|
|
250
|
+
assert.equal(f.fetches.length, 1);
|
|
251
|
+
assert.equal(f.fetches[0].signal.aborted, false, "View cancellation never stops the model");
|
|
252
|
+
f.fetches[0].text("Still working after view replacement.");
|
|
253
|
+
await until(f.changes, () => replacement.received.some(event => event.text?.includes("view replacement")));
|
|
254
|
+
replacement.dispose();
|
|
255
|
+
await until(f.changes, () => f.active() === 0);
|
|
256
|
+
});
|
|
208
257
|
}
|
|
209
258
|
|
|
210
259
|
test("subscription timings identify a blocked snapshot and abandoned observation without stopping work", { timeout: 20_000 }, async t => {
|
|
@@ -247,6 +296,7 @@ test("subscription timings identify a blocked snapshot and abandoned observation
|
|
|
247
296
|
assert.ok(records.every(record => !Object.hasOwn(record, "subscriptionId")),
|
|
248
297
|
"Correlation uses the server epoch rather than arbitrary client subscription text");
|
|
249
298
|
assert.equal(records.find(record => record.stage === "acknowledgement").snapshotTurns, 1);
|
|
299
|
+
assert.ok(records.every(record => record.level === "info"), "Normal subscriptions do not produce warnings");
|
|
250
300
|
assert.equal(recovered.states[0].conversationLog[0].user.messageId, "measured-once");
|
|
251
301
|
assert.equal(f.active(), 1);
|
|
252
302
|
assert.equal(f.fetches.length, 1);
|
|
@@ -255,6 +305,30 @@ test("subscription timings identify a blocked snapshot and abandoned observation
|
|
|
255
305
|
await until(f.changes, () => f.active() === 0);
|
|
256
306
|
});
|
|
257
307
|
|
|
308
|
+
test("slow successful subscriptions warn once with the blocked stage and no conversation content", async t => {
|
|
309
|
+
const f = await fixture(t);
|
|
310
|
+
const socket = await f.connect();
|
|
311
|
+
const id = f.ids[0];
|
|
312
|
+
const barrier = f.holdNextRead(id);
|
|
313
|
+
const view = f.view(socket, id);
|
|
314
|
+
await until(f.changes, () => barrier.started);
|
|
315
|
+
const now = performance.now.bind(performance);
|
|
316
|
+
const clock = t.mock.method(performance, "now", () => now() + 6_000);
|
|
317
|
+
try {
|
|
318
|
+
barrier.resolve();
|
|
319
|
+
await until(f.changes, () => view.states.length === 1);
|
|
320
|
+
} finally { clock.mock.restore(); }
|
|
321
|
+
const warnings = f.subscriptionTimings.filter(record => record.level === "warn");
|
|
322
|
+
assert.equal(warnings.length, 1);
|
|
323
|
+
assert.equal(warnings[0].stage, "acknowledgement");
|
|
324
|
+
assert.equal(warnings[0].previousStage, "snapshot-read");
|
|
325
|
+
assert.ok(warnings[0].durationMs >= 6_000);
|
|
326
|
+
assert.ok(warnings[0].elapsedMs >= warnings[0].durationMs);
|
|
327
|
+
assert.equal(warnings[0].snapshotTurns, 0);
|
|
328
|
+
assert.equal(Object.hasOwn(warnings[0], "state"), false);
|
|
329
|
+
assert.equal(f.fetches.length, 0);
|
|
330
|
+
});
|
|
331
|
+
|
|
258
332
|
test("the real ten-second subscription deadline releases its late observer and Reload never repeats work", { timeout: 25_000 }, async t => {
|
|
259
333
|
const f = await fixture(t);
|
|
260
334
|
const socket = await f.connect();
|
|
@@ -282,6 +356,11 @@ test("the real ten-second subscription deadline releases its late observer and R
|
|
|
282
356
|
barrier.resolve();
|
|
283
357
|
await until(f.changes, () => f.subscriptionTimings.some(record =>
|
|
284
358
|
record.subscriptionEpoch === firstSubscriptionEpoch && record.stage === "abandoned"));
|
|
359
|
+
const abandoned = f.subscriptionTimings.find(record =>
|
|
360
|
+
record.subscriptionEpoch === firstSubscriptionEpoch && record.stage === "abandoned");
|
|
361
|
+
assert.equal(abandoned.level, "warn", "A timed-out snapshot remains visible at the production warning level");
|
|
362
|
+
assert.equal(abandoned.previousStage, "snapshot-read");
|
|
363
|
+
assert.ok(abandoned.durationMs >= 5_000);
|
|
285
364
|
assert.equal(view.states.length, statesBeforeLateRead, "The obsolete server read never acknowledges into the recovered view");
|
|
286
365
|
assert.equal(f.active(), 1, "The late read cannot release the recovered observer");
|
|
287
366
|
assert.equal(f.fetches.length, 1);
|
|
@@ -304,6 +383,7 @@ test("subscription diagnostics never serialize unvalidated client identifiers",
|
|
|
304
383
|
assert.equal(result.status, 400);
|
|
305
384
|
assert.equal(f.active(), 0);
|
|
306
385
|
assert.equal(f.subscriptionTimings.at(-1).stage, "failed");
|
|
386
|
+
assert.equal(f.subscriptionTimings.at(-1).level, "warn");
|
|
307
387
|
assert.ok(f.subscriptionTimings.every(record => /^[0-9a-f-]{36}$/.test(record.subscriptionEpoch)));
|
|
308
388
|
assert.ok(f.subscriptionTimings.every(record => !Object.hasOwn(record, "conversationId") &&
|
|
309
389
|
!Object.hasOwn(record, "subscriptionId")));
|
|
@@ -1148,3 +1228,47 @@ test("retargeting a retained logical observer uses the current engine namespace
|
|
|
1148
1228
|
assert.equal(active, 0);
|
|
1149
1229
|
assert.equal(sends, 0);
|
|
1150
1230
|
});
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
test("opt-in subscription watchdog identifies a pending snapshot before the await returns", async t => {
|
|
1234
|
+
const f = await fixture(t, { diagnostics: true });
|
|
1235
|
+
const socket = await f.connect();
|
|
1236
|
+
const barrier = f.holdNextRead(f.ids[0]);
|
|
1237
|
+
t.mock.timers.enable({ apis: ["setTimeout"] });
|
|
1238
|
+
const view = f.view(socket, f.ids[0]);
|
|
1239
|
+
await until(f.changes, () => barrier.started);
|
|
1240
|
+
t.mock.timers.tick(10_000);
|
|
1241
|
+
t.mock.timers.reset();
|
|
1242
|
+
const pending = f.subscriptionTimings.find(record => record.stage === "pending");
|
|
1243
|
+
const stages = f.subscriptionTimings.filter(record => record.stage !== "pending");
|
|
1244
|
+
assert.deepEqual(stages.map(record => record.stage), ["authentication", "action-admission",
|
|
1245
|
+
"conversation-open", "observer-attach", "snapshot-read"]);
|
|
1246
|
+
assert.ok(stages.every(record => record.level === "warn"), "Opt-in stage timings survive a warning-only operational log");
|
|
1247
|
+
assert.ok(stages.every(record => Number.isFinite(record.durationMs) && Number.isFinite(record.elapsedMs)));
|
|
1248
|
+
assert.equal(pending.previousStage, "snapshot-read");
|
|
1249
|
+
assert.equal(pending.conversationId, f.ids[0]);
|
|
1250
|
+
assert.equal(pending.acknowledgementBudgetMs, 10_000);
|
|
1251
|
+
assert.equal(pending.level, "warn");
|
|
1252
|
+
assert.equal(Object.hasOwn(pending, "state"), false);
|
|
1253
|
+
view.dispose();
|
|
1254
|
+
await until(f.changes, () => f.subscriptionTimings.some(record => record.stage === "released"));
|
|
1255
|
+
assert.equal(f.subscriptionTimings.find(record => record.stage === "released").previousStage, "snapshot-read");
|
|
1256
|
+
barrier.resolve();
|
|
1257
|
+
await until(f.changes, () => f.subscriptionTimings.some(record => record.stage === "abandoned"));
|
|
1258
|
+
assert.equal(view.states.length, 0);
|
|
1259
|
+
assert.equal(f.fetches.length, 0, "Diagnostics do not start or replay a model turn");
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
for (const diagnostics of [false, true]) test(`watchdog timer is removed after acknowledgement (diagnostics=${diagnostics})`, async t => {
|
|
1263
|
+
const f = await fixture(t, { diagnostics });
|
|
1264
|
+
const socket = await f.connect();
|
|
1265
|
+
t.mock.timers.enable({ apis: ["setTimeout"] });
|
|
1266
|
+
const view = f.view(socket, f.ids[0]);
|
|
1267
|
+
await until(f.changes, () => view.states.length === 1);
|
|
1268
|
+
t.mock.timers.tick(20_000);
|
|
1269
|
+
t.mock.timers.reset();
|
|
1270
|
+
view.dispose();
|
|
1271
|
+
await until(f.changes, () => f.active() === 0);
|
|
1272
|
+
assert.equal(f.subscriptionTimings.some(record => record.stage === "pending"), false);
|
|
1273
|
+
assert.equal(f.subscriptionTimings.some(record => record.stage === "released"), diagnostics);
|
|
1274
|
+
});
|