@teamscala/service-boot 0.23.379 → 0.23.381
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/package.json
CHANGED
package/src/agreement-gate.ts
CHANGED
|
@@ -752,17 +752,8 @@ export const agreementGateConfig: ContentGateConfig = {
|
|
|
752
752
|
resourceKey: "agreement",
|
|
753
753
|
agentConfigSlug: "agreement-assistant-agent",
|
|
754
754
|
organisationId: session.organisationId ?? undefined,
|
|
755
|
-
title: "Agreement Assistant",
|
|
756
755
|
launchLabel: "Ask about this agreement",
|
|
757
|
-
welcomeMessage:
|
|
758
|
-
"Hi! Ask me anything about this agreement — clauses, fees, the schedules, or how signing works.",
|
|
759
756
|
placeholder: "Ask about this agreement…",
|
|
760
|
-
conversationStarters: [
|
|
761
|
-
"What are the key terms?",
|
|
762
|
-
"Summarise the fees and payment terms.",
|
|
763
|
-
"What's in Schedule A and Schedule B?",
|
|
764
|
-
"How do I sign this?",
|
|
765
|
-
],
|
|
766
757
|
});
|
|
767
758
|
|
|
768
759
|
// Old-flow only: drop the pre-placed gate block (new flow has none).
|
|
@@ -18,10 +18,23 @@
|
|
|
18
18
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
19
19
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
20
20
|
import { getServerClient } from "./page-server-orpc/server-client.ts";
|
|
21
|
+
import { loadAgentPresentation } from "./resource-chat-inject.ts";
|
|
21
22
|
import * as v from "valibot";
|
|
23
|
+
import type { BlockSchemaKey } from "@teamscala/db/registry/generated/block-schema";
|
|
22
24
|
|
|
23
25
|
const AgentConfigSchema = v.object({ agentId: v.string() });
|
|
24
26
|
|
|
27
|
+
// THE PUSHED BLOCK NAME IS AN IMPORTED IDENTITY, NEVER A SPELLED STRING.
|
|
28
|
+
// discover-blocks keys the Puck registry off block_schema.slug alone
|
|
29
|
+
// (`components[row.slug] = …`), so a type that is not a live slug resolves to
|
|
30
|
+
// nothing — and because injection is fail-soft, the page renders unchanged with
|
|
31
|
+
// no error. That is exactly how the bubble went missing from every gated
|
|
32
|
+
// proposal and agreement: the block was consolidated to ResourceLauncher and
|
|
33
|
+
// these injectors kept pushing their pre-consolidation names. BlockSchemaKey is
|
|
34
|
+
// the generated union of live slugs, so annotating it here makes a stale name a
|
|
35
|
+
// COMPILE error instead of a silent absence (identity-is-imported-never-spelled).
|
|
36
|
+
const LAUNCHER_BLOCK: BlockSchemaKey = "ResourceLauncher";
|
|
37
|
+
|
|
25
38
|
/** Serialise the entire page content tree as text for the GPT context. */
|
|
26
39
|
async function buildProposalContext(resolved: Record<string, unknown>): Promise<string> {
|
|
27
40
|
const proposal = resolved.proposal as Record<string, unknown> | undefined;
|
|
@@ -118,18 +131,22 @@ export async function injectProposalChatLauncher(
|
|
|
118
131
|
|
|
119
132
|
try {
|
|
120
133
|
const runtimeContext = await buildProposalContext(resolved);
|
|
134
|
+
const presentation = await loadAgentPresentation(agentId);
|
|
121
135
|
(page.content as Array<Record<string, unknown>>).push({
|
|
122
|
-
type:
|
|
136
|
+
type: LAUNCHER_BLOCK,
|
|
123
137
|
props: {
|
|
124
138
|
id: "proposal-chat-launcher",
|
|
125
139
|
agentId,
|
|
126
140
|
organisationId,
|
|
127
141
|
viewerEmail,
|
|
128
142
|
runtimeContext,
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
143
|
+
// Copy comes from the agent row (see loadAgentPresentation): these
|
|
144
|
+
// three were spelled here while ai_agents held blank columns for
|
|
145
|
+
// each of them.
|
|
146
|
+
primaryColor: brandPrimaryColor(resolved) || presentation.primaryColor,
|
|
147
|
+
title: presentation.title,
|
|
148
|
+
welcomeMessage: presentation.welcomeMessage,
|
|
149
|
+
conversationStarters: presentation.conversationStarters,
|
|
133
150
|
launchLabel: "Ask about this proposal",
|
|
134
151
|
},
|
|
135
152
|
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Regression test for the block NAME the chat injectors push.
|
|
7
|
+
*
|
|
8
|
+
* THE DEFECT THIS PINS (found 2026-08-19): the floating assistant bubble was
|
|
9
|
+
* silently absent on every gated proposal and agreement. discover-blocks keys
|
|
10
|
+
* the Puck component registry off `block_schema.slug` and nothing else
|
|
11
|
+
* (`components[row.slug] = …`), so a pushed block `type` must EQUAL an active
|
|
12
|
+
* slug. The launcher block was consolidated to the single `ResourceLauncher`
|
|
13
|
+
* row, but the injectors still pushed the pre-consolidation names —
|
|
14
|
+
* `ResourceChatLauncher` here and `ChatLauncherBlock` in proposal-chat-inject.
|
|
15
|
+
* Neither resolves, and because injection is deliberately fail-soft the page
|
|
16
|
+
* rendered unchanged with no error: the classic shape of
|
|
17
|
+
* `a-deleted-producer-leaves-its-consumers-executing`.
|
|
18
|
+
*
|
|
19
|
+
* Asserting the literal is the WEAK half of the fix — it catches a regression
|
|
20
|
+
* of this exact string, not a future rename of the block. The strong half is
|
|
21
|
+
* single-sourcing the name so it cannot be spelled at all
|
|
22
|
+
* (`identity-is-imported-never-spelled`); until that lands, this test is what
|
|
23
|
+
* stands between the bubble and another silent disappearance.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
27
|
+
|
|
28
|
+
/** The ONE active launcher block_schema slug — the Puck registry key. */
|
|
29
|
+
const REGISTERED_LAUNCHER_SLUG = "ResourceLauncher";
|
|
30
|
+
|
|
31
|
+
mock.module("@teamscala/db/registry/load-config", () => ({
|
|
32
|
+
loadRegistryConfig: async () => ({ agentId: "agent-1" }),
|
|
33
|
+
}));
|
|
34
|
+
mock.module("./page-server-orpc/server-client.ts", () => ({
|
|
35
|
+
getServerClient: () => ({}),
|
|
36
|
+
}));
|
|
37
|
+
mock.module("@teamscala/logger/app-loggers", () => ({
|
|
38
|
+
getAppLogger: () => ({ error: () => {} }),
|
|
39
|
+
}));
|
|
40
|
+
mock.module("@teamscala/db/prisma-registry", () => ({
|
|
41
|
+
getPrisma: () => ({
|
|
42
|
+
ai_agents: {
|
|
43
|
+
findUnique: async () => ({
|
|
44
|
+
name: "Agreement Assistant",
|
|
45
|
+
welcome_message: "Hi! Ask me anything about this agreement.",
|
|
46
|
+
conversation_starters: ["What are the key terms?", "How do I sign this?"],
|
|
47
|
+
primary_color: "#6366f1",
|
|
48
|
+
}),
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
const { injectResourceChatLauncher } = await import("./resource-chat-inject.ts");
|
|
54
|
+
const { injectProposalChatLauncher } = await import("./proposal-chat-inject.ts");
|
|
55
|
+
|
|
56
|
+
function makeResolved(resourceKey: string): Record<string, unknown> {
|
|
57
|
+
return {
|
|
58
|
+
[resourceKey]: { organisation_id: "org-1", name: "Demo" },
|
|
59
|
+
brand: { primaryColor: "#123456" },
|
|
60
|
+
page: { puck_data: { root: { props: {} }, zones: {}, content: [] } },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function pushedBlocks(resolved: Record<string, unknown>) {
|
|
65
|
+
const pd = (resolved.page as { puck_data: { content: Array<{ type: string }> } }).puck_data;
|
|
66
|
+
return pd.content;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("chat injectors — pushed block name resolves to a registered block", () => {
|
|
70
|
+
test("the generic resource injector pushes the registered launcher slug", async () => {
|
|
71
|
+
const resolved = makeResolved("agreement");
|
|
72
|
+
await injectResourceChatLauncher(resolved, "viewer@example.com", {
|
|
73
|
+
resourceKey: "agreement",
|
|
74
|
+
agentConfigSlug: "agreement-assistant-agent",
|
|
75
|
+
launchLabel: "Ask about this agreement",
|
|
76
|
+
});
|
|
77
|
+
const blocks = pushedBlocks(resolved);
|
|
78
|
+
expect(blocks).toHaveLength(1);
|
|
79
|
+
expect(blocks[0].type).toBe(REGISTERED_LAUNCHER_SLUG);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("the proposal injector pushes the registered launcher slug", async () => {
|
|
83
|
+
const resolved = makeResolved("proposal");
|
|
84
|
+
await injectProposalChatLauncher(resolved, "viewer@example.com");
|
|
85
|
+
const blocks = pushedBlocks(resolved);
|
|
86
|
+
expect(blocks).toHaveLength(1);
|
|
87
|
+
expect(blocks[0].type).toBe(REGISTERED_LAUNCHER_SLUG);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("neither injector pushes a pre-consolidation launcher name", async () => {
|
|
91
|
+
const a = makeResolved("agreement");
|
|
92
|
+
await injectResourceChatLauncher(a, "viewer@example.com", {
|
|
93
|
+
resourceKey: "agreement",
|
|
94
|
+
agentConfigSlug: "agreement-assistant-agent",
|
|
95
|
+
launchLabel: "Ask about this agreement",
|
|
96
|
+
});
|
|
97
|
+
const p = makeResolved("proposal");
|
|
98
|
+
await injectProposalChatLauncher(p, "viewer@example.com");
|
|
99
|
+
|
|
100
|
+
const dead = ["ResourceChatLauncher", "ChatLauncherBlock"];
|
|
101
|
+
for (const blocks of [pushedBlocks(a), pushedBlocks(p)]) {
|
|
102
|
+
for (const block of blocks) {
|
|
103
|
+
expect(dead).not.toContain(block.type);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("the assistant's words come from the agent row, not the caller", async () => {
|
|
109
|
+
// THE REGRESSION THIS PINS: title, welcome message and starters used to
|
|
110
|
+
// be string literals in the injectors while ai_agents held a blank
|
|
111
|
+
// column for each. Whoever changed the row saw no change on the page.
|
|
112
|
+
const resolved = makeResolved("agreement");
|
|
113
|
+
await injectResourceChatLauncher(resolved, "viewer@example.com", {
|
|
114
|
+
resourceKey: "agreement",
|
|
115
|
+
agentConfigSlug: "agreement-assistant-agent",
|
|
116
|
+
launchLabel: "Ask about this agreement",
|
|
117
|
+
});
|
|
118
|
+
const props = (pushedBlocks(resolved)[0] as { props: Record<string, unknown> }).props;
|
|
119
|
+
expect(props.title).toBe("Agreement Assistant");
|
|
120
|
+
expect(props.welcomeMessage).toBe("Hi! Ask me anything about this agreement.");
|
|
121
|
+
expect(props.conversationStarters).toEqual([
|
|
122
|
+
"What are the key terms?",
|
|
123
|
+
"How do I sign this?",
|
|
124
|
+
]);
|
|
125
|
+
// launchLabel has no column yet, so it is still the caller's.
|
|
126
|
+
expect(props.launchLabel).toBe("Ask about this agreement");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -22,30 +22,95 @@
|
|
|
22
22
|
|
|
23
23
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
24
24
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
25
|
+
import { getPrisma } from "@teamscala/db/prisma-registry";
|
|
25
26
|
import { getServerClient } from "./page-server-orpc/server-client.ts";
|
|
26
27
|
import * as v from "valibot";
|
|
28
|
+
import type { BlockSchemaKey } from "@teamscala/db/registry/generated/block-schema";
|
|
29
|
+
import type { ConfigKey } from "@teamscala/db/registry/generated/config";
|
|
27
30
|
|
|
28
31
|
const AgentConfigSchema = v.object({ agentId: v.string() });
|
|
29
32
|
|
|
33
|
+
// THE PUSHED BLOCK NAME IS AN IMPORTED IDENTITY, NEVER A SPELLED STRING.
|
|
34
|
+
// discover-blocks keys the Puck registry off block_schema.slug alone
|
|
35
|
+
// (`components[row.slug] = …`), so a type that is not a live slug resolves to
|
|
36
|
+
// nothing — and because injection is fail-soft, the page renders unchanged with
|
|
37
|
+
// no error. That is exactly how the bubble went missing from every gated
|
|
38
|
+
// proposal and agreement: the block was consolidated to ResourceLauncher and
|
|
39
|
+
// these injectors kept pushing their pre-consolidation names. BlockSchemaKey is
|
|
40
|
+
// the generated union of live slugs, so annotating it here makes a stale name a
|
|
41
|
+
// COMPILE error instead of a silent absence (identity-is-imported-never-spelled).
|
|
42
|
+
const LAUNCHER_BLOCK: BlockSchemaKey = "ResourceLauncher";
|
|
43
|
+
|
|
30
44
|
/** Per-resource-type injection config — the only thing that varies. */
|
|
31
45
|
export interface ResourceChatOptions {
|
|
32
46
|
/** Key in `resolved` holding the record (e.g. "proposal", "agreement"). */
|
|
33
47
|
resourceKey: string;
|
|
34
48
|
/** `config` row slug carrying { agentId } (e.g. "agreement-assistant-agent"). */
|
|
35
|
-
agentConfigSlug:
|
|
49
|
+
agentConfigSlug: ConfigKey;
|
|
36
50
|
/** Org id to scope the chat. If omitted, read from resolved[resourceKey].organisation_id
|
|
37
51
|
* (proposals); agreements pass it explicitly (org isn't on the resolved record). */
|
|
38
52
|
organisationId?: string;
|
|
39
|
-
/** Overlay title-bar label (e.g. "Agreement Assistant"). */
|
|
40
|
-
title: string;
|
|
41
53
|
/** Floating button label / speech bubble (e.g. "Ask about this agreement"). */
|
|
42
54
|
launchLabel: string;
|
|
43
|
-
/** First assistant message. */
|
|
44
|
-
welcomeMessage?: string;
|
|
45
55
|
/** Input placeholder. */
|
|
46
56
|
placeholder?: string;
|
|
47
|
-
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* THE ASSISTANT'S WORDS BELONG TO THE AGENT ROW, NOT TO THIS FILE.
|
|
61
|
+
*
|
|
62
|
+
* `ai_agents` already carries a column for every one of them -- name,
|
|
63
|
+
* welcome_message, conversation_starters, primary_color -- and on 2026-08-19
|
|
64
|
+
* both live agents had welcome_message NULL and conversation_starters empty
|
|
65
|
+
* while the copy sat spelled in TypeScript here and in proposal-chat-inject.
|
|
66
|
+
* The intended home existed, sat blank, and the hardcoded copy won, so nobody
|
|
67
|
+
* could change a word the assistant says without a code change and a deploy.
|
|
68
|
+
* Two copies of one fact with the stale one winning silently is exactly
|
|
69
|
+
* `information-exists-in-one-location`.
|
|
70
|
+
*
|
|
71
|
+
* `launchLabel` and `placeholder` remain caller arguments only because no
|
|
72
|
+
* column holds them yet.
|
|
73
|
+
*/
|
|
74
|
+
export interface AgentPresentation {
|
|
75
|
+
title?: string;
|
|
76
|
+
welcomeMessage?: string;
|
|
48
77
|
conversationStarters?: string[];
|
|
78
|
+
primaryColor?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function loadAgentPresentation(agentId: string): Promise<AgentPresentation> {
|
|
82
|
+
try {
|
|
83
|
+
const agent = await getPrisma().ai_agents.findUnique({
|
|
84
|
+
where: { id: agentId },
|
|
85
|
+
select: {
|
|
86
|
+
name: true,
|
|
87
|
+
welcome_message: true,
|
|
88
|
+
conversation_starters: true,
|
|
89
|
+
primary_color: true,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
if (!agent) return {};
|
|
93
|
+
const starters = Array.isArray(agent.conversation_starters)
|
|
94
|
+
? (agent.conversation_starters as unknown[]).filter(
|
|
95
|
+
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
|
|
96
|
+
)
|
|
97
|
+
: [];
|
|
98
|
+
return {
|
|
99
|
+
title: agent.name ?? undefined,
|
|
100
|
+
welcomeMessage: agent.welcome_message ?? undefined,
|
|
101
|
+
conversationStarters: starters.length > 0 ? starters : undefined,
|
|
102
|
+
primaryColor: agent.primary_color ?? undefined,
|
|
103
|
+
};
|
|
104
|
+
} catch (err) {
|
|
105
|
+
// FAIL SOFT, LIKE THE REST OF THE INJECTION. A presentation read that
|
|
106
|
+
// fails must not remove the bubble: the launcher carries its own
|
|
107
|
+
// default for every one of these fields, so an unreadable row costs
|
|
108
|
+
// wording, never the assistant itself.
|
|
109
|
+
getAppLogger().error(
|
|
110
|
+
`[resource-chat] agent presentation read failed (${agentId}): ${err instanceof Error ? err.message : String(err)}`,
|
|
111
|
+
);
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
49
114
|
}
|
|
50
115
|
|
|
51
116
|
/** Serialise the resource record + entire page content tree as text for the GPT. */
|
|
@@ -144,20 +209,23 @@ export async function injectResourceChatLauncher(
|
|
|
144
209
|
|
|
145
210
|
try {
|
|
146
211
|
const runtimeContext = await buildResourceContext(resolved, opts.resourceKey);
|
|
212
|
+
const presentation = await loadAgentPresentation(agentId);
|
|
147
213
|
(page.content as Array<Record<string, unknown>>).push({
|
|
148
|
-
type:
|
|
214
|
+
type: LAUNCHER_BLOCK,
|
|
149
215
|
props: {
|
|
150
216
|
id: `${opts.resourceKey}-chat-launcher`,
|
|
151
217
|
agentId,
|
|
152
218
|
organisationId,
|
|
153
219
|
viewerEmail,
|
|
154
220
|
runtimeContext,
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
221
|
+
// The page's own brand still wins over the agent's stored colour:
|
|
222
|
+
// the bubble sits ON this document and must match it.
|
|
223
|
+
primaryColor: brandPrimaryColor(resolved) || presentation.primaryColor,
|
|
224
|
+
title: presentation.title,
|
|
225
|
+
welcomeMessage: presentation.welcomeMessage,
|
|
158
226
|
launchLabel: opts.launchLabel,
|
|
159
227
|
placeholder: opts.placeholder,
|
|
160
|
-
conversationStarters:
|
|
228
|
+
conversationStarters: presentation.conversationStarters,
|
|
161
229
|
},
|
|
162
230
|
});
|
|
163
231
|
} catch (err) {
|