@pie-players/pie-assessment-toolkit 0.3.74 → 1.0.0-beta.0

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.
Files changed (257) hide show
  1. package/README.md +658 -714
  2. package/custom-elements.json +1315 -0
  3. package/dist/attempt/TestSession.d.ts +1 -21
  4. package/dist/attempt/TestSession.js +16 -102
  5. package/dist/components/ItemScope.custom-element.d.ts +1 -0
  6. package/dist/components/ItemScope.custom-element.js +1 -0
  7. package/dist/components/ItemToolBar.custom-element.js +1 -1
  8. package/dist/components/PieAssessmentToolkit.custom-element.js +14 -14
  9. package/dist/components/SectionToolBar.custom-element.js +1 -1
  10. package/dist/components/chunks/ItemToolBar-3zrc4p3d.js +4 -0
  11. package/dist/components/chunks/ItemToolBar-66hcwmfw.js +2 -0
  12. package/dist/components/chunks/ItemToolBar-9gqhfxtn.js +40 -0
  13. package/dist/components/chunks/ItemToolBar-hzc5wyjp.js +2 -0
  14. package/dist/components/chunks/{ItemToolBar-gmrryv2v.js → ItemToolBar-kkj69ptr.js} +1 -1
  15. package/dist/components/chunks/ItemToolBar-vdkyn9f3.js +2 -0
  16. package/dist/components/chunks/sre-engine-bj997w4b.js +1 -0
  17. package/dist/components/item-scope-element.d.ts +2 -0
  18. package/dist/components/item-scope-element.js +2 -0
  19. package/dist/context/assessment-toolkit-context.d.ts +33 -6
  20. package/dist/index.d.ts +25 -50
  21. package/dist/index.js +15 -45
  22. package/dist/policy/core/PolicySource.d.ts +2 -2
  23. package/dist/policy/core/ToolPolicyEngine.d.ts +118 -34
  24. package/dist/policy/core/ToolPolicyEngine.js +203 -56
  25. package/dist/policy/core/compose-decision.d.ts +28 -7
  26. package/dist/policy/core/compose-decision.js +95 -74
  27. package/dist/policy/core/decision-types.d.ts +98 -55
  28. package/dist/policy/core/feature-decision.d.ts +28 -19
  29. package/dist/policy/core/feature-decision.js +15 -11
  30. package/dist/policy/core/pnp-policy-inputs.d.ts +29 -35
  31. package/dist/policy/core/pnp-policy-inputs.js +40 -88
  32. package/dist/policy/core/provenance.d.ts +10 -5
  33. package/dist/policy/core/provenance.js +1 -1
  34. package/dist/policy/core/tool-parameters.d.ts +11 -0
  35. package/dist/policy/core/tool-parameters.js +15 -0
  36. package/dist/policy/engine.d.ts +7 -23
  37. package/dist/policy/engine.js +5 -20
  38. package/dist/policy/sources/PnpPolicySource.d.ts +43 -68
  39. package/dist/policy/sources/PnpPolicySource.js +133 -132
  40. package/dist/runtime/SectionControllerBinding.d.ts +283 -0
  41. package/dist/runtime/SectionControllerBinding.js +456 -0
  42. package/dist/runtime/SectionRuntimeEngine.d.ts +26 -289
  43. package/dist/runtime/SectionRuntimeEngine.js +30 -372
  44. package/dist/runtime/adapter/SectionEngineAdapter.d.ts +19 -80
  45. package/dist/runtime/adapter/SectionEngineAdapter.js +19 -92
  46. package/dist/runtime/adapter/dom-event-bridge.d.ts +15 -30
  47. package/dist/runtime/adapter/dom-event-bridge.js +17 -44
  48. package/dist/runtime/adapter/subscriber-fanout.d.ts +1 -3
  49. package/dist/runtime/adapter/subscriber-fanout.js +2 -6
  50. package/dist/runtime/catalog-context.d.ts +19 -0
  51. package/dist/runtime/catalog-context.js +28 -0
  52. package/dist/runtime/composition-emit-scheduler.d.ts +12 -18
  53. package/dist/runtime/composition-emit-scheduler.js +10 -16
  54. package/dist/runtime/content-language.d.ts +32 -0
  55. package/dist/runtime/content-language.js +46 -0
  56. package/dist/runtime/content-region.d.ts +13 -0
  57. package/dist/runtime/content-region.js +29 -0
  58. package/dist/runtime/content-registration.d.ts +13 -0
  59. package/dist/runtime/content-registration.js +25 -0
  60. package/dist/runtime/core/SectionEngineCore.d.ts +2 -4
  61. package/dist/runtime/core/SectionEngineCore.js +2 -6
  62. package/dist/runtime/core/cohort.d.ts +1 -6
  63. package/dist/runtime/core/cohort.js +1 -10
  64. package/dist/runtime/core/engine-input.d.ts +18 -30
  65. package/dist/runtime/core/engine-input.js +11 -12
  66. package/dist/runtime/core/engine-output.d.ts +17 -40
  67. package/dist/runtime/core/engine-output.js +9 -21
  68. package/dist/runtime/core/engine-readiness.d.ts +5 -37
  69. package/dist/runtime/core/engine-readiness.js +8 -41
  70. package/dist/runtime/core/engine-resolver.d.ts +46 -74
  71. package/dist/runtime/core/engine-resolver.js +18 -43
  72. package/dist/runtime/core/engine-state.d.ts +28 -36
  73. package/dist/runtime/core/engine-state.js +18 -21
  74. package/dist/runtime/core/engine-transition.d.ts +14 -19
  75. package/dist/runtime/core/engine-transition.js +74 -53
  76. package/dist/runtime/engine.d.ts +16 -19
  77. package/dist/runtime/engine.js +12 -19
  78. package/dist/runtime/page-warnings.d.ts +13 -0
  79. package/dist/runtime/page-warnings.js +22 -0
  80. package/dist/runtime/policy-input-forwarding.d.ts +25 -0
  81. package/dist/runtime/policy-input-forwarding.js +25 -0
  82. package/dist/runtime/registration-events.d.ts +22 -14
  83. package/dist/runtime/runtime-event-claim.d.ts +8 -0
  84. package/dist/runtime/runtime-event-claim.js +15 -0
  85. package/dist/runtime/runtime-id.d.ts +1 -1
  86. package/dist/runtime/runtime-id.js +1 -1
  87. package/dist/runtime/shell-event-bridge.d.ts +45 -0
  88. package/dist/runtime/shell-event-bridge.js +127 -0
  89. package/dist/runtime/shell-registration.d.ts +73 -0
  90. package/dist/runtime/shell-registration.js +95 -0
  91. package/dist/runtime/shell-scope.d.ts +68 -0
  92. package/dist/runtime/shell-scope.js +194 -0
  93. package/dist/runtime/tool-host-contract.d.ts +8 -14
  94. package/dist/runtime/tool-host-contract.js +12 -12
  95. package/dist/runtime/unclaimed-registration-watch.d.ts +7 -0
  96. package/dist/runtime/unclaimed-registration-watch.js +23 -0
  97. package/dist/services/AccessibilityCatalogResolver.d.ts +21 -31
  98. package/dist/services/AccessibilityCatalogResolver.js +29 -42
  99. package/dist/services/ElementToolStateStore.d.ts +42 -67
  100. package/dist/services/ElementToolStateStore.js +65 -71
  101. package/dist/services/HighlightCoordinator.d.ts +28 -40
  102. package/dist/services/HighlightCoordinator.js +459 -381
  103. package/dist/services/RangeSerializer.d.ts +11 -33
  104. package/dist/services/RangeSerializer.js +88 -59
  105. package/dist/services/SSMLExtractor.d.ts +8 -31
  106. package/dist/services/SSMLExtractor.js +18 -40
  107. package/dist/services/TTSService.d.ts +159 -119
  108. package/dist/services/TTSService.js +937 -822
  109. package/dist/services/ToolCoordinator.d.ts +42 -102
  110. package/dist/services/ToolCoordinator.js +178 -256
  111. package/dist/services/ToolRegistry.d.ts +152 -133
  112. package/dist/services/ToolRegistry.js +192 -178
  113. package/dist/services/ToolkitCoordinator.d.ts +233 -237
  114. package/dist/services/ToolkitCoordinator.js +615 -557
  115. package/dist/services/audio-handoff.d.ts +6 -7
  116. package/dist/services/audio-handoff.js +20 -14
  117. package/dist/services/catalog-owner.d.ts +0 -1
  118. package/dist/services/framework-error-bus.d.ts +3 -6
  119. package/dist/services/framework-error-bus.js +3 -6
  120. package/dist/services/framework-error.d.ts +51 -13
  121. package/dist/services/framework-error.js +19 -20
  122. package/dist/services/interfaces.d.ts +189 -184
  123. package/dist/services/section-controller-types.d.ts +7 -7
  124. package/dist/services/section-session-assignment.d.ts +21 -0
  125. package/dist/services/section-session-assignment.js +80 -0
  126. package/dist/services/spoken-audio-cards.d.ts +2 -6
  127. package/dist/services/spoken-audio-cards.js +2 -6
  128. package/dist/services/tool-config-defaults.d.ts +3 -3
  129. package/dist/services/tool-config-defaults.js +3 -3
  130. package/dist/services/tool-config-validation.d.ts +9 -12
  131. package/dist/services/tool-config-validation.js +47 -87
  132. package/dist/services/tool-context.d.ts +7 -31
  133. package/dist/services/tool-context.js +99 -65
  134. package/dist/services/tool-failure.d.ts +30 -0
  135. package/dist/services/tool-failure.js +11 -0
  136. package/dist/services/tool-icons.d.ts +5 -5
  137. package/dist/services/tool-icons.js +6 -5
  138. package/dist/services/tool-instance-id.d.ts +8 -7
  139. package/dist/services/tool-instance-id.js +12 -24
  140. package/dist/services/tool-providers/TTSToolProvider.d.ts +20 -58
  141. package/dist/services/tool-providers/TTSToolProvider.js +32 -85
  142. package/dist/services/tool-providers/ToolProviderApi.d.ts +10 -11
  143. package/dist/services/tool-providers/ToolProviderRegistry.d.ts +29 -60
  144. package/dist/services/tool-providers/ToolProviderRegistry.js +128 -144
  145. package/dist/services/tool-request.d.ts +31 -12
  146. package/dist/services/tool-request.js +31 -16
  147. package/dist/services/toolbar-decided-tools.d.ts +16 -0
  148. package/dist/services/toolbar-decided-tools.js +29 -0
  149. package/dist/services/toolbar-items.js +2 -5
  150. package/dist/services/tools-config-normalizer.d.ts +33 -29
  151. package/dist/services/tools-config-normalizer.js +26 -28
  152. package/dist/services/tts/browser-provider.d.ts +39 -6
  153. package/dist/services/tts/browser-provider.js +280 -160
  154. package/dist/services/tts/catalog-span-alignment.js +14 -7
  155. package/dist/services/tts/flat-tree.d.ts +64 -0
  156. package/dist/services/tts/flat-tree.js +269 -0
  157. package/dist/services/tts/generated-speech/assemble-plan.d.ts +3 -1
  158. package/dist/services/tts/generated-speech/assemble-plan.js +3 -1
  159. package/dist/services/tts/generated-speech/dom/build-dom-plan.d.ts +2 -0
  160. package/dist/services/tts/generated-speech/dom/build-dom-plan.js +1 -1
  161. package/dist/services/tts/generated-speech/dom/to-chunk-inputs.d.ts +2 -2
  162. package/dist/services/tts/generated-speech/index.d.ts +1 -1
  163. package/dist/services/tts/generated-speech/index.js +1 -1
  164. package/dist/services/tts/generated-speech/math-speech-cache.d.ts +3 -1
  165. package/dist/services/tts/generated-speech/math-speech-cache.js +5 -1
  166. package/dist/services/tts/generated-speech/types.d.ts +1 -1
  167. package/dist/services/tts/generated-speech/types.js +1 -1
  168. package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +22 -16
  169. package/dist/services/tts/highlight-pipeline/prose-boundary-resolver.js +6 -4
  170. package/dist/services/tts/highlight-pipeline/readable-region.js +3 -2
  171. package/dist/services/tts/highlight-pipeline/types.d.ts +5 -0
  172. package/dist/services/tts/highlight-pipeline/visible-map-range.d.ts +12 -0
  173. package/dist/services/tts/highlight-pipeline/visible-map-range.js +73 -7
  174. package/dist/services/tts/highlight-target-resolver.d.ts +12 -0
  175. package/dist/services/tts/math-aware-text-processing.d.ts +5 -1
  176. package/dist/services/tts/math-aware-text-processing.js +42 -47
  177. package/dist/services/tts/math-control-names.d.ts +21 -0
  178. package/dist/services/tts/math-control-names.js +142 -0
  179. package/dist/services/tts/math-speech.d.ts +12 -1
  180. package/dist/services/tts/math-speech.js +54 -18
  181. package/dist/services/tts/mathml-sanitization.d.ts +7 -0
  182. package/dist/services/tts/mathml-sanitization.js +9 -1
  183. package/dist/services/tts/provider-options.d.ts +52 -0
  184. package/dist/services/tts/provider-options.js +1 -0
  185. package/dist/services/tts/ssml/spoken-text.d.ts +2 -26
  186. package/dist/services/tts/ssml/spoken-text.js +29 -0
  187. package/dist/services/tts/start-failure.d.ts +4 -0
  188. package/dist/services/tts/start-failure.js +19 -0
  189. package/dist/services/tts/text-processing.d.ts +9 -0
  190. package/dist/services/tts/text-processing.js +46 -33
  191. package/dist/services/tts/text-segmentation.d.ts +12 -0
  192. package/dist/services/tts/text-segmentation.js +60 -10
  193. package/dist/services/tts-runtime-config.d.ts +54 -26
  194. package/dist/services/tts-runtime-config.js +27 -112
  195. package/dist/tools/content-capability-resolution.d.ts +9 -11
  196. package/dist/tools/content-capability-resolution.js +8 -45
  197. package/dist/tools/registration.d.ts +59 -0
  198. package/dist/tools/registration.js +55 -0
  199. package/dist/tools/registrations/toolbar-registration-helpers.d.ts +6 -0
  200. package/dist/tools/registrations/toolbar-registration-helpers.js +8 -2
  201. package/dist/tools/tool-surface-host.d.ts +6 -10
  202. package/dist/tools/tool-surface-host.js +45 -50
  203. package/dist/tools/tool-tag-map.d.ts +5 -15
  204. package/dist/tools/tool-tag-map.js +6 -20
  205. package/dist/utils/structural-equality.d.ts +11 -0
  206. package/dist/utils/structural-equality.js +47 -0
  207. package/package.json +15 -29
  208. package/dist/attempt/adapters/activity-to-test-attempt-session.d.ts +0 -49
  209. package/dist/attempt/adapters/activity-to-test-attempt-session.js +0 -138
  210. package/dist/components/chunks/ItemToolBar-b9dz5jvd.js +0 -40
  211. package/dist/components/chunks/ItemToolBar-h4xap0t0.js +0 -4
  212. package/dist/components/chunks/ItemToolBar-wr2myaah.js +0 -2
  213. package/dist/components/chunks/sre-engine-wf9s43gg.js +0 -1
  214. package/dist/context/runtime-context-consumer.d.ts +0 -13
  215. package/dist/context/runtime-context-consumer.js +0 -18
  216. package/dist/item-loader.d.ts +0 -44
  217. package/dist/item-loader.js +0 -61
  218. package/dist/policy/core/engine-context.d.ts +0 -29
  219. package/dist/policy/core/engine-context.js +0 -27
  220. package/dist/policy/core/policy-source-tag.d.ts +0 -31
  221. package/dist/policy/core/policy-source-tag.js +0 -28
  222. package/dist/policy/internal.d.ts +0 -25
  223. package/dist/policy/internal.js +0 -25
  224. package/dist/runtime/adapter/coordinator-bridge.d.ts +0 -92
  225. package/dist/runtime/adapter/coordinator-bridge.js +0 -126
  226. package/dist/runtime/adapter/framework-error-bridge.d.ts +0 -36
  227. package/dist/runtime/adapter/framework-error-bridge.js +0 -46
  228. package/dist/runtime/adapter/instrumentation-bridge.d.ts +0 -31
  229. package/dist/runtime/adapter/instrumentation-bridge.js +0 -38
  230. package/dist/runtime/core/engine-stage-derivation.d.ts +0 -18
  231. package/dist/runtime/core/engine-stage-derivation.js +0 -29
  232. package/dist/runtime/engine-context.d.ts +0 -39
  233. package/dist/runtime/engine-context.js +0 -31
  234. package/dist/runtime/internal.d.ts +0 -43
  235. package/dist/runtime/internal.js +0 -45
  236. package/dist/runtime/section-runtime-engine-host-context.d.ts +0 -74
  237. package/dist/runtime/section-runtime-engine-host-context.js +0 -57
  238. package/dist/runtime/stage-emit-gate.d.ts +0 -92
  239. package/dist/runtime/stage-emit-gate.js +0 -82
  240. package/dist/services/ContextVariableStore.d.ts +0 -113
  241. package/dist/services/ContextVariableStore.js +0 -206
  242. package/dist/services/I18nService.d.ts +0 -47
  243. package/dist/services/I18nService.js +0 -84
  244. package/dist/services/ThemeProvider.d.ts +0 -95
  245. package/dist/services/ThemeProvider.js +0 -334
  246. package/dist/services/defaultPersonalNeedsProfile.d.ts +0 -21
  247. package/dist/services/defaultPersonalNeedsProfile.js +0 -26
  248. package/dist/services/pnp-standard-features.d.ts +0 -238
  249. package/dist/services/pnp-standard-features.js +0 -338
  250. package/dist/services/tts-control-events.d.ts +0 -13
  251. package/dist/services/tts-control-events.js +0 -7
  252. package/dist/tools/client.d.ts +0 -10
  253. package/dist/tools/client.js +0 -10
  254. package/dist/tools/internal.d.ts +0 -44
  255. package/dist/tools/internal.js +0 -39
  256. package/dist/utils/logger.d.ts +0 -6
  257. package/dist/utils/logger.js +0 -10
