@teamscala/service-boot 0.23.380 → 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
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.380",
9
+ "version": "0.23.381",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -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,6 +18,7 @@
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";
22
23
  import type { BlockSchemaKey } from "@teamscala/db/registry/generated/block-schema";
23
24
 
@@ -130,6 +131,7 @@ export async function injectProposalChatLauncher(
130
131
 
131
132
  try {
132
133
  const runtimeContext = await buildProposalContext(resolved);
134
+ const presentation = await loadAgentPresentation(agentId);
133
135
  (page.content as Array<Record<string, unknown>>).push({
134
136
  type: LAUNCHER_BLOCK,
135
137
  props: {
@@ -138,10 +140,13 @@ export async function injectProposalChatLauncher(
138
140
  organisationId,
139
141
  viewerEmail,
140
142
  runtimeContext,
141
- primaryColor: brandPrimaryColor(resolved),
142
- title: "Proposal Assistant",
143
- welcomeMessage:
144
- "Hi! Ask me anything about this proposal — scope, pricing, timeline or next steps.",
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,
145
150
  launchLabel: "Ask about this proposal",
146
151
  },
147
152
  });
@@ -37,6 +37,18 @@ mock.module("./page-server-orpc/server-client.ts", () => ({
37
37
  mock.module("@teamscala/logger/app-loggers", () => ({
38
38
  getAppLogger: () => ({ error: () => {} }),
39
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
+ }));
40
52
 
41
53
  const { injectResourceChatLauncher } = await import("./resource-chat-inject.ts");
42
54
  const { injectProposalChatLauncher } = await import("./proposal-chat-inject.ts");
@@ -60,7 +72,6 @@ describe("chat injectors — pushed block name resolves to a registered block",
60
72
  await injectResourceChatLauncher(resolved, "viewer@example.com", {
61
73
  resourceKey: "agreement",
62
74
  agentConfigSlug: "agreement-assistant-agent",
63
- title: "Agreement Assistant",
64
75
  launchLabel: "Ask about this agreement",
65
76
  });
66
77
  const blocks = pushedBlocks(resolved);
@@ -81,7 +92,6 @@ describe("chat injectors — pushed block name resolves to a registered block",
81
92
  await injectResourceChatLauncher(a, "viewer@example.com", {
82
93
  resourceKey: "agreement",
83
94
  agentConfigSlug: "agreement-assistant-agent",
84
- title: "Agreement Assistant",
85
95
  launchLabel: "Ask about this agreement",
86
96
  });
87
97
  const p = makeResolved("proposal");
@@ -94,4 +104,25 @@ describe("chat injectors — pushed block name resolves to a registered block",
94
104
  }
95
105
  }
96
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
+ });
97
128
  });
@@ -22,6 +22,7 @@
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";
27
28
  import type { BlockSchemaKey } from "@teamscala/db/registry/generated/block-schema";
@@ -49,16 +50,67 @@ export interface ResourceChatOptions {
49
50
  /** Org id to scope the chat. If omitted, read from resolved[resourceKey].organisation_id
50
51
  * (proposals); agreements pass it explicitly (org isn't on the resolved record). */
51
52
  organisationId?: string;
52
- /** Overlay title-bar label (e.g. "Agreement Assistant"). */
53
- title: string;
54
53
  /** Floating button label / speech bubble (e.g. "Ask about this agreement"). */
55
54
  launchLabel: string;
56
- /** First assistant message. */
57
- welcomeMessage?: string;
58
55
  /** Input placeholder. */
59
56
  placeholder?: string;
60
- /** Suggested starter questions. */
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;
61
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
+ }
62
114
  }
63
115
 
64
116
  /** Serialise the resource record + entire page content tree as text for the GPT. */
@@ -157,6 +209,7 @@ export async function injectResourceChatLauncher(
157
209
 
158
210
  try {
159
211
  const runtimeContext = await buildResourceContext(resolved, opts.resourceKey);
212
+ const presentation = await loadAgentPresentation(agentId);
160
213
  (page.content as Array<Record<string, unknown>>).push({
161
214
  type: LAUNCHER_BLOCK,
162
215
  props: {
@@ -165,12 +218,14 @@ export async function injectResourceChatLauncher(
165
218
  organisationId,
166
219
  viewerEmail,
167
220
  runtimeContext,
168
- primaryColor: brandPrimaryColor(resolved),
169
- title: opts.title,
170
- welcomeMessage: opts.welcomeMessage,
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,
171
226
  launchLabel: opts.launchLabel,
172
227
  placeholder: opts.placeholder,
173
- conversationStarters: opts.conversationStarters,
228
+ conversationStarters: presentation.conversationStarters,
174
229
  },
175
230
  });
176
231
  } catch (err) {