@teamscala/service-boot 0.23.495 → 0.23.497
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
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* resource-chat-inject.page-assistant.test.ts — the page assistant must be
|
|
7
|
+
* PRESENT IN RENDERED OUTPUT on every link shape a page resolves.
|
|
8
|
+
*
|
|
9
|
+
* THE DEFECT THIS PINS (found 2026-09-08, quiz report page): the page_route's
|
|
10
|
+
* assistant named resource_key "response" — the loader's RAW response hit.
|
|
11
|
+
* On /report/<response-id> that hit exists and the chat bubble injected. On
|
|
12
|
+
* anchor links (a research-record id, live or superseded) the raw hit is
|
|
13
|
+
* null, injectResourceChatLauncher read no organisation_id off it, returned
|
|
14
|
+
* SILENTLY, and the page rendered a complete report at 200 with the
|
|
15
|
+
* assistant simply absent — 552KB of script gone, no error anywhere. The
|
|
16
|
+
* page's reportData was byte-identical across all three shapes, which is
|
|
17
|
+
* exactly why a data-equality check could not see it: the launcher is a
|
|
18
|
+
* separate BLOCK whose presence depends on resolved[resourceKey], not on the
|
|
19
|
+
* report data.
|
|
20
|
+
*
|
|
21
|
+
* THE FIX SHAPE: the page's resource_key names the PAIRED key ("pair"),
|
|
22
|
+
* which exists on every link shape and carries organisation_id (the pair
|
|
23
|
+
* resolver returns the anchor half's organisation). These tests drive the
|
|
24
|
+
* injector with graphs shaped exactly like the loader produces for each of
|
|
25
|
+
* the three link shapes and assert the launcher lands in the rendered
|
|
26
|
+
* content tree — the thing that becomes markup — for ALL THREE. A
|
|
27
|
+
* data-equality assertion cannot pass in their place.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
31
|
+
|
|
32
|
+
/** The ONE active launcher block_schema slug — the Puck registry key. */
|
|
33
|
+
const REGISTERED_LAUNCHER_SLUG = "ResourceLauncher";
|
|
34
|
+
|
|
35
|
+
mock.module("@teamscala/db/registry/load-config", () => ({
|
|
36
|
+
loadRegistryConfig: async () => ({ agentId: "agent-1" }),
|
|
37
|
+
}));
|
|
38
|
+
mock.module("./page-server-orpc/server-client.ts", () => ({
|
|
39
|
+
getServerClient: () => ({
|
|
40
|
+
ai_agents: {
|
|
41
|
+
findFirst: async () => ({
|
|
42
|
+
name: "Results Assistant",
|
|
43
|
+
welcome_message: "Hi! Ask me anything about your results.",
|
|
44
|
+
conversation_starters: ["What are my strengths?"],
|
|
45
|
+
primary_color: "#6366f1",
|
|
46
|
+
}),
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
}));
|
|
50
|
+
mock.module("@teamscala/logger/app-loggers", () => ({
|
|
51
|
+
getAppLogger: () => ({ error: (...args: unknown[]) => void args }),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
const { injectResourceChatLauncher } = await import("./resource-chat-inject.ts");
|
|
55
|
+
|
|
56
|
+
const ORG = "org-1";
|
|
57
|
+
const AGENT_OPTS = {
|
|
58
|
+
agentConfigSlug: "assessment-assistant-agent" as never,
|
|
59
|
+
launchLabel: "Ask about your results",
|
|
60
|
+
placeholder: "Ask about your results…",
|
|
61
|
+
regionSlots: ["header", "body", "footer"] as readonly string[],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** A resolved response row as the loader's response source selects it. */
|
|
65
|
+
const RESPONSE_ROW = {
|
|
66
|
+
id: "resp-1",
|
|
67
|
+
assessment_id: "asm-1",
|
|
68
|
+
organisation_id: ORG,
|
|
69
|
+
total_score: 64,
|
|
70
|
+
first_name: "Anna",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** The paired key as resolveReportHalves returns it — present on EVERY link
|
|
74
|
+
* shape, carrying the anchor half's organisation. */
|
|
75
|
+
const PAIRED = {
|
|
76
|
+
response: RESPONSE_ROW,
|
|
77
|
+
research: { id: "research-live", assessment_id: "asm-1", organisation_id: ORG },
|
|
78
|
+
assessment_id: "asm-1",
|
|
79
|
+
organisation_id: ORG,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function pageGraph(
|
|
83
|
+
responseSource: unknown,
|
|
84
|
+
pairSource: Record<string, unknown>,
|
|
85
|
+
): Record<string, unknown> {
|
|
86
|
+
return {
|
|
87
|
+
response: responseSource,
|
|
88
|
+
pair: pairSource,
|
|
89
|
+
quiz: { id: "asm-1", organisation_id: ORG, title: "Authority Index" },
|
|
90
|
+
brand: { primaryColor: "#123456" },
|
|
91
|
+
body: { puck_data: { root: { props: {} }, zones: {}, content: [] } },
|
|
92
|
+
header: { puck_data: { root: { props: {} }, zones: {}, content: [] } },
|
|
93
|
+
footer: { puck_data: { root: { props: {} }, zones: {}, content: [] } },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The launcher renders `position: fixed`, so it may land in whichever
|
|
98
|
+
* region slot is declared first — count across EVERY slot, not one. */
|
|
99
|
+
function launcherBlocks(
|
|
100
|
+
resolved: Record<string, unknown>,
|
|
101
|
+
): Array<{ type: string; props?: Record<string, unknown> }> {
|
|
102
|
+
const out: Array<{ type: string; props?: Record<string, unknown> }> = [];
|
|
103
|
+
for (const slot of AGENT_OPTS.regionSlots) {
|
|
104
|
+
const content = (
|
|
105
|
+
resolved[slot] as {
|
|
106
|
+
puck_data?: { content?: Array<{ type: string; props?: Record<string, unknown> }> };
|
|
107
|
+
}
|
|
108
|
+
).puck_data?.content;
|
|
109
|
+
for (const block of content ?? []) {
|
|
110
|
+
if (block.type === REGISTERED_LAUNCHER_SLUG) out.push(block);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function launcherCount(
|
|
117
|
+
responseSource: unknown,
|
|
118
|
+
pairSource: Record<string, unknown>,
|
|
119
|
+
resourceKey: string,
|
|
120
|
+
): Promise<number> {
|
|
121
|
+
const resolved = pageGraph(responseSource, pairSource);
|
|
122
|
+
await injectResourceChatLauncher(resolved, "", { ...AGENT_OPTS, resourceKey });
|
|
123
|
+
return launcherBlocks(resolved).length;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
describe("page assistant renders on every link shape", () => {
|
|
127
|
+
test("response-id link: raw hit exists, launcher renders", async () => {
|
|
128
|
+
expect(await launcherCount(RESPONSE_ROW, PAIRED, "pair")).toBe(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("live research-anchor link: raw hit is null, launcher still renders", async () => {
|
|
132
|
+
expect(await launcherCount(null, PAIRED, "pair")).toBe(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("superseded research-anchor link: raw hit is null, launcher still renders", async () => {
|
|
136
|
+
// identical graph shape to the live anchor — resolution to the live
|
|
137
|
+
// pass happens upstream in the pair resolver, so the injection surface
|
|
138
|
+
// is the same and is exercised identically
|
|
139
|
+
expect(await launcherCount(null, PAIRED, "pair")).toBe(1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("the launcher block carries the agent, org scope and context", async () => {
|
|
143
|
+
const resolved = pageGraph(null, PAIRED);
|
|
144
|
+
await injectResourceChatLauncher(resolved, "viewer@example.com", {
|
|
145
|
+
...AGENT_OPTS,
|
|
146
|
+
resourceKey: "pair",
|
|
147
|
+
});
|
|
148
|
+
const launchers = launcherBlocks(resolved);
|
|
149
|
+
expect(launchers.length).toBe(1);
|
|
150
|
+
const launcher = launchers[0];
|
|
151
|
+
expect(launcher.props.agentId).toBe("agent-1");
|
|
152
|
+
expect(launcher.props.organisationId).toBe(ORG);
|
|
153
|
+
expect(typeof launcher.props.runtimeContext).toBe("string");
|
|
154
|
+
expect((launcher.props.runtimeContext as string).length).toBeGreaterThan(0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("the DEFECT SHAPE: keying the raw response hit on an anchor link silently renders none", async () => {
|
|
158
|
+
// resource_key "response" + a null raw hit is the 2026-09-08 regression.
|
|
159
|
+
// The injector contract is fail-soft for the render, so the absence is
|
|
160
|
+
// quiet — this pins the shape with the reason spelled out, so the row
|
|
161
|
+
// can never go back to a key that is null on anchor links without the
|
|
162
|
+
// next reader finding this.
|
|
163
|
+
expect(await launcherCount(null, PAIRED, "response")).toBe(0);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -315,7 +315,21 @@ export async function injectResourceChatLauncher(
|
|
|
315
315
|
const organisationId =
|
|
316
316
|
opts.organisationId ||
|
|
317
317
|
(typeof record?.organisation_id === "string" ? record.organisation_id : "");
|
|
318
|
-
if (!organisationId)
|
|
318
|
+
if (!organisationId) {
|
|
319
|
+
// THIS BAIL USED TO BE SILENT, AND A SILENT DROP IS THE WORST FAILURE
|
|
320
|
+
// SHAPE A BLOCK CAN HAVE (measured 2026-09-08 on the quiz report): the
|
|
321
|
+
// page returns 200 and looks complete while the assistant is simply
|
|
322
|
+
// absent — resource_key named a resolved key that is null on some link
|
|
323
|
+
// shapes (a research-anchor link has no raw response match), and the
|
|
324
|
+
// page rendered a full report with no bubble and no error anywhere.
|
|
325
|
+
// The page must stay usable (fail-soft for the RENDER is the contract),
|
|
326
|
+
// but a declared assistant that did not inject is never "fine": name
|
|
327
|
+
// the key and the reason so the absence is observable.
|
|
328
|
+
getAppLogger().error(
|
|
329
|
+
`[resource-chat] assistant declared but NOT injected: resolved["${opts.resourceKey}"] is ${record === undefined ? "absent" : "null"} or carries no organisation_id — the launcher is silently missing from this page`,
|
|
330
|
+
);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
319
333
|
|
|
320
334
|
let agentId = "";
|
|
321
335
|
try {
|