@@ -1,17 +1,25 @@
1
1
  /**
2
- * ToolkitCoordinator - Centralized Assessment Toolkit Service Management
2
+ * ToolkitCoordinator: one per assessment, the owner of every toolkit service
3
+ * (TTSService, ToolCoordinator, HighlightCoordinator, ElementToolStateStore,
4
+ * AccessibilityCatalogResolver, ToolProviderRegistry), the ToolPolicyEngine,
5
+ * the framework-error bus and the section controllers. It holds toolkit
6
+ * concerns only; navigation, timing and progress belong to the player.
3
7
  *
4
- * Orchestrates all toolkit services (TTS, tools, accessibility, state management) from a single entry point.
5
- * Provides centralized configuration for tool availability and settings.
6
- *
7
- * Key features:
8
- * - Owns all toolkit services (TTSService, ToolCoordinator, HighlightCoordinator, ElementToolStateStore, AccessibilityCatalogResolver)
9
- * - Single configuration point for tool availability and settings
10
- * - Sensible defaults for standalone usage
11
- * - Direct access for settings widgets (no player dependency)
12
- * - Clean separation: toolkit concerns only (NOT assessment state like navigation, timing, progress)
13
- *
14
- * Part of PIE Assessment Toolkit.
8
+ * Layout: the module's exported config, hook and event types come first, then
9
+ * the class, whose members run in this order:
10
+ * - config resolution and the constructor;
11
+ * - error, telemetry and tool-failure reporting, readiness listeners and the
12
+ * framework-error bus;
13
+ * - element tool-state persistence;
14
+ * - tool registry adoption and provider registration;
15
+ * - section-event subscriptions, which follow the active cohort;
16
+ * - section controller creation, publication and disposal;
17
+ * - coordinator disposal;
18
+ * - TTS bring-up and readiness;
19
+ * - tool config updates and the policy engine surface, with the tool-context
20
+ * resolvers split around it (installation before, queries after);
21
+ * - tool-open requests;
22
+ * - config-change application and TTS reconfiguration, last.
15
23
  */
16
24
  import { normalizeToolsConfig, } from "./tools-config-normalizer.js";
17
25
  import { collectToolConfigDiagnostics, normalizeToolConfigStrictness, reportToolConfigDiagnostics, } from "./tool-config-validation.js";
@@ -22,13 +30,13 @@ import { FrameworkErrorBus, } from "./framework-error-bus.js";
22
30
  import { HighlightCoordinator } from "./HighlightCoordinator.js";
23
31
  import { ToolCoordinator } from "./ToolCoordinator.js";
24
32
  import { TTSService } from "./TTSService.js";
25
- import { BrowserTTSProvider } from "./tts/browser-provider.js";
33
+ import { BrowserTTSProvider, browserFallbackConfig, } from "./tts/browser-provider.js";
26
34
  import { buildRuntimeTTSConfig, resolveTTSBackend, resolveTTSRuntimeSettings, } from "./tts-runtime-config.js";
27
35
  import { ToolProviderRegistry } from "./tool-providers/index.js";
28
- import { resolveToolProviderId, ToolRegistry } from "./ToolRegistry.js";
36
+ import { ToolRegistry } from "./ToolRegistry.js";
29
37
  import { ToolRequestRegistry } from "./tool-request.js";
30
38
  import { ToolPolicyEngine, } from "../policy/engine.js";
