@jskit-ai/assistant-runtime 0.1.193 → 0.1.195
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/fixtures/canonical-conversation/App.vue +188 -0
- package/fixtures/canonical-conversation/DraftConversation.vue +32 -0
- package/fixtures/canonical-conversation/index.html +1 -0
- package/fixtures/canonical-conversation/main.js +61 -0
- package/fixtures/canonical-conversation/socket.js +50 -0
- package/fixtures/canonical-conversation/vite.config.mjs +178 -0
- package/fixtures/conversation/vite.config.mjs +12 -2
- package/package.json +23 -10
- package/src/client/components/AssistantConversationClientElement.vue +57 -0
- package/src/client/composables/useAssistantConversation.js +874 -0
- package/src/client/index.js +2 -0
- package/src/client/support/assistantRuntimeState.js +1 -1
- package/src/client/support/subscribeAssistantConversation.js +205 -0
- package/src/server/AssistantProvider.js +82 -106
- package/src/server/actionIds.js +10 -0
- package/src/server/actions.js +287 -79
- package/src/server/createAssistantRuntime.js +138 -0
- package/src/server/createAssistantSettingsActions.js +89 -0
- package/src/server/index.js +6 -0
- package/src/server/inputSchemas.js +90 -0
- package/src/server/registerConversationSubscriptions.js +163 -0
- package/src/server/registerFastifyConversations.js +76 -0
- package/src/server/registerRoutes.js +220 -198
- package/src/server/registerSettingsRoutes.js +113 -0
- package/src/server/repositories/conversationsRepository.js +12 -5
- package/src/server/repositories/messagesRepository.js +12 -0
- package/src/server/services/chatService.js +88 -694
- package/src/server/services/transcriptService.js +56 -52
- package/src/server/sqlConversationStorage.js +192 -0
- package/src/server/support/assistantRouteContext.js +91 -0
- package/src/server/support/createSurfaceAwareToolCatalog.js +6 -2
- package/src/shared/assistantSurfaces.js +14 -0
- package/src/shared/conversationRealtime.js +9 -0
- package/src/shared/index.js +1 -0
- package/test/actionDefinitions.test.js +44 -0
- package/test/aiConnections.test.js +1 -1
- package/test/assistantConversationBinding.browser.test.js +1868 -0
- package/test/assistantConversationOperations.test.js +459 -0
- package/test/assistantConversationSubscriptions.test.js +692 -0
- package/test/assistantIntegrationBoundary.test.js +103 -0
- package/test/assistantRuntimeComposition.test.js +68 -0
- package/test/assistantSurfacePattern.test.js +1 -1
- package/test/assistantSurfaces.test.js +23 -0
- package/test/chatServiceLifecycle.test.js +12 -1
- package/test/featureRuntime.test.js +238 -0
- package/test/registerFastifyConversations.test.js +169 -0
- package/test/sqlConversationStorage.test.js +205 -0
- package/test/support/memoryAssistantRows.js +51 -0
- package/test/turnRequests.test.js +11 -2
- package/src/shared/assistantResponseText.js +0 -29
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { EventEmitter, once } from "node:events";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import { createActionCatalogue } from "@jskit-ai/kernel/server/actions";
|
|
9
|
+
import { createCapabilityRuntime, defineProvider } from "@jskit-ai/kernel/shared/capabilities";
|
|
10
|
+
import { EventProvider } from "@jskit-ai/kernel/server/runtime";
|
|
11
|
+
import { createConversationRuntime, createFileConversationStorage } from "@jskit-ai/assistant-core/server/conversation";
|
|
12
|
+
import { createAuthActionContextContributor } from "../../auth-core/src/server/lib/actionContextContributor.js";
|
|
13
|
+
import { RealtimeProvider } from "../../realtime/src/server/RealtimeProvider.js";
|
|
14
|
+
import { createSocketIoClient } from "../../realtime/src/client/runtime.js";
|
|
15
|
+
import { attachSocketConnectionRecovery } from "../../realtime/src/client/connectionRecovery.js";
|
|
16
|
+
import { createAssistantActions } from "../src/server/actions.js";
|
|
17
|
+
import { actionIds } from "../src/server/actionIds.js";
|
|
18
|
+
import { registerConversationSubscriptions } from "../src/server/registerConversationSubscriptions.js";
|
|
19
|
+
import { subscribeAssistantConversation } from "../src/client/support/subscribeAssistantConversation.js";
|
|
20
|
+
import {
|
|
21
|
+
ASSISTANT_CONVERSATION_EVENT,
|
|
22
|
+
ASSISTANT_CONVERSATION_SUBSCRIBE,
|
|
23
|
+
ASSISTANT_CONVERSATION_UNSUBSCRIBE
|
|
24
|
+
} from "../src/shared/conversationRealtime.js";
|
|
25
|
+
|
|
26
|
+
const config = {
|
|
27
|
+
surfaceDefinitions: { home: { enabled: true, requiresWorkspace: false } },
|
|
28
|
+
assistantSurfaces: { home: { settingsSurfaceId: "home", configScope: "global" } }
|
|
29
|
+
};
|
|
30
|
+
const logger = { debug() {}, info() {}, warn() {}, error() {} };
|
|
31
|
+
|
|
32
|
+
async function until(changes, predicate) {
|
|
33
|
+
while (!predicate()) await once(changes, "change", { signal: AbortSignal.timeout(8_000) });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function requestSubscription(socket, input) {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
socket.timeout(5_000).emit(ASSISTANT_CONVERSATION_SUBSCRIBE, input, (error, result) => {
|
|
39
|
+
if (error) reject(error);
|
|
40
|
+
else resolve(result);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function fixture(t) {
|
|
46
|
+
const directory = await mkdtemp(join(tmpdir(), "assistant-subscriptions-"));
|
|
47
|
+
const changes = new EventEmitter();
|
|
48
|
+
const notify = () => changes.emit("change");
|
|
49
|
+
const sessions = new Map([["owner-session", { id: "42" }], ["other-session", { id: "43" }]]);
|
|
50
|
+
const revoked = new Set();
|
|
51
|
+
const ids = Array.from({ length: 5 }, (_, index) => `chat:${index + 1}`);
|
|
52
|
+
const fetches = [];
|
|
53
|
+
const runtimeEvents = [];
|
|
54
|
+
const authRequests = [];
|
|
55
|
+
const nextReadBarriers = new Map();
|
|
56
|
+
const clientSessions = new WeakMap();
|
|
57
|
+
const context = { actor: { id: "42" }, surface: "home", channel: "internal" };
|
|
58
|
+
const runtime = createConversationRuntime({
|
|
59
|
+
storage: createFileConversationStorage({ directory }),
|
|
60
|
+
authorize: ({ context: current, conversationId, operation }) => current.actor?.id === "42" &&
|
|
61
|
+
ids.includes(conversationId) && (operation !== "subscribe" || !revoked.has(conversationId)),
|
|
62
|
+
connections: { async resolve() {
|
|
63
|
+
return { providerId: "test", model: "test-model", sdkPackage: "@ai-sdk/openai-compatible",
|
|
64
|
+
apiKey: "test", baseURL: "http://test.invalid/v1" };
|
|
65
|
+
} },
|
|
66
|
+
fetch: async (_url, request) => {
|
|
67
|
+
const encoder = new TextEncoder();
|
|
68
|
+
let output;
|
|
69
|
+
const body = new ReadableStream({ start(controller) { output = controller; } });
|
|
70
|
+
const push = (delta, finish_reason = null) => output.enqueue(encoder.encode(`data: ${JSON.stringify({
|
|
71
|
+
id: "reply", object: "chat.completion.chunk", created: 1, model: "test-model",
|
|
72
|
+
choices: [{ index: 0, delta, finish_reason }]
|
|
73
|
+
})}\n\n`));
|
|
74
|
+
request.signal.addEventListener("abort", () => { try { output.error(request.signal.reason); } catch {} }, { once: true });
|
|
75
|
+
fetches.push({ signal: request.signal, text: text => push({ content: text }),
|
|
76
|
+
finish() { push({}, "stop"); output.close(); } });
|
|
77
|
+
notify();
|
|
78
|
+
return new Response(body, { headers: { "content-type": "text/event-stream" } });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
const conversations = new Map();
|
|
82
|
+
for (const id of ids) {
|
|
83
|
+
const conversation = await runtime.open({ id, context, configuration: { systemPrompt: "Answer briefly.", integrationId: "test" } });
|
|
84
|
+
await conversation.subscribe(event => { runtimeEvents.push(event); notify(); });
|
|
85
|
+
conversations.set(id, conversation);
|
|
86
|
+
}
|
|
87
|
+
let active = 0;
|
|
88
|
+
const actions = createActionCatalogue();
|
|
89
|
+
const contributor = createAuthActionContextContributor();
|
|
90
|
+
actions.registerContextContributor({ id: contributor.contributorId, contribute: contributor.contribute });
|
|
91
|
+
actions.register({ contributorId: "test.assistant", domain: "assistant", actions: createAssistantActions({ config,
|
|
92
|
+
conversationRuntime: { async open(input) {
|
|
93
|
+
const conversation = await runtime.open(input);
|
|
94
|
+
return { ...conversation, async read() {
|
|
95
|
+
const barrier = nextReadBarriers.get(input.id);
|
|
96
|
+
if (barrier) {
|
|
97
|
+
nextReadBarriers.delete(input.id);
|
|
98
|
+
barrier.started = true;
|
|
99
|
+
notify();
|
|
100
|
+
await barrier.promise;
|
|
101
|
+
}
|
|
102
|
+
return conversation.read();
|
|
103
|
+
}, async subscribe(listener) {
|
|
104
|
+
const release = await conversation.subscribe(listener);
|
|
105
|
+
let released = false;
|
|
106
|
+
active += 1;
|
|
107
|
+
notify();
|
|
108
|
+
return () => {
|
|
109
|
+
if (released) return;
|
|
110
|
+
released = true;
|
|
111
|
+
release();
|
|
112
|
+
active -= 1;
|
|
113
|
+
notify();
|
|
114
|
+
};
|
|
115
|
+
} };
|
|
116
|
+
} }
|
|
117
|
+
}) });
|
|
118
|
+
const http = createServer();
|
|
119
|
+
let realtime;
|
|
120
|
+
let events;
|
|
121
|
+
const probe = defineProvider({
|
|
122
|
+
id: "test.assistant.realtime",
|
|
123
|
+
requires: { realtime: "runtime.realtime", events: "runtime.events" },
|
|
124
|
+
setup(dependencies) { ({ realtime, events } = dependencies); return {}; }
|
|
125
|
+
});
|
|
126
|
+
const transport = createCapabilityRuntime({ providers: [EventProvider, RealtimeProvider, probe], inputs: {
|
|
127
|
+
"runtime.config": {}, "runtime.env": {}, "runtime.fastify": { server: http }, "runtime.logger": logger,
|
|
128
|
+
"auth.service": {
|
|
129
|
+
realtime: { requireAuthentication: true },
|
|
130
|
+
async authenticateRequest(request) {
|
|
131
|
+
authRequests.push(request);
|
|
132
|
+
const actor = sessions.get(request.cookies.session);
|
|
133
|
+
return { authenticated: Boolean(actor), actor };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} });
|
|
137
|
+
await transport.start();
|
|
138
|
+
const serverSockets = new Set();
|
|
139
|
+
const stopProbe = realtime.onConnection(({ socket }) => {
|
|
140
|
+
serverSockets.add(socket);
|
|
141
|
+
return () => { serverSockets.delete(socket); notify(); };
|
|
142
|
+
});
|
|
143
|
+
const stopSubscriptions = registerConversationSubscriptions({ realtime, events, actions, config });
|
|
144
|
+
const published = [];
|
|
145
|
+
events.register({ id: "test.assistant.published", handle(event) { published.push(event); notify(); } });
|
|
146
|
+
http.listen(0, "127.0.0.1");
|
|
147
|
+
await once(http, "listening");
|
|
148
|
+
const clients = [];
|
|
149
|
+
const cleanups = [];
|
|
150
|
+
t.after(async () => {
|
|
151
|
+
for (const cleanup of cleanups) cleanup();
|
|
152
|
+
for (const client of clients) client.disconnect();
|
|
153
|
+
stopSubscriptions();
|
|
154
|
+
stopProbe();
|
|
155
|
+
await transport.shutdown();
|
|
156
|
+
await runtime.close();
|
|
157
|
+
await rm(directory, { recursive: true, force: true });
|
|
158
|
+
});
|
|
159
|
+
async function connect(session = "owner-session", { recovery = false } = {}) {
|
|
160
|
+
const socket = createSocketIoClient({ url: `http://127.0.0.1:${http.address().port}`, options: {
|
|
161
|
+
transports: ["websocket"], extraHeaders: { Cookie: `session=${session}` }, autoConnect: false
|
|
162
|
+
} });
|
|
163
|
+
clients.push(socket);
|
|
164
|
+
clientSessions.set(socket, session);
|
|
165
|
+
if (recovery) cleanups.push(attachSocketConnectionRecovery(socket));
|
|
166
|
+
await new Promise((resolve, reject) => {
|
|
167
|
+
const timer = setTimeout(() => finish(new Error("Socket connection timed out.")), 5_000);
|
|
168
|
+
const connected = () => finish();
|
|
169
|
+
function finish(error) {
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
socket.off("connect", connected);
|
|
172
|
+
socket.off("connect_error", finish);
|
|
173
|
+
if (error) reject(error);
|
|
174
|
+
else resolve();
|
|
175
|
+
}
|
|
176
|
+
socket.once("connect", connected);
|
|
177
|
+
socket.once("connect_error", finish);
|
|
178
|
+
socket.connect();
|
|
179
|
+
});
|
|
180
|
+
return socket;
|
|
181
|
+
}
|
|
182
|
+
function view(socket, id) {
|
|
183
|
+
const states = [], received = [], errors = [];
|
|
184
|
+
const dispose = subscribeAssistantConversation({ socket, conversationId: id, targetSurfaceId: "home", hostSurfaceId: "home",
|
|
185
|
+
read() {
|
|
186
|
+
return actions.execute({ actionId: actionIds.conversationRead,
|
|
187
|
+
input: { targetSurfaceId: "home", conversationId: id },
|
|
188
|
+
context: { ...context, actor: sessions.get(clientSessions.get(socket)) || null } });
|
|
189
|
+
},
|
|
190
|
+
onState(state) { states.push(state); notify(); },
|
|
191
|
+
onEvent(event) { received.push(event); notify(); },
|
|
192
|
+
onError(error) { errors.push(error); notify(); }
|
|
193
|
+
});
|
|
194
|
+
cleanups.push(dispose);
|
|
195
|
+
return { states, received, errors, dispose };
|
|
196
|
+
}
|
|
197
|
+
function holdNextRead(id) {
|
|
198
|
+
const barrier = { ...Promise.withResolvers(), started: false };
|
|
199
|
+
nextReadBarriers.set(id, barrier);
|
|
200
|
+
cleanups.push(barrier.resolve);
|
|
201
|
+
return barrier;
|
|
202
|
+
}
|
|
203
|
+
return { ids, connect, view, changes, sessions, revoked, fetches, conversations, runtimeEvents,
|
|
204
|
+
realtime, transport, serverSockets, published, events, stopSubscriptions, authRequests, holdNextRead, active: () => active };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
test("five chats share one authenticated socket and release independently without stopping turns", { timeout: 20_000 }, async t => {
|
|
208
|
+
const f = await fixture(t);
|
|
209
|
+
const socket = await f.connect();
|
|
210
|
+
const views = f.ids.map(id => f.view(socket, id));
|
|
211
|
+
await until(f.changes, () => views.every(view => view.states.length));
|
|
212
|
+
assert.equal(f.realtime.diagnostics().connectedClients, 1);
|
|
213
|
+
assert.equal(f.active(), 5);
|
|
214
|
+
assert.deepEqual(views.map(view => view.states[0].id), f.ids);
|
|
215
|
+
for (const [index, id] of f.ids.slice(0, 2).entries()) {
|
|
216
|
+
await f.conversations.get(id).send({ messageId: `question-${index + 1}`, text: id });
|
|
217
|
+
}
|
|
218
|
+
await until(f.changes, () => f.fetches.length === 2);
|
|
219
|
+
f.fetches[0].text("First chat is still running.");
|
|
220
|
+
f.fetches[1].text("Second chat is still running.");
|
|
221
|
+
await until(f.changes, () => views.slice(0, 2).every(view => view.received.some(event => event.type === "message")));
|
|
222
|
+
const before = views[0].received.length;
|
|
223
|
+
views[0].dispose();
|
|
224
|
+
await until(f.changes, () => f.active() === 4);
|
|
225
|
+
assert.equal(socket.connected, true);
|
|
226
|
+
assert.equal(f.fetches[0].signal.aborted, false);
|
|
227
|
+
assert.equal(f.fetches[1].signal.aborted, false);
|
|
228
|
+
f.fetches[0].text(" It continues after its view closes.");
|
|
229
|
+
f.fetches[1].text(" Other views still receive output.");
|
|
230
|
+
await until(f.changes, () => views[1].received.some(event => event.text?.includes("Other views")));
|
|
231
|
+
assert.equal(views[0].received.length, before);
|
|
232
|
+
assert.ok(views[1].received.every(event => event.conversationId === f.ids[1]));
|
|
233
|
+
socket.disconnect();
|
|
234
|
+
await until(f.changes, () => f.active() === 0);
|
|
235
|
+
assert.equal(f.fetches[0].signal.aborted, false);
|
|
236
|
+
assert.equal((await f.conversations.get(f.ids[1]).read()).status, "working");
|
|
237
|
+
assert.ok(views.every(view => view.errors.length === 0));
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("reconnect reauthorizes and reads current state without repeating an admitted submission", { timeout: 20_000 }, async t => {
|
|
241
|
+
const f = await fixture(t);
|
|
242
|
+
const socket = await f.connect("owner-session", { recovery: true });
|
|
243
|
+
const view = f.view(socket, f.ids[0]);
|
|
244
|
+
await until(f.changes, () => view.states.length === 1);
|
|
245
|
+
await f.conversations.get(f.ids[0]).send({ messageId: "accepted-once", text: "A long answer" });
|
|
246
|
+
await until(f.changes, () => f.fetches.length === 1);
|
|
247
|
+
f.fetches[0].text("Before disconnection.");
|
|
248
|
+
await until(f.changes, () => view.received.some(event => event.type === "message"));
|
|
249
|
+
const previousEpochEvent = structuredClone(f.published.findLast(event => event.realtime?.payload?.event?.type === "message"));
|
|
250
|
+
const beforeId = socket.id;
|
|
251
|
+
const disconnected = once(socket, "disconnect");
|
|
252
|
+
[...f.serverSockets][0].disconnect(true);
|
|
253
|
+
await disconnected;
|
|
254
|
+
await until(f.changes, () => f.active() === 0);
|
|
255
|
+
f.fetches[0].text(" Output continued while disconnected.");
|
|
256
|
+
await until(f.changes, () => f.runtimeEvents.some(event => event.text?.includes("continued while disconnected")));
|
|
257
|
+
await until(f.changes, () => socket.id !== beforeId && f.active() === 1 &&
|
|
258
|
+
view.states.at(-1)?.streaming.messages[0]?.text.includes("continued while disconnected"));
|
|
259
|
+
assert.notEqual(socket.id, beforeId);
|
|
260
|
+
assert.equal(f.realtime.diagnostics().connectedClients, 1);
|
|
261
|
+
assert.equal(f.active(), 1);
|
|
262
|
+
assert.equal(f.fetches.length, 1, "Reconnect never resends the accepted message");
|
|
263
|
+
assert.equal(view.states.at(-1).conversationLog[0].user.messageId, "accepted-once");
|
|
264
|
+
assert.match(view.states.at(-1).streaming.messages[0].text, /continued while disconnected/);
|
|
265
|
+
assert.ok(f.authRequests.every(request => request.cookies.session === "owner-session"));
|
|
266
|
+
f.fetches[0].text(" And continued after reconnect.");
|
|
267
|
+
await until(f.changes, () => view.received.some(event => event.text?.includes("after reconnect")));
|
|
268
|
+
const currentEvent = structuredClone(f.published.findLast(event => event.realtime?.payload?.event?.type === "message"));
|
|
269
|
+
const receivedCount = view.received.length;
|
|
270
|
+
for (const stale of [previousEpochEvent, currentEvent]) {
|
|
271
|
+
stale.realtime.audience = { room: [...f.serverSockets][0].id };
|
|
272
|
+
if (stale === currentEvent) stale.realtime.payload.streamRevision -= 1;
|
|
273
|
+
const received = once(socket, ASSISTANT_CONVERSATION_EVENT);
|
|
274
|
+
await f.events.publish(stale);
|
|
275
|
+
await received;
|
|
276
|
+
assert.equal(view.received.length, receivedCount, "An old epoch or revision cannot rewind the live reply");
|
|
277
|
+
}
|
|
278
|
+
assert.equal(f.fetches[0].signal.aborted, false);
|
|
279
|
+
assert.deepEqual(view.errors, []);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("a completed answer in the initial read cannot be replaced by buffered partial output", { timeout: 20_000 }, async t => {
|
|
283
|
+
const f = await fixture(t);
|
|
284
|
+
const socket = await f.connect();
|
|
285
|
+
const barrier = f.holdNextRead(f.ids[0]);
|
|
286
|
+
const packets = [];
|
|
287
|
+
socket.on(ASSISTANT_CONVERSATION_EVENT, packet => { packets.push(packet); f.changes.emit("change"); });
|
|
288
|
+
const view = f.view(socket, f.ids[0]);
|
|
289
|
+
await until(f.changes, () => barrier.started);
|
|
290
|
+
await f.conversations.get(f.ids[0]).send({ messageId: "before-snapshot", text: "Finish before the initial read" });
|
|
291
|
+
await until(f.changes, () => f.fetches.length === 1);
|
|
292
|
+
f.fetches[0].text("An early partial");
|
|
293
|
+
await until(f.changes, () => packets.some(packet => packet.event.type === "message"));
|
|
294
|
+
f.fetches[0].text(" becomes the complete answer.");
|
|
295
|
+
f.fetches[0].finish();
|
|
296
|
+
await f.conversations.get(f.ids[0]).wait();
|
|
297
|
+
await until(f.changes, () => packets.some(packet => packet.event.type === "settled"));
|
|
298
|
+
assert.equal(view.states.length, 0, "The subscription read is still held while realtime packets arrive");
|
|
299
|
+
barrier.resolve();
|
|
300
|
+
await until(f.changes, () => view.received.some(event => event.type === "settled"));
|
|
301
|
+
for (const state of view.states) {
|
|
302
|
+
const answers = state.turns.flatMap(turn => turn.messages).filter(message => message.role === "assistant");
|
|
303
|
+
assert.equal(answers.length, 1);
|
|
304
|
+
assert.equal(answers[0].text, "An early partial becomes the complete answer.");
|
|
305
|
+
assert.deepEqual(state.streaming.messages, []);
|
|
306
|
+
}
|
|
307
|
+
assert.equal(view.received.some(event => event.type === "message" && event.status === "inProgress"), true,
|
|
308
|
+
"The race actually delivers older notifications after the completed initial state");
|
|
309
|
+
assert.deepEqual(view.errors, []);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("stream snapshots preserve completed commentary and coalesce lifecycle reads without reading for text chunks", async t => {
|
|
313
|
+
const changes = new EventEmitter();
|
|
314
|
+
const socket = new EventEmitter();
|
|
315
|
+
socket.connected = true;
|
|
316
|
+
const user = { messageId: "question", role: "user", text: "Question" };
|
|
317
|
+
const initial = { id: "chat", status: "working", conversationLog: [
|
|
318
|
+
{ turnId: "turn", user, messages: [user], metadata: { runtime: { status: "running" } } }
|
|
319
|
+
], streaming: { revision: 0, messages: [] } };
|
|
320
|
+
let subscriptionId;
|
|
321
|
+
let revision = 0;
|
|
322
|
+
socket.timeout = () => ({ emit(_name, input, acknowledge) {
|
|
323
|
+
subscriptionId = input.subscriptionId;
|
|
324
|
+
acknowledge(null, { ok: true, streamEpoch: "epoch", state: initial });
|
|
325
|
+
} });
|
|
326
|
+
const reads = [], states = [], errors = [];
|
|
327
|
+
const dispose = subscribeAssistantConversation({ socket, conversationId: "chat", targetSurfaceId: "home", hostSurfaceId: "home",
|
|
328
|
+
read() { const read = Promise.withResolvers(); reads.push(read); changes.emit("change"); return read.promise; },
|
|
329
|
+
onState(state) { states.push(state); changes.emit("change"); },
|
|
330
|
+
onError(error) { errors.push(error); changes.emit("change"); }
|
|
331
|
+
});
|
|
332
|
+
t.after(dispose);
|
|
333
|
+
function deliver(event) {
|
|
334
|
+
socket.emit(ASSISTANT_CONVERSATION_EVENT, { subscriptionId, conversationId: "chat", streamEpoch: "epoch",
|
|
335
|
+
streamRevision: ++revision, event: { conversationId: "chat", turnId: "turn", ...event } });
|
|
336
|
+
}
|
|
337
|
+
function messages() { return states.at(-1).turns.flatMap(turn => turn.messages); }
|
|
338
|
+
const progress = { messageId: "progress", role: "commentary", text: "I checked the source.", status: "complete", origin: "user" };
|
|
339
|
+
const answer = { messageId: "answer", role: "assistant", text: "The answer is arriving.", status: "inProgress", turnId: "turn", origin: "user" };
|
|
340
|
+
deliver({ type: "message", ...progress, streaming: { revision: 1, messages: [] } });
|
|
341
|
+
deliver({ type: "message", ...answer, streaming: { revision: 2, messages: [answer] } });
|
|
342
|
+
assert.deepEqual(messages().map(message => message.text), [user.text, progress.text, answer.text]);
|
|
343
|
+
assert.equal(messages()[1].turnId, "turn", "Completed overlays retain the authored turn identity");
|
|
344
|
+
assert.equal(messages()[1].origin, "user", "Completed overlays retain the request origin");
|
|
345
|
+
assert.equal(states.at(-1).turns.length, 1);
|
|
346
|
+
assert.equal(reads.length, 0, "Text and completed commentary use their existing stream snapshots");
|
|
347
|
+
|
|
348
|
+
deliver({ type: "phase", phase: "working" });
|
|
349
|
+
await until(changes, () => reads.length === 1);
|
|
350
|
+
deliver({ type: "phase", phase: "compacting" });
|
|
351
|
+
deliver({ type: "goal", goal: { text: "Keep going" } });
|
|
352
|
+
const stale = { ...initial, streaming: { revision: 1, messages: [] } };
|
|
353
|
+
reads[0].resolve(stale);
|
|
354
|
+
await until(changes, () => reads.length === 2);
|
|
355
|
+
assert.equal(states.at(-1).streaming.revision, 2, "A read started before live output cannot rewind its snapshot");
|
|
356
|
+
assert.deepEqual(messages().map(message => message.text), [user.text, progress.text, answer.text]);
|
|
357
|
+
const before = states.length;
|
|
358
|
+
reads[1].resolve(stale);
|
|
359
|
+
await until(changes, () => states.length > before);
|
|
360
|
+
assert.equal(reads.length, 2, "Several lifecycle events during one read queue one further read");
|
|
361
|
+
|
|
362
|
+
const finalAnswer = { ...answer, text: "The complete answer.", status: "complete" };
|
|
363
|
+
deliver({ type: "settled", status: "complete" });
|
|
364
|
+
await until(changes, () => reads.length === 3);
|
|
365
|
+
reads[2].resolve({ ...initial, status: "ready", streaming: { revision: 3, messages: [] }, conversationLog: [
|
|
366
|
+
{ ...initial.conversationLog[0], commentary: [progress], assistant: finalAnswer, messages: [user, progress, finalAnswer],
|
|
367
|
+
metadata: { runtime: { status: "complete" } } }
|
|
368
|
+
] });
|
|
369
|
+
await until(changes, () => states.at(-1).status === "ready");
|
|
370
|
+
assert.deepEqual(messages().map(message => message.messageId), ["question", "progress", "answer"]);
|
|
371
|
+
assert.equal(messages().at(-1).text, "The complete answer.");
|
|
372
|
+
const completedCount = states.length;
|
|
373
|
+
deliver({ type: "message", ...answer, streaming: { revision: 2, messages: [answer] } });
|
|
374
|
+
assert.equal(states.length, completedCount, "Late partial text cannot resurrect a saved answer");
|
|
375
|
+
assert.deepEqual(errors, []);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("original transcript patches survive a read already in flight and the next canonical read remains authoritative", async t => {
|
|
379
|
+
const changes = new EventEmitter();
|
|
380
|
+
const socket = new EventEmitter();
|
|
381
|
+
socket.connected = true;
|
|
382
|
+
const initial = { id: "chat", status: "working", conversationLog: [],
|
|
383
|
+
pagination: { limit: 2 }, streaming: { revision: 0, messages: [] } };
|
|
384
|
+
let subscriptionId;
|
|
385
|
+
let revision = 0;
|
|
386
|
+
socket.timeout = () => ({ emit(_name, input, acknowledge) {
|
|
387
|
+
subscriptionId = input.subscriptionId;
|
|
388
|
+
acknowledge(null, { ok: true, streamEpoch: "epoch", state: initial });
|
|
389
|
+
} });
|
|
390
|
+
const reads = [], states = [], errors = [];
|
|
391
|
+
const dispose = subscribeAssistantConversation({ socket, conversationId: "chat", targetSurfaceId: "home", hostSurfaceId: "home",
|
|
392
|
+
read() { const read = Promise.withResolvers(); reads.push(read); changes.emit("change"); return read.promise; },
|
|
393
|
+
onState(state) { states.push(state); changes.emit("change"); },
|
|
394
|
+
onError(error) { errors.push(error); changes.emit("change"); }
|
|
395
|
+
});
|
|
396
|
+
t.after(dispose);
|
|
397
|
+
function deliver(event) {
|
|
398
|
+
socket.emit(ASSISTANT_CONVERSATION_EVENT, { subscriptionId, conversationId: "chat", streamEpoch: "epoch",
|
|
399
|
+
streamRevision: ++revision, event: { conversationId: "chat", ...event } });
|
|
400
|
+
}
|
|
401
|
+
deliver({ type: "phase", phase: "working" });
|
|
402
|
+
await until(changes, () => reads.length === 1);
|
|
403
|
+
const user = { messageId: "question", role: "user", text: "Keep the original receipt." };
|
|
404
|
+
const thought = { role: "thinking", at: "2026-09-24T07:45:00.000Z", text: "Preparing" };
|
|
405
|
+
const answer = { messageId: "answer", role: "assistant", text: "Saved reply." };
|
|
406
|
+
deliver({ type: "transcript", patch: { type: "upsert-turn", turn: { turnId: "000001", user, thinking: [thought] } } });
|
|
407
|
+
deliver({ type: "transcript", patch: { type: "upsert-turn", turn: { turnId: "000001", user, assistant: answer } } });
|
|
408
|
+
const latestThought = { ...thought, text: "Preparing the final change" };
|
|
409
|
+
deliver({ type: "transcript", patch: { type: "upsert-turn", turn: { turnId: "000001", thinking: [latestThought] } } });
|
|
410
|
+
assert.equal(states.at(-1).turns[0].assistant.text, answer.text, "A partial saved turn cannot erase its final answer");
|
|
411
|
+
assert.deepEqual(states.at(-1).turns[0].thinking, [latestThought], "Timestamped reasoning updates its existing paragraph");
|
|
412
|
+
assert.equal(reads.length, 1, "Delivered turns do not trigger another history request");
|
|
413
|
+
const beforeRead = states.length;
|
|
414
|
+
reads[0].resolve(structuredClone(initial));
|
|
415
|
+
await until(changes, () => states.length > beforeRead);
|
|
416
|
+
assert.equal(states.at(-1).turns[0].user.messageId, user.messageId);
|
|
417
|
+
assert.equal(states.at(-1).turns[0].assistant.text, answer.text);
|
|
418
|
+
assert.deepEqual(states.at(-1).turns[0].thinking, [latestThought]);
|
|
419
|
+
const beforeRefresh = states.length;
|
|
420
|
+
dispose.reload();
|
|
421
|
+
await until(changes, () => reads.length === 2);
|
|
422
|
+
reads[1].resolve(structuredClone(initial));
|
|
423
|
+
await until(changes, () => states.length > beforeRefresh);
|
|
424
|
+
assert.deepEqual(states.at(-1).turns, [], "An independent later canonical read is not patched with old notifications");
|
|
425
|
+
assert.deepEqual(errors, []);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("authenticated presentation updates retain one interim reply beside exact output identities", async t => {
|
|
429
|
+
const changes = new EventEmitter();
|
|
430
|
+
const socket = new EventEmitter();
|
|
431
|
+
socket.connected = true;
|
|
432
|
+
const user = { messageId: "request", role: "user", text: "Question" };
|
|
433
|
+
const initial = { id: "chat", status: "working", interimReply: null, conversationLog: [
|
|
434
|
+
{ turnId: "turn", user, messages: [user], metadata: { runtime: { status: "running" } } }
|
|
435
|
+
], streaming: { revision: 0, messages: [] } };
|
|
436
|
+
let subscriptionId;
|
|
437
|
+
let revision = 0;
|
|
438
|
+
let reads = 0;
|
|
439
|
+
let snapshot = initial;
|
|
440
|
+
const states = [];
|
|
441
|
+
socket.timeout = () => ({ emit(_name, input, acknowledge) {
|
|
442
|
+
subscriptionId = input.subscriptionId;
|
|
443
|
+
acknowledge(null, { ok: true, streamEpoch: "epoch", state: initial });
|
|
444
|
+
} });
|
|
445
|
+
const dispose = subscribeAssistantConversation({ socket, conversationId: "chat", targetSurfaceId: "home", hostSurfaceId: "home",
|
|
446
|
+
async read() { reads += 1; return snapshot; },
|
|
447
|
+
onState(state) { states.push(state); changes.emit("change"); }
|
|
448
|
+
});
|
|
449
|
+
t.after(dispose);
|
|
450
|
+
function deliver(event, envelope = {}) {
|
|
451
|
+
socket.emit(ASSISTANT_CONVERSATION_EVENT, { subscriptionId, conversationId: "chat", streamEpoch: "epoch",
|
|
452
|
+
streamRevision: ++revision, event: { conversationId: "chat", turnId: "turn", ...event }, ...envelope });
|
|
453
|
+
}
|
|
454
|
+
const interimReply = { id: "selected-progress", outputId: "turn:provider-progress", role: "assistant",
|
|
455
|
+
text: "I will check.", status: "completed" };
|
|
456
|
+
const before = states.length;
|
|
457
|
+
deliver({ type: "presentation", interimReply });
|
|
458
|
+
assert.equal(states.length, before + 1);
|
|
459
|
+
assert.deepEqual(states.at(-1).interimReply, interimReply);
|
|
460
|
+
assert.deepEqual(states.at(-1).turns[0].messages, [user], "Transient presentation is not saved history.");
|
|
461
|
+
assert.equal(reads, 0, "A selected presentation scalar needs no transcript read.");
|
|
462
|
+
deliver({ type: "presentation", interimReply: null }, { subscriptionId: "different" });
|
|
463
|
+
deliver({ type: "presentation", interimReply: null }, { streamEpoch: "old" });
|
|
464
|
+
assert.equal(states.length, before + 1, "The existing subscription and epoch checks protect presentation too.");
|
|
465
|
+
|
|
466
|
+
const progress = { messageId: "saved-progress", outputId: interimReply.outputId, role: "commentary",
|
|
467
|
+
text: interimReply.text, status: "complete", origin: "user" };
|
|
468
|
+
deliver({ type: "message", ...progress, interimReply, streaming: { revision: 1, messages: [] } });
|
|
469
|
+
assert.equal(states.at(-1).turns[0].commentary[0].outputId, interimReply.outputId);
|
|
470
|
+
assert.equal(states.at(-1).turns[0].commentary[0].messageId, "saved-progress");
|
|
471
|
+
const answer = { messageId: "live-answer", outputId: "turn:provider-answer", role: "assistant", text: "The answer.",
|
|
472
|
+
status: "inProgress", turnId: "turn", origin: "user" };
|
|
473
|
+
deliver({ type: "message", ...answer, interimReply: null, streaming: { revision: 2, messages: [answer] } });
|
|
474
|
+
assert.equal(states.at(-1).interimReply, null);
|
|
475
|
+
assert.equal(states.at(-1).turns[0].assistant.outputId, answer.outputId);
|
|
476
|
+
assert.equal(reads, 0);
|
|
477
|
+
|
|
478
|
+
const finalAnswer = { ...answer, messageId: "saved-answer", status: "complete" };
|
|
479
|
+
snapshot = { ...initial, status: "ready", interimReply: null, streaming: { revision: 3, messages: [] }, conversationLog: [
|
|
480
|
+
{ ...initial.conversationLog[0], commentary: [progress], assistant: finalAnswer, messages: [user, progress, finalAnswer],
|
|
481
|
+
metadata: { runtime: { status: "complete" } } }
|
|
482
|
+
] };
|
|
483
|
+
deliver({ type: "settled", status: "complete" });
|
|
484
|
+
await until(changes, () => states.at(-1).status === "ready");
|
|
485
|
+
assert.equal(states.at(-1).turns[0].assistant.messageId, "saved-answer");
|
|
486
|
+
assert.equal(states.at(-1).turns[0].assistant.outputId, answer.outputId);
|
|
487
|
+
assert.equal(states.at(-1).interimReply, null);
|
|
488
|
+
assert.equal(reads, 1);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("subscription identity and surface checks reject forged bodies and honor conversation and session revocation", { timeout: 20_000 }, async t => {
|
|
492
|
+
const f = await fixture(t);
|
|
493
|
+
const owner = await f.connect();
|
|
494
|
+
const other = await f.connect("other-session");
|
|
495
|
+
const input = { subscriptionId: "forged", conversationId: f.ids[0], targetSurfaceId: "home", hostSurfaceId: "home",
|
|
496
|
+
actor: { id: "42" }, context: { actor: { id: "42" } }, room: owner.id, host: { nativeTools: true } };
|
|
497
|
+
assert.equal((await requestSubscription(other, input)).status, 403);
|
|
498
|
+
assert.equal((await requestSubscription(owner, { ...input, conversationId: "unknown" })).status, 403);
|
|
499
|
+
assert.equal((await requestSubscription(owner, { ...input, hostSurfaceId: "admin" })).status, 403);
|
|
500
|
+
assert.equal(f.active(), 0);
|
|
501
|
+
const view = f.view(owner, f.ids[0]);
|
|
502
|
+
const otherEvents = [];
|
|
503
|
+
other.on(ASSISTANT_CONVERSATION_EVENT, event => otherEvents.push(event));
|
|
504
|
+
await until(f.changes, () => view.states.length === 1);
|
|
505
|
+
await f.conversations.get(f.ids[0]).send({ messageId: "private-message", text: "Private answer" });
|
|
506
|
+
await until(f.changes, () => f.fetches.length === 1);
|
|
507
|
+
f.fetches[0].text("Allowed output.");
|
|
508
|
+
await until(f.changes, () => view.received.some(event => event.type === "message"));
|
|
509
|
+
assert.equal(otherEvents.length, 0);
|
|
510
|
+
f.revoked.add(f.ids[0]);
|
|
511
|
+
const before = view.received.length;
|
|
512
|
+
f.fetches[0].text(" Secret output after access was revoked.");
|
|
513
|
+
f.fetches[0].finish();
|
|
514
|
+
await f.conversations.get(f.ids[0]).wait();
|
|
515
|
+
assert.equal(view.received.length, before);
|
|
516
|
+
assert.equal(f.published.some(event => event.realtime?.payload?.event?.text?.includes("Secret output")), false);
|
|
517
|
+
const second = f.view(owner, f.ids[1]);
|
|
518
|
+
await until(f.changes, () => second.states.length === 1);
|
|
519
|
+
f.sessions.delete("owner-session");
|
|
520
|
+
const disconnected = once(owner, "disconnect");
|
|
521
|
+
await f.conversations.get(f.ids[1]).send({ messageId: "revoked-session", text: "Session now revoked" });
|
|
522
|
+
await disconnected;
|
|
523
|
+
await until(f.changes, () => f.active() === 0);
|
|
524
|
+
assert.equal(other.connected, true);
|
|
525
|
+
assert.equal(otherEvents.length, 0);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test("removing the integration releases every observer and callback while the shared socket stays available", { timeout: 20_000 }, async t => {
|
|
529
|
+
const f = await fixture(t);
|
|
530
|
+
const socket = await f.connect();
|
|
531
|
+
const view = f.view(socket, f.ids[0]);
|
|
532
|
+
await until(f.changes, () => view.states.length === 1);
|
|
533
|
+
const [serverSocket] = f.serverSockets;
|
|
534
|
+
assert.equal(serverSocket.listenerCount(ASSISTANT_CONVERSATION_SUBSCRIBE), 1);
|
|
535
|
+
f.stopSubscriptions();
|
|
536
|
+
assert.equal(f.active(), 0);
|
|
537
|
+
assert.equal(serverSocket.listenerCount(ASSISTANT_CONVERSATION_SUBSCRIBE), 0);
|
|
538
|
+
assert.equal(socket.connected, true);
|
|
539
|
+
assert.equal(f.realtime.diagnostics().connectedClients, 1);
|
|
540
|
+
await f.transport.shutdown();
|
|
541
|
+
assert.equal(f.serverSockets.size, 0, "Provider shutdown also runs unrelated connection cleanup");
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
async function hostSubscriptionFixture(t, authService = null) {
|
|
545
|
+
const changes = new EventEmitter();
|
|
546
|
+
const notify = () => changes.emit("change");
|
|
547
|
+
const listeners = new Set();
|
|
548
|
+
const calls = [];
|
|
549
|
+
const published = [];
|
|
550
|
+
const clients = [];
|
|
551
|
+
const actions = createActionCatalogue();
|
|
552
|
+
const contributor = createAuthActionContextContributor();
|
|
553
|
+
actions.registerContextContributor({ id: contributor.contributorId, contribute: contributor.contribute });
|
|
554
|
+
const conversation = { async read() {
|
|
555
|
+
return { id: "host-conversation", status: "ready", conversationLog: [], streaming: { revision: 0, messages: [] } };
|
|
556
|
+
}, async subscribe(listener) {
|
|
557
|
+
listeners.add(listener);
|
|
558
|
+
notify();
|
|
559
|
+
return () => { listeners.delete(listener); notify(); };
|
|
560
|
+
} };
|
|
561
|
+
const definitions = createAssistantActions({ config, conversationRuntime: { async open() { return conversation; } } });
|
|
562
|
+
const original = definitions.find(definition => definition.id === actionIds.conversationSubscribe);
|
|
563
|
+
const hostActionId = "test.host.conversation.subscribe";
|
|
564
|
+
actions.register({ contributorId: "test.host", domain: "assistant", actions: [...definitions, {
|
|
565
|
+
...original, id: hostActionId, permission: { require: "none" },
|
|
566
|
+
async execute(input, context, deps) {
|
|
567
|
+
const request = context.requestMeta.request;
|
|
568
|
+
// Test host policy only. Public's production action owns its original
|
|
569
|
+
// local Studio guard and hosted context contributor, outside this package.
|
|
570
|
+
if (request.headers.origin !== `http://${request.headers.host}` || input.conversationId !== "host-conversation") {
|
|
571
|
+
throw Object.assign(new Error("Host request denied."), { statusCode: 403 });
|
|
572
|
+
}
|
|
573
|
+
calls.push({ input, context });
|
|
574
|
+
return original.execute(input, context, deps);
|
|
575
|
+
}
|
|
576
|
+
}] });
|
|
577
|
+
const http = createServer();
|
|
578
|
+
let realtime, events;
|
|
579
|
+
const probe = defineProvider({ id: "test.host.realtime", requires: { realtime: "runtime.realtime", events: "runtime.events" },
|
|
580
|
+
setup(dependencies) { ({ realtime, events } = dependencies); return {}; } });
|
|
581
|
+
const transport = createCapabilityRuntime({ providers: [EventProvider, RealtimeProvider, probe], inputs: {
|
|
582
|
+
"runtime.config": {}, "runtime.env": {}, "runtime.fastify": { server: http }, "runtime.logger": logger,
|
|
583
|
+
...(authService ? { "auth.service": authService } : {})
|
|
584
|
+
} });
|
|
585
|
+
await transport.start();
|
|
586
|
+
const dependencies = { realtime, events, actions, config };
|
|
587
|
+
let release = registerConversationSubscriptions(dependencies);
|
|
588
|
+
events.register({ id: "test.host.published", handle(event) { published.push(event); notify(); } });
|
|
589
|
+
t.after(async () => {
|
|
590
|
+
for (const client of clients) client.disconnect();
|
|
591
|
+
release();
|
|
592
|
+
await transport.shutdown();
|
|
593
|
+
});
|
|
594
|
+
http.listen(0, "127.0.0.1");
|
|
595
|
+
await once(http, "listening");
|
|
596
|
+
const url = `http://127.0.0.1:${http.address().port}`;
|
|
597
|
+
return {
|
|
598
|
+
changes, calls, listeners, published, dependencies,
|
|
599
|
+
registerHost() {
|
|
600
|
+
release();
|
|
601
|
+
release = registerConversationSubscriptions({ ...dependencies, subscribeActionId: hostActionId, requestPolicy: "host" });
|
|
602
|
+
},
|
|
603
|
+
emit(event) { for (const listener of listeners) listener({ conversationId: "host-conversation", ...event }); },
|
|
604
|
+
async connect({ origin = url } = {}) {
|
|
605
|
+
const socket = createSocketIoClient({ url, options: { autoConnect: false, reconnection: false,
|
|
606
|
+
transports: ["websocket"], extraHeaders: { Cookie: "session=host-session", Origin: origin } } });
|
|
607
|
+
clients.push(socket);
|
|
608
|
+
const connected = once(socket, "connect", { signal: AbortSignal.timeout(5_000) });
|
|
609
|
+
socket.connect();
|
|
610
|
+
await connected;
|
|
611
|
+
return socket;
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
test("local host subscriptions preserve the strict default and authorize a fixed action on the real request", async t => {
|
|
617
|
+
const f = await hostSubscriptionFixture(t);
|
|
618
|
+
assert.throws(() => registerConversationSubscriptions({ ...f.dependencies, requestPolicy: "public" }), /requestPolicy/);
|
|
619
|
+
assert.throws(() => registerConversationSubscriptions({ ...f.dependencies, requestPolicy: "host" }), /subscribeActionId/);
|
|
620
|
+
const socket = await f.connect();
|
|
621
|
+
const input = { subscriptionId: "local", conversationId: "host-conversation", targetSurfaceId: "home", hostSurfaceId: "home",
|
|
622
|
+
requestPolicy: "host", subscribeActionId: "test.host.conversation.subscribe", actionId: "test.host.conversation.subscribe",
|
|
623
|
+
actor: { id: "42" }, context: { actor: { id: "42" } }, host: { nativeTools: true }, room: "clients" };
|
|
624
|
+
assert.equal((await requestSubscription(socket, input)).status, 401);
|
|
625
|
+
assert.equal(f.calls.length, 0);
|
|
626
|
+
f.registerHost();
|
|
627
|
+
const foreign = await f.connect({ origin: "https://foreign.invalid" });
|
|
628
|
+
assert.equal((await requestSubscription(foreign, { ...input, headers: { origin: "http://127.0.0.1" } })).status, 403);
|
|
629
|
+
assert.equal((await requestSubscription(socket, { ...input, hostSurfaceId: "admin" })).status, 403);
|
|
630
|
+
assert.equal((await requestSubscription(socket, { ...input, conversationId: "another-conversation" })).status, 403);
|
|
631
|
+
assert.equal(f.listeners.size, 0);
|
|
632
|
+
const response = await requestSubscription(socket, input);
|
|
633
|
+
assert.equal(response.ok, true);
|
|
634
|
+
assert.equal(response.state.id, "host-conversation");
|
|
635
|
+
assert.equal(f.calls.length, 1);
|
|
636
|
+
assert.deepEqual(f.calls[0].input, { targetSurfaceId: "home", conversationId: "host-conversation" });
|
|
637
|
+
assert.equal(f.calls[0].context.actor, null);
|
|
638
|
+
assert.equal(Object.hasOwn(f.calls[0].context.requestMeta.request, "user"), false);
|
|
639
|
+
const foreignEvents = [];
|
|
640
|
+
foreign.on(ASSISTANT_CONVERSATION_EVENT, event => foreignEvents.push(event));
|
|
641
|
+
const received = once(socket, ASSISTANT_CONVERSATION_EVENT, { signal: AbortSignal.timeout(5_000) });
|
|
642
|
+
f.emit({ type: "phase", phase: "working" });
|
|
643
|
+
const [event] = await received;
|
|
644
|
+
assert.equal(event.conversationId, "host-conversation");
|
|
645
|
+
assert.equal(Object.hasOwn(event, "actorId"), false);
|
|
646
|
+
assert.equal(Object.hasOwn(event, "scope"), false);
|
|
647
|
+
assert.deepEqual(f.published.at(-1).realtime.audience, { room: socket.id });
|
|
648
|
+
assert.equal(foreignEvents.length, 0);
|
|
649
|
+
const released = new Promise((resolve, reject) => socket.timeout(5_000).emit(ASSISTANT_CONVERSATION_UNSUBSCRIBE,
|
|
650
|
+
{ subscriptionId: "local" }, (error, response) => error ? reject(error) : resolve(response)));
|
|
651
|
+
assert.deepEqual(await released, { ok: true });
|
|
652
|
+
assert.equal(f.listeners.size, 0);
|
|
653
|
+
assert.equal(socket.connected, true);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test("host subscription policy retains authenticated actor metadata and per-event session revocation", async t => {
|
|
657
|
+
let actor = { id: "42" };
|
|
658
|
+
let allowed = true;
|
|
659
|
+
const cookies = [];
|
|
660
|
+
const f = await hostSubscriptionFixture(t, {
|
|
661
|
+
realtime: { requireAuthentication: true, authorizeEvent: ({ actor: current }) => allowed && current?.id === "42" },
|
|
662
|
+
async authenticateRequest(request) {
|
|
663
|
+
cookies.push(request.cookies.session);
|
|
664
|
+
return { authenticated: Boolean(actor), actor };
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
f.registerHost();
|
|
668
|
+
const socket = await f.connect();
|
|
669
|
+
const input = { subscriptionId: "hosted", conversationId: "host-conversation", targetSurfaceId: "home", hostSurfaceId: "home" };
|
|
670
|
+
assert.equal((await requestSubscription(socket, input)).ok, true);
|
|
671
|
+
assert.equal(f.calls[0].context.actor.id, "42");
|
|
672
|
+
const packets = [];
|
|
673
|
+
socket.on(ASSISTANT_CONVERSATION_EVENT, event => packets.push(event));
|
|
674
|
+
allowed = false;
|
|
675
|
+
f.emit({ type: "phase", phase: "blocked" });
|
|
676
|
+
await until(f.changes, () => f.published.length === 1);
|
|
677
|
+
allowed = true;
|
|
678
|
+
const received = once(socket, ASSISTANT_CONVERSATION_EVENT, { signal: AbortSignal.timeout(5_000) });
|
|
679
|
+
f.emit({ type: "phase", phase: "working" });
|
|
680
|
+
const [event] = await received;
|
|
681
|
+
assert.equal(event.actorId, "42");
|
|
682
|
+
assert.deepEqual(event.scope, { kind: "user", id: "42" });
|
|
683
|
+
assert.deepEqual(packets.map(packet => packet.event.phase), ["working"]);
|
|
684
|
+
actor = null;
|
|
685
|
+
const disconnected = once(socket, "disconnect", { signal: AbortSignal.timeout(5_000) });
|
|
686
|
+
f.emit({ type: "phase", phase: "revoked" });
|
|
687
|
+
await disconnected;
|
|
688
|
+
await until(f.changes, () => f.listeners.size === 0);
|
|
689
|
+
assert.deepEqual(packets.map(packet => packet.event.phase), ["working"]);
|
|
690
|
+
assert.ok(cookies.length >= 5);
|
|
691
|
+
assert.ok(cookies.every(value => value === "host-session"));
|
|
692
|
+
});
|