@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
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.379",
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,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: "ChatLauncherBlock",
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
- primaryColor: brandPrimaryColor(resolved),
130
- title: "Proposal Assistant",
131
- welcomeMessage:
132
- "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,
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: string;
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
- /** 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;
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: "ResourceChatLauncher",
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
- primaryColor: brandPrimaryColor(resolved),
156
- title: opts.title,
157
- 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,
158
226
  launchLabel: opts.launchLabel,
159
227
  placeholder: opts.placeholder,
160
- conversationStarters: opts.conversationStarters,
228
+ conversationStarters: presentation.conversationStarters,
161
229
  },
162
230
  });
163
231
  } catch (err) {