31
- import { resolveDefaultPnpEnforcement } from "../policy/internal.js";
39
+ import { resolveSectionSessionAssignment } from "./section-session-assignment.js";
32
40
  class ToolkitCoordinatorDisposedError extends Error {
33
41
  constructor() {
34
42
  super("ToolkitCoordinator has been disposed.");
@@ -45,19 +53,22 @@ const isPlainRecord = (value) => !!value && typeof value === "object" && !Array.
45
53
  const mergeToolConfigUpdate = (toolId, current, updates) => {
46
54
  const next = { ...current, ...updates };
47
55
  if (toolId === "textToSpeech" &&
48
- isPlainRecord(current.settings) &&
49
- isPlainRecord(updates.settings)) {
50
- next.settings = { ...current.settings, ...updates.settings };
51
- if (isPlainRecord(current.settings.mathSpeech) &&
52
- isPlainRecord(updates.settings.mathSpeech)) {
53
- next.settings.mathSpeech = {
54
- ...current.settings.mathSpeech,
55
- ...updates.settings.mathSpeech,
56
- };
57
- }
56
+ isPlainRecord(current.mathSpeech) &&
57
+ isPlainRecord(updates.mathSpeech)) {
58
+ next.mathSpeech = { ...current.mathSpeech, ...updates.mathSpeech };
58
59
  }
59
60
  return next;
60
61
  };
62
+ const TOOL_FAILURE_SUMMARY = {
63
+ "tool-module-load": "failed to load",
64
+ "tool-request-open": "failed to open on request",
65
+ "tool-request-host-check": "failed its toolbar's host check",
66
+ "tool-visibility": "failed its relevance check",
67
+ "tool-applicability": "failed its applicability check",
68
+ "tool-state-load": "failed to restore its state",
69
+ "tool-state-save": "failed to save its state",
70
+ "tool-playback": "failed during playback",
71
+ };
61
72
  const SECTION_ITEM_EVENT_TYPES = [
62
73
  "item-selected",
63
74
  "item-session-data-changed",
@@ -81,7 +92,7 @@ const SECTION_SCOPED_EVENT_TYPES = [
81
92
  *
82
93
  * @example
83
94
  * ```typescript
84
- * // createPackagedToolRegistry is exported by @pie-players/pie-default-tool-loaders
95
+ * // Exported by @pie-players/pie-default-tool-loaders
85
96
  * const toolRegistry = createPackagedToolRegistry();
86
97
  *
87
98
  * // Create coordinator with configuration
@@ -91,7 +102,7 @@ const SECTION_SCOPED_EVENT_TYPES = [
91
102
  * tools: {
92
103
  * providers: {
93
104
  * textToSpeech: { enabled: true, backend: 'browser' },
94
- * answerEliminator: { enabled: true, strategy: 'strikethrough' }
105
+ * answerEliminator: { enabled: true }
95
106
  * },
96
107
  * placement: {
97
108
  * item: ['textToSpeech', 'answerEliminator']
@@ -123,25 +134,45 @@ export class ToolkitCoordinator {
123
134
  lazyInit;
124
135
  /** Track TTS initialization state */
125
136
  ttsInitialized = false;
137
+ /** Text-to-speech failed to start and policy does not grant it. */
138
+ ttsDegraded = false;
126
139
  ttsInitPromise;
127
140
  ttsReconfigurePromise;
128
- stateLoaded = false;
141
+ /**
142
+ * The loader ran, whether or not it returned state. A failed load is
143
+ * reported once and the coordinator runs without saved tool state.
144
+ */
145
+ stateLoadSettled = false;
129
146
  stateLoadPromise;
130
147
  coordinatorReadyPromise;
131
148
  coordinatorReadyNotified = false;
132
149
  providerInitPromises = new Map();
150
+ /**
151
+ * Recoverable failures, by tool. A policy change that grants one of these
152
+ * tools reports its failure again as fatal.
153
+ */
154
+ degradedTools = new Map();
155
+ /** Tool failures already reported, by phase and tool, each reported once. */
156
+ reportedToolFailures = new Set();
157
+ readyChangeListeners = new Set();
158
+ eagerInit;
133
159
  toolRegistry;
134
160
  /** Whether the host passed `toolRegistry`, which then always stands. */
135
161
  toolRegistrySupplied;
136
162
  /**
137
163
  * Whether the registry is final: supplied at construction, or adopted from
138
- * the toolkit this coordinator is bound to. Until then the empty placeholder
139
- * reports no missing registry, since the toolkit will supply one.
164
+ * the toolkit this coordinator is bound to.
140
165
  */
141
166
  toolRegistrySettled;
167
+ /**
168
+ * Whether a bound toolkit reported having no registry. Until then, or until a
169
+ * registry settles, the empty placeholder reports no missing registry, since
170
+ * the toolkit may supply one.
171
+ */
172
+ toolRegistryAbsent = false;
142
173
  toolContextResolvers = new Map();
143
174
  toolContextResolverChangeListeners = new Set();
144
- toolRequests = new ToolRequestRegistry();
175
+ toolRequests = new ToolRequestRegistry((toolId, phase, error) => this.reportToolFailure(toolId, phase, error));
145
176
  sectionControllers = new Map();
146
177
  sectionControllerKeys = new Map();
147
178
  sectionControllerInitEntries = new Map();
@@ -149,8 +180,8 @@ export class ToolkitCoordinator {
149
180
  sectionPersistenceStrategies = new Map();
150
181
  sectionControllerLifecycleListeners = new Set();
151
182
  /**
152
- * Phase D: every active section-event subscription, indexed by
153
- * listener identity. The listener follows the toolkit's active
183
+ * Every active section-event subscription, indexed by listener
184
+ * identity. The listener follows the toolkit's active
154
185
  * section cohort across transitions; this map is the registry the
155
186
  * coordinator iterates on every cohort change to detach the listener
156
187
  * from the outgoing controller, attach it to the new one, and replay
@@ -162,22 +193,26 @@ export class ToolkitCoordinator {
162
193
  */
163
194
  activeSubscriptions = new Map();
164
195
  /**
165
- * Phase D: map key of the section controller currently treated as
166
- * the *active cohort*. Set by `getOrCreateSectionController` (both
196
+ * Map key of the section controller currently treated as the
197
+ * *active cohort*. Set by `getOrCreateSectionController` (both
167
198
  * the create-new and resolve-existing paths) and cleared when the
168
- * matching controller is disposed. `null` while no cohort exists,
169
- * which is the only state where `subscribeSectionEvents` throws.
199
+ * matching controller is disposed, and `null` while the next requested
200
+ * cohort is still starting.
170
201
  */
171
202
  activeCohortMapKey = null;
172
203
  /**
173
204
  * Latest requested active cohort. Async controller initialization can
174
205
  * resolve out of order; only the most recent request may migrate active
175
206
  * subscriptions. Older completions still populate the controller cache.
207
+ * `null` until the first request, which is the only state where
208
+ * `subscribeSectionEvents` throws.
176
209
  */
177
210
  latestRequestedActiveCohortMapKey = null;
178
211
  telemetryListeners = new Set();
179
212
  frameworkErrorBus;
180
213
  ownsFrameworkErrorBus;
214
+ /** The instance behind {@link toolCoordinator}, kept for disposal. */
215
+ ownedToolCoordinator;
181
216
  frameworkErrorHookUnsubscribe = null;
182
217
  disposePromise = null;
183
218
  /**
@@ -188,32 +223,12 @@ export class ToolkitCoordinator {
188
223
  */
189
224
  policyEngine;
190
225
  /**
191
- * Host-set override for PNP/profile enforcement. `null` (the default) means
192
- * "auto" — the coordinator infers the effective mode from the
193
- * PNP/profile policy inputs the bound `AssessmentEntity` and `AssessmentItemRef`
194
- * actually carry (see {@link resolveEffectivePnpEnforcement}).
195
- * `"on"` / `"off"` are explicit host opt-in / opt-out and stick
196
- * across subsequent assessment / item swaps until the host clears
197
- * the override by calling `setPnpEnforcement(null)`.
198
- */
199
- pnpEnforcementOverride = null;
200
- /**
201
- * Last assessment passed to {@link updateAssessment}. Read by
202
- * {@link resolveEffectivePnpEnforcement} to compute auto-mode.
203
- * The engine's own copy is the canonical record for decisions;
204
- * this mirror exists only so the auto-mode helper does not need
205
- * to round-trip through {@link policyEngine}'s frozen snapshot.
206
- */
207
- boundAssessment = null;
208
- /**
209
- * Last item reference passed to {@link updateCurrentItemRef}.
210
- * Mirrored alongside {@link boundAssessment} so
211
- * {@link resolveEffectivePnpEnforcement} can detect item-level
212
- * profile policy inputs (`requiredTools` / `restrictedTools` /
213
- * `toolParameters`) without round-tripping through the engine's
214
- * frozen snapshot.
226
+ * Policy diagnostics reported since the last input change, keyed by code,
227
+ * tool id and, for `tool-policy.itemSettingNotApplied`, item id; see
228
+ * {@link warnPolicyDiagnostics}.
215
229
  */
216
- boundCurrentItemRef = null;
230
+ reportedPolicyDiagnostics = new Map();
231
+ policyDiagnosticListeners = new Set();
217
232
  /**
218
233
  * Whether {@link decideFeaturePolicy} has already reported serving a decision
219
234
  * with no assessment bound.
@@ -227,7 +242,7 @@ export class ToolkitCoordinator {
227
242
  reportedUnboundFeaturePolicy = false;
228
243
  /**
229
244
  * Whether {@link _initializeTTS} has already reported a non-browser backend
230
- * falling back to browser speech because no `tts` provider is registered.
245
+ * falling back to browser speech because no text-to-speech provider is registered.
231
246
  * Once per coordinator: a text-to-speech config change re-runs
232
247
  * initialization, and the missing provider is the same gap each time.
233
248
  */
@@ -292,7 +307,7 @@ export class ToolkitCoordinator {
292
307
  */
293
308
  validateToolsConfig(tools, options) {
294
309
  const { config, diagnostics } = collectToolConfigDiagnostics(tools, options.toolRegistry);
295
- const unreported = this.reportedRegistryUnavailable || !this.toolRegistrySettled
310
+ const unreported = this.reportedRegistryUnavailable || this.toolRegistryPending()
296
311
  ? diagnostics.filter((entry) => entry.code !== "tools.registryUnavailable")
297
312
  : diagnostics;
298
313
  reportToolConfigDiagnostics(unreported, options);
@@ -313,9 +328,11 @@ export class ToolkitCoordinator {
313
328
  this.config = resolvedConfig;
314
329
  this.toolRegistry = resolvedConfig.toolRegistry ?? new ToolRegistry();
315
330
  this.installToolContextResolvers(resolvedConfig.toolContextResolvers);
316
- this.hooks = resolvedConfig.hooks ?? {};
331
+ // The coordinator's own copy, so `setHooks` leaves the host's object as it
332
+ // passed it.
333
+ this.hooks = { ...resolvedConfig.hooks };
317
334
  this.lazyInit = config.lazyInit === true;
318
- this.pnpEnforcementOverride = this.resolveConfiguredPnpEnforcement();
335
+ this.eagerInit = config.eagerInit ?? !this.lazyInit;
319
336
  // Use the host-provided framework-error bus if one was passed
320
337
  // (typical when embedded inside <pie-assessment-toolkit>, so
321
338
  // pre-coordinator failures from the CE flow through the same
@@ -324,44 +341,54 @@ export class ToolkitCoordinator {
324
341
  config.frameworkErrorBus ?? new FrameworkErrorBus();
325
342
  this.ownsFrameworkErrorBus = !config.frameworkErrorBus;
326
343
  this.subscribeFrameworkErrorHookAdapters();
327
- // Initialize all services
328
- this.toolCoordinator = new ToolCoordinator();
344
+ this.ownedToolCoordinator = new ToolCoordinator();
345
+ this.toolCoordinator = this.ownedToolCoordinator;
329
346
  this.highlightCoordinator = new HighlightCoordinator();
330
347
  this.elementToolStateStore = new ElementToolStateStore();
331
348
  this.catalogResolver = new AccessibilityCatalogResolver(resolvedConfig.accessibility?.catalogs || [], resolvedConfig.accessibility?.language || "en-US");
332
- // Initialize tool provider registry
333
349
  this.toolProviderRegistry = new ToolProviderRegistry();
334
350
  this._registerToolProviders();
335
- // Initialize TTS service based on config
336
351
  this.ttsService = new TTSService();
337
352
  // Selection read-aloud speaks through this service without the inline TTS
338
353
  // tool ever having run, so it cannot rely on that tool to attach highlights.
339
354
  this.ttsService.setHighlightCoordinator(this.highlightCoordinator);
355
+ // A caller that speaks without `ensureTTSReady()` first, such as selection
356
+ // read-aloud, starts the service instead of finding it uninitialized.
357
+ this.ttsService.setReadinessGate(() => this.ensureTTSReady());
358
+ this.ttsService.setMathSpeechSource(() => buildRuntimeTTSConfig(resolveTTSRuntimeSettings(this.resolveTTSToolConfig())).providerOptions?.mathSpeech);
340
359
  this.setupStatePersistenceHooks();
341
- // M8 PR 2 — construct the unified ToolPolicyEngine seeded with
342
- // the validated tools config. PNP/profile inputs (`assessment`,
343
- // `currentItemRef`) start `null`; `pnpEnforcement` is resolved
344
- // through {@link resolveEffectivePnpEnforcement}, which flips
345
- // to `"on"` only once the bound assessment or item carries
346
- // actual profile material. Hosts that only consume the
347
- // engine for placement/policy gating get the pre-PR-2 behavior
348
- // bit-for-bit.
360
+ // The unified ToolPolicyEngine, seeded with the validated tools config.
361
+ // The assessment starts `null` and items register their settings as they
362
+ // mount; auto-mode enforcement turns on only once the policy material a
363
+ // decision reads is present, so a host that consumes the engine for
364
+ // placement/policy gating alone sees no PNP/profile gate.
349
365
  this.policyEngine = new ToolPolicyEngine({
350
366
  toolRegistry: this.toolRegistry,
351
367
  contextId: `toolkit-coordinator:${this.assessmentId}`,
368
+ assessmentOptional: this.config.assessmentOptional === true,
352
369
  inputs: {
353
370
  tools: this.config.tools,
354
371
  assessment: null,
355
- currentItemRef: null,
356
- pnpEnforcement: this.resolveEffectivePnpEnforcement(),
372
+ pnpEnforcement: this.resolveConfiguredPnpEnforcement(),
357
373
  },
358
374
  });
359
- if (!this.lazyInit) {
375
+ // Input diagnostics are computed once per change, so they are reported
376
+ // here rather than with each decision.
377
+ this.policyEngine.onPolicyChange((event) => {
378
+ if (event.reason === "disposed")
379
+ return;
380
+ if (event.reason === "inputs" || event.reason === "pnp-enforcement") {
381
+ this.reportedPolicyDiagnostics.clear();
382
+ }
383
+ this.warnPolicyDiagnostics(event.inputs.diagnostics);
384
+ this.reportNewlyGrantedFailures();
385
+ });
386
+ if (this.eagerInit) {
360
387
  void this.waitUntilReady().catch((err) => {
361
388
  if (err instanceof ToolkitCoordinatorDisposedError)
362
389
  return;
363
390
  console.error("[ToolkitCoordinator] Failed eager initialization:", err);
364
- this.handleError(err, { phase: "coordinator-ready" });
391
+ this.handleError(err, { phase: "coordinator-ready", recoverable: false });
365
392
  });
366
393
  }
367
394
  }
@@ -445,13 +472,108 @@ export class ToolkitCoordinator {
445
472
  }
446
473
  }
447
474
  }
448
- handleError(error, context) {
475
+ /**
476
+ * Every report site states whether the coordinator carries on after the
477
+ * failure; one that takes down only its section passes that section.
478
+ */
479
+ handleError(error, context, cohort) {
449
480
  const model = frameworkErrorFromCoordinatorContext({
450
481
  error,
451
482
  context,
483
+ recoverable: context.recoverable,
484
+ scope: cohort ? "cohort" : "runtime",
485
+ cohort,
452
486
  });
453
487
  this.frameworkErrorBus.reportFrameworkError(model);
454
488
  }
489
+ /**
490
+ * Whether policy grants `toolId` as an accommodation on some surface: a
491
+ * test-administration override, a mounted item's or a district requirement,
492
+ * or profile support. A toolbar tool counts only surfaces where PNP
493
+ * enforcement is on; a region feature ignores enforcement, as its decisions
494
+ * do. Read without the unbound-assessment warning.
495
+ */
496
+ isToolGranted(toolId) {
497
+ try {
498
+ return this.policyEngine.grantsFeatureAnywhere(toolId, {
499
+ enforced: this.toolRegistry.getToolActivation(toolId) !== "region",
500
+ });
501
+ }
502
+ catch {
503
+ return false;
504
+ }
505
+ }
506
+ /**
507
+ * Reports a tool's start failure: recoverable, so the tool reports itself
508
+ * unavailable and the assessment goes on, unless policy grants the tool.
509
+ * `fallbackFollows` marks a failure another path absorbs, which is recoverable
510
+ * whatever the policy.
511
+ */
512
+ reportStartFailure(error, context, toolIds, fallbackFollows = false) {
513
+ const ids = Array.from(toolIds);
514
+ const recoverable = fallbackFollows || !ids.some((toolId) => this.isToolGranted(toolId));
515
+ if (recoverable && !fallbackFollows) {
516
+ for (const toolId of ids)
517
+ this.degradedTools.set(toolId, { error, context });
518
+ }
519
+ this.handleError(error, { ...context, recoverable });
520
+ }
521
+ /**
522
+ * Report that a tool failed in `phase`. A module that failed to load follows
523
+ * the start-failure policy: the tool degrades, so toolbars withhold it, unless
524
+ * policy grants it. Every other phase is recoverable, since its report site
525
+ * keeps a recovery, and is reported on a microtask. Reported once per tool and
526
+ * phase however many toolbars or instances fail. A module failure re-announces the request targets, since
527
+ * the reporting toolbar no longer hosts the tool.
528
+ */
529
+ reportToolFailure(toolId, phase, error) {
530
+ if (phase === "tool-module-load")
531
+ this.toolRequests.notifyTargetsChange();
532
+ const key = `${phase}\u0000${toolId}`;
533
+ if (this.reportedToolFailures.has(key))
534
+ return;
535
+ this.reportedToolFailures.add(key);
536
+ const detail = error instanceof Error && error.message.trim().length > 0
537
+ ? error.message
538
+ : String(error);
539
+ const report = () => this.reportStartFailure(new Error(`Tool "${toolId}" ${TOOL_FAILURE_SUMMARY[phase]}: ${detail}`, {
540
+ cause: error,
541
+ }), { phase, details: { toolId } }, [toolId], phase !== "tool-module-load");
542
+ // The recoverable phases fail inside a toolbar's derived state, where a
543
+ // listener writing state would throw.
544
+ if (phase === "tool-module-load")
545
+ report();
546
+ else
547
+ queueMicrotask(report);
548
+ }
549
+ reportNewlyGrantedFailures() {
550
+ for (const [toolId, failure] of this.degradedTools) {
551
+ if (!this.isToolGranted(toolId))
552
+ continue;
553
+ this.degradedTools.delete(toolId);
554
+ this.handleError(failure.error, { ...failure.context, recoverable: false });
555
+ }
556
+ }
557
+ /**
558
+ * Subscribe to changes of {@link isReady}: state loading, text-to-speech
559
+ * starting, failing or reconfiguring. The listener reads the status itself.
560
+ */
561
+ onReadyChange(listener) {
562
+ this.readyChangeListeners.add(listener);
563
+ return () => {
564
+ this.readyChangeListeners.delete(listener);
565
+ };
566
+ }
567
+ notifyReadyChange() {
568
+ for (const listener of this.readyChangeListeners) {
569
+ try {
570
+ listener();
571
+ }
572
+ catch (error) {
573
+ console.warn("[ToolkitCoordinator] ready-change listener failed:", error);
574
+ }
575
+ }
576
+ }
455
577
  /**
456
578
  * Subscribe to framework-error events emitted by this coordinator.
457
579
  *
@@ -481,6 +603,13 @@ export class ToolkitCoordinator {
481
603
  reportFrameworkError(model) {
482
604
  this.frameworkErrorBus.reportFrameworkError(model);
483
605
  }
606
+ sectionControllerKey(args) {
607
+ return {
608
+ assessmentId: this.assessmentId,
609
+ sectionId: args.sectionId,
610
+ attemptId: args.attemptId,
611
+ };
612
+ }
484
613
  getSectionControllerMapKey(key) {
485
614
  return `${key.assessmentId}::${key.sectionId}::${key.attemptId || ""}`;
486
615
  }
@@ -495,15 +624,20 @@ export class ToolkitCoordinator {
495
624
  return null;
496
625
  }
497
626
  })();
627
+ // Stored per attempt. Without an attempt id nothing tells two learners on
628
+ // one device apart, so the section is neither read nor written.
498
629
  const getStorageKey = (context) => {
499
630
  const { assessmentId, sectionId, attemptId } = context.key;
500
- return `pie:section-controller:v1:${assessmentId}:${sectionId}:${attemptId || "default"}`;
631
+ if (!attemptId)
632
+ return null;
633
+ return `pie:section-controller:v1:${assessmentId}:${sectionId}:${attemptId}`;
501
634
  };
502
635
  return {
503
636
  async loadSession(context) {
504
- if (!storage)
637
+ const key = getStorageKey(context);
638
+ if (!storage || !key)
505
639
  return null;
506
- const value = storage.getItem(getStorageKey(context));
640
+ const value = storage.getItem(key);
507
641
  if (!value)
508
642
  return null;
509
643
  try {
@@ -514,14 +648,16 @@ export class ToolkitCoordinator {
514
648
  }
515
649
  },
516
650
  async saveSession(context, session) {
517
- if (!storage)
651
+ const key = getStorageKey(context);
652
+ if (!storage || !key)
518
653
  return;
519
- storage.setItem(getStorageKey(context), JSON.stringify(session));
654
+ storage.setItem(key, JSON.stringify(session));
520
655
  },
521
656
  async clearSession(context) {
522
- if (!storage)
657
+ const key = getStorageKey(context);
658
+ if (!storage || !key)
523
659
  return;
524
- storage.removeItem(getStorageKey(context));
660
+ storage.removeItem(key);
525
661
  },
526
662
  };
527
663
  }
@@ -543,12 +679,12 @@ export class ToolkitCoordinator {
543
679
  if (!this.hooks.saveToolState)
544
680
  return;
545
681
  void Promise.resolve(this.hooks.saveToolState(state)).catch((err) => {
546
- this.handleError(err, { phase: "state-save" });
682
+ this.handleError(err, { phase: "state-save", recoverable: true });
547
683
  });
548
684
  });
549
685
  }
550
686
  async ensureStateLoaded() {
551
- if (this.stateLoaded)
687
+ if (this.stateLoadSettled)
552
688
  return;
553
689
  if (this.stateLoadPromise)
554
690
  return this.stateLoadPromise;
@@ -563,7 +699,7 @@ export class ToolkitCoordinator {
563
699
  if (state && typeof state === "object") {
564
700
  this.elementToolStateStore.loadState(state);
565
701
  }
566
- this.stateLoaded = true;
702
+ this.stateLoadSettled = true;
567
703
  await this.emitTelemetry("pie-toolkit-tool-state-loaded", {
568
704
  hasState: Boolean(state),
569
705
  });
@@ -571,43 +707,52 @@ export class ToolkitCoordinator {
571
707
  catch (err) {
572
708
  if (err instanceof ToolkitCoordinatorDisposedError)
573
709
  throw err;
574
- this.handleError(err, { phase: "state-load" });
710
+ this.stateLoadSettled = true;
711
+ this.handleError(err, { phase: "state-load", recoverable: true });
575
712
  }
576
713
  })().finally(() => {
577
714
  this.stateLoadPromise = undefined;
715
+ this.notifyReadyChange();
578
716
  });
579
717
  return this.stateLoadPromise;
580
718
  }
719
+ /** Whether the toolkit this coordinator binds to may still supply a registry. */
720
+ toolRegistryPending() {
721
+ return !this.toolRegistrySettled && !this.toolRegistryAbsent;
722
+ }
581
723
  reportMissingTTSProvider(backend) {
582
724
  // The toolkit this coordinator binds to may still supply the provider.
583
- if (!this.toolRegistrySettled) {
725
+ if (this.toolRegistryPending()) {
584
726
  this.heldMissingTTSProviderBackend = backend;
585
727
  return;
586
728
  }
587
729
  if (this.reportedMissingTTSProvider)
588
730
  return;
589
731
  this.reportedMissingTTSProvider = true;
590
- console.warn(`[ToolkitCoordinator] Text-to-speech is configured for the "${backend}" backend but falls back to browser speech: no "tts" tool provider is registered. A coordinator registers tool providers only from its \`toolRegistry\`, or from its toolkit's when constructed without one, so supply one that carries the text-to-speech registration — for the packaged capability set, \`createPackagedToolRegistry()\` from "@pie-players/pie-default-tool-loaders". Reported once per coordinator.`);
732
+ console.warn(`[ToolkitCoordinator] Text-to-speech is configured for the "${backend}" backend but falls back to browser speech: no "textToSpeech" tool provider is registered. A coordinator registers tool providers only from its \`toolRegistry\`, or from its toolkit's when constructed without one, so supply one that carries the text-to-speech registration — for the packaged capability set, \`createPackagedToolRegistry()\` from "@pie-players/pie-default-tool-loaders". Reported once per coordinator.`);
591
733
  }
592
734
  /**
593
735
  * Take the registry of the toolkit this coordinator is bound to, when the host
594
736
  * constructed it without one: the config is validated against it, its
595
737
  * providers register, and text-to-speech re-initializes through it if it
596
- * already started. The toolkit elements call this on binding. A registry
597
- * passed at construction stands, and the first call wins; `null` records a
598
- * toolkit without one, which reports the registry as missing.
738
+ * already started. The toolkit elements call this on binding, and again when
739
+ * their registry changes. A registry passed at construction stands, and the
740
+ * first registry adopted wins; `null` records a toolkit without one, which
741
+ * reports the registry as missing and still adopts one that arrives later.
599
742
  *
600
743
  * @returns Whether `registry` was adopted.
601
744
  */
602
745
  adoptToolRegistry(registry) {
603
746
  if (this.disposePromise !== null || this.toolRegistrySettled)
604
747
  return false;
605
- this.toolRegistrySettled = true;
748
+ if (!registry && this.toolRegistryAbsent)
749
+ return false;
606
750
  const heldBackend = this.heldMissingTTSProviderBackend;
607
751
  this.heldMissingTTSProviderBackend = null;
608
752
  const strictness = this.config.toolConfigStrictness ?? "error";
609
753
  const source = "ToolkitCoordinator.adoptToolRegistry";
610
754
  if (!registry) {
755
+ this.toolRegistryAbsent = true;
611
756
  this.validateToolsConfig(this.config.tools, {
612
757
  strictness,
613
758
  source,
@@ -617,6 +762,7 @@ export class ToolkitCoordinator {
617
762
  this.reportMissingTTSProvider(heldBackend);
618
763
  return false;
619
764
  }
765
+ this.toolRegistrySettled = true;
620
766
  this.toolRegistry = registry;
621
767
  this.config.toolRegistry = registry;
622
768
  try {
@@ -644,9 +790,6 @@ export class ToolkitCoordinator {
644
790
  this.scheduleTTSReconfigure();
645
791
  return true;
646
792
  }
647
- /**
648
- * Register tool providers in the registry
649
- */
650
793
  _registerToolProviders() {
651
794
  const descriptorTools = this.getProviderDescriptorTools();
652
795
  for (const tool of descriptorTools) {
@@ -658,35 +801,32 @@ export class ToolkitCoordinator {
658
801
  }
659
802
  /**
660
803
  * Register the provider `tool`'s descriptor creates for the tool's current
661
- * config, unless the tool is disabled or that provider id is registered. A
662
- * descriptor that throws is reported the way a failed registration is.
804
+ * config under the tool's id, unless the tool is disabled or, without
805
+ * `replace`, a provider is registered under that id. A descriptor that throws
806
+ * is reported the way a failed registration is.
663
807
  */
664
- async registerProviderFromTool(tool) {
808
+ async registerProviderFromTool(tool, replace = false) {
665
809
  const descriptor = tool.provider;
666
810
  if (!descriptor)
667
811
  return;
668
- let providerId = null;
812
+ const { toolId } = tool;
669
813
  let registration;
670
814
  try {
671
- const toolConfig = this.getToolConfig(tool.toolId) || undefined;
672
- if (toolConfig?.enabled === false)
815
+ const toolConfig = this.getToolConfig(toolId) || undefined;
816
+ if (toolConfig?.enabled === false) {
817
+ // A disabled tool keeps no provider, including the one it replaces.
818
+ if (replace)
819
+ void this.toolProviderRegistry.unregister(toolId);
673
820
  return;
674
- providerId = resolveToolProviderId(tool, toolConfig);
675
- if (!providerId || this.toolProviderRegistry.has(providerId))
821
+ }
822
+ if (!replace && this.toolProviderRegistry.has(toolId))
676
823
  return;
677
824
  const provider = descriptor.createProvider(toolConfig);
678
825
  const initConfig = descriptor.getInitConfig?.(toolConfig) ??
679
826
  toolConfig?.provider?.init ??
680
827
  {};
681
- const initConfigWithTelemetry = this.addToolTelemetryReporter({
682
- toolId: tool.toolId,
683
- providerId,
684
- initConfig,
685
- });
686
- const registryTelemetry = this.createToolTelemetryForwarder({
687
- toolId: tool.toolId,
688
- providerId,
689
- });
828
+ const initConfigWithTelemetry = this.addToolTelemetryReporter(toolId, initConfig);
829
+ const registryTelemetry = this.createToolTelemetryForwarder(toolId);
690
830
  const authFetcher = descriptor.getAuthFetcher?.(toolConfig) ??
691
831
  toolConfig?.provider?.runtime?.authFetcher;
692
832
  registration = {
@@ -698,31 +838,24 @@ export class ToolkitCoordinator {
698
838
  };
699
839
  }
700
840
  catch (err) {
701
- this.reportProviderRegisterFailure(err, providerId, tool.toolId);
841
+ this.reportProviderRegisterFailure(err, toolId);
702
842
  return;
703
843
  }
704
- await this.registerProvider(providerId, registration);
844
+ await this.registerProvider(toolId, registration);
705
845
  }
706
- createToolTelemetryForwarder(args) {
846
+ createToolTelemetryForwarder(toolId) {
707
847
  return async (eventName, payload) => {
708
- await this.emitTelemetry(eventName, {
709
- ...(payload || {}),
710
- toolId: args.toolId,
711
- providerId: args.providerId,
712
- });
848
+ await this.emitTelemetry(eventName, { ...(payload || {}), toolId });
713
849
  };
714
850
  }
715
- addToolTelemetryReporter(args) {
716
- const configObject = args.initConfig && typeof args.initConfig === "object"
717
- ? { ...args.initConfig }
851
+ addToolTelemetryReporter(toolId, initConfig) {
852
+ const configObject = initConfig && typeof initConfig === "object"
853
+ ? { ...initConfig }
718
854
  : {};
719
855
  const existingReporter = typeof configObject.onTelemetry === "function"
720
856
  ? configObject.onTelemetry
721
857
  : null;
722
- const forwardTelemetry = this.createToolTelemetryForwarder({
723
- toolId: args.toolId,
724
- providerId: args.providerId,
725
- });
858
+ const forwardTelemetry = this.createToolTelemetryForwarder(toolId);
726
859
  configObject.onTelemetry = async (eventName, payload) => {
727
860
  if (existingReporter) {
728
861
  await existingReporter(eventName, payload);
@@ -731,56 +864,58 @@ export class ToolkitCoordinator {
731
864
  };
732
865
  return configObject;
733
866
  }
734
- async registerProvider(providerId, config) {
867
+ async registerProvider(toolId, config) {
735
868
  try {
736
- this.toolProviderRegistry.register(providerId, config);
869
+ this.toolProviderRegistry.register(toolId, config);
737
870
  const meta = {
738
- providerId,
739
871
  providerName: config.provider.providerName,
740
872
  };
741
- await this.hooks.onProviderRegistered?.(providerId, meta);
873
+ await this.hooks.onProviderRegistered?.(toolId, meta);
742
874
  await this.emitTelemetry("pie-toolkit-provider-registered", {
743
- providerId,
875
+ toolId,
744
876
  providerName: config.provider.providerName,
745
877
  });
746
878
  }
747
879
  catch (err) {
748
- this.reportProviderRegisterFailure(err, providerId);
880
+ this.reportProviderRegisterFailure(err, toolId);
749
881
  }
750
882
  }
751
883
  /**
752
- * A console warning and a `provider-register` framework error. `providerId`
753
- * is null when resolving it is what failed; the warning names the tool then.
884
+ * A console warning and a `provider-register` framework error, under the
885
+ * start-failure policy every other tool failure follows.
754
886
  */
755
- reportProviderRegisterFailure(err, providerId, toolId) {
756
- console.warn(providerId
757
- ? `[ToolkitCoordinator] Failed to register provider "${providerId}":`
758
- : `[ToolkitCoordinator] Failed to register the provider of tool "${toolId}":`, err);
759
- this.handleError(err, {
760
- phase: "provider-register",
761
- providerId: providerId ?? undefined,
762
- });
887
+ reportProviderRegisterFailure(err, toolId) {
888
+ console.warn(`[ToolkitCoordinator] Failed to register the provider of tool "${toolId}":`, err);
889
+ this.reportStartFailure(err, { phase: "provider-register", toolId }, [toolId]);
890
+ }
891
+ async ensureProviderReady(toolId) {
892
+ return this.initializeProvider(toolId, false);
763
893
  }
764
- async ensureProviderReady(providerId) {
894
+ /** `fallbackFollows`: the caller recovers from a failure on its own. */
895
+ async initializeProvider(toolId, fallbackFollows) {
765
896
  this.assertNotDisposed();
766
- const existing = this.providerInitPromises.get(providerId);
897
+ const existing = this.providerInitPromises.get(toolId);
767
898
  if (existing)
768
899
  return existing;
769
900
  const promise = (async () => {
770
- const provider = await this.toolProviderRegistry.getProvider(providerId, false);
771
- this.assertNotDisposed();
772
- const meta = {
773
- providerId,
774
- providerName: provider.providerName,
775
- };
776
901
  try {
777
- await this.hooks.onProviderInitStart?.(providerId, meta);
902
+ let provider = await this.toolProviderRegistry.getProvider(toolId, false);
903
+ this.assertNotDisposed();
904
+ // A tool asks each time it opens; the lifecycle hooks report the start once.
905
+ if (this.toolProviderRegistry.isInitialized(toolId))
906
+ return provider;
907
+ const meta = {
908
+ providerName: provider.providerName,
909
+ };
910
+ await this.hooks.onProviderInitStart?.(toolId, meta);
778
911
  this.assertNotDisposed();
779
- await this.toolProviderRegistry.initialize(providerId);
912
+ await this.toolProviderRegistry.initialize(toolId);
780
913
  this.assertNotDisposed();
781
- await this.hooks.onProviderReady?.(providerId, meta);
914
+ // A config update may have replaced the provider during its start.
915
+ provider = await this.toolProviderRegistry.getProvider(toolId, false);
916
+ await this.hooks.onProviderReady?.(toolId, meta);
782
917
  this.assertNotDisposed();
783
- await this.emitTelemetry("pie-toolkit-provider-ready", { providerId });
918
+ await this.emitTelemetry("pie-toolkit-provider-ready", { toolId });
784
919
  this.assertNotDisposed();
785
920
  return provider;
786
921
  }
@@ -788,13 +923,16 @@ export class ToolkitCoordinator {
788
923
  if (err instanceof ToolkitCoordinatorDisposedError)
789
924
  throw err;
790
925
  const error = err instanceof Error ? err : new Error(String(err));
791
- this.handleError(error, { phase: "provider-init", providerId });
926
+ this.reportStartFailure(error, { phase: "provider-init", toolId },
927
+ // Attributed when the id names a tool with a provider; any other id
928
+ // was never a tool's.
929
+ this.toolRegistry.get(toolId)?.provider ? [toolId] : [], fallbackFollows);
792
930
  throw error;
793
931
  }
794
932
  })().finally(() => {
795
- this.providerInitPromises.delete(providerId);
933
+ this.providerInitPromises.delete(toolId);
796
934
  });
797
- this.providerInitPromises.set(providerId, promise);
935
+ this.providerInitPromises.set(toolId, promise);
798
936
  return promise;
799
937
  }
800
938
  setHooks(hooks) {
@@ -804,7 +942,7 @@ export class ToolkitCoordinator {
804
942
  hooks.onCoordinatorReady &&
805
943
  this.isReady()) {
806
944
  void Promise.resolve(hooks.onCoordinatorReady(this)).catch((err) => {
807
- this.handleError(err, { phase: "coordinator-ready" });
945
+ this.handleError(err, { phase: "coordinator-ready", recoverable: false });
808
946
  });
809
947
  }
810
948
  }
@@ -815,20 +953,17 @@ export class ToolkitCoordinator {
815
953
  };
816
954
  }
817
955
  getSectionController(args) {
818
- const key = {
819
- assessmentId: this.assessmentId,
820
- sectionId: args.sectionId,
821
- attemptId: args.attemptId,
822
- };
956
+ const key = this.sectionControllerKey(args);
823
957
  return this.sectionControllers.get(this.getSectionControllerMapKey(key));
824
958
  }
825
959
  subscribeSectionEvents(args) {
826
- // Phase D: subscriptions follow the toolkit's *active section
827
- // cohort* automatically. We bind the listener to whichever
828
- // controller is currently active and re-bind it on every
829
- // `getOrCreateSectionController` / `disposeSectionController`
830
- // transition, with snapshot replay on each migration.
831
- if (this.activeCohortMapKey === null) {
960
+ // The listener binds to whichever controller is currently active and
961
+ // re-binds on every `getOrCreateSectionController` /
962
+ // `disposeSectionController` transition, with snapshot replay on each
963
+ // migration. A listener added while the next section is still
964
+ // starting binds when it becomes active.
965
+ if (this.activeCohortMapKey === null &&
966
+ this.latestRequestedActiveCohortMapKey === null) {
832
967
  throw new Error("[ToolkitCoordinator] subscribeSectionEvents requires an active section cohort; call getOrCreateSectionController first.");
833
968
  }
834
969
  const previous = this.activeSubscriptions.get(args.listener);
@@ -843,7 +978,9 @@ export class ToolkitCoordinator {
843
978
  unsubscribeCurrent: null,
844
979
  };
845
980
  this.activeSubscriptions.set(args.listener, sub);
846
- const controller = this.sectionControllers.get(this.activeCohortMapKey);
981
+ const controller = this.activeCohortMapKey === null
982
+ ? undefined
983
+ : this.sectionControllers.get(this.activeCohortMapKey);
847
984
  if (controller) {
848
985
  this.bindSubscriptionToController(sub, controller);
849
986
  }
@@ -863,7 +1000,7 @@ export class ToolkitCoordinator {
863
1000
  };
864
1001
  }
865
1002
  /**
866
- * Bind a Phase D subscription to a section controller. Detaches any
1003
+ * Bind an active-cohort subscription to a section controller. Detaches any
867
1004
  * prior controller binding the subscription was holding, attaches a
868
1005
  * fresh `controller.subscribe(...)` callback, and replays the
869
1006
  * canonical late-subscribe sequence (content-loaded × N then
@@ -922,15 +1059,9 @@ export class ToolkitCoordinator {
922
1059
  const controller = this.sectionControllers.get(mapKey);
923
1060
  if (!controller)
924
1061
  return;
925
- // Snapshot the subscription list before iterating: a listener may
926
- // synchronously call `subscribeSectionEvents(...)` from inside its
927
- // replay delivery, which inserts a new entry into
928
- // `activeSubscriptions`. Map iteration yields keys inserted
929
- // during the loop; without snapshotting, the inner subscribe path
930
- // would bind the new listener once (correctly, with replay) and
931
- // the outer loop would then visit it a second time, double-
932
- // replaying the snapshot. Snapshot keeps "replay once per cohort
933
- // transition" intact.
1062
+ // Iterate a snapshot: a listener may call `subscribeSectionEvents` from
1063
+ // inside its replay delivery, and live Map iteration would then visit the
1064
+ // new entry and replay it a second time.
934
1065
  for (const sub of Array.from(this.activeSubscriptions.values())) {
935
1066
  this.bindSubscriptionToController(sub, controller);
936
1067
  }
@@ -946,10 +1077,7 @@ export class ToolkitCoordinator {
946
1077
  if (this.activeCohortMapKey === mapKey)
947
1078
  return;
948
1079
  this.activeCohortMapKey = null;
949
- for (const sub of Array.from(this.activeSubscriptions.values())) {
950
- sub.unsubscribeCurrent?.();
951
- sub.unsubscribeCurrent = null;
952
- }
1080
+ this.detachActiveSubscriptions();
953
1081
  }
954
1082
  /**
955
1083
  * Clear the active cohort if `mapKey` matches it, detaching every
@@ -961,10 +1089,14 @@ export class ToolkitCoordinator {
961
1089
  if (this.activeCohortMapKey !== mapKey)
962
1090
  return;
963
1091
  this.activeCohortMapKey = null;
964
- // Symmetric snapshot for safety: a disposer fired during detach
965
- // could mutate `activeSubscriptions`. Detach is silent (no listener
966
- // fan-out), so re-entrancy exposure is lower than `setActiveCohort`,
967
- // but the snapshot keeps both paths uniform.
1092
+ this.detachActiveSubscriptions();
1093
+ }
1094
+ /**
1095
+ * Detach every active subscription from its controller. Iterates a snapshot,
1096
+ * as `setActiveCohort` does, because a disposer may mutate
1097
+ * `activeSubscriptions`.
1098
+ */
1099
+ detachActiveSubscriptions() {
968
1100
  for (const sub of Array.from(this.activeSubscriptions.values())) {
969
1101
  sub.unsubscribeCurrent?.();
970
1102
  sub.unsubscribeCurrent = null;
@@ -974,20 +1106,14 @@ export class ToolkitCoordinator {
974
1106
  const eventTypeFilter = args.eventTypes ? new Set(args.eventTypes) : null;
975
1107
  const itemIdFilter = args.itemIds ? new Set(args.itemIds) : null;
976
1108
  return (event) => {
977
- if (eventTypeFilter || itemIdFilter) {
978
- const eventType = event?.type || null;
979
- if (eventTypeFilter &&
980
- (!eventType || !eventTypeFilter.has(eventType))) {
1109
+ if (eventTypeFilter) {
1110
+ const eventType = event?.type;
1111
+ if (!eventType || !eventTypeFilter.has(eventType))
981
1112
  return false;
982
- }
983
- if (itemIdFilter) {
984
- const hasMatchingItem = Array.from(this.collectEventItemIds(event)).some((itemId) => itemIdFilter.has(itemId));
985
- if (!hasMatchingItem) {
986
- return false;
987
- }
988
- }
989
1113
  }
990
- return true;
1114
+ if (!itemIdFilter)
1115
+ return true;
1116
+ return Array.from(this.collectEventItemIds(event)).some((itemId) => itemIdFilter.has(itemId));
991
1117
  };
992
1118
  }
993
1119
  collectEventItemIds(event) {
@@ -1113,26 +1239,33 @@ export class ToolkitCoordinator {
1113
1239
  if (this.disposePromise !== null) {
1114
1240
  throw new ToolkitCoordinatorDisposedError();
1115
1241
  }
1116
- const key = {
1117
- assessmentId: this.assessmentId,
1118
- sectionId: args.sectionId,
1119
- attemptId: args.attemptId,
1120
- };
1242
+ const key = this.sectionControllerKey(args);
1121
1243
  const mapKey = this.getSectionControllerMapKey(key);
1122
1244
  this.latestRequestedActiveCohortMapKey = mapKey;
1123
1245
  this.suspendActiveCohortIfSuperseded(mapKey);
1124
- const existingController = await this.resolveExistingSectionController({
1125
- mapKey,
1126
- key,
1127
- input: args.input,
1128
- updateExisting: args.updateExisting,
1129
- });
1130
- if (existingController) {
1131
- if (this.disposePromise !== null) {
1132
- throw new ToolkitCoordinatorDisposedError();
1246
+ let existingController;
1247
+ try {
1248
+ existingController = await this.resolveExistingSectionController({
1249
+ mapKey,
1250
+ key,
1251
+ input: args.input,
1252
+ updateExisting: args.updateExisting,
1253
+ });
1254
+ if (existingController) {
1255
+ if (this.disposePromise !== null) {
1256
+ throw new ToolkitCoordinatorDisposedError();
1257
+ }
1258
+ if (args.initialSession) {
1259
+ await this.assignSessionToPublishedController(existingController, args.initialSession);
1260
+ }
1133
1261
  }
1134
- return existingController;
1135
1262
  }
1263
+ catch (err) {
1264
+ this.deliverSectionStartError(mapKey, err);
1265
+ throw err;
1266
+ }
1267
+ if (existingController)
1268
+ return existingController;
1136
1269
  const pendingDisposal = this.sectionControllerDisposePromises.get(mapKey);
1137
1270
  if (pendingDisposal) {
1138
1271
  // Persistence and hydration share the cohort's durable state. Keep the
@@ -1145,8 +1278,13 @@ export class ToolkitCoordinator {
1145
1278
  throw new ToolkitCoordinatorDisposedError();
1146
1279
  }
1147
1280
  const existingEntry = this.sectionControllerInitEntries.get(mapKey);
1148
- if (existingEntry)
1149
- return existingEntry.promise;
1281
+ if (existingEntry) {
1282
+ if (!args.initialSession)
1283
+ return existingEntry.promise;
1284
+ const pendingController = await existingEntry.promise;
1285
+ await this.assignSessionToPublishedController(pendingController, args.initialSession);
1286
+ return pendingController;
1287
+ }
1150
1288
  const token = {
1151
1289
  retired: false,
1152
1290
  candidateClaimed: false,
@@ -1159,6 +1297,7 @@ export class ToolkitCoordinator {
1159
1297
  })
1160
1298
  .catch((err) => {
1161
1299
  this.handleSectionControllerInitError(err, args);
1300
+ this.deliverSectionStartError(mapKey, err);
1162
1301
  throw err;
1163
1302
  })
1164
1303
  .finally(() => {
@@ -1189,8 +1328,7 @@ export class ToolkitCoordinator {
1189
1328
  if (this.sectionControllers.get(args.mapKey) !== existingController) {
1190
1329
  return undefined;
1191
1330
  }
1192
- // PIE-512 Phase D: a `getOrCreateSectionController` call that
1193
- // resolves to a previously-created controller still represents a
1331
+ // A `getOrCreateSectionController` call that resolves to a previously-created controller still represents a
1194
1332
  // cohort transition from the toolkit's perspective (same-cohort
1195
1333
  // re-entry is a no-op inside `setActiveCohort`). Active
1196
1334
  // subscriptions migrate here so a host that subscribed once on
@@ -1239,7 +1377,20 @@ export class ToolkitCoordinator {
1239
1377
  await this.retireUnpublishedSectionControllerIfNeeded(candidate);
1240
1378
  await controller.initialize?.(args.args.input);
1241
1379
  await this.retireUnpublishedSectionControllerIfNeeded(candidate);
1242
- await controller.hydrate?.();
1380
+ // A session the host supplied takes the place of the strategy's stored
1381
+ // snapshot, and is applied before publication so the ready lifecycle
1382
+ // event and every reader after it see it. The strategy still receives
1383
+ // every `persist()`.
1384
+ const initialSession = args.args.initialSession;
1385
+ if (initialSession) {
1386
+ if (!controller.applySession) {
1387
+ throw new Error("Section controller cannot apply the session it was created with: it has no applySession.");
1388
+ }
1389
+ await controller.applySession(initialSession, { mode: "replace" });
1390
+ }
1391
+ else {
1392
+ await controller.hydrate?.();
1393
+ }
1243
1394
  await this.finalizeSectionControllerReady({
1244
1395
  ...candidate,
1245
1396
  context,
@@ -1251,6 +1402,22 @@ export class ToolkitCoordinator {
1251
1402
  throw error;
1252
1403
  }
1253
1404
  }
1405
+ /**
1406
+ * Apply a host-supplied session to a controller that is already published,
1407
+ * under the rules `resolveSectionSessionAssignment` sets: an equal session is a
1408
+ * no-op and recorded responses are not replaced by response-free ones.
1409
+ */
1410
+ async assignSessionToPublishedController(controller, session) {
1411
+ if (!session)
1412
+ return;
1413
+ if (!controller.applySession) {
1414
+ throw new Error("Section controller cannot apply the assigned session: it has no applySession.");
1415
+ }
1416
+ const resolved = resolveSectionSessionAssignment(controller.getSession?.() ?? null, session);
1417
+ if (!resolved)
1418
+ return;
1419
+ await controller.applySession(resolved, { mode: "replace" });
1420
+ }
1254
1421
  createSectionControllerRetirementError() {
1255
1422
  return this.disposePromise !== null
1256
1423
  ? new ToolkitCoordinatorDisposedError()
@@ -1276,6 +1443,7 @@ export class ToolkitCoordinator {
1276
1443
  sectionId: args.key.sectionId,
1277
1444
  attemptId: args.key.attemptId,
1278
1445
  },
1446
+ recoverable: true,
1279
1447
  });
1280
1448
  }
1281
1449
  finally {
@@ -1300,8 +1468,7 @@ export class ToolkitCoordinator {
1300
1468
  this.sectionControllers.set(args.mapKey, args.controller);
1301
1469
  this.sectionControllerKeys.set(args.mapKey, args.key);
1302
1470
  args.token.candidateClaimed = true;
1303
- // PIE-512 Phase D: a freshly-resolved controller becomes the
1304
- // active cohort. Active subscriptions migrate to it before the
1471
+ // A freshly-resolved controller becomes the active cohort. Active subscriptions migrate to it before the
1305
1472
  // `ready` lifecycle event and `onSectionControllerReady` hook
1306
1473
  // fire so any synchronous post-ready work observes a coherent
1307
1474
  // active-cohort view.
@@ -1331,14 +1498,36 @@ export class ToolkitCoordinator {
1331
1498
  sectionId: args.sectionId,
1332
1499
  attemptId: args.attemptId,
1333
1500
  },
1334
- });
1501
+ recoverable: false,
1502
+ }, { sectionId: args.sectionId, attemptId: args.attemptId });
1335
1503
  }
1336
- disposeSectionController(args) {
1337
- const key = {
1338
- assessmentId: this.assessmentId,
1339
- sectionId: args.sectionId,
1340
- attemptId: args.attemptId,
1504
+ /**
1505
+ * A section that fails to start has no controller of its own to emit its
1506
+ * `section-error`, and the host's subscriptions are already off the outgoing
1507
+ * section, so the coordinator delivers the error to them while that section
1508
+ * is still the one requested.
1509
+ */
1510
+ deliverSectionStartError(mapKey, error) {
1511
+ if (error instanceof ToolkitCoordinatorDisposedError ||
1512
+ error instanceof SectionControllerRetiredError ||
1513
+ this.latestRequestedActiveCohortMapKey !== mapKey) {
1514
+ return;
1515
+ }
1516
+ const event = {
1517
+ type: "section-error",
1518
+ source: "section-runtime",
1519
+ error,
1520
+ currentItemIndex: 0,
1521
+ timestamp: Date.now(),
1341
1522
  };
1523
+ for (const sub of Array.from(this.activeSubscriptions.values())) {
1524
+ if (this.buildSectionEventPredicate(sub)(event)) {
1525
+ this.deliverSectionEventSafely(sub.listener, event);
1526
+ }
1527
+ }
1528
+ }
1529
+ disposeSectionController(args) {
1530
+ const key = this.sectionControllerKey(args);
1342
1531
  const mapKey = this.getSectionControllerMapKey(key);
1343
1532
  const initEntry = this.sectionControllerInitEntries.get(mapKey);
1344
1533
  if (initEntry) {
@@ -1355,8 +1544,7 @@ export class ToolkitCoordinator {
1355
1544
  return this.trackSectionControllerDisposal(mapKey, retirementBarrier);
1356
1545
  }
1357
1546
  const persistenceStrategy = this.sectionPersistenceStrategies.get(mapKey);
1358
- // PIE-512 Phase D: if the disposing cohort is the active one,
1359
- // detach all listener-controller bindings before the controller
1547
+ // If the disposing cohort is the active one, detach all listener-controller bindings before the controller
1360
1548
  // itself disposes. The subscription registry stays intact so a
1361
1549
  // later `getOrCreateSectionController(...)` re-binds the same
1362
1550
  // listeners to the new controller.
@@ -1418,6 +1606,7 @@ export class ToolkitCoordinator {
1418
1606
  sectionId: args.args.sectionId,
1419
1607
  attemptId: args.args.attemptId,
1420
1608
  },
1609
+ recoverable: true,
1421
1610
  });
1422
1611
  }
1423
1612
  finally {
@@ -1517,17 +1706,16 @@ export class ToolkitCoordinator {
1517
1706
  this.activeSubscriptions.clear();
1518
1707
  this.activeCohortMapKey = null;
1519
1708
  this.latestRequestedActiveCohortMapKey = null;
1520
- await cleanup(() => this.ttsService.stop());
1709
+ await cleanup(() => this.ttsService.dispose());
1521
1710
  await cleanup(() => this.toolProviderRegistry.destroy());
1522
1711
  await cleanup(() => this.highlightCoordinator.destroy());
1523
- for (const toolId of this.toolCoordinator.getRegisteredTools()) {
1524
- await cleanup(() => this.toolCoordinator.releaseTool(toolId));
1525
- }
1712
+ await cleanup(() => this.ownedToolCoordinator.destroy());
1526
1713
  await cleanup(() => this.catalogResolver.destroy());
1527
1714
  await cleanup(() => this.toolRequests.dispose());
1528
1715
  await cleanup(() => this.policyEngine.dispose());
1529
1716
  this.toolContextResolvers.clear();
1530
1717
  this.toolContextResolverChangeListeners.clear();
1718
+ this.policyDiagnosticListeners.clear();
1531
1719
  this.sectionControllerLifecycleListeners.clear();
1532
1720
  this.telemetryListeners.clear();
1533
1721
  this.frameworkErrorHookUnsubscribe?.();
@@ -1557,9 +1745,9 @@ export class ToolkitCoordinator {
1557
1745
  await Promise.allSettled(pending);
1558
1746
  }
1559
1747
  /**
1560
- * Initialize TTS service with provider
1748
+ * Initialize the TTS service from the `textToSpeech` tool config.
1561
1749
  */
1562
- async ensureTTSReady(config) {
1750
+ async ensureTTSReady() {
1563
1751
  this.assertNotDisposed();
1564
1752
  await this.waitForPendingTTSReconfigure();
1565
1753
  this.assertNotDisposed();
@@ -1567,10 +1755,11 @@ export class ToolkitCoordinator {
1567
1755
  return;
1568
1756
  if (this.ttsInitPromise)
1569
1757
  return this.ttsInitPromise;
1570
- this.ttsInitPromise = this._initializeTTS(config)
1758
+ this.ttsInitPromise = this._initializeTTS()
1571
1759
  .then(() => this.assertNotDisposed())
1572
1760
  .finally(() => {
1573
1761
  this.ttsInitPromise = undefined;
1762
+ this.notifyReadyChange();
1574
1763
  });
1575
1764
  return this.ttsInitPromise;
1576
1765
  }
@@ -1581,10 +1770,10 @@ export class ToolkitCoordinator {
1581
1770
  pending = this.ttsReconfigurePromise;
1582
1771
  }
1583
1772
  }
1584
- async _initializeTTS(config) {
1773
+ async _initializeTTS() {
1585
1774
  if (this.ttsInitialized)
1586
1775
  return;
1587
- const resolvedToolConfig = this.resolveTTSToolConfig(config);
1776
+ const resolvedToolConfig = this.resolveTTSToolConfig();
1588
1777
  const runtimeSettings = resolveTTSRuntimeSettings(resolvedToolConfig);
1589
1778
  const resolvedBackend = resolveTTSBackend(runtimeSettings);
1590
1779
  const runtimeTTSConfig = buildRuntimeTTSConfig(runtimeSettings);
@@ -1603,10 +1792,10 @@ export class ToolkitCoordinator {
1603
1792
  operation: "tts-init",
1604
1793
  backend: resolvedBackend,
1605
1794
  });
1606
- // Try to use TTS provider from registry if available
1607
- if (this.toolProviderRegistry.has("tts")) {
1795
+ if (this.toolProviderRegistry.has("textToSpeech")) {
1608
1796
  try {
1609
- const ttsProvider = await this.ensureProviderReady("tts");
1797
+ // Browser speech follows a server provider that fails to start.
1798
+ const ttsProvider = await this.initializeProvider("textToSpeech", true);
1610
1799
  const providerInstance = await ttsProvider.createInstance();
1611
1800
  await this.initializeTTSService(providerInstance, runtimeTTSConfig);
1612
1801
  await this.emitTelemetry("pie-toolkit-tts-init-success", {
@@ -1618,25 +1807,14 @@ export class ToolkitCoordinator {
1618
1807
  backend: resolvedBackend,
1619
1808
  provider: "registry",
1620
1809
  });
1621
- console.log("[ToolkitCoordinator] TTS initialized via ToolProviderRegistry");
1622
1810
  return;
1623
1811
  }
1624
1812
  catch (error) {
1625
1813
  if (error instanceof ToolkitCoordinatorDisposedError)
1626
1814
  throw error;
1627
1815
  const normalized = error instanceof Error ? error : new Error(String(error));
1628
- await this.emitTelemetry("pie-tool-init-error", {
1629
- toolId: "textToSpeech",
1630
- providerId: "tts",
1631
- operation: "tts-init",
1632
- backend: resolvedBackend,
1633
- errorType: "TTSRegistryInitError",
1634
- message: normalized.message,
1635
- recovered: true,
1636
- });
1637
1816
  await this.emitTelemetry("pie-tool-init-fallback", {
1638
1817
  toolId: "textToSpeech",
1639
- providerId: "tts",
1640
1818
  operation: "tts-init",
1641
1819
  backend: resolvedBackend,
1642
1820
  fromProvider: "registry",
@@ -1649,10 +1827,13 @@ export class ToolkitCoordinator {
1649
1827
  else if (resolvedBackend !== "browser") {
1650
1828
  this.reportMissingTTSProvider(resolvedBackend);
1651
1829
  }
1652
- // Fallback to browser provider
1830
+ // Browser speech, configured or standing in for a backend that has no
1831
+ // provider or failed to start.
1653
1832
  const provider = new BrowserTTSProvider();
1654
1833
  try {
1655
- await this.initializeTTSService(provider, runtimeTTSConfig);
1834
+ await this.initializeTTSService(provider, resolvedBackend === "browser"
1835
+ ? runtimeTTSConfig
1836
+ : browserFallbackConfig(runtimeTTSConfig));
1656
1837
  await this.emitTelemetry("pie-toolkit-tts-init-success", {
1657
1838
  provider: "browser-fallback",
1658
1839
  });
@@ -1667,7 +1848,7 @@ export class ToolkitCoordinator {
1667
1848
  if (error instanceof ToolkitCoordinatorDisposedError)
1668
1849
  throw error;
1669
1850
  const normalized = error instanceof Error ? error : new Error(String(error));
1670
- this.handleError(normalized, { phase: "tts-init" });
1851
+ this.reportStartFailure(normalized, { phase: "tts-init" }, ["textToSpeech"]);
1671
1852
  await this.emitTelemetry("pie-toolkit-tts-init-error", {
1672
1853
  message: normalized.message,
1673
1854
  });
@@ -1681,12 +1862,12 @@ export class ToolkitCoordinator {
1681
1862
  throw normalized;
1682
1863
  }
1683
1864
  }
1684
- resolveTTSToolConfig(config) {
1685
- return config || this.getTTSConfigFromProviders() || {};
1865
+ resolveTTSToolConfig() {
1866
+ return this.getTTSConfigFromProviders() || {};
1686
1867
  }
1687
1868
  async initializeTTSService(provider, config) {
1688
1869
  const nextProviderOptions = {
1689
- ...((config.providerOptions || {}) || {}),
1870
+ ...config.providerOptions,
1690
1871
  __pieTelemetry: async (eventName, payload) => {
1691
1872
  await this.emitTelemetry(eventName, {
1692
1873
  toolId: "textToSpeech",
@@ -1699,93 +1880,14 @@ export class ToolkitCoordinator {
1699
1880
  providerOptions: nextProviderOptions,
1700
1881
  };
1701
1882
  await this.ttsService.initialize(provider, nextConfig);
1702
- await this.ensureBrowserVoicesReady(provider);
1703
1883
  this.assertNotDisposed();
1704
1884
  this.ttsService.setCatalogResolver(this.catalogResolver);
1705
1885
  this.ttsInitialized = true;
1886
+ this.ttsDegraded = false;
1887
+ this.degradedTools.delete("textToSpeech");
1706
1888
  await this.hooks.onTTSReady?.();
1707
1889
  this.assertNotDisposed();
1708
1890
  }
1709
- async ensureBrowserVoicesReady(provider, timeoutMs = 1200) {
1710
- if (provider.providerId !== "browser")
1711
- return;
1712
- if (typeof window === "undefined")
1713
- return;
1714
- if (!("speechSynthesis" in window))
1715
- return;
1716
- const synth = window.speechSynthesis;
1717
- const voices = synth.getVoices();
1718
- if (voices.length > 0)
1719
- return;
1720
- await new Promise((resolve) => {
1721
- let settled = false;
1722
- const canUseEventTarget = typeof synth.addEventListener === "function" &&
1723
- typeof synth.removeEventListener === "function";
1724
- const canUseHandler = !canUseEventTarget && "onvoiceschanged" in synth;
1725
- const previousHandler = canUseHandler ? synth.onvoiceschanged : null;
1726
- let assignedHandler = false;
1727
- const finish = () => {
1728
- if (settled)
1729
- return;
1730
- settled = true;
1731
- window.clearTimeout(timeoutId);
1732
- if (canUseEventTarget) {
1733
- synth.removeEventListener("voiceschanged", onVoicesChanged);
1734
- }
1735
- else if (assignedHandler &&
1736
- synth.onvoiceschanged === onVoicesChanged) {
1737
- synth.onvoiceschanged = previousHandler;
1738
- }
1739
- resolve();
1740
- };
1741
- const onVoicesChanged = (event) => {
1742
- if (!canUseEventTarget &&
1743
- typeof previousHandler === "function" &&
1744
- event) {
1745
- previousHandler.call(synth, event);
1746
- }
1747
- finish();
1748
- };
1749
- const timeoutId = window.setTimeout(finish, timeoutMs);
1750
- if (canUseEventTarget) {
1751
- synth.addEventListener("voiceschanged", onVoicesChanged, {
1752
- once: true,
1753
- });
1754
- }
1755
- else if (canUseHandler) {
1756
- synth.onvoiceschanged = onVoicesChanged;
1757
- assignedHandler = true;
1758
- }
1759
- });
1760
- const voicesAfterWait = synth.getVoices();
1761
- await this.emitTelemetry("pie-toolkit-tts-browser-voices-ready", {
1762
- voiceCount: voicesAfterWait.length,
1763
- timedOut: voicesAfterWait.length === 0,
1764
- });
1765
- }
1766
- /**
1767
- * Get all services as a bundle for section player convenience.
1768
- * Section player can extract and pass services to child components.
1769
- */
1770
- getServiceBundle() {
1771
- return {
1772
- ttsService: this.ttsService,
1773
- toolCoordinator: this.toolCoordinator,
1774
- highlightCoordinator: this.highlightCoordinator,
1775
- elementToolStateStore: this.elementToolStateStore,
1776
- catalogResolver: this.catalogResolver,
1777
- toolProviderRegistry: this.toolProviderRegistry,
1778
- };
1779
- }
1780
- /**
1781
- * Get a tool provider from the registry
1782
- *
1783
- * @param providerId Provider identifier
1784
- * @returns Tool provider instance
1785
- */
1786
- async getToolProvider(providerId) {
1787
- return this.ensureProviderReady(providerId);
1788
- }
1789
1891
  async waitUntilReady() {
1790
1892
  this.assertNotDisposed();
1791
1893
  if (this.isReady())
@@ -1795,9 +1897,8 @@ export class ToolkitCoordinator {
1795
1897
  this.coordinatorReadyPromise = (async () => {
1796
1898
  await this.ensureStateLoaded();
1797
1899
  this.assertNotDisposed();
1798
- const ttsConfig = this.getTTSConfigFromProviders();
1799
- if (ttsConfig?.enabled !== false) {
1800
- await this.ensureTTSReady(ttsConfig);
1900
+ if (this.ttsRequiredForReadiness()) {
1901
+ await this.startTTSForReadiness();
1801
1902
  }
1802
1903
  this.assertNotDisposed();
1803
1904
  if (!this.coordinatorReadyNotified) {
@@ -1814,22 +1915,37 @@ export class ToolkitCoordinator {
1814
1915
  return this.coordinatorReadyPromise;
1815
1916
  }
1816
1917
  isReady() {
1817
- return this.getInitStatus().coordinator;
1918
+ return ((this.stateLoadSettled || !this.hooks.loadToolState) &&
1919
+ (this.ttsInitialized ||
1920
+ this.ttsDegraded ||
1921
+ !this.ttsRequiredForReadiness()));
1922
+ }
1923
+ /**
1924
+ * Readiness waits for text-to-speech when it is enabled and either starts
1925
+ * eagerly or is granted: a granted read-aloud fails before the learner starts.
1926
+ */
1927
+ ttsRequiredForReadiness() {
1928
+ if (this.getTTSConfigFromProviders()?.enabled === false)
1929
+ return false;
1930
+ return !this.lazyInit || this.isToolGranted("textToSpeech");
1818
1931
  }
1819
- getInitStatus() {
1820
- const providers = {};
1821
- for (const providerId of this.toolProviderRegistry.getProviderIds()) {
1822
- providers[providerId] =
1823
- this.toolProviderRegistry.isInitialized(providerId);
1932
+ /**
1933
+ * Starts text-to-speech for readiness. A failure policy does not grant leaves
1934
+ * the tool degraded, reporting itself unavailable, and readiness settles; a
1935
+ * granted tool's failure rejects.
1936
+ */
1937
+ async startTTSForReadiness() {
1938
+ try {
1939
+ await this.ensureTTSReady();
1940
+ }
1941
+ catch (error) {
1942
+ if (error instanceof ToolkitCoordinatorDisposedError)
1943
+ throw error;
1944
+ if (this.isToolGranted("textToSpeech"))
1945
+ throw error;
1946
+ this.ttsDegraded = true;
1947
+ this.notifyReadyChange();
1824
1948
  }
1825
- const ttsConfig = this.getTTSConfigFromProviders();
1826
- return {
1827
- tts: this.ttsInitialized,
1828
- stateLoaded: this.stateLoaded || !this.hooks.loadToolState,
1829
- coordinator: (this.stateLoaded || !this.hooks.loadToolState) &&
1830
- (this.ttsInitialized || ttsConfig?.enabled === false),
1831
- providers,
1832
- };
1833
1949
  }
1834
1950
  getTTSConfigFromProviders() {
1835
1951
  return this.config.tools?.providers?.textToSpeech;
@@ -1838,16 +1954,11 @@ export class ToolkitCoordinator {
1838
1954
  if (typeof toolId !== "string" || toolId.trim().length === 0) {
1839
1955
  throw new Error("Tool id must be a non-empty string.");
1840
1956
  }
1841
- if (toolId === "tts") {
1842
- throw new Error(`Tool id "tts" is no longer supported. Use "textToSpeech".`);
1843
- }
1844
- // An empty registry means the host supplied none, not that every id is
1845
- // wrong. There is nothing to check an id against, and throwing turns a
1846
- // host's every tool-config call into an exception — including the calls
1847
- // this coordinator's own default-provider block provokes, which is how a
1848
- // host that passes no registry ended up unable to read its own config.
1849
- // Validation reports the missing registry once per coordinator, as
1850
- // `tools.registryUnavailable`; a report per call would be noise.
1957
+ // An empty registry means the host supplied none, so there is nothing to
1958
+ // check an id against. Throwing here would fail every tool-config call,
1959
+ // the default providers' own included, and once left a host without a
1960
+ // registry unable to read its config. Validation reports the missing
1961
+ // registry once per coordinator, as `tools.registryUnavailable`.
1851
1962
  if (this.toolRegistry.getAllToolIds().length === 0)
1852
1963
  return;
1853
1964
  if (!this.toolRegistry.get(toolId)) {
@@ -1867,6 +1978,11 @@ export class ToolkitCoordinator {
1867
1978
  this.toolContextResolvers.set(toolId, resolver);
1868
1979
  }
1869
1980
  }
1981
+ /**
1982
+ * Replace all host-owned render-context resolvers. The toolkit element
1983
+ * calls this from `runtime.toolContextResolvers`; it is not on
1984
+ * `ToolkitCoordinatorApi`.
1985
+ */
1870
1986
  setToolContextResolvers(resolvers) {
1871
1987
  this.toolContextResolvers.clear();
1872
1988
  this.installToolContextResolvers(resolvers ?? undefined);
@@ -1882,25 +1998,11 @@ export class ToolkitCoordinator {
1882
1998
  }
1883
1999
  }
1884
2000
  }
1885
- /**
1886
- * Check if a tool is enabled.
1887
- * Tools are enabled by default unless explicitly disabled.
1888
- *
1889
- * @param toolId Tool identifier (e.g., 'textToSpeech', 'answerEliminator')
1890
- * @returns True if tool is enabled
1891
- */
1892
- isToolEnabled(toolId) {
1893
- this.assertCanonicalToolId(toolId);
1894
- const toolConfig = this.config.tools?.providers?.[toolId];
1895
- // Enabled by default unless explicitly set to false
1896
- return toolConfig?.enabled !== false;
1897
- }
1898
2001
  getToolConfig(toolId) {
1899
2002
  this.assertCanonicalToolId(toolId);
1900
2003
  return this.config.tools?.providers?.[toolId] || null;
1901
2004
  }
1902
2005
  updateToolConfig(toolId, updates) {
1903
- // Update config
1904
2006
  this.assertCanonicalToolId(toolId);
1905
2007
  const current = this.getToolConfig(toolId) || {};
1906
2008
  if (!this.config.tools) {
@@ -1914,10 +2016,10 @@ export class ToolkitCoordinator {
1914
2016
  this.config.tools.providers = {};
1915
2017
  }
1916
2018
  const nextProviders = {
1917
- ...(this.config.tools.providers || {}),
2019
+ ...this.config.tools.providers,
1918
2020
  [toolId]: mergeToolConfigUpdate(toolId, current, updates),
1919
2021
  };
1920
- this.config.tools = this.validateToolsConfig({
2022
+ const validated = this.validateToolsConfig({
1921
2023
  ...this.config.tools,
1922
2024
  providers: nextProviders,
1923
2025
  }, {
@@ -1925,31 +2027,21 @@ export class ToolkitCoordinator {
1925
2027
  source: "ToolkitCoordinator.updateToolConfig",
1926
2028
  toolRegistry: this.toolRegistry,
1927
2029
  });
1928
- // M8 PR 2 — keep the policy engine's tools input in lockstep
1929
- // with the validated coordinator config. The engine emits an
1930
- // `inputs` change event so subscribers (e.g. PR 3 toolbars) can
1931
- // re-decide without us managing a parallel pub/sub.
1932
- this.policyEngine.updateInputs({
1933
- tools: this.config.tools,
1934
- });
2030
+ this.config.tools = validated;
2031
+ // The engine's tools input follows the validated config; its `inputs`
2032
+ // change event is what makes toolbars re-decide.
2033
+ this.policyEngine.updateInputs({ tools: validated });
1935
2034
  void this.emitTelemetry("pie-toolkit-tool-config-updated", { toolId });
1936
- // Apply configuration changes to services
1937
- this._applyToolConfigChange(toolId, current);
2035
+ this._applyToolConfigChange(toolId);
1938
2036
  }
1939
2037
  /**
1940
- * Update the enabled tool list for one placement level.
2038
+ * Patch one or more placement levels in the canonical tools config.
1941
2039
  *
1942
2040
  * This is the generic placement companion to {@link updateToolConfig}:
1943
2041
  * it validates the next canonical tools config, keeps the policy
1944
2042
  * engine in lockstep, and emits the same policy-change event used by
1945
2043
  * live toolbars/debug panels.
1946
2044
  */
1947
- updateToolPlacement(level, toolIds) {
1948
- this.updateToolsPlacement({ [level]: [...toolIds] });
1949
- }
1950
- /**
1951
- * Patch one or more placement levels in the canonical tools config.
1952
- */
1953
2045
  updateToolsPlacement(partial) {
1954
2046
  if (!this.config.tools) {
1955
2047
  this.config.tools = this.validateToolsConfig(undefined, {
@@ -1978,17 +2070,58 @@ export class ToolkitCoordinator {
1978
2070
  });
1979
2071
  validated.placement = nextPlacement;
1980
2072
  this.config.tools = validated;
1981
- this.policyEngine.updateInputs({
1982
- tools: this.config.tools,
1983
- });
2073
+ this.policyEngine.updateInputs({ tools: validated });
1984
2074
  }
1985
2075
  /**
1986
2076
  * Resolve the visible tool set for a given placement level + scope.
1987
2077
  *
1988
- * Thin shim over the owned tool-policy engine.
2078
+ * Delegates to the owned tool-policy engine and logs the decision's
2079
+ * diagnostics through {@link warnPolicyDiagnostics}.
1989
2080
  */
1990
2081
  decideToolPolicy(request) {
1991
- return this.policyEngine.decide(request);
2082
+ const decision = this.policyEngine.decide(request);
2083
+ this.warnPolicyDiagnostics(decision.diagnostics);
2084
+ return decision;
2085
+ }
2086
+ /**
2087
+ * Report each policy diagnostic as a console warning and to the
2088
+ * {@link onPolicyDiagnostic} listeners, once per code and tool id, and per
2089
+ * item for `tool-policy.itemSettingNotApplied`. Every toolbar and feature
2090
+ * asks for decisions, so a conflict would otherwise repeat on each; the
2091
+ * engine's input diagnostics arrive with each policy change.
2092
+ */
2093
+ warnPolicyDiagnostics(diagnostics) {
2094
+ for (const diagnostic of diagnostics) {
2095
+ const itemId = diagnostic.code === "tool-policy.itemSettingNotApplied"
2096
+ ? diagnostic.details.itemId
2097
+ : "";
2098
+ const key = `${diagnostic.code}\0${diagnostic.toolId}\0${itemId}`;
2099
+ if (this.reportedPolicyDiagnostics.has(key))
2100
+ continue;
2101
+ this.reportedPolicyDiagnostics.set(key, diagnostic);
2102
+ console.warn(`[ToolkitCoordinator] ${diagnostic.message}`);
2103
+ for (const listener of Array.from(this.policyDiagnosticListeners)) {
2104
+ this.notifyPolicyDiagnosticListener(listener, diagnostic);
2105
+ }
2106
+ }
2107
+ }
2108
+ notifyPolicyDiagnosticListener(listener, diagnostic) {
2109
+ try {
2110
+ listener(diagnostic);
2111
+ }
2112
+ catch (error) {
2113
+ console.error("[ToolkitCoordinator] Policy diagnostic listener failed:", error);
2114
+ }
2115
+ }
2116
+ /**
2117
+ * File a mounted item's policy settings (`requiredTools`, `restrictedTools`,
2118
+ * `toolParameters`) under its canonical id. They govern the decisions scoped
2119
+ * to that item: its own item-level toolbar, and the feature decisions its
2120
+ * content asks with the item's scope. `pie-item-scope` registers them from
2121
+ * its `settings` property; the returned function withdraws them.
2122
+ */
2123
+ registerItemSettings(itemId, settings) {
2124
+ return this.policyEngine.registerItemSettings(itemId, settings);
1992
2125
  }
1993
2126
  /**
1994
2127
  * Resolve eligibility for one PNP/AfA feature id, independent of toolbar
@@ -1999,8 +2132,8 @@ export class ToolkitCoordinator {
1999
2132
  * `pnpEnforcement` is not consulted.
2000
2133
  *
2001
2134
  * Reports once per coordinator when it is asked about a feature with no
2002
- * assessment bound. A host in that state gets a correct denial for every
2003
- * capability it asks about, which is indistinguishable from a student who was
2135
+ * assessment bound. A host in that state gets a denial for every capability
2136
+ * no item requires, which is indistinguishable from a student who was
2004
2137
  * properly declined — so without this, forgetting {@link updateAssessment}
2005
2138
  * presents as an accommodation that silently never appears.
2006
2139
  *
@@ -2008,25 +2141,28 @@ export class ToolkitCoordinator {
2008
2141
  * enforcement is explicitly `"on"`. Its toolkit binds whatever assessment the
2009
2142
  * host gave, usually none, and a host that passes no profile and leaves
2010
2143
  * enforcement unset or `"off"` has asked for no accommodation.
2144
+ *
2145
+ * `scope` is the surface asking; an item's scope brings in that item's
2146
+ * registered settings ({@link registerItemSettings}).
2011
2147
  */
2012
- decideFeaturePolicy(featureId) {
2013
- const decision = this.policyEngine.decideFeature(featureId);
2014
- const unboundIsMisconfigured = this.config.assessmentOptional !== true ||
2015
- this.pnpEnforcementOverride === "on";
2148
+ decideFeaturePolicy(featureId, scope) {
2149
+ const decision = this.policyEngine.decideFeature(featureId, scope);
2150
+ this.warnPolicyDiagnostics(decision.diagnostics);
2016
2151
  if (!decision.assessmentBound &&
2017
- unboundIsMisconfigured &&
2152
+ this.policyEngine.getInputs().assessmentExpected &&
2018
2153
  !this.reportedUnboundFeaturePolicy) {
2019
2154
  this.reportedUnboundFeaturePolicy = true;
2020
- console.warn(`[ToolkitCoordinator] Feature policy was asked about "${featureId}" with no assessment bound, so every capability will be declined for want of a profile to read. Call updateAssessment(...) with the assessment (its personalNeedsProfile, settings.districtPolicy and settings.testAdministration are what policy reads) before relying on any accommodation. Reported once per coordinator.`);
2155
+ console.warn(`[ToolkitCoordinator] Feature policy was asked about "${featureId}" with no assessment bound, so profile, district and test-administration policy have nothing to read and only an item's requiredTools can grant. Pass the assessment to the toolkit's assessment property, or call updateAssessment(...), before relying on any accommodation. Reported once per coordinator.`);
2021
2156
  }
2022
2157
  return decision;
2023
2158
  }
2024
2159
  /**
2025
2160
  * Subscribe to policy-engine change events. Fires whenever the
2026
2161
  * coordinator's bound inputs change (`updateToolConfig`,
2027
- * `updateToolPlacement`, `updateAssessment`, `updateCurrentItemRef`,
2028
- * `setPnpEnforcement`) or a custom `PolicySource` is registered /
2029
- * removed via {@link registerPolicySource}.
2162
+ * `updateToolsPlacement`, `updateAssessment`, `setPnpEnforcement`), an
2163
+ * item's settings are registered or withdrawn ({@link registerItemSettings}),
2164
+ * or a custom `PolicySource` is registered / removed via
2165
+ * {@link registerPolicySource}.
2030
2166
  *
2031
2167
  * The listener receives a `ToolPolicyChangeEvent` with the event
2032
2168
  * `reason` and a frozen snapshot of the engine inputs. Listeners
@@ -2040,6 +2176,15 @@ export class ToolkitCoordinator {
2040
2176
  onPolicyChange(listener) {
2041
2177
  return this.policyEngine.onPolicyChange(listener);
2042
2178
  }
2179
+ onPolicyDiagnostic(listener) {
2180
+ this.policyDiagnosticListeners.add(listener);
2181
+ for (const diagnostic of Array.from(this.reportedPolicyDiagnostics.values())) {
2182
+ this.notifyPolicyDiagnosticListener(listener, diagnostic);
2183
+ }
2184
+ return () => {
2185
+ this.policyDiagnosticListeners.delete(listener);
2186
+ };
2187
+ }
2043
2188
  /**
2044
2189
  * Subscribe to accessibility-catalog registrations and removals.
2045
2190
  *
@@ -2056,49 +2201,26 @@ export class ToolkitCoordinator {
2056
2201
  /**
2057
2202
  * Bind (or clear) the active assessment for PNP/profile policy decisions.
2058
2203
  *
2059
- * Under auto-mode (no host override via {@link setPnpEnforcement}),
2060
- * the coordinator promotes the engine to `pnpEnforcement: "on"`
2061
- * iff the assessment carries any profile precedence material
2062
- * (`personalNeedsProfile`, `settings.districtPolicy`,
2063
- * `settings.testAdministration`) or the currently-bound item ref
2064
- * carries item-level profile policy inputs. A bare assessment record (just
2204
+ * Under auto-mode (no host override via {@link setPnpEnforcement}), a
2205
+ * decision enforces PNP/profile policy iff the assessment carries profile
2206
+ * precedence material (`personalNeedsProfile`, `settings.districtPolicy`,
2207
+ * `settings.testAdministration`), or the decision is scoped to an item whose
2208
+ * settings carry item-level policy inputs. A bare assessment record (just
2065
2209
  * `id` / `name`, no PNP, no settings) keeps `"off"`.
2066
2210
  *
2067
2211
  * The host override set via {@link setPnpEnforcement} is sticky
2068
- * across assessment swaps; calling with `null` clears the binding
2069
- * and re-runs the auto-mode helper.
2212
+ * across assessment swaps.
2070
2213
  */
2071
2214
  updateAssessment(assessment) {
2072
- this.boundAssessment = assessment;
2073
- this.policyEngine.updateInputs({
2074
- assessment,
2075
- pnpEnforcement: this.resolveEffectivePnpEnforcement(),
2076
- });
2215
+ this.policyEngine.updateInputs({ assessment });
2077
2216
  }
2078
2217
  /**
2079
- * Bind (or clear) the current item reference for policy decisions.
2080
- *
2081
- * Used by item-level profile gates (item `requiredTools` /
2082
- * `restrictedTools` / `toolParameters`). Item-level profile material
2083
- * also feeds {@link resolveEffectivePnpEnforcement} — navigating
2084
- * to an item with profile settings can flip auto-mode to `"on"` even
2085
- * when the parent assessment carries no profile block of its own.
2086
- */
2087
- updateCurrentItemRef(itemRef) {
2088
- this.boundCurrentItemRef = itemRef;
2089
- this.policyEngine.updateInputs({
2090
- currentItemRef: itemRef,
2091
- pnpEnforcement: this.resolveEffectivePnpEnforcement(),
2092
- });
2093
- }
2094
- /**
2095
- * Override the auto-mode PNP/profile enforcement decision.
2218
+ * Override the auto-mode PNP/profile enforcement decision; `null` returns
2219
+ * to auto-mode. `"on"` and `"off"` stick across assessment swaps until
2220
+ * cleared.
2096
2221
  */
2097
2222
  setPnpEnforcement(mode) {
2098
- this.pnpEnforcementOverride = mode;
2099
- this.policyEngine.updateInputs({
2100
- pnpEnforcement: this.resolveEffectivePnpEnforcement(),
2101
- });
2223
+ this.policyEngine.updateInputs({ pnpEnforcement: mode });
2102
2224
  }
2103
2225
  /**
2104
2226
  * Get the policy engine inputs currently driving decisions. Useful
@@ -2122,29 +2244,6 @@ export class ToolkitCoordinator {
2122
2244
  registerPolicySource(source) {
2123
2245
  return this.policyEngine.registerPolicySource(source);
2124
2246
  }
2125
- /**
2126
- * Register a host-owned resolver for one tool's render context.
2127
- *
2128
- * Resolvers are evaluated only for tools that already survived the
2129
- * framework policy pipeline. They can hide the tool for the current
2130
- * scope or attach render params consumed by the packaged registration;
2131
- * they cannot re-enable tools blocked by placement, provider config,
2132
- * host policy, or PNP/profile rules.
2133
- */
2134
- registerToolContextResolver(toolId, resolver) {
2135
- this.assertCanonicalToolId(toolId);
2136
- if (typeof resolver !== "function") {
2137
- throw new Error(`Invalid tool context resolver for "${toolId}": expected a function.`);
2138
- }
2139
- this.toolContextResolvers.set(toolId, resolver);
2140
- this.notifyToolContextResolverChange();
2141
- return () => {
2142
- if (this.toolContextResolvers.get(toolId) !== resolver)
2143
- return;
2144
- this.toolContextResolvers.delete(toolId);
2145
- this.notifyToolContextResolverChange();
2146
- };
2147
- }
2148
2247
  hasToolContextResolver(toolId) {
2149
2248
  this.assertCanonicalToolId(toolId);
2150
2249
  return this.toolContextResolvers.has(toolId);
@@ -2192,6 +2291,9 @@ export class ToolkitCoordinator {
2192
2291
  this.toolContextResolverChangeListeners.delete(listener);
2193
2292
  };
2194
2293
  }
2294
+ getToolRegistry() {
2295
+ return this.toolRegistry;
2296
+ }
2195
2297
  /**
2196
2298
  * Claim requests for one placement level. Called by a toolbar on mount.
2197
2299
  */
@@ -2215,95 +2317,46 @@ export class ToolkitCoordinator {
2215
2317
  * surface, and a host that swapped the registry for one without the tool would
2216
2318
  * otherwise lose the whole surface to an exception over an absent action.
2217
2319
  */
2218
- canRequestTool(toolId, level) {
2320
+ canRequestTool(toolId, level, scopeId) {
2219
2321
  try {
2220
2322
  this.assertCanonicalToolId(toolId);
2221
2323
  }
2222
2324
  catch {
2223
2325
  return false;
2224
2326
  }
2225
- return this.toolRequests.canRequest(toolId, level);
2327
+ return this.toolRequests.canRequest(toolId, level, scopeId);
2226
2328
  }
2227
2329
  onToolRequestTargetsChange(listener) {
2228
2330
  return this.toolRequests.onTargetsChange(listener);
2229
2331
  }
2230
- /**
2231
- * Compute the effective PNP/profile enforcement mode given the explicit
2232
- * host override and the auto-mode helper.
2233
- *
2234
- * Auto-mode (no override) defers to
2235
- * {@link resolveDefaultPnpEnforcement}, which returns `"on"`
2236
- * exactly when the bound assessment or current item ref carries
2237
- * profile precedence material (PNP, district policy, test
2238
- * administration, item-level required/restricted/parameters), and
2239
- * `"off"` otherwise. Profile gates engage the moment profile material
2240
- * is present.
2241
- */
2242
- resolveEffectivePnpEnforcement() {
2243
- if (this.pnpEnforcementOverride !== null) {
2244
- return this.pnpEnforcementOverride;
2245
- }
2246
- return resolveDefaultPnpEnforcement({
2247
- assessment: this.boundAssessment,
2248
- currentItemRef: this.boundCurrentItemRef,
2249
- });
2250
- }
2251
2332
  resolveConfiguredPnpEnforcement() {
2252
- const tools = this.config.tools;
2253
- return tools?.pnpEnforcement ?? null;
2333
+ return this.config.tools?.pnpEnforcement ?? null;
2254
2334
  }
2255
- /**
2256
- * Apply tool configuration changes to underlying services.
2257
- * Called after updateToolConfig() with the tool's config before the update.
2258
- */
2259
- _applyToolConfigChange(toolId, previousConfig) {
2335
+ /** Apply an `updateToolConfig` change to the underlying services. */
2336
+ _applyToolConfigChange(toolId) {
2260
2337
  if (this.disposePromise !== null)
2261
2338
  return;
2262
- // Apply configuration changes based on tool
2263
- switch (toolId) {
2264
- case "textToSpeech":
2265
- this.scheduleTTSReconfigure();
2266
- break;
2267
- case "answerEliminator":
2268
- // Future: Could notify answer eliminator tools of strategy change
2269
- break;
2270
- default:
2271
- this._registerChangedToolProvider(toolId, previousConfig);
2339
+ if (toolId === "textToSpeech") {
2340
+ this.scheduleTTSReconfigure();
2341
+ return;
2272
2342
  }
2343
+ this._registerChangedToolProvider(toolId);
2273
2344
  }
2274
2345
  /**
2275
- * Register the provider a tool's updated config names, and unregister the
2276
- * one its previous config named when the provider id changed, as
2277
- * {@link _reconfigureTTSProvider} does for `tts`. Registration completes
2278
- * before this returns, so a check on the policy change the update dispatched
2279
- * finds the new provider.
2346
+ * Register the provider a tool's updated config builds, replacing the one
2347
+ * registered under the tool's id. Registration completes before this returns,
2348
+ * so a check on the policy change the update dispatched finds the new provider.
2280
2349
  */
2281
- _registerChangedToolProvider(toolId, previousConfig) {
2350
+ _registerChangedToolProvider(toolId) {
2282
2351
  const registration = this.getProviderDescriptorTools().find((tool) => tool.toolId === toolId);
2283
2352
  if (!registration)
2284
2353
  return;
2285
- void this.registerProviderFromTool(registration);
2286
- const resolveQuietly = (config) => {
2287
- try {
2288
- return resolveToolProviderId(registration, config);
2289
- }
2290
- catch {
2291
- return null;
2292
- }
2293
- };
2294
- const previousId = resolveQuietly(previousConfig);
2295
- const nextId = resolveQuietly(this.getToolConfig(toolId) || undefined);
2296
- if (!previousId ||
2297
- previousId === nextId ||
2298
- !this.toolProviderRegistry.has(previousId)) {
2299
- return;
2300
- }
2301
- void this.toolProviderRegistry.unregister(previousId).catch((err) => {
2302
- console.warn(`[ToolkitCoordinator] Failed to unregister provider "${previousId}":`, err);
2303
- });
2354
+ // A failure of the replaced provider no longer describes the tool.
2355
+ this.degradedTools.delete(toolId);
2356
+ void this.registerProviderFromTool(registration, true);
2304
2357
  }
2305
2358
  /**
2306
- * Re-register the `tts` provider for the current config and registry, then
2359
+ * Re-register the text-to-speech provider for the current config and registry, then
2307
2360
  * re-initialize unless initialization is lazy. An initialization already
2308
2361
  * running finishes first: it would otherwise mark its provider ready after
2309
2362
  * the reset.
@@ -2319,26 +2372,31 @@ export class ToolkitCoordinator {
2319
2372
  this.ttsReconfigurePromise = undefined;
2320
2373
  }
2321
2374
  });
2322
- void reconfigurePromise.then(async () => {
2375
+ void reconfigurePromise
2376
+ .then(async () => {
2323
2377
  if (this.disposePromise !== null)
2324
2378
  return;
2325
- const ttsConfig = this.getTTSConfigFromProviders();
2326
- if (!this.lazyInit && ttsConfig?.enabled !== false) {
2327
- await this.ensureTTSReady(ttsConfig);
2328
- }
2329
- });
2379
+ if (this.ttsRequiredForReadiness())
2380
+ await this.startTTSForReadiness();
2381
+ })
2382
+ // Reported where it failed; a granted failure has nowhere else to go.
2383
+ .catch(() => { });
2330
2384
  }
2331
2385
  async _reconfigureTTSProvider() {
2332
2386
  this.ttsInitialized = false;
2387
+ this.ttsDegraded = false;
2388
+ this.degradedTools.delete("textToSpeech");
2333
2389
  this.ttsInitPromise = undefined;
2390
+ this.notifyReadyChange();
2334
2391
  try {
2335
- this.ttsService.stop();
2392
+ // The next speak waits on readiness, which starts the new provider.
2393
+ this.ttsService.releaseProvider();
2336
2394
  }
2337
2395
  catch {
2338
- // noop: stop best effort
2396
+ // noop: release best effort
2339
2397
  }
2340
- if (this.toolProviderRegistry.has("tts")) {
2341
- await this.toolProviderRegistry.unregister("tts");
2398
+ if (this.toolProviderRegistry.has("textToSpeech")) {
2399
+ await this.toolProviderRegistry.unregister("textToSpeech");
2342
2400
  }
2343
2401
  if (this.disposePromise !== null)
2344
2402
  return;