oysterun 1.6.0-beta.4 → 1.6.0-beta.6
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/dev/client/connectivity/completed-message-neighbor-click.mjs +78 -0
- package/dev/client/connectivity/completed-message-neighbor-contract.mjs +205 -0
- package/dev/client/connectivity/test-completed-message-neighbor-click.mjs +180 -0
- package/dev/client/web/index.html +757 -85
- package/dev/client/web-chat/dist/assets/{ReactPrism-BuUmuUha.js → ReactPrism-DcNXef2k.js} +1 -1
- package/dev/client/web-chat/dist/assets/ReactPrism-DcNXef2k.js.map +1 -0
- package/dev/client/web-chat/dist/assets/index-B9fWrEk-.js.map +1 -1
- package/dev/client/web-chat/dist/assets/{index-BH_BhJkj.js → index-BcplGVpl.js} +1 -1
- package/dev/client/web-chat/dist/assets/index-BcplGVpl.js.map +1 -0
- package/dev/client/web-chat/dist/assets/{index-B8DZaf0p.js → index-Da35WQaN.js} +2 -2
- package/dev/client/web-chat/dist/assets/index-Da35WQaN.js.map +1 -0
- package/dev/client/web-chat/dist/assets/{index-CU330HS9.js → index-DnIWxx_Q.js} +85 -85
- package/dev/client/web-chat/dist/assets/index-DnIWxx_Q.js.map +1 -0
- package/dev/client/web-chat/dist/assets/{index-B9ymKor4.css → index-NYnaSGBb.css} +1 -1
- package/dev/client/web-chat/dist/assets/{pdf-CvhY1cT6.js → pdf-DTluiTFV.js} +3 -3
- package/dev/client/web-chat/dist/assets/pdf-DTluiTFV.js.map +1 -0
- package/dev/client/web-chat/dist/assets/{web-CLjeBhsF.js → web-Br6xI6YK.js} +1 -1
- package/dev/client/web-chat/dist/assets/web-Br6xI6YK.js.map +1 -0
- package/dev/client/web-chat/dist/assets/{web-BhUj2o7d.js → web-D_ECINqC.js} +1 -1
- package/dev/client/web-chat/dist/assets/web-D_ECINqC.js.map +1 -0
- package/dev/client/web-chat/dist/index.html +10 -4
- package/host-service/adapters/debug-fixture-provider-adapter.mjs +100 -1
- package/host-service/agent-config.mjs +16 -5
- package/host-service/completed-session-unread.mjs +121 -42
- package/host-service/completed-turn-navigation.mjs +443 -143
- package/host-service/config.mjs +37 -5
- package/host-service/host-lifecycle-recovery.mjs +1 -1
- package/host-service/lifecycle-continuity-runner.mjs +1 -1
- package/host-service/lifecycle-continuity.mjs +10 -10
- package/host-service/matrix-event-correlator.mjs +110 -6
- package/host-service/matrix-room-binding.mjs +9 -0
- package/host-service/matrix-transcript-read-adapter.mjs +9 -3
- package/host-service/package.json +1 -1
- package/host-service/routec-matrix-sqlite-store.mjs +111 -5
- package/host-service/routec-matrix-storage-adapter.mjs +26 -4
- package/host-service/routec-request-delay-window.mjs +55 -0
- package/host-service/server.mjs +130 -131
- package/host-service/session-list-preview-service.mjs +87 -12
- package/host-service/session-list-projection-service.mjs +180 -0
- package/host-service/session-manager.mjs +4 -1
- package/host-service/session-switcher-projection.mjs +4 -3
- package/host-service/setup.mjs +10 -1
- package/host-service/test-fixtures/p236-navigation-original.mjs +500 -0
- package/package.json +1 -1
- package/tool_scripts/oysterun_service_common.sh +0 -20
- package/tool_scripts/p233_legacy_bootstrap.mjs +1 -1
- package/dev/client/web-chat/dist/assets/ReactPrism-BuUmuUha.js.map +0 -1
- package/dev/client/web-chat/dist/assets/index-B8DZaf0p.js.map +0 -1
- package/dev/client/web-chat/dist/assets/index-BH_BhJkj.js.map +0 -1
- package/dev/client/web-chat/dist/assets/index-CU330HS9.js.map +0 -1
- package/dev/client/web-chat/dist/assets/pdf-CvhY1cT6.js.map +0 -1
- package/dev/client/web-chat/dist/assets/web-BhUj2o7d.js.map +0 -1
- package/dev/client/web-chat/dist/assets/web-CLjeBhsF.js.map +0 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Owns the lifetime of one completed-message navigation request at a time.
|
|
5
|
+
* Timeline movement is deliberately absent from this API: only click() reads.
|
|
6
|
+
*
|
|
7
|
+
* @template TResponse
|
|
8
|
+
* @param {{ readNeighbors: (request: { roomId: string, anchorEventId?: string, anchorPosition?: "latest" }) => Promise<TResponse>, selectTarget: (response: TResponse, direction: "previous" | "next") => string | undefined }} options
|
|
9
|
+
*/
|
|
10
|
+
export function createCompletedMessageNeighborClickCoordinator(options) {
|
|
11
|
+
/** @type {string | null} */
|
|
12
|
+
let scope = null;
|
|
13
|
+
let generation = 0;
|
|
14
|
+
/** @type {{ generation: number, scope: string } | null} */
|
|
15
|
+
let active = null;
|
|
16
|
+
let disposed = false;
|
|
17
|
+
|
|
18
|
+
/** @param {string} nextScope */
|
|
19
|
+
function setScope(nextScope) {
|
|
20
|
+
if (scope === nextScope) return;
|
|
21
|
+
scope = nextScope;
|
|
22
|
+
generation += 1;
|
|
23
|
+
active = null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
setScope,
|
|
28
|
+
dispose() {
|
|
29
|
+
disposed = true;
|
|
30
|
+
generation += 1;
|
|
31
|
+
active = null;
|
|
32
|
+
},
|
|
33
|
+
isLoading() {
|
|
34
|
+
return active !== null;
|
|
35
|
+
},
|
|
36
|
+
/**
|
|
37
|
+
* @param {{ scope: string, roomId: string, direction: "previous" | "next", anchorEventId?: string, anchorPosition?: "latest" }} request
|
|
38
|
+
*/
|
|
39
|
+
async click(request) {
|
|
40
|
+
if (disposed || request.scope !== scope) return { status: "stale" };
|
|
41
|
+
if (active !== null) return { status: "busy" };
|
|
42
|
+
|
|
43
|
+
const transaction = { generation, scope: request.scope };
|
|
44
|
+
active = transaction;
|
|
45
|
+
try {
|
|
46
|
+
const response = await options.readNeighbors({
|
|
47
|
+
roomId: request.roomId,
|
|
48
|
+
anchorEventId: request.anchorEventId,
|
|
49
|
+
anchorPosition: request.anchorPosition,
|
|
50
|
+
});
|
|
51
|
+
if (
|
|
52
|
+
disposed ||
|
|
53
|
+
active !== transaction ||
|
|
54
|
+
generation !== transaction.generation ||
|
|
55
|
+
scope !== transaction.scope
|
|
56
|
+
) {
|
|
57
|
+
return { status: "stale" };
|
|
58
|
+
}
|
|
59
|
+
const targetEventId = options.selectTarget(response, request.direction);
|
|
60
|
+
return targetEventId
|
|
61
|
+
? { status: "target", response, targetEventId }
|
|
62
|
+
: { status: "no_target", response };
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (
|
|
65
|
+
disposed ||
|
|
66
|
+
active !== transaction ||
|
|
67
|
+
generation !== transaction.generation ||
|
|
68
|
+
scope !== transaction.scope
|
|
69
|
+
) {
|
|
70
|
+
return { status: "stale" };
|
|
71
|
+
}
|
|
72
|
+
return { status: "error", error };
|
|
73
|
+
} finally {
|
|
74
|
+
if (active === transaction) active = null;
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
export const COMPLETED_MESSAGE_NEIGHBOR_SCHEMA_VERSION =
|
|
4
|
+
"routec.p206_completed_turn_final_message_neighbors.v1";
|
|
5
|
+
|
|
6
|
+
/** @typedef {{ event_id: string, routec_stream_seq: number, origin_server_ts: number | null, provider_turn_id: string }} CompletedMessageNeighborEvent */
|
|
7
|
+
/** @typedef {{ anchor_position: "event", event_id: string, routec_stream_seq: number } | { anchor_position: "latest", event_id: null, routec_stream_seq: number | null }} CompletedMessageNeighborAnchor */
|
|
8
|
+
/** @typedef {{ schema_version: typeof COMPLETED_MESSAGE_NEIGHBOR_SCHEMA_VERSION, lookup_source: string, lookup_strategy: string, order_by: "final_candidate_stream_seq", candidate_predicate: "complete_message_notification_candidate", completion_predicate: "complete_message_notification_completion", navigation_index_version: number | null, navigation_index_names: string[], raw_event_scan_limit: null, raw_event_payload_returned: false, message_body_returned: false }} CompletedMessageNeighborProof */
|
|
9
|
+
/** @typedef {{ status: "ok", session_id: string, matrix_room_id: string, anchor: CompletedMessageNeighborAnchor, previous: CompletedMessageNeighborEvent | null, next: CompletedMessageNeighborEvent | null, boundaries: { no_completed_turns: boolean, at_first_completed_message: boolean, at_latest_completed_message: boolean }, proof: CompletedMessageNeighborProof, committed_transcript_truth?: "matrix_room_timeline", routec_completed_message_neighbor_endpoint?: true, completed_message_truth_source?: "complete_message_notification_candidate_and_completion_predicates", raw_event_payload_returned: false, message_body_returned: false }} CompletedMessageNeighborsResponse */
|
|
10
|
+
/** @typedef {{ sessionId?: string, matrixRoomId?: string, anchorEventId?: string | null, anchorPosition?: "latest" | null }} CompletedMessageNeighborExpectedIdentity */
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {unknown} value
|
|
14
|
+
* @returns {value is Record<string, unknown>}
|
|
15
|
+
*/
|
|
16
|
+
function isRecord(value) {
|
|
17
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {unknown} value
|
|
22
|
+
* @returns {value is string}
|
|
23
|
+
*/
|
|
24
|
+
function nonEmptyString(value) {
|
|
25
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {unknown} value
|
|
30
|
+
* @returns {value is number}
|
|
31
|
+
*/
|
|
32
|
+
function isNonnegativeSafeInteger(value) {
|
|
33
|
+
return Number.isSafeInteger(value) && Number(value) >= 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {unknown} value
|
|
38
|
+
* @returns {value is CompletedMessageNeighborEvent}
|
|
39
|
+
*/
|
|
40
|
+
function isNeighbor(value) {
|
|
41
|
+
return (
|
|
42
|
+
isRecord(value) &&
|
|
43
|
+
nonEmptyString(value.event_id) &&
|
|
44
|
+
isNonnegativeSafeInteger(value.routec_stream_seq) &&
|
|
45
|
+
(typeof value.origin_server_ts === "number" ||
|
|
46
|
+
value.origin_server_ts === null) &&
|
|
47
|
+
nonEmptyString(value.provider_turn_id)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {CompletedMessageNeighborsResponse} value
|
|
53
|
+
* @param {CompletedMessageNeighborExpectedIdentity} expected
|
|
54
|
+
*/
|
|
55
|
+
function assertRequestIdentity(value, expected) {
|
|
56
|
+
if (expected.sessionId && value.session_id !== expected.sessionId) {
|
|
57
|
+
throw new Error("Completed-message neighbor Session identity mismatch.");
|
|
58
|
+
}
|
|
59
|
+
if (expected.matrixRoomId && value.matrix_room_id !== expected.matrixRoomId) {
|
|
60
|
+
throw new Error("Completed-message neighbor room identity mismatch.");
|
|
61
|
+
}
|
|
62
|
+
if (expected.anchorEventId) {
|
|
63
|
+
if (
|
|
64
|
+
value.anchor.anchor_position !== "event" ||
|
|
65
|
+
value.anchor.event_id !== expected.anchorEventId
|
|
66
|
+
) {
|
|
67
|
+
throw new Error("Completed-message neighbor anchor identity mismatch.");
|
|
68
|
+
}
|
|
69
|
+
} else if (expected.anchorPosition === "latest") {
|
|
70
|
+
if (
|
|
71
|
+
value.anchor.anchor_position !== "latest" ||
|
|
72
|
+
value.anchor.event_id !== null
|
|
73
|
+
) {
|
|
74
|
+
throw new Error("Completed-message neighbor latest anchor mismatch.");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {unknown} value
|
|
81
|
+
* @param {CompletedMessageNeighborExpectedIdentity} [expected]
|
|
82
|
+
* @returns {CompletedMessageNeighborsResponse}
|
|
83
|
+
*/
|
|
84
|
+
export function assertCompletedMessageNeighborsResponse(value, expected = {}) {
|
|
85
|
+
if (!isRecord(value)) {
|
|
86
|
+
throw new Error("Completed-message neighbor response must be an object.");
|
|
87
|
+
}
|
|
88
|
+
const anchor = value.anchor;
|
|
89
|
+
const boundaries = value.boundaries;
|
|
90
|
+
const proof = value.proof;
|
|
91
|
+
const validAnchor =
|
|
92
|
+
isRecord(anchor) &&
|
|
93
|
+
(anchor.anchor_position === "event" ||
|
|
94
|
+
anchor.anchor_position === "latest") &&
|
|
95
|
+
(typeof anchor.event_id === "string" || anchor.event_id === null) &&
|
|
96
|
+
(isNonnegativeSafeInteger(anchor.routec_stream_seq) ||
|
|
97
|
+
anchor.routec_stream_seq === null) &&
|
|
98
|
+
(anchor.anchor_position === "event"
|
|
99
|
+
? nonEmptyString(anchor.event_id) &&
|
|
100
|
+
isNonnegativeSafeInteger(anchor.routec_stream_seq)
|
|
101
|
+
: anchor.event_id === null);
|
|
102
|
+
const indexNames = isRecord(proof) ? proof.navigation_index_names : null;
|
|
103
|
+
const indexVersion = isRecord(proof)
|
|
104
|
+
? proof.navigation_index_version
|
|
105
|
+
: undefined;
|
|
106
|
+
const validIndexDiagnostics =
|
|
107
|
+
Array.isArray(indexNames) &&
|
|
108
|
+
indexNames.every(nonEmptyString) &&
|
|
109
|
+
new Set(indexNames).size === indexNames.length &&
|
|
110
|
+
(Number.isSafeInteger(indexVersion) || indexVersion === null) &&
|
|
111
|
+
((indexVersion === null && indexNames.length === 0) ||
|
|
112
|
+
(Number.isSafeInteger(indexVersion) && indexNames.length > 0));
|
|
113
|
+
const validProof =
|
|
114
|
+
isRecord(proof) &&
|
|
115
|
+
proof.schema_version === COMPLETED_MESSAGE_NEIGHBOR_SCHEMA_VERSION &&
|
|
116
|
+
nonEmptyString(proof.lookup_source) &&
|
|
117
|
+
nonEmptyString(proof.lookup_strategy) &&
|
|
118
|
+
proof.order_by === "final_candidate_stream_seq" &&
|
|
119
|
+
proof.candidate_predicate === "complete_message_notification_candidate" &&
|
|
120
|
+
proof.completion_predicate === "complete_message_notification_completion" &&
|
|
121
|
+
validIndexDiagnostics &&
|
|
122
|
+
proof.raw_event_scan_limit === null &&
|
|
123
|
+
proof.raw_event_payload_returned === false &&
|
|
124
|
+
proof.message_body_returned === false;
|
|
125
|
+
if (
|
|
126
|
+
value.status !== "ok" ||
|
|
127
|
+
!nonEmptyString(value.session_id) ||
|
|
128
|
+
!nonEmptyString(value.matrix_room_id) ||
|
|
129
|
+
!validAnchor ||
|
|
130
|
+
!(value.previous === null || isNeighbor(value.previous)) ||
|
|
131
|
+
!(value.next === null || isNeighbor(value.next)) ||
|
|
132
|
+
!isRecord(boundaries) ||
|
|
133
|
+
typeof boundaries.no_completed_turns !== "boolean" ||
|
|
134
|
+
typeof boundaries.at_first_completed_message !== "boolean" ||
|
|
135
|
+
typeof boundaries.at_latest_completed_message !== "boolean" ||
|
|
136
|
+
!validProof ||
|
|
137
|
+
!(
|
|
138
|
+
value.committed_transcript_truth === undefined ||
|
|
139
|
+
value.committed_transcript_truth === "matrix_room_timeline"
|
|
140
|
+
) ||
|
|
141
|
+
!(
|
|
142
|
+
value.routec_completed_message_neighbor_endpoint === undefined ||
|
|
143
|
+
value.routec_completed_message_neighbor_endpoint === true
|
|
144
|
+
) ||
|
|
145
|
+
!(
|
|
146
|
+
value.completed_message_truth_source === undefined ||
|
|
147
|
+
value.completed_message_truth_source ===
|
|
148
|
+
"complete_message_notification_candidate_and_completion_predicates"
|
|
149
|
+
) ||
|
|
150
|
+
value.raw_event_payload_returned !== false ||
|
|
151
|
+
value.message_body_returned !== false
|
|
152
|
+
) {
|
|
153
|
+
throw new Error("Completed-message neighbor response failed validation.");
|
|
154
|
+
}
|
|
155
|
+
const validated = /** @type {CompletedMessageNeighborsResponse} */ (value);
|
|
156
|
+
if (
|
|
157
|
+
validated.boundaries.at_first_completed_message !==
|
|
158
|
+
(validated.previous === null) ||
|
|
159
|
+
validated.boundaries.at_latest_completed_message !==
|
|
160
|
+
(validated.next === null) ||
|
|
161
|
+
(boundaries.no_completed_turns &&
|
|
162
|
+
(validated.previous !== null ||
|
|
163
|
+
validated.next !== null ||
|
|
164
|
+
!boundaries.at_first_completed_message ||
|
|
165
|
+
!boundaries.at_latest_completed_message))
|
|
166
|
+
) {
|
|
167
|
+
throw new Error("Completed-message neighbor boundaries are inconsistent.");
|
|
168
|
+
}
|
|
169
|
+
if (validated.anchor.anchor_position === "event") {
|
|
170
|
+
if (
|
|
171
|
+
(validated.previous &&
|
|
172
|
+
validated.previous.routec_stream_seq >=
|
|
173
|
+
validated.anchor.routec_stream_seq) ||
|
|
174
|
+
(validated.next &&
|
|
175
|
+
validated.next.routec_stream_seq <= validated.anchor.routec_stream_seq)
|
|
176
|
+
) {
|
|
177
|
+
throw new Error("Completed-message neighbor ordering is inconsistent.");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
assertRequestIdentity(validated, expected);
|
|
181
|
+
return validated;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {Omit<CompletedMessageNeighborsResponse, "status" | "raw_event_payload_returned" | "message_body_returned">} input
|
|
186
|
+
* @returns {CompletedMessageNeighborsResponse}
|
|
187
|
+
*/
|
|
188
|
+
export function buildCompletedMessageNeighborsResponse(input) {
|
|
189
|
+
return assertCompletedMessageNeighborsResponse(
|
|
190
|
+
{
|
|
191
|
+
status: "ok",
|
|
192
|
+
...input,
|
|
193
|
+
raw_event_payload_returned: false,
|
|
194
|
+
message_body_returned: false,
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
sessionId: input.session_id,
|
|
198
|
+
matrixRoomId: input.matrix_room_id,
|
|
199
|
+
anchorEventId:
|
|
200
|
+
input.anchor.anchor_position === "event" ? input.anchor.event_id : null,
|
|
201
|
+
anchorPosition:
|
|
202
|
+
input.anchor.anchor_position === "latest" ? "latest" : null,
|
|
203
|
+
}
|
|
204
|
+
);
|
|
205
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
|
|
6
|
+
import { createCompletedMessageNeighborClickCoordinator } from "./completed-message-neighbor-click.mjs";
|
|
7
|
+
|
|
8
|
+
const response = (previous, next) => ({ previous, next });
|
|
9
|
+
const selectTarget = (value, direction) => value[direction]?.event_id;
|
|
10
|
+
|
|
11
|
+
function deferred() {
|
|
12
|
+
let resolve;
|
|
13
|
+
let reject;
|
|
14
|
+
const promise = new Promise((yes, no) => {
|
|
15
|
+
resolve = yes;
|
|
16
|
+
reject = no;
|
|
17
|
+
});
|
|
18
|
+
return { promise, resolve, reject };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test("mount, refresh, anchor and 100 local focus/scroll changes do not read", async () => {
|
|
22
|
+
let reads = 0;
|
|
23
|
+
const coordinator = createCompletedMessageNeighborClickCoordinator({
|
|
24
|
+
readNeighbors: async () => {
|
|
25
|
+
reads += 1;
|
|
26
|
+
return response(null, { event_id: "$next" });
|
|
27
|
+
},
|
|
28
|
+
selectTarget,
|
|
29
|
+
});
|
|
30
|
+
coordinator.setScope("session:room");
|
|
31
|
+
for (const signal of ["mount", "anchor-change", "session-data-refresh-after-five-seconds"]) {
|
|
32
|
+
assert.ok(signal);
|
|
33
|
+
assert.equal(coordinator.isLoading(), false);
|
|
34
|
+
}
|
|
35
|
+
for (let index = 0; index < 100; index += 1) {
|
|
36
|
+
// These represent timeline-local anchor/focus/scroll changes. The
|
|
37
|
+
// coordinator intentionally exposes no background-read operation.
|
|
38
|
+
assert.equal(coordinator.isLoading(), false);
|
|
39
|
+
}
|
|
40
|
+
assert.equal(reads, 0);
|
|
41
|
+
|
|
42
|
+
const result = await coordinator.click({
|
|
43
|
+
scope: "session:room",
|
|
44
|
+
roomId: "room",
|
|
45
|
+
direction: "next",
|
|
46
|
+
anchorPosition: "latest",
|
|
47
|
+
});
|
|
48
|
+
assert.equal(result.status, "target");
|
|
49
|
+
assert.equal(result.targetEventId, "$next");
|
|
50
|
+
assert.equal(reads, 1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("each direction uses exactly one request and preserves click-time anchor", async () => {
|
|
54
|
+
const requests = [];
|
|
55
|
+
const coordinator = createCompletedMessageNeighborClickCoordinator({
|
|
56
|
+
readNeighbors: async (request) => {
|
|
57
|
+
requests.push(request);
|
|
58
|
+
return response({ event_id: "$previous" }, { event_id: "$next" });
|
|
59
|
+
},
|
|
60
|
+
selectTarget,
|
|
61
|
+
});
|
|
62
|
+
coordinator.setScope("session:room");
|
|
63
|
+
const previous = await coordinator.click({
|
|
64
|
+
scope: "session:room",
|
|
65
|
+
roomId: "room",
|
|
66
|
+
direction: "previous",
|
|
67
|
+
anchorEventId: "$visible-anchor",
|
|
68
|
+
});
|
|
69
|
+
const next = await coordinator.click({
|
|
70
|
+
scope: "session:room",
|
|
71
|
+
roomId: "room",
|
|
72
|
+
direction: "next",
|
|
73
|
+
anchorPosition: "latest",
|
|
74
|
+
});
|
|
75
|
+
assert.deepEqual(
|
|
76
|
+
[previous.status, previous.targetEventId, next.status, next.targetEventId],
|
|
77
|
+
["target", "$previous", "target", "$next"]
|
|
78
|
+
);
|
|
79
|
+
assert.deepEqual(requests, [
|
|
80
|
+
{
|
|
81
|
+
roomId: "room",
|
|
82
|
+
anchorEventId: "$visible-anchor",
|
|
83
|
+
anchorPosition: undefined,
|
|
84
|
+
},
|
|
85
|
+
{ roomId: "room", anchorEventId: undefined, anchorPosition: "latest" },
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("double click is rejected while the first transaction owns its response", async () => {
|
|
90
|
+
const pending = deferred();
|
|
91
|
+
let reads = 0;
|
|
92
|
+
const coordinator = createCompletedMessageNeighborClickCoordinator({
|
|
93
|
+
readNeighbors: () => {
|
|
94
|
+
reads += 1;
|
|
95
|
+
return pending.promise;
|
|
96
|
+
},
|
|
97
|
+
selectTarget,
|
|
98
|
+
});
|
|
99
|
+
coordinator.setScope("session:room");
|
|
100
|
+
const first = coordinator.click({
|
|
101
|
+
scope: "session:room",
|
|
102
|
+
roomId: "room",
|
|
103
|
+
direction: "next",
|
|
104
|
+
anchorPosition: "latest",
|
|
105
|
+
});
|
|
106
|
+
const second = await coordinator.click({
|
|
107
|
+
scope: "session:room",
|
|
108
|
+
roomId: "room",
|
|
109
|
+
direction: "next",
|
|
110
|
+
anchorPosition: "latest",
|
|
111
|
+
});
|
|
112
|
+
assert.equal(second.status, "busy");
|
|
113
|
+
assert.equal(reads, 1);
|
|
114
|
+
pending.resolve(response(null, { event_id: "$fixed" }));
|
|
115
|
+
assert.equal((await first).targetEventId, "$fixed");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("no-result and error release the next click", async () => {
|
|
119
|
+
const replies = [
|
|
120
|
+
response(null, null),
|
|
121
|
+
new Error("offline"),
|
|
122
|
+
response(null, { event_id: "$new" }),
|
|
123
|
+
];
|
|
124
|
+
let reads = 0;
|
|
125
|
+
const coordinator = createCompletedMessageNeighborClickCoordinator({
|
|
126
|
+
readNeighbors: async () => {
|
|
127
|
+
const reply = replies[reads++];
|
|
128
|
+
if (reply instanceof Error) throw reply;
|
|
129
|
+
return reply;
|
|
130
|
+
},
|
|
131
|
+
selectTarget,
|
|
132
|
+
});
|
|
133
|
+
coordinator.setScope("session:room");
|
|
134
|
+
const click = () =>
|
|
135
|
+
coordinator.click({
|
|
136
|
+
scope: "session:room",
|
|
137
|
+
roomId: "room",
|
|
138
|
+
direction: "next",
|
|
139
|
+
anchorPosition: "latest",
|
|
140
|
+
});
|
|
141
|
+
assert.equal((await click()).status, "no_target");
|
|
142
|
+
assert.equal((await click()).status, "error");
|
|
143
|
+
const recovered = await click();
|
|
144
|
+
assert.equal(recovered.status, "target");
|
|
145
|
+
assert.equal(recovered.targetEventId, "$new");
|
|
146
|
+
assert.equal(reads, 3);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("scope change rejects a late response and never substitutes a new target", async () => {
|
|
150
|
+
const pending = deferred();
|
|
151
|
+
const coordinator = createCompletedMessageNeighborClickCoordinator({
|
|
152
|
+
readNeighbors: () => pending.promise,
|
|
153
|
+
selectTarget,
|
|
154
|
+
});
|
|
155
|
+
coordinator.setScope("old-session:old-room");
|
|
156
|
+
const oldClick = coordinator.click({
|
|
157
|
+
scope: "old-session:old-room",
|
|
158
|
+
roomId: "old-room",
|
|
159
|
+
direction: "next",
|
|
160
|
+
anchorPosition: "latest",
|
|
161
|
+
});
|
|
162
|
+
coordinator.setScope("new-session:new-room");
|
|
163
|
+
pending.resolve(response(null, { event_id: "$old-target" }));
|
|
164
|
+
assert.equal((await oldClick).status, "stale");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("RoomTimeline keeps the established focus and cold-load paths", () => {
|
|
168
|
+
const clientRoot = join(import.meta.dirname, "..");
|
|
169
|
+
const source = readFileSync(
|
|
170
|
+
join(clientRoot, "web-chat/src/app/features/room/RoomTimeline.tsx"),
|
|
171
|
+
"utf8"
|
|
172
|
+
);
|
|
173
|
+
assert.match(source, /requestOysterunActiveRoomTimelineFocus\(\{/);
|
|
174
|
+
assert.match(source, /void handleOpenEvent\(targetEventId\)/);
|
|
175
|
+
assert.match(source, /loadEventTimeline\(evtId\)/);
|
|
176
|
+
assert.match(source, /getOysterunHostSessionChatFocusPath\(targetEventId\)/);
|
|
177
|
+
assert.doesNotMatch(source, /debouncedRequestRouteCHostOwnerNeighbors/);
|
|
178
|
+
assert.doesNotMatch(source, /routeCHostOwnerNeighborPendingRequestRef/);
|
|
179
|
+
assert.doesNotMatch(source, /routeCHostOwnerNeighborInvalidationSeq/);
|
|
180
|
+
});
|