@jskit-ai/assistant-runtime 0.1.193 → 0.1.194
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,169 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { EventEmitter, once } from "node:events";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import Fastify from "fastify";
|
|
5
|
+
import { createConversationRuntime, createMemoryConversationStorage } from "@jskit-ai/assistant-core/server/conversation";
|
|
6
|
+
import { createSocketIoClient } from "@jskit-ai/realtime/client/runtime";
|
|
7
|
+
import { registerFastifyConversations } from "../src/server/registerFastifyConversations.js";
|
|
8
|
+
import { ASSISTANT_CONVERSATION_EVENT, ASSISTANT_CONVERSATION_SUBSCRIBE } from "../src/shared/conversationRealtime.js";
|
|
9
|
+
|
|
10
|
+
const config = {
|
|
11
|
+
surfaceDefinitions: { home: { enabled: true, requiresWorkspace: false } },
|
|
12
|
+
assistantSurfaces: { home: { settingsSurfaceId: "home", configScope: "global" } }
|
|
13
|
+
};
|
|
14
|
+
const route = id => `/api/assistant/home/conversations/${encodeURIComponent(id)}`;
|
|
15
|
+
|
|
16
|
+
function subscribe(socket, id, extra = {}) {
|
|
17
|
+
return new Promise((resolve, reject) => socket.timeout(5_000).emit(ASSISTANT_CONVERSATION_SUBSCRIBE, {
|
|
18
|
+
subscriptionId: `view-${id}`, conversationId: id, targetSurfaceId: "home", hostSurfaceId: "home", ...extra
|
|
19
|
+
}, (error, response) => error ? reject(error) : resolve(response)));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function until(changes, predicate) {
|
|
23
|
+
while (!predicate()) await once(changes, "change", { signal: AbortSignal.timeout(5_000) });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test("Fastify conversations await trusted authentication across HTTP and shared sockets without owning the runtime", { timeout: 20_000 }, async t => {
|
|
27
|
+
const trusted = { actor: { id: "42" }, applicationId: "host-application", policy: { owner: "server" } };
|
|
28
|
+
const sessions = new Map([["owner-session", trusted]]);
|
|
29
|
+
const authorizations = [];
|
|
30
|
+
const authenticated = [];
|
|
31
|
+
const ids = ["planning", "notes"];
|
|
32
|
+
const changes = new EventEmitter();
|
|
33
|
+
const packets = [];
|
|
34
|
+
let providerRequests = 0;
|
|
35
|
+
const runtime = createConversationRuntime({
|
|
36
|
+
storage: createMemoryConversationStorage(),
|
|
37
|
+
authorize(input) {
|
|
38
|
+
authorizations.push(input);
|
|
39
|
+
return input.context.actor?.id === trusted.actor.id && input.context.policy === trusted.policy &&
|
|
40
|
+
input.context.applicationId === trusted.applicationId && ids.includes(input.conversationId);
|
|
41
|
+
},
|
|
42
|
+
connections: { async resolve() {
|
|
43
|
+
return { providerId: "test", model: "test-model", sdkPackage: "@ai-sdk/openai-compatible",
|
|
44
|
+
apiKey: "test", baseURL: "http://test.invalid/v1" };
|
|
45
|
+
} },
|
|
46
|
+
// The existing subscriptions fixture's API SSE boundary, with completed
|
|
47
|
+
// replies: the real driver, runtime, HTTP actions and sockets run unchanged.
|
|
48
|
+
fetch: async () => {
|
|
49
|
+
const index = providerRequests++;
|
|
50
|
+
const chunks = [{ content: `Reply for ${ids[index]}.` }, {}].map((delta, chunk) => ({
|
|
51
|
+
id: `reply-${index}`, object: "chat.completion.chunk", created: 1, model: "test-model",
|
|
52
|
+
choices: [{ index: 0, delta, finish_reason: chunk ? "stop" : null }]
|
|
53
|
+
}));
|
|
54
|
+
return new Response(chunks.map(value => `data: ${JSON.stringify(value)}\n\n`).join(""),
|
|
55
|
+
{ headers: { "content-type": "text/event-stream" } });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
const app = Fastify();
|
|
59
|
+
let socket;
|
|
60
|
+
t.after(async () => {
|
|
61
|
+
socket?.disconnect();
|
|
62
|
+
try { await app.close(); }
|
|
63
|
+
finally { await runtime.close(); }
|
|
64
|
+
});
|
|
65
|
+
const conversations = new Map();
|
|
66
|
+
for (const id of ids) conversations.set(id, await runtime.open({ id, context: trusted,
|
|
67
|
+
configuration: { integrationId: "test", systemPrompt: "Answer briefly." } }));
|
|
68
|
+
authorizations.length = 0;
|
|
69
|
+
await registerFastifyConversations(app, { runtime, config, env: {},
|
|
70
|
+
async authenticate(request) {
|
|
71
|
+
await Promise.resolve();
|
|
72
|
+
authenticated.push(request);
|
|
73
|
+
const session = request.cookies?.session || request.headers?.cookie?.match(/(?:^|;\s*)session=([^;]*)/u)?.[1];
|
|
74
|
+
return sessions.get(session) || null;
|
|
75
|
+
},
|
|
76
|
+
bootstrap({ request, context }) {
|
|
77
|
+
assert.equal(context, trusted);
|
|
78
|
+
assert.equal(request.headers.cookie, "session=owner-session");
|
|
79
|
+
return { host: { actorId: context.actor.id, applicationId: context.applicationId } };
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
const url = await app.listen({ host: "127.0.0.1", port: 0 });
|
|
83
|
+
let connections = 0;
|
|
84
|
+
app.server.on("connection", () => { connections++; });
|
|
85
|
+
const headers = { cookie: "session=owner-session", "x-jskit-surface": "home" };
|
|
86
|
+
for (const path of ["/api/bootstrap", route(ids[0])]) {
|
|
87
|
+
const denied = await app.inject({ method: "GET", url: path, headers: { "x-jskit-surface": "home" } });
|
|
88
|
+
assert.equal(denied.statusCode, 401, denied.body);
|
|
89
|
+
}
|
|
90
|
+
assert.equal(authorizations.length, 0, "missing authentication never reaches the caller's runtime");
|
|
91
|
+
const bootstrap = await app.inject({ method: "GET", url: "/api/bootstrap", headers });
|
|
92
|
+
assert.equal(bootstrap.statusCode, 200, bootstrap.body);
|
|
93
|
+
assert.deepEqual(bootstrap.json().host, { actorId: "42", applicationId: "host-application" });
|
|
94
|
+
|
|
95
|
+
socket = createSocketIoClient({ url, options: {
|
|
96
|
+
transports: ["websocket"], autoConnect: false, reconnection: false,
|
|
97
|
+
extraHeaders: { Cookie: "session=owner-session" }
|
|
98
|
+
} });
|
|
99
|
+
socket.on(ASSISTANT_CONVERSATION_EVENT, packet => { packets.push(packet); changes.emit("change"); });
|
|
100
|
+
const connected = once(socket, "connect", { signal: AbortSignal.timeout(5_000) });
|
|
101
|
+
socket.connect();
|
|
102
|
+
await connected;
|
|
103
|
+
const connectionId = socket.id;
|
|
104
|
+
const forged = { actor: { id: "forged" }, applicationId: "wire-application", policy: { owner: "wire" },
|
|
105
|
+
context: { actor: { id: "forged" }, applicationId: "wire-application", policy: { owner: "wire" } } };
|
|
106
|
+
for (const id of ids) {
|
|
107
|
+
const subscription = await subscribe(socket, id, { ...forged,
|
|
108
|
+
headers: { cookie: "session=forged-session" }, requestPolicy: "authenticated",
|
|
109
|
+
subscribeActionId: "wire.subscribe", room: "wire-room" });
|
|
110
|
+
assert.equal(subscription.ok, true, subscription.error);
|
|
111
|
+
assert.equal(subscription.state.id, id);
|
|
112
|
+
assert.deepEqual(subscription.state.conversationLog, []);
|
|
113
|
+
const forgedSend = await app.inject({ method: "POST", url: `${route(id)}/messages`, headers,
|
|
114
|
+
payload: { messageId: `question-${id}`, text: `Question for ${id}.`, ...forged } });
|
|
115
|
+
assert.equal(forgedSend.statusCode, 400, "HTTP schema rejects caller-supplied identity fields");
|
|
116
|
+
assert.equal(providerRequests, ids.indexOf(id), "rejected forged fields cannot dispatch a turn");
|
|
117
|
+
const sent = await app.inject({ method: "POST", url: `${route(id)}/messages`, headers,
|
|
118
|
+
payload: { messageId: `question-${id}`, text: `Question for ${id}.` } });
|
|
119
|
+
assert.equal(sent.statusCode, 202, sent.body);
|
|
120
|
+
const state = await conversations.get(id).wait();
|
|
121
|
+
assert.equal(state.status, "ready", state.error);
|
|
122
|
+
assert.equal(state.conversationLog.length, 1);
|
|
123
|
+
assert.equal(state.conversationLog[0].user.text, `Question for ${id}.`);
|
|
124
|
+
assert.equal(state.conversationLog[0].assistant.text, `Reply for ${id}.`);
|
|
125
|
+
}
|
|
126
|
+
await until(changes, () => ids.every(id => packets.some(packet => packet.subscriptionId === `view-${id}` &&
|
|
127
|
+
packet.event.type === "message" && packet.event.text === `Reply for ${id}.`)));
|
|
128
|
+
assert.equal(connections, 1);
|
|
129
|
+
assert.equal(socket.id, connectionId);
|
|
130
|
+
assert.ok(packets.every(packet => packet.subscriptionId === `view-${packet.conversationId}`));
|
|
131
|
+
for (const id of ids) {
|
|
132
|
+
assert.ok(authorizations.some(input => input.conversationId === id && input.operation === "subscribe"));
|
|
133
|
+
assert.ok(authorizations.some(input => input.conversationId === id && input.operation === "send"));
|
|
134
|
+
}
|
|
135
|
+
for (const { context } of authorizations) {
|
|
136
|
+
assert.deepEqual(context.actor, trusted.actor);
|
|
137
|
+
assert.equal(context.policy, trusted.policy);
|
|
138
|
+
assert.equal(context.applicationId, trusted.applicationId);
|
|
139
|
+
}
|
|
140
|
+
assert.ok(authenticated.some(request => request.method === "POST"));
|
|
141
|
+
assert.ok(authenticated.some(request => request.method === undefined), "socket requests also use the async authenticator");
|
|
142
|
+
|
|
143
|
+
sessions.delete("owner-session");
|
|
144
|
+
for (const path of ["/api/bootstrap", route(ids[0])]) {
|
|
145
|
+
const denied = await app.inject({ method: "GET", url: path, headers });
|
|
146
|
+
assert.equal(denied.statusCode, 401, denied.body);
|
|
147
|
+
}
|
|
148
|
+
const deniedSend = await app.inject({ method: "POST", url: `${route(ids[0])}/messages`, headers,
|
|
149
|
+
payload: { messageId: "revoked-attempt", text: "Never dispatch." } });
|
|
150
|
+
assert.equal(deniedSend.statusCode, 401, deniedSend.body);
|
|
151
|
+
const deniedSubscription = await subscribe(socket, ids[0], {
|
|
152
|
+
subscriptionId: "after-revocation", actor: trusted.actor, context: trusted,
|
|
153
|
+
headers: { cookie: "session=owner-session" }
|
|
154
|
+
});
|
|
155
|
+
assert.equal(deniedSubscription.ok, false);
|
|
156
|
+
assert.equal(deniedSubscription.status, 401);
|
|
157
|
+
assert.equal(providerRequests, 2, "wire credentials cannot dispatch after the host session is revoked");
|
|
158
|
+
assert.equal(socket.connected, true);
|
|
159
|
+
assert.equal(socket.id, connectionId);
|
|
160
|
+
|
|
161
|
+
const disconnected = once(socket, "disconnect", { signal: AbortSignal.timeout(5_000) });
|
|
162
|
+
await app.close();
|
|
163
|
+
await disconnected;
|
|
164
|
+
assert.equal(app.server.listening, false);
|
|
165
|
+
assert.equal(socket.connected, false);
|
|
166
|
+
const retained = await runtime.open({ id: ids[0], context: trusted });
|
|
167
|
+
assert.equal((await retained.read()).conversationLog[0].assistant.text, "Reply for planning.");
|
|
168
|
+
await retained.configure({ systemPrompt: "The caller still owns this runtime." });
|
|
169
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import knex from "knex";
|
|
7
|
+
import { createConversationTranscript } from "@jskit-ai/assistant-core/server/conversation";
|
|
8
|
+
import { verifyConversationStorageContract } from "@jskit-ai/assistant-core/testing/conversation-storage";
|
|
9
|
+
import transcriptsMigration from "../migrations/assistant_transcripts_initial.cjs";
|
|
10
|
+
import claimsMigration from "../migrations/assistant_turn_requests.cjs";
|
|
11
|
+
import { createRepository as createConversationsRepository } from "../src/server/repositories/conversationsRepository.js";
|
|
12
|
+
import { createRepository as createMessagesRepository } from "../src/server/repositories/messagesRepository.js";
|
|
13
|
+
import { createRepository as createTurnRequestsRepository } from "../src/server/repositories/turnRequestsRepository.js";
|
|
14
|
+
import { createSqlConversationStorage } from "../src/server/sqlConversationStorage.js";
|
|
15
|
+
import { createAssistantConversationRuntime } from "../src/server/createAssistantRuntime.js";
|
|
16
|
+
import { createTranscriptService } from "../src/server/services/transcriptService.js";
|
|
17
|
+
import { createChatService } from "../src/server/services/chatService.js";
|
|
18
|
+
|
|
19
|
+
async function fixture(t) {
|
|
20
|
+
const directory = await mkdtemp(path.join(tmpdir(), "assistant-sql-runtime-"));
|
|
21
|
+
const clients = [];
|
|
22
|
+
function connect() {
|
|
23
|
+
const db = knex({ client: "better-sqlite3", connection: { filename: path.join(directory, "assistant.sqlite") },
|
|
24
|
+
useNullAsDefault: true, pool: { min: 1, max: 1, afterCreate(connection, done) {
|
|
25
|
+
connection.pragma("busy_timeout = 0");
|
|
26
|
+
done(null, connection);
|
|
27
|
+
} } });
|
|
28
|
+
clients.push(db);
|
|
29
|
+
const repositories = { conversationsRepository: createConversationsRepository(db), messagesRepository: createMessagesRepository(db) };
|
|
30
|
+
return { db, ...repositories, storage: createSqlConversationStorage(repositories) };
|
|
31
|
+
}
|
|
32
|
+
t.after(async () => { for (const client of clients) await client.destroy(); await rm(directory, { recursive: true, force: true }); });
|
|
33
|
+
const first = connect();
|
|
34
|
+
await first.db.schema.createTable("users", table => table.bigInteger("id").primary());
|
|
35
|
+
await first.db("users").insert([{ id: 1 }, { id: 2 }]);
|
|
36
|
+
await transcriptsMigration.up(first.db);
|
|
37
|
+
await claimsMigration.up(first.db);
|
|
38
|
+
await first.db.raw("PRAGMA journal_mode = WAL");
|
|
39
|
+
const conversation = actor => first.conversationsRepository.create({ createdByUserId: actor || "1", surfaceId: "assistant", metadata: {} });
|
|
40
|
+
return { ...first, connect, conversation };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function rows(db) {
|
|
44
|
+
return { conversations: await db("assistant_conversations").orderBy("id"), messages: await db("assistant_messages").orderBy("id") };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test("the existing runtime storage contract uses the original SQL rows and rolls back row, title, count and metadata together", async t => {
|
|
48
|
+
const f = await fixture(t);
|
|
49
|
+
const first = await f.conversation();
|
|
50
|
+
const other = await f.conversation("2");
|
|
51
|
+
const scopes = new Map([["actor-a:workspace-a:conversation-a", first.id], ["actor-b:workspace-a:conversation-a", other.id]]);
|
|
52
|
+
await verifyConversationStorageContract({
|
|
53
|
+
read: (scope, callback) => f.storage.read(scopes.get(scope), callback),
|
|
54
|
+
write: (scope, callback) => f.storage.write(scopes.get(scope), callback)
|
|
55
|
+
}, { runtime: true });
|
|
56
|
+
const saved = await rows(f.db);
|
|
57
|
+
assert.equal(saved.conversations[0].message_count, saved.messages.length);
|
|
58
|
+
assert.equal(saved.conversations[0].title, "First question");
|
|
59
|
+
assert.equal(saved.messages.filter(row => row.role === "assistant").length, 1);
|
|
60
|
+
assert.equal(saved.messages.find(row => row.role === "assistant").content_text, "Final answer");
|
|
61
|
+
assert.equal(saved.messages[0].client_message_sid, "request-a");
|
|
62
|
+
|
|
63
|
+
const failing = createSqlConversationStorage({ conversationsRepository: f.conversationsRepository,
|
|
64
|
+
messagesRepository: { ...f.messagesRepository, async create(input, options) {
|
|
65
|
+
const result = await f.messagesRepository.create(input, options);
|
|
66
|
+
if (input.contentText === "Fail after insert") throw new Error("forced-row-commit-failure");
|
|
67
|
+
return result;
|
|
68
|
+
} } });
|
|
69
|
+
await assert.rejects(failing.write(first.id, async transaction => {
|
|
70
|
+
await transaction.writeMetadata({ runtime: { uncommitted: true } });
|
|
71
|
+
await transaction.updateTurnMetadata("000001", { product: { uncommitted: true } });
|
|
72
|
+
await transaction.appendMessage(await transaction.nextTurnId(), {
|
|
73
|
+
role: "user", messageId: "failed-write", text: "Fail after insert", at: "2026-01-02T00:00:00Z"
|
|
74
|
+
});
|
|
75
|
+
}), /forced-row-commit-failure/);
|
|
76
|
+
assert.deepEqual(await rows(f.db), saved, "A failed SQL commit cannot leave an authored row, count, title or runtime metadata behind");
|
|
77
|
+
let invocations = 0;
|
|
78
|
+
await assert.rejects(f.storage.write(first.id, async () => {
|
|
79
|
+
invocations += 1;
|
|
80
|
+
throw Object.assign(new Error("Caller failure"), { code: "SQLITE_BUSY" });
|
|
81
|
+
}), /Caller failure/);
|
|
82
|
+
assert.equal(invocations, 1, "Lock acquisition retry must never replay a storage callback");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("independent SQL connections serialize authored receipt and sequence allocation", async t => {
|
|
86
|
+
const f = await fixture(t);
|
|
87
|
+
const conversation = await f.conversation();
|
|
88
|
+
const second = f.connect();
|
|
89
|
+
const transcripts = [f.storage, second.storage].map(storage => createConversationTranscript({ storage }));
|
|
90
|
+
const input = { messageId: "invoice/42:follow-up", text: "Only once" };
|
|
91
|
+
const results = await Promise.all(transcripts.map(transcript => transcript.writeConversationUserMessage(conversation.id, input)));
|
|
92
|
+
assert.equal(results.filter(Boolean).length, 1);
|
|
93
|
+
await Promise.all(transcripts.map((transcript, index) => transcript.writeConversationUserMessage(conversation.id,
|
|
94
|
+
{ messageId: `next-${index}`, text: `Next ${index}` })));
|
|
95
|
+
const saved = await f.messagesRepository.listByConversationScope(conversation.id);
|
|
96
|
+
assert.deepEqual(saved.map(row => row.seq), [1, 2, 3]);
|
|
97
|
+
assert.equal(saved[0].clientMessageSid, input.messageId);
|
|
98
|
+
assert.equal((await f.conversationsRepository.findById(conversation.id)).messageCount, 3);
|
|
99
|
+
assert.equal((await second.storage.read(conversation.id, transaction => transaction.listTurnIds())).length, 3);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("historical SQL projection preserves exact rows and blocks ambiguous history with read-only preflight", async t => {
|
|
103
|
+
const f = await fixture(t);
|
|
104
|
+
const conversation = await f.conversation();
|
|
105
|
+
const create = fields => f.messagesRepository.create({ conversationId: conversation.id, actorUserId: "1", role: "assistant", kind: "chat", ...fields });
|
|
106
|
+
const user = await create({ role: "user", clientMessageSid: "customer/123:review", contentText: "Existing question", metadata: { surfaceId: "assistant" } });
|
|
107
|
+
await create({ kind: "tool_call", contentText: '{"key":1}', metadata: { toolCallId: "lookup", tool: "inventory_lookup" } });
|
|
108
|
+
await create({ kind: "tool_result", contentText: '{"ok":true,"result":{"found":1}}', metadata: { toolCallId: "lookup", tool: "inventory_lookup", ok: true } });
|
|
109
|
+
await create({ contentText: "Existing answer" });
|
|
110
|
+
const before = await rows(f.db);
|
|
111
|
+
assert.deepEqual((await f.storage.preflight(conversation.id)).issues, []);
|
|
112
|
+
const turn = await f.storage.read(conversation.id, transaction => transaction.readTurn(user.id));
|
|
113
|
+
assert.equal(turn.user.messageId, "customer/123:review");
|
|
114
|
+
assert.equal(turn.assistant.text, "Existing answer");
|
|
115
|
+
assert.deepEqual(turn.metadata.applicationTools[0].result, { ok: true, result: { found: 1 } });
|
|
116
|
+
assert.deepEqual(await rows(f.db), before);
|
|
117
|
+
await f.storage.write(conversation.id, transaction => transaction.writeMetadata({ runtime: { inspection: "metadata only" } }));
|
|
118
|
+
assert.deepEqual((await rows(f.db)).messages, before.messages, "Runtime initialization does not backfill historical message rows");
|
|
119
|
+
|
|
120
|
+
const ambiguous = await f.conversation();
|
|
121
|
+
const first = await f.messagesRepository.create({ conversationId: ambiguous.id, role: "user", contentText: "First", clientMessageSid: "first" });
|
|
122
|
+
const next = await f.messagesRepository.create({ conversationId: ambiguous.id, role: "user", contentText: "Overlapping", clientMessageSid: "next" });
|
|
123
|
+
await f.messagesRepository.create({ conversationId: ambiguous.id, role: "assistant", contentText: "Unknown owner" });
|
|
124
|
+
const unchanged = await rows(f.db);
|
|
125
|
+
assert.deepEqual((await f.storage.preflight(ambiguous.id)).issues, [{ code: "overlapping_historical_turns", rowIds: [first.id, next.id] }]);
|
|
126
|
+
await assert.rejects(f.storage.write(ambiguous.id, transaction => transaction.writeMetadata({ runtime: {} })), /ambiguous historical SQL rows/);
|
|
127
|
+
assert.deepEqual(await rows(f.db), unchanged);
|
|
128
|
+
|
|
129
|
+
const interrupted = await f.conversation();
|
|
130
|
+
await f.messagesRepository.create({ conversationId: interrupted.id, role: "user", contentText: "Mutate", clientMessageSid: "mutate" });
|
|
131
|
+
const call = await f.messagesRepository.create({ conversationId: interrupted.id, role: "assistant", kind: "tool_call", contentText: "{}", metadata: { toolCallId: "unknown", tool: "mutate" } });
|
|
132
|
+
assert.deepEqual((await f.storage.preflight(interrupted.id)).issues, [{ code: "unconfirmed_historical_tool", rowIds: [call.id] }]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("the SQL facade uses common inference and durable tool rows while supplied history stays request context", async t => {
|
|
136
|
+
const f = await fixture(t);
|
|
137
|
+
const conversation = await f.conversation();
|
|
138
|
+
await f.messagesRepository.create({ conversationId: conversation.id, role: "user", clientMessageSid: "previous", contentText: "Canonical history outside the selected context" });
|
|
139
|
+
await f.messagesRepository.create({ conversationId: conversation.id, role: "assistant", contentText: "Previous saved answer" });
|
|
140
|
+
await f.conversationsRepository.updateById(conversation.id, { messageCount: 2 });
|
|
141
|
+
const requests = [], resolved = [], events = [];
|
|
142
|
+
let executions = 0;
|
|
143
|
+
const aiClientFactory = { resolveClient(surface, input) {
|
|
144
|
+
resolved.push({ surface, integrationId: input.integrationId, actor: input.context.actor.id });
|
|
145
|
+
return { enabled: true, provider: "test", defaultModel: "fixture-model", async createChatCompletionStream(request) {
|
|
146
|
+
requests.push(structuredClone(request.messages));
|
|
147
|
+
const tool = requests.length === 1;
|
|
148
|
+
return (async function* () {
|
|
149
|
+
yield { choices: [{ delta: tool ? { tool_calls: [{ index: 0, id: "lookup", function: { name: "inventory_lookup", arguments: "{}" } }] }
|
|
150
|
+
: { content: "Stored once." }, finish_reason: tool ? "tool_calls" : "stop" }] };
|
|
151
|
+
})();
|
|
152
|
+
} };
|
|
153
|
+
} };
|
|
154
|
+
const catalog = { limits: { maxToolArgumentBytes: 4096 },
|
|
155
|
+
resolveToolSet: () => ({ tools: [{ name: "inventory_lookup", parameters: { type: "object" } }] }),
|
|
156
|
+
toOpenAiToolSchema: tool => ({ type: "function", function: tool }),
|
|
157
|
+
async executeToolCall() {
|
|
158
|
+
executions += 1;
|
|
159
|
+
const calls = await f.db("assistant_messages").where({ conversation_id: conversation.id, kind: "tool_call" });
|
|
160
|
+
const results = await f.db("assistant_messages").where({ conversation_id: conversation.id, kind: "tool_result" });
|
|
161
|
+
assert.equal(calls.length, 1, "The original tool-call row is committed before the application effect");
|
|
162
|
+
assert.equal(results.length, 0);
|
|
163
|
+
assert.equal(JSON.parse(calls[0].metadata_json).conversationRuntime.status, "running");
|
|
164
|
+
return { ok: true, result: { found: 1 } };
|
|
165
|
+
} };
|
|
166
|
+
const runtime = createAssistantConversationRuntime({ ...f, aiClientFactory, toolCatalog: catalog });
|
|
167
|
+
t.after(() => runtime.close());
|
|
168
|
+
const dependencies = { conversationRuntime: runtime, aiClientFactory, serviceToolCatalog: catalog,
|
|
169
|
+
transcriptService: createTranscriptService(f), turnRequests: createTurnRequestsRepository(f.db),
|
|
170
|
+
assistantConfigService: { resolveSystemPrompt: async () => "Retained SQL instructions" },
|
|
171
|
+
appConfig: { surfaceDefinitions: { assistant: { enabled: true, requiresWorkspace: false } },
|
|
172
|
+
assistantSurfaces: { assistant: { settingsSurfaceId: "assistant", configScope: "global" } } } };
|
|
173
|
+
const service = createChatService(dependencies);
|
|
174
|
+
const input = { targetSurfaceId: "assistant", conversationId: conversation.id, messageId: "order/42:review", input: "Read the record",
|
|
175
|
+
integrationId: "allowed-choice", history: [{ role: "user", content: "Selected request context only" }] };
|
|
176
|
+
const options = { context: { actor: { id: "1" } }, streamWriter: Object.fromEntries(
|
|
177
|
+
["sendMeta", "sendAssistantDelta", "sendAssistantMessage", "sendToolCall", "sendToolResult", "sendError", "sendDone"]
|
|
178
|
+
.map(name => [name, event => events.push({ name, event })])) };
|
|
179
|
+
assert.equal((await service.streamChat(input, options)).status, "completed");
|
|
180
|
+
assert.equal(executions, 1);
|
|
181
|
+
assert.equal(requests.length, 2);
|
|
182
|
+
assert.equal(resolved.every(value => value.surface === "assistant" && value.integrationId === "allowed-choice" && value.actor === "1"), true);
|
|
183
|
+
assert.equal(requests[0].some(message => message.content === "Selected request context only"), true);
|
|
184
|
+
assert.equal(requests[0].some(message => message.content === "Canonical history outside the selected context"), false);
|
|
185
|
+
assert.deepEqual(JSON.parse(requests[1].find(message => message.role === "tool").content), { found: 1 },
|
|
186
|
+
"The model receives the original action result body, while its durable receipt keeps the full envelope");
|
|
187
|
+
const saved = await rows(f.db);
|
|
188
|
+
assert.equal(saved.messages.filter(row => row.kind === "tool_call").length, 1);
|
|
189
|
+
assert.equal(saved.messages.filter(row => row.kind === "tool_result").length, 1);
|
|
190
|
+
assert.equal(saved.messages.filter(row => row.client_message_sid === input.messageId).length, 1);
|
|
191
|
+
assert.equal(saved.messages.some(row => row.content_text === "Selected request context only"), false);
|
|
192
|
+
assert.doesNotMatch(saved.conversations[0].metadata_json, /Selected request context only/);
|
|
193
|
+
assert.doesNotMatch(saved.messages.find(row => row.client_message_sid === input.messageId).metadata_json, /applicationTools/);
|
|
194
|
+
const claimed = await f.db("assistant_turn_requests").first();
|
|
195
|
+
assert.equal(claimed.status, "completed");
|
|
196
|
+
assert.equal(JSON.parse(claimed.request_json).history[0].content, "Selected request context only");
|
|
197
|
+
assert.equal(events.at(-1).name, "sendDone");
|
|
198
|
+
assert.equal((await service.streamChat(input, options)).status, "completed");
|
|
199
|
+
assert.equal(requests.length, 2, "The original SQL claim replays without inference or duplicate effects");
|
|
200
|
+
for (const change of [{ actor: { id: "2" } }, { surface: "other" }, { assistantRequest: { conversation, workspace: { id: "77" }, history: [] } }]) {
|
|
201
|
+
await assert.rejects(runtime.open({ id: conversation.id,
|
|
202
|
+
context: { actor: { id: "1" }, surface: "assistant", assistantRequest: { conversation, workspace: null, history: [] }, ...change } }),
|
|
203
|
+
{ code: "conversation_forbidden" });
|
|
204
|
+
}
|
|
205
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Repository fixture for the original chat-service unit cases. The production
|
|
2
|
+
* SQL adapter and common runtime run unchanged over these transactional rows.
|
|
3
|
+
*/
|
|
4
|
+
export function createMemoryAssistantRows({ id = "conversation_1", surfaceId = "assistant", messages = [] } = {}) {
|
|
5
|
+
let state = { conversation: { id, workspaceId: null, createdByUserId: "1", surfaceId,
|
|
6
|
+
title: "New conversation", status: "active", messageCount: 0, metadata: {} }, messages: [] };
|
|
7
|
+
let pending = Promise.resolve();
|
|
8
|
+
const read = options => options?.trx || state;
|
|
9
|
+
const conversationsRepository = {
|
|
10
|
+
async findById(value, options) { return value === id ? structuredClone(read(options).conversation) : null; },
|
|
11
|
+
async findByIdForActorScope(value, scope, options) {
|
|
12
|
+
const row = await this.findById(value, options);
|
|
13
|
+
return row && scope.actorUserId === row.createdByUserId && scope.surfaceId === row.surfaceId && scope.workspaceId === row.workspaceId ? row : null;
|
|
14
|
+
},
|
|
15
|
+
async updateById(value, patch, options) {
|
|
16
|
+
const target = read(options);
|
|
17
|
+
if (value !== id) return null;
|
|
18
|
+
Object.assign(target.conversation, structuredClone(patch));
|
|
19
|
+
return structuredClone(target.conversation);
|
|
20
|
+
},
|
|
21
|
+
transaction(operation) {
|
|
22
|
+
const next = pending.then(async () => {
|
|
23
|
+
const trx = structuredClone(state);
|
|
24
|
+
const value = await operation(trx);
|
|
25
|
+
state = trx;
|
|
26
|
+
messages.splice(0, messages.length, ...structuredClone(state.messages));
|
|
27
|
+
return value;
|
|
28
|
+
});
|
|
29
|
+
pending = next.catch(() => {});
|
|
30
|
+
return next;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const messagesRepository = {
|
|
34
|
+
async listByConversationScope(_id, _scope, { page, pageSize }, options) {
|
|
35
|
+
return structuredClone(read(options).messages.slice((page - 1) * pageSize, page * pageSize));
|
|
36
|
+
},
|
|
37
|
+
async create(payload, options) {
|
|
38
|
+
const rows = read(options).messages;
|
|
39
|
+
const row = { ...structuredClone(payload), id: String(rows.length + 1), seq: rows.length + 1,
|
|
40
|
+
clientMessageSid: payload.clientMessageSid || "", createdAt: payload.createdAt || new Date().toISOString() };
|
|
41
|
+
rows.push(row);
|
|
42
|
+
return structuredClone(row);
|
|
43
|
+
},
|
|
44
|
+
async updateById(value, patch, options) {
|
|
45
|
+
const row = read(options).messages.find(message => message.id === value);
|
|
46
|
+
Object.assign(row, structuredClone(patch));
|
|
47
|
+
return structuredClone(row);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
return { conversationsRepository, messagesRepository };
|
|
51
|
+
}
|
|
@@ -7,6 +7,8 @@ import knex from "knex";
|
|
|
7
7
|
import migration from "../migrations/assistant_turn_requests.cjs";
|
|
8
8
|
import { createRepository } from "../src/server/repositories/turnRequestsRepository.js";
|
|
9
9
|
import { createChatService } from "../src/server/services/chatService.js";
|
|
10
|
+
import { createAssistantConversationRuntime } from "../src/server/createAssistantRuntime.js";
|
|
11
|
+
import { createMemoryAssistantRows } from "./support/memoryAssistantRows.js";
|
|
10
12
|
|
|
11
13
|
async function database(t) {
|
|
12
14
|
const dir = await mkdtemp(path.join(tmpdir(), "assistant-requests-"));
|
|
@@ -45,7 +47,7 @@ test("migration preserves existing claims and database uniqueness arbitrates ind
|
|
|
45
47
|
});
|
|
46
48
|
|
|
47
49
|
function service(repository, { provider, transcript = [] } = {}) {
|
|
48
|
-
|
|
50
|
+
const dependencies = {
|
|
49
51
|
turnRequests: repository,
|
|
50
52
|
aiClientFactory: { resolveClient: () => ({ enabled: true, provider: "test", defaultModel: "test", createChatCompletionStream: provider }) },
|
|
51
53
|
transcriptService: {
|
|
@@ -53,13 +55,19 @@ function service(repository, { provider, transcript = [] } = {}) {
|
|
|
53
55
|
async appendMessage(_surface, _id, message) { transcript.push(message); },
|
|
54
56
|
async completeConversation() {}
|
|
55
57
|
},
|
|
56
|
-
serviceToolCatalog: { resolveToolSet: () => ({ tools: [] })
|
|
58
|
+
serviceToolCatalog: { limits: { maxToolArgumentBytes: 64 * 1024 }, resolveToolSet: () => ({ tools: [] }),
|
|
59
|
+
toOpenAiToolSchema: tool => ({ type: "function", function: tool }) },
|
|
57
60
|
assistantConfigService: { resolveSystemPrompt: async () => "Answer." },
|
|
58
61
|
appConfig: {
|
|
59
62
|
surfaceDefinitions: { assistant: { id: "assistant", enabled: true, requiresWorkspace: false, accessPolicyId: "public" } },
|
|
60
63
|
assistantSurfaces: { assistant: { settingsSurfaceId: "assistant", configScope: "global" } }
|
|
61
64
|
}
|
|
65
|
+
};
|
|
66
|
+
const conversationRuntime = createAssistantConversationRuntime({
|
|
67
|
+
...createMemoryAssistantRows({ id: "100", messages: transcript }),
|
|
68
|
+
aiClientFactory: dependencies.aiClientFactory, toolCatalog: dependencies.serviceToolCatalog
|
|
62
69
|
});
|
|
70
|
+
return createChatService({ ...dependencies, conversationRuntime });
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
function run(chat, input = request, { events = [], disconnectAfterCompletion = false } = {}) {
|
|
@@ -82,6 +90,7 @@ test("concurrent submissions, lost completion responses and a fresh service repl
|
|
|
82
90
|
started.resolve();
|
|
83
91
|
await waiting.promise;
|
|
84
92
|
yield { choices: [{ delta: { content: "Completed once." } }] };
|
|
93
|
+
yield { choices: [{ delta: {}, finish_reason: "stop" }] };
|
|
85
94
|
};
|
|
86
95
|
const first = service(createRepository(db), { provider, transcript });
|
|
87
96
|
const pending = run(first, request, { disconnectAfterCompletion: true });
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
const MAX_PROGRESS_ONLY_TEXT_CHARS = 600;
|
|
2
|
-
|
|
3
|
-
const PROGRESS_SENTENCE_PATTERNS = Object.freeze([
|
|
4
|
-
/^(?:let me|i(?:'|’)ll|i will|i(?:'|’)m going to|i am going to)\s+(?:(?:first|now|quickly)\s+)*(?:analy[sz]e|call|check|confirm|do|execute|fetch|find|inspect|investigate|load|look up|open|prepare|query|read|retrieve|review|run|search|summarize|test|try|use|verify)\b/iu,
|
|
5
|
-
/^(?:analy[sz]ing|calling|checking|confirming|executing|fetching|finding|inspecting|investigating|loading|looking up|opening|preparing|querying|reading|retrieving|reviewing|running|searching|summarizing|testing|trying|using|verifying)\b/iu,
|
|
6
|
-
/^(?:one moment|please wait)\b/iu
|
|
7
|
-
]);
|
|
8
|
-
|
|
9
|
-
function isAssistantProgressOnlyText(value) {
|
|
10
|
-
const text = String(value || "")
|
|
11
|
-
.replace(/\s+/gu, " ")
|
|
12
|
-
.trim()
|
|
13
|
-
.replace(/^(?:okay|sure)[,;:!\s—-]+/iu, "");
|
|
14
|
-
|
|
15
|
-
if (!text || text.length > MAX_PROGRESS_ONLY_TEXT_CHARS) {
|
|
16
|
-
return false;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const sentences = text
|
|
20
|
-
.split(/(?<=[.!?…])\s+/u)
|
|
21
|
-
.map((sentence) => sentence.trim())
|
|
22
|
-
.filter(Boolean);
|
|
23
|
-
|
|
24
|
-
return sentences.length > 0 && sentences.every((sentence) =>
|
|
25
|
-
PROGRESS_SENTENCE_PATTERNS.some((pattern) => pattern.test(sentence))
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export { isAssistantProgressOnlyText };
|