@pie-players/pie-assessment-toolkit 0.3.73 → 0.3.75

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 (274) hide show
  1. package/README.md +469 -318
  2. package/dist/attempt/AssessmentSession.d.ts +1 -5
  3. package/dist/attempt/AssessmentSession.js +0 -29
  4. package/dist/attempt/TestSession.d.ts +1 -20
  5. package/dist/attempt/TestSession.js +8 -99
  6. package/dist/components/ItemScope.custom-element.d.ts +1 -0
  7. package/dist/components/ItemScope.custom-element.js +1 -0
  8. package/dist/components/ItemToolBar.custom-element.d.ts +1 -0
  9. package/dist/components/ItemToolBar.custom-element.js +1 -1
  10. package/dist/components/PieAssessmentToolkit.custom-element.d.ts +1 -0
  11. package/dist/components/PieAssessmentToolkit.custom-element.js +13 -13
  12. package/dist/components/SectionToolBar.custom-element.d.ts +1 -0
  13. package/dist/components/SectionToolBar.custom-element.js +1 -1
  14. package/dist/components/chunks/ItemToolBar-7tt7v2xp.js +4 -0
  15. package/dist/components/chunks/ItemToolBar-97hx60z4.js +2 -0
  16. package/dist/components/chunks/ItemToolBar-bxwyhr5p.js +2 -0
  17. package/dist/components/chunks/ItemToolBar-jx38w99s.js +2 -0
  18. package/dist/components/chunks/ItemToolBar-rd2bb4p6.js +40 -0
  19. package/dist/components/chunks/ItemToolBar-vdkyn9f3.js +2 -0
  20. package/dist/components/chunks/sre-engine-jd8cc08x.js +1 -0
  21. package/dist/components/item-scope-element.d.ts +2 -0
  22. package/dist/components/item-scope-element.js +2 -0
  23. package/dist/context/assessment-toolkit-context.d.ts +32 -6
  24. package/dist/index.d.ts +23 -48
  25. package/dist/index.js +12 -37
  26. package/dist/policy/core/PolicySource.d.ts +1 -1
  27. package/dist/policy/core/ToolPolicyEngine.d.ts +85 -34
  28. package/dist/policy/core/ToolPolicyEngine.js +148 -46
  29. package/dist/policy/core/compose-decision.d.ts +28 -7
  30. package/dist/policy/core/compose-decision.js +75 -54
  31. package/dist/policy/core/decision-types.d.ts +65 -34
  32. package/dist/policy/core/feature-decision.d.ts +16 -9
  33. package/dist/policy/core/feature-decision.js +14 -8
  34. package/dist/policy/core/pnp-policy-inputs.d.ts +24 -33
  35. package/dist/policy/core/pnp-policy-inputs.js +28 -56
  36. package/dist/policy/core/provenance.d.ts +9 -4
  37. package/dist/policy/core/provenance.js +1 -1
  38. package/dist/policy/engine.d.ts +7 -23
  39. package/dist/policy/engine.js +5 -20
  40. package/dist/policy/sources/PnpPolicySource.d.ts +44 -61
  41. package/dist/policy/sources/PnpPolicySource.js +117 -118
  42. package/dist/runtime/SectionControllerBinding.d.ts +279 -0
  43. package/dist/runtime/SectionControllerBinding.js +450 -0
  44. package/dist/runtime/SectionRuntimeEngine.d.ts +26 -289
  45. package/dist/runtime/SectionRuntimeEngine.js +30 -372
  46. package/dist/runtime/adapter/SectionEngineAdapter.d.ts +19 -80
  47. package/dist/runtime/adapter/SectionEngineAdapter.js +19 -92
  48. package/dist/runtime/adapter/dom-event-bridge.d.ts +15 -30
  49. package/dist/runtime/adapter/dom-event-bridge.js +17 -44
  50. package/dist/runtime/adapter/subscriber-fanout.d.ts +1 -2
  51. package/dist/runtime/adapter/subscriber-fanout.js +1 -2
  52. package/dist/runtime/catalog-context.d.ts +19 -0
  53. package/dist/runtime/catalog-context.js +28 -0
  54. package/dist/runtime/composition-emit-scheduler.d.ts +9 -15
  55. package/dist/runtime/composition-emit-scheduler.js +9 -15
  56. package/dist/runtime/content-language.d.ts +32 -0
  57. package/dist/runtime/content-language.js +46 -0
  58. package/dist/runtime/content-region.d.ts +17 -0
  59. package/dist/runtime/content-region.js +29 -0
  60. package/dist/runtime/content-registration.d.ts +13 -0
  61. package/dist/runtime/content-registration.js +25 -0
  62. package/dist/runtime/core/SectionEngineCore.d.ts +2 -2
  63. package/dist/runtime/core/SectionEngineCore.js +2 -2
  64. package/dist/runtime/core/cohort.d.ts +1 -1
  65. package/dist/runtime/core/cohort.js +1 -1
  66. package/dist/runtime/core/engine-input.d.ts +18 -30
  67. package/dist/runtime/core/engine-input.js +11 -12
  68. package/dist/runtime/core/engine-output.d.ts +16 -40
  69. package/dist/runtime/core/engine-output.js +9 -21
  70. package/dist/runtime/core/engine-readiness.d.ts +5 -37
  71. package/dist/runtime/core/engine-readiness.js +8 -41
  72. package/dist/runtime/core/engine-resolver.d.ts +46 -74
  73. package/dist/runtime/core/engine-resolver.js +18 -43
  74. package/dist/runtime/core/engine-state.d.ts +28 -36
  75. package/dist/runtime/core/engine-state.js +18 -21
  76. package/dist/runtime/core/engine-transition.d.ts +14 -19
  77. package/dist/runtime/core/engine-transition.js +71 -51
  78. package/dist/runtime/engine.d.ts +16 -19
  79. package/dist/runtime/engine.js +12 -19
  80. package/dist/runtime/page-warnings.d.ts +13 -0
  81. package/dist/runtime/page-warnings.js +22 -0
  82. package/dist/runtime/policy-input-forwarding.d.ts +25 -0
  83. package/dist/runtime/policy-input-forwarding.js +25 -0
  84. package/dist/runtime/registration-events.d.ts +14 -0
  85. package/dist/runtime/runtime-event-claim.d.ts +8 -0
  86. package/dist/runtime/runtime-event-claim.js +15 -0
  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 +188 -0
  93. package/dist/runtime/tool-host-contract.d.ts +28 -10
  94. package/dist/runtime/tool-host-contract.js +31 -10
  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/ElementToolStateStore.d.ts +14 -10
  98. package/dist/services/ElementToolStateStore.js +37 -13
  99. package/dist/services/HighlightCoordinator.d.ts +22 -28
  100. package/dist/services/HighlightCoordinator.js +431 -327
  101. package/dist/services/RangeSerializer.d.ts +10 -1
  102. package/dist/services/RangeSerializer.js +87 -14
  103. package/dist/services/TTSService.d.ts +130 -69
  104. package/dist/services/TTSService.js +713 -570
  105. package/dist/services/ToolCoordinator.d.ts +43 -66
  106. package/dist/services/ToolCoordinator.js +164 -208
  107. package/dist/services/ToolRegistry.d.ts +78 -54
  108. package/dist/services/ToolRegistry.js +107 -105
  109. package/dist/services/ToolkitCoordinator.d.ts +261 -175
  110. package/dist/services/ToolkitCoordinator.js +667 -433
  111. package/dist/services/audio-handoff.d.ts +6 -7
  112. package/dist/services/audio-handoff.js +20 -14
  113. package/dist/services/catalog-owner.d.ts +11 -1
  114. package/dist/services/catalog-owner.js +25 -0
  115. package/dist/services/create-tools-config.d.ts +2 -2
  116. package/dist/services/framework-error-bus.d.ts +3 -6
  117. package/dist/services/framework-error-bus.js +3 -6
  118. package/dist/services/framework-error.d.ts +30 -8
  119. package/dist/services/framework-error.js +9 -7
  120. package/dist/services/interfaces.d.ts +158 -129
  121. package/dist/services/section-controller-types.d.ts +15 -0
  122. package/dist/services/section-session-assignment.d.ts +21 -0
  123. package/dist/services/section-session-assignment.js +82 -0
  124. package/dist/services/spoken-audio-cards.d.ts +0 -2
  125. package/dist/services/spoken-audio-cards.js +1 -3
  126. package/dist/services/tool-config-defaults.d.ts +3 -3
  127. package/dist/services/tool-config-defaults.js +3 -3
  128. package/dist/services/tool-config-validation.d.ts +14 -4
  129. package/dist/services/tool-config-validation.js +63 -71
  130. package/dist/services/tool-context.d.ts +0 -8
  131. package/dist/services/tool-context.js +29 -25
  132. package/dist/services/tool-icons.d.ts +4 -3
  133. package/dist/services/tool-icons.js +4 -3
  134. package/dist/services/tool-instance-id.d.ts +0 -1
  135. package/dist/services/tool-instance-id.js +0 -6
  136. package/dist/services/tool-providers/TTSToolProvider.d.ts +41 -80
  137. package/dist/services/tool-providers/TTSToolProvider.js +81 -40
  138. package/dist/services/tool-providers/ToolProviderApi.d.ts +10 -4
  139. package/dist/services/tool-providers/ToolProviderRegistry.d.ts +32 -28
  140. package/dist/services/tool-providers/ToolProviderRegistry.js +121 -107
  141. package/dist/services/tool-providers/index.d.ts +0 -9
  142. package/dist/services/tool-providers/index.js +2 -4
  143. package/dist/services/tool-request.d.ts +27 -12
  144. package/dist/services/tool-request.js +24 -14
  145. package/dist/services/toolbar-decided-tools.d.ts +15 -0
  146. package/dist/services/toolbar-decided-tools.js +28 -0
  147. package/dist/services/tools-config-normalizer.d.ts +37 -23
  148. package/dist/services/tools-config-normalizer.js +2 -10
  149. package/dist/services/tts/browser-provider.d.ts +38 -2
  150. package/dist/services/tts/browser-provider.js +276 -140
  151. package/dist/services/tts/catalog-span-alignment.d.ts +2 -17
  152. package/dist/services/tts/catalog-span-alignment.js +17 -142
  153. package/dist/services/tts/flat-tree.d.ts +71 -0
  154. package/dist/services/tts/flat-tree.js +269 -0
  155. package/dist/services/tts/generated-speech/assemble-plan.d.ts +3 -1
  156. package/dist/services/tts/generated-speech/assemble-plan.js +3 -1
  157. package/dist/services/tts/generated-speech/dom/build-dom-plan.d.ts +2 -0
  158. package/dist/services/tts/generated-speech/dom/build-dom-plan.js +1 -1
  159. package/dist/services/tts/generated-speech/math-speech-cache.d.ts +3 -1
  160. package/dist/services/tts/generated-speech/math-speech-cache.js +5 -1
  161. package/dist/services/tts/generated-speech/types.d.ts +0 -12
  162. package/dist/services/tts/highlight-pipeline/boundary-normalizer.js +12 -62
  163. package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +38 -5
  164. package/dist/services/tts/highlight-pipeline/normalize-speech-chunks.js +3 -6
  165. package/dist/services/tts/highlight-pipeline/prose-boundary-resolver.js +6 -4
  166. package/dist/services/tts/highlight-pipeline/readable-region.js +3 -2
  167. package/dist/services/tts/highlight-pipeline/types.d.ts +5 -0
  168. package/dist/services/tts/highlight-pipeline/visible-map-range.d.ts +12 -0
  169. package/dist/services/tts/highlight-pipeline/visible-map-range.js +73 -7
  170. package/dist/services/tts/math-alignment/index.d.ts +0 -5
  171. package/dist/services/tts/math-alignment/index.js +5 -25
  172. package/dist/services/tts/math-alignment/speech-tokenizer.d.ts +9 -1
  173. package/dist/services/tts/math-alignment/speech-tokenizer.js +38 -36
  174. package/dist/services/tts/math-alignment/types.d.ts +4 -2
  175. package/dist/services/tts/math-aware-text-processing.d.ts +5 -1
  176. package/dist/services/tts/math-aware-text-processing.js +74 -18
  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 +16 -1
  180. package/dist/services/tts/math-speech.js +55 -19
  181. package/dist/services/tts/provider-options.d.ts +41 -0
  182. package/dist/services/tts/provider-options.js +1 -0
  183. package/dist/services/tts/sre-engine.d.ts +7 -0
  184. package/dist/services/tts/sre-engine.js +11 -0
  185. package/dist/services/tts/sre-locales.d.ts +14 -0
  186. package/dist/services/tts/sre-locales.js +57 -0
  187. package/dist/services/tts/ssml/spoken-text.d.ts +2 -26
  188. package/dist/services/tts/ssml/spoken-text.js +29 -0
  189. package/dist/services/tts/start-failure.d.ts +11 -0
  190. package/dist/services/tts/start-failure.js +19 -0
  191. package/dist/services/tts/text-processing.d.ts +9 -1
  192. package/dist/services/tts/text-processing.js +46 -34
  193. package/dist/services/tts/text-segmentation.d.ts +7 -0
  194. package/dist/services/tts/text-segmentation.js +47 -0
  195. package/dist/services/tts-runtime-config.d.ts +44 -18
  196. package/dist/services/tts-runtime-config.js +22 -93
  197. package/dist/tools/content-capability-resolution.d.ts +7 -9
  198. package/dist/tools/content-capability-resolution.js +8 -45
  199. package/dist/tools/registration.d.ts +58 -0
  200. package/dist/tools/registration.js +54 -0
  201. package/dist/tools/registrations/toolbar-registration-helpers.js +2 -2
  202. package/dist/tools/tool-surface-host.d.ts +6 -10
  203. package/dist/tools/tool-surface-host.js +44 -42
  204. package/dist/tools/tool-tag-map.d.ts +5 -15
  205. package/dist/tools/tool-tag-map.js +6 -20
  206. package/dist/utils/structural-equality.d.ts +11 -0
  207. package/dist/utils/structural-equality.js +47 -0
  208. package/package.json +24 -59
  209. package/dist/attempt/adapters/activity-to-test-attempt-session.d.ts +0 -49
  210. package/dist/attempt/adapters/activity-to-test-attempt-session.js +0 -138
  211. package/dist/components/chunks/ItemToolBar-byq80z42.js +0 -40
  212. package/dist/components/chunks/ItemToolBar-d36g3aw4.js +0 -49
  213. package/dist/context/runtime-context-consumer.d.ts +0 -13
  214. package/dist/context/runtime-context-consumer.js +0 -51
  215. package/dist/item-loader.d.ts +0 -44
  216. package/dist/item-loader.js +0 -61
  217. package/dist/policy/core/engine-context.d.ts +0 -29
  218. package/dist/policy/core/engine-context.js +0 -27
  219. package/dist/policy/core/policy-source-tag.d.ts +0 -31
  220. package/dist/policy/core/policy-source-tag.js +0 -28
  221. package/dist/policy/internal.d.ts +0 -25
  222. package/dist/policy/internal.js +0 -25
  223. package/dist/runtime/adapter/coordinator-bridge.d.ts +0 -92
  224. package/dist/runtime/adapter/coordinator-bridge.js +0 -126
  225. package/dist/runtime/adapter/framework-error-bridge.d.ts +0 -36
  226. package/dist/runtime/adapter/framework-error-bridge.js +0 -46
  227. package/dist/runtime/adapter/instrumentation-bridge.d.ts +0 -31
  228. package/dist/runtime/adapter/instrumentation-bridge.js +0 -38
  229. package/dist/runtime/core/engine-stage-derivation.d.ts +0 -18
  230. package/dist/runtime/core/engine-stage-derivation.js +0 -29
  231. package/dist/runtime/engine-context.d.ts +0 -39
  232. package/dist/runtime/engine-context.js +0 -31
  233. package/dist/runtime/internal.d.ts +0 -43
  234. package/dist/runtime/internal.js +0 -45
  235. package/dist/runtime/section-runtime-engine-host-context.d.ts +0 -80
  236. package/dist/runtime/section-runtime-engine-host-context.js +0 -95
  237. package/dist/runtime/stage-emit-gate.d.ts +0 -92
  238. package/dist/runtime/stage-emit-gate.js +0 -82
  239. package/dist/services/I18nService.d.ts +0 -47
  240. package/dist/services/I18nService.js +0 -84
  241. package/dist/services/ThemeProvider.d.ts +0 -95
  242. package/dist/services/ThemeProvider.js +0 -334
  243. package/dist/services/ToolConfigResolver.d.ts +0 -161
  244. package/dist/services/ToolConfigResolver.js +0 -229
  245. package/dist/services/catalog-media.d.ts +0 -56
  246. package/dist/services/catalog-media.js +0 -190
  247. package/dist/services/createDefaultToolRegistry.d.ts +0 -53
  248. package/dist/services/createDefaultToolRegistry.js +0 -45
  249. package/dist/services/defaultPersonalNeedsProfile.d.ts +0 -21
  250. package/dist/services/defaultPersonalNeedsProfile.js +0 -26
  251. package/dist/services/pnp-standard-features.d.ts +0 -238
  252. package/dist/services/pnp-standard-features.js +0 -338
  253. package/dist/services/tool-providers/CortexToolProvider.d.ts +0 -13
  254. package/dist/services/tool-providers/CortexToolProvider.js +0 -32
  255. package/dist/services/tool-providers/DesmosToolProvider.d.ts +0 -14
  256. package/dist/services/tool-providers/DesmosToolProvider.js +0 -32
  257. package/dist/services/tool-providers/GeoGebraToolProvider.d.ts +0 -16
  258. package/dist/services/tool-providers/GeoGebraToolProvider.js +0 -32
  259. package/dist/services/tool-providers/LazyCalculatorToolProvider.d.ts +0 -35
  260. package/dist/services/tool-providers/LazyCalculatorToolProvider.js +0 -95
  261. package/dist/services/tts/visual-line-ranges.d.ts +0 -18
  262. package/dist/services/tts/visual-line-ranges.js +0 -226
  263. package/dist/tools/client.d.ts +0 -12
  264. package/dist/tools/client.js +0 -14
  265. package/dist/tools/index.d.ts +0 -8
  266. package/dist/tools/index.js +0 -10
  267. package/dist/tools/internal.d.ts +0 -43
  268. package/dist/tools/internal.js +0 -41
  269. package/dist/tools/response-discovery.d.ts +0 -61
  270. package/dist/tools/response-discovery.js +0 -113
  271. package/dist/tools/types.d.ts +0 -337
  272. package/dist/tools/types.js +0 -12
  273. package/dist/tools/variant-resolver.d.ts +0 -41
  274. package/dist/tools/variant-resolver.js +0 -207
@@ -14,7 +14,7 @@
14
14
  * Part of PIE Assessment Toolkit.
15
15
  */
16
16
  import { normalizeToolsConfig, } from "./tools-config-normalizer.js";
17
- import { normalizeAndValidateToolsConfig, normalizeToolConfigStrictness, } from "./tool-config-validation.js";
17
+ import { collectToolConfigDiagnostics, normalizeToolConfigStrictness, reportToolConfigDiagnostics, } from "./tool-config-validation.js";
18
18
  import { AccessibilityCatalogResolver } from "./AccessibilityCatalogResolver.js";
19
19
  import { ElementToolStateStore } from "./ElementToolStateStore.js";
20
20
  import { frameworkErrorFromCoordinatorContext, } from "./framework-error.js";
@@ -22,13 +22,13 @@ import { FrameworkErrorBus, } from "./framework-error-bus.js";
22
22
  import { HighlightCoordinator } from "./HighlightCoordinator.js";
23
23
  import { ToolCoordinator } from "./ToolCoordinator.js";
24
24
  import { TTSService } from "./TTSService.js";
25
- import { BrowserTTSProvider } from "./tts/browser-provider.js";
25
+ import { BrowserTTSProvider, browserFallbackConfig, } from "./tts/browser-provider.js";
26
26
  import { buildRuntimeTTSConfig, resolveTTSBackend, resolveTTSRuntimeSettings, } from "./tts-runtime-config.js";
27
27
  import { ToolProviderRegistry } from "./tool-providers/index.js";
28
28
  import { ToolRegistry } from "./ToolRegistry.js";
29
29
  import { ToolRequestRegistry } from "./tool-request.js";
30
30
  import { ToolPolicyEngine, } from "../policy/engine.js";
31
- import { resolveDefaultPnpEnforcement } from "../policy/internal.js";
31
+ import { resolveSectionSessionAssignment } from "./section-session-assignment.js";
32
32
  class ToolkitCoordinatorDisposedError extends Error {
33
33
  constructor() {
34
34
  super("ToolkitCoordinator has been disposed.");
@@ -45,16 +45,9 @@ const isPlainRecord = (value) => !!value && typeof value === "object" && !Array.
45
45
  const mergeToolConfigUpdate = (toolId, current, updates) => {
46
46
  const next = { ...current, ...updates };
47
47
  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
- }
48
+ isPlainRecord(current.mathSpeech) &&
49
+ isPlainRecord(updates.mathSpeech)) {
50
+ next.mathSpeech = { ...current.mathSpeech, ...updates.mathSpeech };
58
51
  }
59
52
  return next;
60
53
  };
@@ -81,13 +74,19 @@ const SECTION_SCOPED_EVENT_TYPES = [
81
74
  *
82
75
  * @example
83
76
  * ```typescript
77
+ * // Both are exported by @pie-players/pie-default-tool-loaders
78
+ * const toolRegistry = createPackagedToolRegistry({
79
+ * toolModuleLoaders: DEFAULT_TOOL_MODULE_LOADERS,
80
+ * });
81
+ *
84
82
  * // Create coordinator with configuration
85
83
  * const coordinator = new ToolkitCoordinator({
86
84
  * assessmentId: 'demo-three-questions',
85
+ * toolRegistry,
87
86
  * tools: {
88
87
  * providers: {
89
88
  * textToSpeech: { enabled: true, backend: 'browser' },
90
- * answerEliminator: { enabled: true, strategy: 'strikethrough' }
89
+ * answerEliminator: { enabled: true }
91
90
  * },
92
91
  * placement: {
93
92
  * item: ['textToSpeech', 'answerEliminator']
@@ -96,7 +95,7 @@ const SECTION_SCOPED_EVENT_TYPES = [
96
95
  * });
97
96
  *
98
97
  * // Pass to section player
99
- * player.toolkitCoordinator = coordinator;
98
+ * player.runtime = { ...(player.runtime ?? {}), coordinator };
100
99
  *
101
100
  * // Access services directly
102
101
  * const ttsService = coordinator.ttsService;
@@ -119,14 +118,43 @@ export class ToolkitCoordinator {
119
118
  lazyInit;
120
119
  /** Track TTS initialization state */
121
120
  ttsInitialized = false;
121
+ /** Text-to-speech failed to start and policy does not grant it. */
122
+ ttsDegraded = false;
122
123
  ttsInitPromise;
123
124
  ttsReconfigurePromise;
124
125
  stateLoaded = false;
126
+ /**
127
+ * The loader ran, whether or not it returned state. A failed load is
128
+ * reported once and the coordinator runs without saved tool state.
129
+ */
130
+ stateLoadSettled = false;
125
131
  stateLoadPromise;
126
132
  coordinatorReadyPromise;
127
133
  coordinatorReadyNotified = false;
128
134
  providerInitPromises = new Map();
135
+ /**
136
+ * Recoverable failures, by tool. A policy change that grants one of these
137
+ * tools reports its failure again as fatal.
138
+ */
139
+ degradedTools = new Map();
140
+ /** Tools a toolbar reported a module failure for, each reported once. */
141
+ toolModuleFailures = new Set();
142
+ readyChangeListeners = new Set();
143
+ eagerInit;
129
144
  toolRegistry;
145
+ /** Whether the host passed `toolRegistry`, which then always stands. */
146
+ toolRegistrySupplied;
147
+ /**
148
+ * Whether the registry is final: supplied at construction, or adopted from
149
+ * the toolkit this coordinator is bound to.
150
+ */
151
+ toolRegistrySettled;
152
+ /**
153
+ * Whether a bound toolkit reported having no registry. Until then, or until a
154
+ * registry settles, the empty placeholder reports no missing registry, since
155
+ * the toolkit may supply one.
156
+ */
157
+ toolRegistryAbsent = false;
130
158
  toolContextResolvers = new Map();
131
159
  toolContextResolverChangeListeners = new Set();
132
160
  toolRequests = new ToolRequestRegistry();
@@ -153,14 +181,16 @@ export class ToolkitCoordinator {
153
181
  * Phase D: map key of the section controller currently treated as
154
182
  * the *active cohort*. Set by `getOrCreateSectionController` (both
155
183
  * the create-new and resolve-existing paths) and cleared when the
156
- * matching controller is disposed. `null` while no cohort exists,
157
- * which is the only state where `subscribeSectionEvents` throws.
184
+ * matching controller is disposed, and `null` while the next requested
185
+ * cohort is still starting.
158
186
  */
159
187
  activeCohortMapKey = null;
160
188
  /**
161
189
  * Latest requested active cohort. Async controller initialization can
162
190
  * resolve out of order; only the most recent request may migrate active
163
191
  * subscriptions. Older completions still populate the controller cache.
192
+ * `null` until the first request, which is the only state where
193
+ * `subscribeSectionEvents` throws.
164
194
  */
165
195
  latestRequestedActiveCohortMapKey = null;
166
196
  telemetryListeners = new Set();
@@ -176,32 +206,20 @@ export class ToolkitCoordinator {
176
206
  */
177
207
  policyEngine;
178
208
  /**
179
- * Host-set override for PNP/profile enforcement. `null` (the default) means
180
- * "auto" — the coordinator infers the effective mode from the
181
- * PNP/profile policy inputs the bound `AssessmentEntity` and `AssessmentItemRef`
182
- * actually carry (see {@link resolveEffectivePnpEnforcement}).
209
+ * Host-set override for PNP/profile enforcement, passed to the engine.
210
+ * `null` (the default) is auto-mode, which the engine resolves per decision
211
+ * from the policy material the bound assessment and the decision's item carry.
183
212
  * `"on"` / `"off"` are explicit host opt-in / opt-out and stick
184
- * across subsequent assessment / item swaps until the host clears
213
+ * across subsequent assessment swaps until the host clears
185
214
  * the override by calling `setPnpEnforcement(null)`.
186
215
  */
187
216
  pnpEnforcementOverride = null;
188
217
  /**
189
- * Last assessment passed to {@link updateAssessment}. Read by
190
- * {@link resolveEffectivePnpEnforcement} to compute auto-mode.
191
- * The engine's own copy is the canonical record for decisions;
192
- * this mirror exists only so the auto-mode helper does not need
193
- * to round-trip through {@link policyEngine}'s frozen snapshot.
194
- */
195
- boundAssessment = null;
196
- /**
197
- * Last item reference passed to {@link updateCurrentItemRef}.
198
- * Mirrored alongside {@link boundAssessment} so
199
- * {@link resolveEffectivePnpEnforcement} can detect item-level
200
- * profile policy inputs (`requiredTools` / `restrictedTools` /
201
- * `toolParameters`) without round-tripping through the engine's
202
- * frozen snapshot.
218
+ * Policy diagnostics already logged, keyed by code, tool id and, for
219
+ * `tool-policy.itemSettingNotApplied`, item id; see
220
+ * {@link warnPolicyDiagnostics}.
203
221
  */
204
- boundCurrentItemRef = null;
222
+ warnedPolicyDiagnostics = new Set();
205
223
  /**
206
224
  * Whether {@link decideFeaturePolicy} has already reported serving a decision
207
225
  * with no assessment bound.
@@ -213,22 +231,40 @@ export class ToolkitCoordinator {
213
231
  * lifetime for no new information.
214
232
  */
215
233
  reportedUnboundFeaturePolicy = false;
216
- static resolveConfig(config) {
234
+ /**
235
+ * Whether {@link _initializeTTS} has already reported a non-browser backend
236
+ * falling back to browser speech because no text-to-speech provider is registered.
237
+ * Once per coordinator: a text-to-speech config change re-runs
238
+ * initialization, and the missing provider is the same gap each time.
239
+ */
240
+ reportedMissingTTSProvider = false;
241
+ /**
242
+ * The backend of a fallback {@link reportMissingTTSProvider} held back while
243
+ * the registry was unsettled, reported if the bound toolkit has no registry.
244
+ */
245
+ heldMissingTTSProviderBackend = null;
246
+ /**
247
+ * Whether {@link validateToolsConfig} has already reported validating with
248
+ * no registry (`tools.registryUnavailable`). Once per coordinator: every
249
+ * {@link updateToolConfig} and {@link updateToolsPlacement} re-validates, and
250
+ * the missing registry is the same gap each time.
251
+ */
252
+ reportedRegistryUnavailable = false;
253
+ resolveConfig(config) {
217
254
  const strictness = normalizeToolConfigStrictness(config.toolConfigStrictness);
218
- // An empty registry when the host supplies none. This package no longer
219
- // holds the packaged capability set, so there is nothing to fall back to —
220
- // a host that wants stock tools passes a registry from
221
- // `@pie-players/pie-default-tool-loaders`. Tool-id validation is skipped
222
- // against an empty registry (with a diagnostic saying so) rather than
255
+ // An empty placeholder when the host supplies none, replaced by the bound
256
+ // toolkit's registry in `adoptToolRegistry`. This package does not hold the
257
+ // packaged capability set, and importing it would be a dependency cycle.
258
+ // Tool-id validation is skipped against an empty registry rather than
223
259
  // rejecting every configured id.
224
260
  const toolRegistry = config.toolRegistry ?? new ToolRegistry();
225
261
  const normalized = config.deferToolConfigValidation === true
226
262
  ? normalizeToolsConfig(config.tools)
227
- : normalizeAndValidateToolsConfig(config.tools, {
263
+ : this.validateToolsConfig(config.tools, {
228
264
  strictness,
229
265
  source: "ToolkitCoordinator.init",
230
266
  toolRegistry,
231
- }).config;
267
+ });
232
268
  const defaultProviders = {
233
269
  textToSpeech: {
234
270
  enabled: true,
@@ -256,17 +292,38 @@ export class ToolkitCoordinator {
256
292
  },
257
293
  };
258
294
  }
295
+ /**
296
+ * `normalizeAndValidateToolsConfig`, except that `tools.registryUnavailable`
297
+ * is reported once per coordinator, whichever call validates.
298
+ */
299
+ validateToolsConfig(tools, options) {
300
+ const { config, diagnostics } = collectToolConfigDiagnostics(tools, options.toolRegistry);
301
+ const unreported = this.reportedRegistryUnavailable || this.toolRegistryPending()
302
+ ? diagnostics.filter((entry) => entry.code !== "tools.registryUnavailable")
303
+ : diagnostics;
304
+ reportToolConfigDiagnostics(unreported, options);
305
+ if (options.strictness !== "off" &&
306
+ unreported.some((entry) => entry.code === "tools.registryUnavailable")) {
307
+ this.reportedRegistryUnavailable = true;
308
+ }
309
+ return config;
310
+ }
259
311
  constructor(config) {
260
312
  if (!config.assessmentId) {
261
313
  throw new Error("ToolkitCoordinator requires assessmentId in config");
262
314
  }
263
- const resolvedConfig = ToolkitCoordinator.resolveConfig(config);
315
+ this.toolRegistrySupplied = config.toolRegistry != null;
316
+ this.toolRegistrySettled = this.toolRegistrySupplied;
317
+ const resolvedConfig = this.resolveConfig(config);
264
318
  this.assessmentId = resolvedConfig.assessmentId;
265
319
  this.config = resolvedConfig;
266
320
  this.toolRegistry = resolvedConfig.toolRegistry ?? new ToolRegistry();
267
321
  this.installToolContextResolvers(resolvedConfig.toolContextResolvers);
268
- this.hooks = resolvedConfig.hooks ?? {};
322
+ // The coordinator's own copy, so `setHooks` leaves the host's object as it
323
+ // passed it.
324
+ this.hooks = { ...resolvedConfig.hooks };
269
325
  this.lazyInit = config.lazyInit === true;
326
+ this.eagerInit = config.eagerInit ?? !this.lazyInit;
270
327
  this.pnpEnforcementOverride = this.resolveConfiguredPnpEnforcement();
271
328
  // Use the host-provided framework-error bus if one was passed
272
329
  // (typical when embedded inside <pie-assessment-toolkit>, so
@@ -286,31 +343,38 @@ export class ToolkitCoordinator {
286
343
  this._registerToolProviders();
287
344
  // Initialize TTS service based on config
288
345
  this.ttsService = new TTSService();
346
+ // Selection read-aloud speaks through this service without the inline TTS
347
+ // tool ever having run, so it cannot rely on that tool to attach highlights.
348
+ this.ttsService.setHighlightCoordinator(this.highlightCoordinator);
349
+ // A caller that speaks without `ensureTTSReady()` first, such as selection
350
+ // read-aloud, starts the service instead of finding it uninitialized.
351
+ this.ttsService.setReadinessGate(() => this.ensureTTSReady());
352
+ this.ttsService.setMathSpeechSource(() => buildRuntimeTTSConfig(resolveTTSRuntimeSettings(this.resolveTTSToolConfig())).providerOptions?.mathSpeech);
289
353
  this.setupStatePersistenceHooks();
290
- // M8 PR 2 — construct the unified ToolPolicyEngine seeded with
291
- // the validated tools config. PNP/profile inputs (`assessment`,
292
- // `currentItemRef`) start `null`; `pnpEnforcement` is resolved
293
- // through {@link resolveEffectivePnpEnforcement}, which flips
294
- // to `"on"` only once the bound assessment or item carries
295
- // actual profile material. Hosts that only consume the
296
- // engine for placement/policy gating get the pre-PR-2 behavior
297
- // bit-for-bit.
354
+ // The unified ToolPolicyEngine, seeded with the validated tools config.
355
+ // The assessment starts `null` and items register their settings as they
356
+ // mount; auto-mode enforcement turns on only once the policy material a
357
+ // decision reads is present, so a host that consumes the engine for
358
+ // placement/policy gating alone sees no PNP/profile gate.
298
359
  this.policyEngine = new ToolPolicyEngine({
299
360
  toolRegistry: this.toolRegistry,
300
361
  contextId: `toolkit-coordinator:${this.assessmentId}`,
301
362
  inputs: {
302
363
  tools: this.config.tools,
303
364
  assessment: null,
304
- currentItemRef: null,
305
- pnpEnforcement: this.resolveEffectivePnpEnforcement(),
365
+ pnpEnforcement: this.pnpEnforcementOverride,
306
366
  },
307
367
  });
308
- if (!this.lazyInit) {
368
+ this.policyEngine.onPolicyChange((event) => {
369
+ if (event.reason !== "disposed")
370
+ this.reportNewlyGrantedFailures();
371
+ });
372
+ if (this.eagerInit) {
309
373
  void this.waitUntilReady().catch((err) => {
310
374
  if (err instanceof ToolkitCoordinatorDisposedError)
311
375
  return;
312
376
  console.error("[ToolkitCoordinator] Failed eager initialization:", err);
313
- this.handleError(err, { phase: "coordinator-ready" });
377
+ this.handleError(err, { phase: "coordinator-ready", recoverable: false });
314
378
  });
315
379
  }
316
380
  }
@@ -394,13 +458,92 @@ export class ToolkitCoordinator {
394
458
  }
395
459
  }
396
460
  }
397
- handleError(error, context) {
461
+ /**
462
+ * Every report site states whether the coordinator carries on after the
463
+ * failure; one that takes down only its section passes `scope: "cohort"`.
464
+ */
465
+ handleError(error, context, scope = "runtime") {
398
466
  const model = frameworkErrorFromCoordinatorContext({
399
467
  error,
400
468
  context,
469
+ recoverable: context.recoverable,
470
+ scope,
401
471
  });
402
472
  this.frameworkErrorBus.reportFrameworkError(model);
403
473
  }
474
+ /**
475
+ * Whether policy grants `toolId` as an accommodation on some surface: a
476
+ * test-administration override, a mounted item's or a district requirement,
477
+ * or profile support. Read without the unbound-assessment warning.
478
+ */
479
+ isToolGranted(toolId) {
480
+ try {
481
+ return this.policyEngine.grantsFeatureAnywhere(toolId);
482
+ }
483
+ catch {
484
+ return false;
485
+ }
486
+ }
487
+ /**
488
+ * Reports a tool's start failure: recoverable, so the tool reports itself
489
+ * unavailable and the assessment goes on, unless policy grants the tool.
490
+ * `fallbackFollows` marks a failure another path absorbs, which is recoverable
491
+ * whatever the policy.
492
+ */
493
+ reportToolFailure(error, context, toolIds, fallbackFollows = false) {
494
+ const ids = Array.from(toolIds);
495
+ const recoverable = fallbackFollows || !ids.some((toolId) => this.isToolGranted(toolId));
496
+ if (recoverable && !fallbackFollows) {
497
+ for (const toolId of ids)
498
+ this.degradedTools.set(toolId, { error, context });
499
+ }
500
+ this.handleError(error, { ...context, recoverable });
501
+ }
502
+ /**
503
+ * Report that a toolbar could not load a tool's module. The failure follows
504
+ * the start-failure policy: the tool degrades, so the toolbar withholds it,
505
+ * unless policy grants it. Reported once per tool however many toolbars fail
506
+ * to load it. Every report re-announces the request targets, since the
507
+ * reporting toolbar no longer hosts the tool.
508
+ */
509
+ reportToolModuleFailure(toolId, error) {
510
+ this.toolRequests.notifyTargetsChange();
511
+ if (this.toolModuleFailures.has(toolId))
512
+ return;
513
+ this.toolModuleFailures.add(toolId);
514
+ const detail = error instanceof Error && error.message.trim().length > 0
515
+ ? error.message
516
+ : String(error);
517
+ this.reportToolFailure(new Error(`Tool "${toolId}" failed to load: ${detail}`, { cause: error }), { phase: "tool-module-load", details: { toolId } }, [toolId]);
518
+ }
519
+ reportNewlyGrantedFailures() {
520
+ for (const [toolId, failure] of this.degradedTools) {
521
+ if (!this.isToolGranted(toolId))
522
+ continue;
523
+ this.degradedTools.delete(toolId);
524
+ this.handleError(failure.error, { ...failure.context, recoverable: false });
525
+ }
526
+ }
527
+ /**
528
+ * Subscribe to changes of {@link isReady}: state loading, text-to-speech
529
+ * starting, failing or reconfiguring. The listener reads the status itself.
530
+ */
531
+ onReadyChange(listener) {
532
+ this.readyChangeListeners.add(listener);
533
+ return () => {
534
+ this.readyChangeListeners.delete(listener);
535
+ };
536
+ }
537
+ notifyReadyChange() {
538
+ for (const listener of this.readyChangeListeners) {
539
+ try {
540
+ listener();
541
+ }
542
+ catch (error) {
543
+ console.warn("[ToolkitCoordinator] ready-change listener failed:", error);
544
+ }
545
+ }
546
+ }
404
547
  /**
405
548
  * Subscribe to framework-error events emitted by this coordinator.
406
549
  *
@@ -444,15 +587,20 @@ export class ToolkitCoordinator {
444
587
  return null;
445
588
  }
446
589
  })();
590
+ // Stored per attempt. Without an attempt id nothing tells two learners on
591
+ // one device apart, so the section is neither read nor written.
447
592
  const getStorageKey = (context) => {
448
593
  const { assessmentId, sectionId, attemptId } = context.key;
449
- return `pie:section-controller:v1:${assessmentId}:${sectionId}:${attemptId || "default"}`;
594
+ if (!attemptId)
595
+ return null;
596
+ return `pie:section-controller:v1:${assessmentId}:${sectionId}:${attemptId}`;
450
597
  };
451
598
  return {
452
599
  async loadSession(context) {
453
- if (!storage)
600
+ const key = getStorageKey(context);
601
+ if (!storage || !key)
454
602
  return null;
455
- const value = storage.getItem(getStorageKey(context));
603
+ const value = storage.getItem(key);
456
604
  if (!value)
457
605
  return null;
458
606
  try {
@@ -463,14 +611,16 @@ export class ToolkitCoordinator {
463
611
  }
464
612
  },
465
613
  async saveSession(context, session) {
466
- if (!storage)
614
+ const key = getStorageKey(context);
615
+ if (!storage || !key)
467
616
  return;
468
- storage.setItem(getStorageKey(context), JSON.stringify(session));
617
+ storage.setItem(key, JSON.stringify(session));
469
618
  },
470
619
  async clearSession(context) {
471
- if (!storage)
620
+ const key = getStorageKey(context);
621
+ if (!storage || !key)
472
622
  return;
473
- storage.removeItem(getStorageKey(context));
623
+ storage.removeItem(key);
474
624
  },
475
625
  };
476
626
  }
@@ -492,12 +642,12 @@ export class ToolkitCoordinator {
492
642
  if (!this.hooks.saveToolState)
493
643
  return;
494
644
  void Promise.resolve(this.hooks.saveToolState(state)).catch((err) => {
495
- this.handleError(err, { phase: "state-save" });
645
+ this.handleError(err, { phase: "state-save", recoverable: true });
496
646
  });
497
647
  });
498
648
  }
499
649
  async ensureStateLoaded() {
500
- if (this.stateLoaded)
650
+ if (this.stateLoadSettled)
501
651
  return;
502
652
  if (this.stateLoadPromise)
503
653
  return this.stateLoadPromise;
@@ -513,6 +663,7 @@ export class ToolkitCoordinator {
513
663
  this.elementToolStateStore.loadState(state);
514
664
  }
515
665
  this.stateLoaded = true;
666
+ this.stateLoadSettled = true;
516
667
  await this.emitTelemetry("pie-toolkit-tool-state-loaded", {
517
668
  hasState: Boolean(state),
518
669
  });
@@ -520,13 +671,89 @@ export class ToolkitCoordinator {
520
671
  catch (err) {
521
672
  if (err instanceof ToolkitCoordinatorDisposedError)
522
673
  throw err;
523
- this.handleError(err, { phase: "state-load" });
674
+ this.stateLoadSettled = true;
675
+ this.handleError(err, { phase: "state-load", recoverable: true });
524
676
  }
525
677
  })().finally(() => {
526
678
  this.stateLoadPromise = undefined;
679
+ this.notifyReadyChange();
527
680
  });
528
681
  return this.stateLoadPromise;
529
682
  }
683
+ /** Whether the toolkit this coordinator binds to may still supply a registry. */
684
+ toolRegistryPending() {
685
+ return !this.toolRegistrySettled && !this.toolRegistryAbsent;
686
+ }
687
+ reportMissingTTSProvider(backend) {
688
+ // The toolkit this coordinator binds to may still supply the provider.
689
+ if (this.toolRegistryPending()) {
690
+ this.heldMissingTTSProviderBackend = backend;
691
+ return;
692
+ }
693
+ if (this.reportedMissingTTSProvider)
694
+ return;
695
+ this.reportedMissingTTSProvider = true;
696
+ 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.`);
697
+ }
698
+ /**
699
+ * Take the registry of the toolkit this coordinator is bound to, when the host
700
+ * constructed it without one: the config is validated against it, its
701
+ * providers register, and text-to-speech re-initializes through it if it
702
+ * already started. The toolkit elements call this on binding, and again when
703
+ * their registry changes. A registry passed at construction stands, and the
704
+ * first registry adopted wins; `null` records a toolkit without one, which
705
+ * reports the registry as missing and still adopts one that arrives later.
706
+ *
707
+ * @returns Whether `registry` was adopted.
708
+ */
709
+ adoptToolRegistry(registry) {
710
+ if (this.disposePromise !== null || this.toolRegistrySettled)
711
+ return false;
712
+ if (!registry && this.toolRegistryAbsent)
713
+ return false;
714
+ const heldBackend = this.heldMissingTTSProviderBackend;
715
+ this.heldMissingTTSProviderBackend = null;
716
+ const strictness = this.config.toolConfigStrictness ?? "error";
717
+ const source = "ToolkitCoordinator.adoptToolRegistry";
718
+ if (!registry) {
719
+ this.toolRegistryAbsent = true;
720
+ this.validateToolsConfig(this.config.tools, {
721
+ strictness,
722
+ source,
723
+ toolRegistry: this.toolRegistry,
724
+ });
725
+ if (heldBackend)
726
+ this.reportMissingTTSProvider(heldBackend);
727
+ return false;
728
+ }
729
+ this.toolRegistrySettled = true;
730
+ this.toolRegistry = registry;
731
+ this.config.toolRegistry = registry;
732
+ try {
733
+ // Warn at most: the config was accepted at construction, and a binding
734
+ // toolkit has no caller to throw to.
735
+ this.config.tools = this.validateToolsConfig(this.config.tools, {
736
+ strictness: strictness === "off" ? "off" : "warn",
737
+ source,
738
+ toolRegistry: registry,
739
+ });
740
+ }
741
+ catch (err) {
742
+ console.warn("[ToolkitCoordinator] Tool config does not validate against the adopted tool registry:", err);
743
+ }
744
+ this.policyEngine.replaceToolRegistry(registry, this.config.tools);
745
+ const ttsStarted = this.ttsInitialized ||
746
+ this.ttsInitPromise !== undefined ||
747
+ this.ttsReconfigurePromise !== undefined;
748
+ for (const tool of this.getProviderDescriptorTools()) {
749
+ if (ttsStarted && tool.toolId === "textToSpeech")
750
+ continue;
751
+ void this.registerProviderFromTool(tool);
752
+ }
753
+ if (ttsStarted)
754
+ this.scheduleTTSReconfigure();
755
+ return true;
756
+ }
530
757
  /**
531
758
  * Register tool providers in the registry
532
759
  */
@@ -539,61 +766,63 @@ export class ToolkitCoordinator {
539
766
  getProviderDescriptorTools() {
540
767
  return this.toolRegistry.getAllTools().filter((tool) => !!tool.provider);
541
768
  }
542
- async registerProviderFromTool(tool) {
769
+ /**
770
+ * Register the provider `tool`'s descriptor creates for the tool's current
771
+ * config under the tool's id, unless the tool is disabled or, without
772
+ * `replace`, a provider is registered under that id. A descriptor that throws
773
+ * is reported the way a failed registration is.
774
+ */
775
+ async registerProviderFromTool(tool, replace = false) {
543
776
  const descriptor = tool.provider;
544
777
  if (!descriptor)
545
778
  return;
546
- const toolConfig = this.getToolConfig(tool.toolId) || undefined;
547
- if (toolConfig?.enabled === false)
548
- return;
549
- const providerId = descriptor.getProviderId?.(toolConfig) ??
550
- toolConfig?.provider?.id ??
551
- tool.toolId;
552
- if (this.toolProviderRegistry.has(providerId))
779
+ const { toolId } = tool;
780
+ let registration;
781
+ try {
782
+ const toolConfig = this.getToolConfig(toolId) || undefined;
783
+ if (toolConfig?.enabled === false) {
784
+ // A disabled tool keeps no provider, including the one it replaces.
785
+ if (replace)
786
+ void this.toolProviderRegistry.unregister(toolId);
787
+ return;
788
+ }
789
+ if (!replace && this.toolProviderRegistry.has(toolId))
790
+ return;
791
+ const provider = descriptor.createProvider(toolConfig);
792
+ const initConfig = descriptor.getInitConfig?.(toolConfig) ??
793
+ toolConfig?.provider?.init ??
794
+ {};
795
+ const initConfigWithTelemetry = this.addToolTelemetryReporter(toolId, initConfig);
796
+ const registryTelemetry = this.createToolTelemetryForwarder(toolId);
797
+ const authFetcher = descriptor.getAuthFetcher?.(toolConfig) ??
798
+ toolConfig?.provider?.runtime?.authFetcher;
799
+ registration = {
800
+ provider,
801
+ config: initConfigWithTelemetry,
802
+ lazy: descriptor.lazy ?? true,
803
+ authFetcher,
804
+ onTelemetry: registryTelemetry,
805
+ };
806
+ }
807
+ catch (err) {
808
+ this.reportProviderRegisterFailure(err, toolId);
553
809
  return;
554
- const provider = descriptor.createProvider(toolConfig);
555
- const initConfig = descriptor.getInitConfig?.(toolConfig) ??
556
- toolConfig?.provider?.init ??
557
- {};
558
- const initConfigWithTelemetry = this.addToolTelemetryReporter({
559
- toolId: tool.toolId,
560
- providerId,
561
- initConfig,
562
- });
563
- const registryTelemetry = this.createToolTelemetryForwarder({
564
- toolId: tool.toolId,
565
- providerId,
566
- });
567
- const authFetcher = descriptor.getAuthFetcher?.(toolConfig) ??
568
- toolConfig?.provider?.runtime?.authFetcher;
569
- await this.registerProvider(providerId, {
570
- provider,
571
- config: initConfigWithTelemetry,
572
- lazy: descriptor.lazy ?? true,
573
- authFetcher,
574
- onTelemetry: registryTelemetry,
575
- });
810
+ }
811
+ await this.registerProvider(toolId, registration);
576
812
  }
577
- createToolTelemetryForwarder(args) {
813
+ createToolTelemetryForwarder(toolId) {
578
814
  return async (eventName, payload) => {
579
- await this.emitTelemetry(eventName, {
580
- ...(payload || {}),
581
- toolId: args.toolId,
582
- providerId: args.providerId,
583
- });
815
+ await this.emitTelemetry(eventName, { ...(payload || {}), toolId });
584
816
  };
585
817
  }
586
- addToolTelemetryReporter(args) {
587
- const configObject = args.initConfig && typeof args.initConfig === "object"
588
- ? { ...args.initConfig }
818
+ addToolTelemetryReporter(toolId, initConfig) {
819
+ const configObject = initConfig && typeof initConfig === "object"
820
+ ? { ...initConfig }
589
821
  : {};
590
822
  const existingReporter = typeof configObject.onTelemetry === "function"
591
823
  ? configObject.onTelemetry
592
824
  : null;
593
- const forwardTelemetry = this.createToolTelemetryForwarder({
594
- toolId: args.toolId,
595
- providerId: args.providerId,
596
- });
825
+ const forwardTelemetry = this.createToolTelemetryForwarder(toolId);
597
826
  configObject.onTelemetry = async (eventName, payload) => {
598
827
  if (existingReporter) {
599
828
  await existingReporter(eventName, payload);
@@ -602,44 +831,58 @@ export class ToolkitCoordinator {
602
831
  };
603
832
  return configObject;
604
833
  }
605
- async registerProvider(providerId, config) {
834
+ async registerProvider(toolId, config) {
606
835
  try {
607
- this.toolProviderRegistry.register(providerId, config);
836
+ this.toolProviderRegistry.register(toolId, config);
608
837
  const meta = {
609
- providerId,
610
838
  providerName: config.provider.providerName,
611
839
  };
612
- await this.hooks.onProviderRegistered?.(providerId, meta);
840
+ await this.hooks.onProviderRegistered?.(toolId, meta);
613
841
  await this.emitTelemetry("pie-toolkit-provider-registered", {
614
- providerId,
842
+ toolId,
615
843
  providerName: config.provider.providerName,
616
844
  });
617
845
  }
618
846
  catch (err) {
619
- console.warn(`[ToolkitCoordinator] Failed to register provider "${providerId}":`, err);
620
- this.handleError(err, { phase: "provider-register", providerId });
847
+ this.reportProviderRegisterFailure(err, toolId);
621
848
  }
622
849
  }
623
- async ensureProviderReady(providerId) {
850
+ /**
851
+ * A console warning and a `provider-register` framework error, under the
852
+ * start-failure policy every other tool failure follows.
853
+ */
854
+ reportProviderRegisterFailure(err, toolId) {
855
+ console.warn(`[ToolkitCoordinator] Failed to register the provider of tool "${toolId}":`, err);
856
+ this.reportToolFailure(err, { phase: "provider-register", toolId }, [toolId]);
857
+ }
858
+ async ensureProviderReady(toolId) {
859
+ return this.initializeProvider(toolId, false);
860
+ }
861
+ /** `fallbackFollows`: the caller recovers from a failure on its own. */
862
+ async initializeProvider(toolId, fallbackFollows) {
624
863
  this.assertNotDisposed();
625
- const existing = this.providerInitPromises.get(providerId);
864
+ const existing = this.providerInitPromises.get(toolId);
626
865
  if (existing)
627
866
  return existing;
628
867
  const promise = (async () => {
629
- const provider = await this.toolProviderRegistry.getProvider(providerId, false);
630
- this.assertNotDisposed();
631
- const meta = {
632
- providerId,
633
- providerName: provider.providerName,
634
- };
635
868
  try {
636
- await this.hooks.onProviderInitStart?.(providerId, meta);
869
+ let provider = await this.toolProviderRegistry.getProvider(toolId, false);
637
870
  this.assertNotDisposed();
638
- await this.toolProviderRegistry.initialize(providerId);
871
+ // A tool asks each time it opens; the lifecycle hooks report the start once.
872
+ if (this.toolProviderRegistry.isInitialized(toolId))
873
+ return provider;
874
+ const meta = {
875
+ providerName: provider.providerName,
876
+ };
877
+ await this.hooks.onProviderInitStart?.(toolId, meta);
639
878
  this.assertNotDisposed();
640
- await this.hooks.onProviderReady?.(providerId, meta);
879
+ await this.toolProviderRegistry.initialize(toolId);
641
880
  this.assertNotDisposed();
642
- await this.emitTelemetry("pie-toolkit-provider-ready", { providerId });
881
+ // A config update may have replaced the provider during its start.
882
+ provider = await this.toolProviderRegistry.getProvider(toolId, false);
883
+ await this.hooks.onProviderReady?.(toolId, meta);
884
+ this.assertNotDisposed();
885
+ await this.emitTelemetry("pie-toolkit-provider-ready", { toolId });
643
886
  this.assertNotDisposed();
644
887
  return provider;
645
888
  }
@@ -647,13 +890,16 @@ export class ToolkitCoordinator {
647
890
  if (err instanceof ToolkitCoordinatorDisposedError)
648
891
  throw err;
649
892
  const error = err instanceof Error ? err : new Error(String(err));
650
- this.handleError(error, { phase: "provider-init", providerId });
893
+ this.reportToolFailure(error, { phase: "provider-init", toolId },
894
+ // Attributed when the id names a tool with a provider; any other id
895
+ // was never a tool's.
896
+ this.toolRegistry.get(toolId)?.provider ? [toolId] : [], fallbackFollows);
651
897
  throw error;
652
898
  }
653
899
  })().finally(() => {
654
- this.providerInitPromises.delete(providerId);
900
+ this.providerInitPromises.delete(toolId);
655
901
  });
656
- this.providerInitPromises.set(providerId, promise);
902
+ this.providerInitPromises.set(toolId, promise);
657
903
  return promise;
658
904
  }
659
905
  setHooks(hooks) {
@@ -663,7 +909,7 @@ export class ToolkitCoordinator {
663
909
  hooks.onCoordinatorReady &&
664
910
  this.isReady()) {
665
911
  void Promise.resolve(hooks.onCoordinatorReady(this)).catch((err) => {
666
- this.handleError(err, { phase: "coordinator-ready" });
912
+ this.handleError(err, { phase: "coordinator-ready", recoverable: false });
667
913
  });
668
914
  }
669
915
  }
@@ -686,8 +932,11 @@ export class ToolkitCoordinator {
686
932
  // cohort* automatically. We bind the listener to whichever
687
933
  // controller is currently active and re-bind it on every
688
934
  // `getOrCreateSectionController` / `disposeSectionController`
689
- // transition, with snapshot replay on each migration.
690
- if (this.activeCohortMapKey === null) {
935
+ // transition, with snapshot replay on each migration. A listener
936
+ // added while the next section is still starting binds when it
937
+ // becomes active.
938
+ if (this.activeCohortMapKey === null &&
939
+ this.latestRequestedActiveCohortMapKey === null) {
691
940
  throw new Error("[ToolkitCoordinator] subscribeSectionEvents requires an active section cohort; call getOrCreateSectionController first.");
692
941
  }
693
942
  const previous = this.activeSubscriptions.get(args.listener);
@@ -702,7 +951,9 @@ export class ToolkitCoordinator {
702
951
  unsubscribeCurrent: null,
703
952
  };
704
953
  this.activeSubscriptions.set(args.listener, sub);
705
- const controller = this.sectionControllers.get(this.activeCohortMapKey);
954
+ const controller = this.activeCohortMapKey === null
955
+ ? undefined
956
+ : this.sectionControllers.get(this.activeCohortMapKey);
706
957
  if (controller) {
707
958
  this.bindSubscriptionToController(sub, controller);
708
959
  }
@@ -980,18 +1231,29 @@ export class ToolkitCoordinator {
980
1231
  const mapKey = this.getSectionControllerMapKey(key);
981
1232
  this.latestRequestedActiveCohortMapKey = mapKey;
982
1233
  this.suspendActiveCohortIfSuperseded(mapKey);
983
- const existingController = await this.resolveExistingSectionController({
984
- mapKey,
985
- key,
986
- input: args.input,
987
- updateExisting: args.updateExisting,
988
- });
989
- if (existingController) {
990
- if (this.disposePromise !== null) {
991
- throw new ToolkitCoordinatorDisposedError();
1234
+ let existingController;
1235
+ try {
1236
+ existingController = await this.resolveExistingSectionController({
1237
+ mapKey,
1238
+ key,
1239
+ input: args.input,
1240
+ updateExisting: args.updateExisting,
1241
+ });
1242
+ if (existingController) {
1243
+ if (this.disposePromise !== null) {
1244
+ throw new ToolkitCoordinatorDisposedError();
1245
+ }
1246
+ if (args.initialSession) {
1247
+ await this.assignSessionToPublishedController(existingController, args.initialSession);
1248
+ }
992
1249
  }
993
- return existingController;
994
1250
  }
1251
+ catch (err) {
1252
+ this.deliverSectionStartError(mapKey, err);
1253
+ throw err;
1254
+ }
1255
+ if (existingController)
1256
+ return existingController;
995
1257
  const pendingDisposal = this.sectionControllerDisposePromises.get(mapKey);
996
1258
  if (pendingDisposal) {
997
1259
  // Persistence and hydration share the cohort's durable state. Keep the
@@ -1004,8 +1266,13 @@ export class ToolkitCoordinator {
1004
1266
  throw new ToolkitCoordinatorDisposedError();
1005
1267
  }
1006
1268
  const existingEntry = this.sectionControllerInitEntries.get(mapKey);
1007
- if (existingEntry)
1008
- return existingEntry.promise;
1269
+ if (existingEntry) {
1270
+ if (!args.initialSession)
1271
+ return existingEntry.promise;
1272
+ const pendingController = await existingEntry.promise;
1273
+ await this.assignSessionToPublishedController(pendingController, args.initialSession);
1274
+ return pendingController;
1275
+ }
1009
1276
  const token = {
1010
1277
  retired: false,
1011
1278
  candidateClaimed: false,
@@ -1018,6 +1285,7 @@ export class ToolkitCoordinator {
1018
1285
  })
1019
1286
  .catch((err) => {
1020
1287
  this.handleSectionControllerInitError(err, args);
1288
+ this.deliverSectionStartError(mapKey, err);
1021
1289
  throw err;
1022
1290
  })
1023
1291
  .finally(() => {
@@ -1098,7 +1366,20 @@ export class ToolkitCoordinator {
1098
1366
  await this.retireUnpublishedSectionControllerIfNeeded(candidate);
1099
1367
  await controller.initialize?.(args.args.input);
1100
1368
  await this.retireUnpublishedSectionControllerIfNeeded(candidate);
1101
- await controller.hydrate?.();
1369
+ // A session the host supplied takes the place of the strategy's stored
1370
+ // snapshot, and is applied before publication so the ready lifecycle
1371
+ // event and every reader after it see it. The strategy still receives
1372
+ // every `persist()`.
1373
+ const initialSession = args.args.initialSession;
1374
+ if (initialSession) {
1375
+ if (!controller.applySession) {
1376
+ throw new Error("Section controller cannot apply the session it was created with: it has no applySession.");
1377
+ }
1378
+ await controller.applySession(initialSession, { mode: "replace" });
1379
+ }
1380
+ else {
1381
+ await controller.hydrate?.();
1382
+ }
1102
1383
  await this.finalizeSectionControllerReady({
1103
1384
  ...candidate,
1104
1385
  context,
@@ -1110,6 +1391,22 @@ export class ToolkitCoordinator {
1110
1391
  throw error;
1111
1392
  }
1112
1393
  }
1394
+ /**
1395
+ * Apply a host-supplied session to a controller that is already published,
1396
+ * under the rules `resolveSectionSessionAssignment` sets: an equal session is a
1397
+ * no-op and recorded responses are not replaced by response-free ones.
1398
+ */
1399
+ async assignSessionToPublishedController(controller, session) {
1400
+ if (!session)
1401
+ return;
1402
+ if (!controller.applySession) {
1403
+ throw new Error("Section controller cannot apply the assigned session: it has no applySession.");
1404
+ }
1405
+ const resolved = resolveSectionSessionAssignment(controller.getSession?.() ?? null, session);
1406
+ if (!resolved)
1407
+ return;
1408
+ await controller.applySession(resolved, { mode: "replace" });
1409
+ }
1113
1410
  createSectionControllerRetirementError() {
1114
1411
  return this.disposePromise !== null
1115
1412
  ? new ToolkitCoordinatorDisposedError()
@@ -1135,6 +1432,7 @@ export class ToolkitCoordinator {
1135
1432
  sectionId: args.key.sectionId,
1136
1433
  attemptId: args.key.attemptId,
1137
1434
  },
1435
+ recoverable: true,
1138
1436
  });
1139
1437
  }
1140
1438
  finally {
@@ -1190,7 +1488,33 @@ export class ToolkitCoordinator {
1190
1488
  sectionId: args.sectionId,
1191
1489
  attemptId: args.attemptId,
1192
1490
  },
1193
- });
1491
+ recoverable: false,
1492
+ }, "cohort");
1493
+ }
1494
+ /**
1495
+ * A section that fails to start has no controller of its own to emit its
1496
+ * `section-error`, and the host's subscriptions are already off the outgoing
1497
+ * section, so the coordinator delivers the error to them while that section
1498
+ * is still the one requested.
1499
+ */
1500
+ deliverSectionStartError(mapKey, error) {
1501
+ if (error instanceof ToolkitCoordinatorDisposedError ||
1502
+ error instanceof SectionControllerRetiredError ||
1503
+ this.latestRequestedActiveCohortMapKey !== mapKey) {
1504
+ return;
1505
+ }
1506
+ const event = {
1507
+ type: "section-error",
1508
+ source: "section-runtime",
1509
+ error,
1510
+ currentItemIndex: 0,
1511
+ timestamp: Date.now(),
1512
+ };
1513
+ for (const sub of Array.from(this.activeSubscriptions.values())) {
1514
+ if (this.buildSectionEventPredicate(sub)(event)) {
1515
+ this.deliverSectionEventSafely(sub.listener, event);
1516
+ }
1517
+ }
1194
1518
  }
1195
1519
  disposeSectionController(args) {
1196
1520
  const key = {
@@ -1277,6 +1601,7 @@ export class ToolkitCoordinator {
1277
1601
  sectionId: args.args.sectionId,
1278
1602
  attemptId: args.args.attemptId,
1279
1603
  },
1604
+ recoverable: true,
1280
1605
  });
1281
1606
  }
1282
1607
  finally {
@@ -1376,7 +1701,7 @@ export class ToolkitCoordinator {
1376
1701
  this.activeSubscriptions.clear();
1377
1702
  this.activeCohortMapKey = null;
1378
1703
  this.latestRequestedActiveCohortMapKey = null;
1379
- await cleanup(() => this.ttsService.stop());
1704
+ await cleanup(() => this.ttsService.dispose());
1380
1705
  await cleanup(() => this.toolProviderRegistry.destroy());
1381
1706
  await cleanup(() => this.highlightCoordinator.destroy());
1382
1707
  for (const toolId of this.toolCoordinator.getRegisteredTools()) {
@@ -1430,6 +1755,7 @@ export class ToolkitCoordinator {
1430
1755
  .then(() => this.assertNotDisposed())
1431
1756
  .finally(() => {
1432
1757
  this.ttsInitPromise = undefined;
1758
+ this.notifyReadyChange();
1433
1759
  });
1434
1760
  return this.ttsInitPromise;
1435
1761
  }
@@ -1463,9 +1789,10 @@ export class ToolkitCoordinator {
1463
1789
  backend: resolvedBackend,
1464
1790
  });
1465
1791
  // Try to use TTS provider from registry if available
1466
- if (this.toolProviderRegistry.has("tts")) {
1792
+ if (this.toolProviderRegistry.has("textToSpeech")) {
1467
1793
  try {
1468
- const ttsProvider = await this.ensureProviderReady("tts");
1794
+ // Browser speech follows a server provider that fails to start.
1795
+ const ttsProvider = await this.initializeProvider("textToSpeech", true);
1469
1796
  const providerInstance = await ttsProvider.createInstance();
1470
1797
  await this.initializeTTSService(providerInstance, runtimeTTSConfig);
1471
1798
  await this.emitTelemetry("pie-toolkit-tts-init-success", {
@@ -1477,25 +1804,14 @@ export class ToolkitCoordinator {
1477
1804
  backend: resolvedBackend,
1478
1805
  provider: "registry",
1479
1806
  });
1480
- console.log("[ToolkitCoordinator] TTS initialized via ToolProviderRegistry");
1481
1807
  return;
1482
1808
  }
1483
1809
  catch (error) {
1484
1810
  if (error instanceof ToolkitCoordinatorDisposedError)
1485
1811
  throw error;
1486
1812
  const normalized = error instanceof Error ? error : new Error(String(error));
1487
- await this.emitTelemetry("pie-tool-init-error", {
1488
- toolId: "textToSpeech",
1489
- providerId: "tts",
1490
- operation: "tts-init",
1491
- backend: resolvedBackend,
1492
- errorType: "TTSRegistryInitError",
1493
- message: normalized.message,
1494
- recovered: true,
1495
- });
1496
1813
  await this.emitTelemetry("pie-tool-init-fallback", {
1497
1814
  toolId: "textToSpeech",
1498
- providerId: "tts",
1499
1815
  operation: "tts-init",
1500
1816
  backend: resolvedBackend,
1501
1817
  fromProvider: "registry",
@@ -1505,10 +1821,16 @@ export class ToolkitCoordinator {
1505
1821
  console.warn("[ToolkitCoordinator] Failed to initialize TTS via registry, falling back to browser provider:", normalized);
1506
1822
  }
1507
1823
  }
1508
- // Fallback to browser provider
1824
+ else if (resolvedBackend !== "browser") {
1825
+ this.reportMissingTTSProvider(resolvedBackend);
1826
+ }
1827
+ // Browser speech, configured or standing in for a backend that has no
1828
+ // provider or failed to start.
1509
1829
  const provider = new BrowserTTSProvider();
1510
1830
  try {
1511
- await this.initializeTTSService(provider, runtimeTTSConfig);
1831
+ await this.initializeTTSService(provider, resolvedBackend === "browser"
1832
+ ? runtimeTTSConfig
1833
+ : browserFallbackConfig(runtimeTTSConfig));
1512
1834
  await this.emitTelemetry("pie-toolkit-tts-init-success", {
1513
1835
  provider: "browser-fallback",
1514
1836
  });
@@ -1523,7 +1845,7 @@ export class ToolkitCoordinator {
1523
1845
  if (error instanceof ToolkitCoordinatorDisposedError)
1524
1846
  throw error;
1525
1847
  const normalized = error instanceof Error ? error : new Error(String(error));
1526
- this.handleError(normalized, { phase: "tts-init" });
1848
+ this.reportToolFailure(normalized, { phase: "tts-init" }, ["textToSpeech"]);
1527
1849
  await this.emitTelemetry("pie-toolkit-tts-init-error", {
1528
1850
  message: normalized.message,
1529
1851
  });
@@ -1542,7 +1864,7 @@ export class ToolkitCoordinator {
1542
1864
  }
1543
1865
  async initializeTTSService(provider, config) {
1544
1866
  const nextProviderOptions = {
1545
- ...((config.providerOptions || {}) || {}),
1867
+ ...config.providerOptions,
1546
1868
  __pieTelemetry: async (eventName, payload) => {
1547
1869
  await this.emitTelemetry(eventName, {
1548
1870
  toolId: "textToSpeech",
@@ -1555,93 +1877,14 @@ export class ToolkitCoordinator {
1555
1877
  providerOptions: nextProviderOptions,
1556
1878
  };
1557
1879
  await this.ttsService.initialize(provider, nextConfig);
1558
- await this.ensureBrowserVoicesReady(provider);
1559
1880
  this.assertNotDisposed();
1560
1881
  this.ttsService.setCatalogResolver(this.catalogResolver);
1561
1882
  this.ttsInitialized = true;
1883
+ this.ttsDegraded = false;
1884
+ this.degradedTools.delete("textToSpeech");
1562
1885
  await this.hooks.onTTSReady?.();
1563
1886
  this.assertNotDisposed();
1564
1887
  }
1565
- async ensureBrowserVoicesReady(provider, timeoutMs = 1200) {
1566
- if (provider.providerId !== "browser")
1567
- return;
1568
- if (typeof window === "undefined")
1569
- return;
1570
- if (!("speechSynthesis" in window))
1571
- return;
1572
- const synth = window.speechSynthesis;
1573
- const voices = synth.getVoices();
1574
- if (voices.length > 0)
1575
- return;
1576
- await new Promise((resolve) => {
1577
- let settled = false;
1578
- const canUseEventTarget = typeof synth.addEventListener === "function" &&
1579
- typeof synth.removeEventListener === "function";
1580
- const canUseHandler = !canUseEventTarget && "onvoiceschanged" in synth;
1581
- const previousHandler = canUseHandler ? synth.onvoiceschanged : null;
1582
- let assignedHandler = false;
1583
- const finish = () => {
1584
- if (settled)
1585
- return;
1586
- settled = true;
1587
- window.clearTimeout(timeoutId);
1588
- if (canUseEventTarget) {
1589
- synth.removeEventListener("voiceschanged", onVoicesChanged);
1590
- }
1591
- else if (assignedHandler &&
1592
- synth.onvoiceschanged === onVoicesChanged) {
1593
- synth.onvoiceschanged = previousHandler;
1594
- }
1595
- resolve();
1596
- };
1597
- const onVoicesChanged = (event) => {
1598
- if (!canUseEventTarget &&
1599
- typeof previousHandler === "function" &&
1600
- event) {
1601
- previousHandler.call(synth, event);
1602
- }
1603
- finish();
1604
- };
1605
- const timeoutId = window.setTimeout(finish, timeoutMs);
1606
- if (canUseEventTarget) {
1607
- synth.addEventListener("voiceschanged", onVoicesChanged, {
1608
- once: true,
1609
- });
1610
- }
1611
- else if (canUseHandler) {
1612
- synth.onvoiceschanged = onVoicesChanged;
1613
- assignedHandler = true;
1614
- }
1615
- });
1616
- const voicesAfterWait = synth.getVoices();
1617
- await this.emitTelemetry("pie-toolkit-tts-browser-voices-ready", {
1618
- voiceCount: voicesAfterWait.length,
1619
- timedOut: voicesAfterWait.length === 0,
1620
- });
1621
- }
1622
- /**
1623
- * Get all services as a bundle for section player convenience.
1624
- * Section player can extract and pass services to child components.
1625
- */
1626
- getServiceBundle() {
1627
- return {
1628
- ttsService: this.ttsService,
1629
- toolCoordinator: this.toolCoordinator,
1630
- highlightCoordinator: this.highlightCoordinator,
1631
- elementToolStateStore: this.elementToolStateStore,
1632
- catalogResolver: this.catalogResolver,
1633
- toolProviderRegistry: this.toolProviderRegistry,
1634
- };
1635
- }
1636
- /**
1637
- * Get a tool provider from the registry
1638
- *
1639
- * @param providerId Provider identifier
1640
- * @returns Tool provider instance
1641
- */
1642
- async getToolProvider(providerId) {
1643
- return this.ensureProviderReady(providerId);
1644
- }
1645
1888
  async waitUntilReady() {
1646
1889
  this.assertNotDisposed();
1647
1890
  if (this.isReady())
@@ -1651,9 +1894,8 @@ export class ToolkitCoordinator {
1651
1894
  this.coordinatorReadyPromise = (async () => {
1652
1895
  await this.ensureStateLoaded();
1653
1896
  this.assertNotDisposed();
1654
- const ttsConfig = this.getTTSConfigFromProviders();
1655
- if (ttsConfig?.enabled !== false) {
1656
- await this.ensureTTSReady(ttsConfig);
1897
+ if (this.ttsRequiredForReadiness()) {
1898
+ await this.startTTSForReadiness();
1657
1899
  }
1658
1900
  this.assertNotDisposed();
1659
1901
  if (!this.coordinatorReadyNotified) {
@@ -1670,41 +1912,52 @@ export class ToolkitCoordinator {
1670
1912
  return this.coordinatorReadyPromise;
1671
1913
  }
1672
1914
  isReady() {
1673
- return this.getInitStatus().coordinator;
1915
+ return ((this.stateLoadSettled || !this.hooks.loadToolState) &&
1916
+ (this.ttsInitialized ||
1917
+ this.ttsDegraded ||
1918
+ !this.ttsRequiredForReadiness()));
1919
+ }
1920
+ /**
1921
+ * Readiness waits for text-to-speech when it is enabled and either starts
1922
+ * eagerly or is granted: a granted read-aloud fails before the learner starts.
1923
+ */
1924
+ ttsRequiredForReadiness() {
1925
+ if (this.getTTSConfigFromProviders()?.enabled === false)
1926
+ return false;
1927
+ return !this.lazyInit || this.isToolGranted("textToSpeech");
1674
1928
  }
1675
- getInitStatus() {
1676
- const providers = {};
1677
- for (const providerId of this.toolProviderRegistry.getProviderIds()) {
1678
- providers[providerId] =
1679
- this.toolProviderRegistry.isInitialized(providerId);
1929
+ /**
1930
+ * Starts text-to-speech for readiness. A failure policy does not grant leaves
1931
+ * the tool degraded, reporting itself unavailable, and readiness settles; a
1932
+ * granted tool's failure rejects.
1933
+ */
1934
+ async startTTSForReadiness() {
1935
+ try {
1936
+ await this.ensureTTSReady(this.getTTSConfigFromProviders());
1937
+ }
1938
+ catch (error) {
1939
+ if (error instanceof ToolkitCoordinatorDisposedError)
1940
+ throw error;
1941
+ if (this.isToolGranted("textToSpeech"))
1942
+ throw error;
1943
+ this.ttsDegraded = true;
1944
+ this.notifyReadyChange();
1680
1945
  }
1681
- const ttsConfig = this.getTTSConfigFromProviders();
1682
- return {
1683
- tts: this.ttsInitialized,
1684
- stateLoaded: this.stateLoaded || !this.hooks.loadToolState,
1685
- coordinator: (this.stateLoaded || !this.hooks.loadToolState) &&
1686
- (this.ttsInitialized || ttsConfig?.enabled === false),
1687
- providers,
1688
- };
1689
1946
  }
1690
1947
  getTTSConfigFromProviders() {
1691
- const providers = this.config.tools?.providers || {};
1692
- return providers.textToSpeech;
1948
+ return this.config.tools?.providers?.textToSpeech;
1693
1949
  }
1694
1950
  assertCanonicalToolId(toolId) {
1695
1951
  if (typeof toolId !== "string" || toolId.trim().length === 0) {
1696
1952
  throw new Error("Tool id must be a non-empty string.");
1697
1953
  }
1698
- if (toolId === "tts") {
1699
- throw new Error(`Tool id "tts" is no longer supported. Use "textToSpeech".`);
1700
- }
1701
1954
  // An empty registry means the host supplied none, not that every id is
1702
1955
  // wrong. There is nothing to check an id against, and throwing turns a
1703
1956
  // host's every tool-config call into an exception — including the calls
1704
1957
  // this coordinator's own default-provider block provokes, which is how a
1705
1958
  // host that passes no registry ended up unable to read its own config.
1706
- // `normalizeAndValidateToolsConfig` already reports the missing registry
1707
- // once, as `tools.registryUnavailable`; a second report per call is noise.
1959
+ // Validation reports the missing registry once per coordinator, as
1960
+ // `tools.registryUnavailable`; a report per call would be noise.
1708
1961
  if (this.toolRegistry.getAllToolIds().length === 0)
1709
1962
  return;
1710
1963
  if (!this.toolRegistry.get(toolId)) {
@@ -1724,6 +1977,11 @@ export class ToolkitCoordinator {
1724
1977
  this.toolContextResolvers.set(toolId, resolver);
1725
1978
  }
1726
1979
  }
1980
+ /**
1981
+ * Replace all host-owned render-context resolvers. The toolkit element
1982
+ * calls this from `runtime.toolContextResolvers`; it is not on
1983
+ * `ToolkitCoordinatorApi`.
1984
+ */
1727
1985
  setToolContextResolvers(resolvers) {
1728
1986
  this.toolContextResolvers.clear();
1729
1987
  this.installToolContextResolvers(resolvers ?? undefined);
@@ -1739,39 +1997,20 @@ export class ToolkitCoordinator {
1739
1997
  }
1740
1998
  }
1741
1999
  }
1742
- /**
1743
- * Check if a tool is enabled.
1744
- * Tools are enabled by default unless explicitly disabled.
1745
- *
1746
- * @param toolId Tool identifier (e.g., 'textToSpeech', 'answerEliminator')
1747
- * @returns True if tool is enabled
1748
- */
1749
- isToolEnabled(toolId) {
1750
- this.assertCanonicalToolId(toolId);
1751
- const toolConfig = this.config.tools?.providers?.[toolId];
1752
- // Enabled by default unless explicitly set to false
1753
- return toolConfig?.enabled !== false;
1754
- }
1755
- /**
1756
- * Get tool configuration.
1757
- *
1758
- * @param toolId Tool identifier
1759
- * @returns Tool configuration or null if not configured
1760
- */
1761
2000
  getToolConfig(toolId) {
1762
2001
  this.assertCanonicalToolId(toolId);
1763
- return (this.config.tools?.providers?.[toolId] || null);
2002
+ return this.config.tools?.providers?.[toolId] || null;
1764
2003
  }
1765
2004
  updateToolConfig(toolId, updates) {
1766
2005
  // Update config
1767
2006
  this.assertCanonicalToolId(toolId);
1768
2007
  const current = this.getToolConfig(toolId) || {};
1769
2008
  if (!this.config.tools) {
1770
- this.config.tools = normalizeAndValidateToolsConfig(undefined, {
2009
+ this.config.tools = this.validateToolsConfig(undefined, {
1771
2010
  strictness: this.config.toolConfigStrictness ?? "error",
1772
2011
  source: "ToolkitCoordinator.updateToolConfig",
1773
2012
  toolRegistry: this.toolRegistry,
1774
- }).config;
2013
+ });
1775
2014
  }
1776
2015
  if (!this.config.tools.providers) {
1777
2016
  this.config.tools.providers = {};
@@ -1780,7 +2019,7 @@ export class ToolkitCoordinator {
1780
2019
  ...(this.config.tools.providers || {}),
1781
2020
  [toolId]: mergeToolConfigUpdate(toolId, current, updates),
1782
2021
  };
1783
- const validated = normalizeAndValidateToolsConfig({
2022
+ this.config.tools = this.validateToolsConfig({
1784
2023
  ...this.config.tools,
1785
2024
  providers: nextProviders,
1786
2025
  }, {
@@ -1788,39 +2027,30 @@ export class ToolkitCoordinator {
1788
2027
  source: "ToolkitCoordinator.updateToolConfig",
1789
2028
  toolRegistry: this.toolRegistry,
1790
2029
  });
1791
- this.config.tools = validated.config;
1792
- // M8 PR 2 — keep the policy engine's tools input in lockstep
1793
- // with the validated coordinator config. The engine emits an
1794
- // `inputs` change event so subscribers (e.g. PR 3 toolbars) can
1795
- // re-decide without us managing a parallel pub/sub.
2030
+ // The engine's tools input follows the validated config; its `inputs`
2031
+ // change event is what makes toolbars re-decide.
1796
2032
  this.policyEngine.updateInputs({
1797
2033
  tools: this.config.tools,
1798
2034
  });
1799
2035
  void this.emitTelemetry("pie-toolkit-tool-config-updated", { toolId });
1800
2036
  // Apply configuration changes to services
1801
- this._applyToolConfigChange(toolId, updates);
2037
+ this._applyToolConfigChange(toolId);
1802
2038
  }
1803
2039
  /**
1804
- * Update the enabled tool list for one placement level.
2040
+ * Patch one or more placement levels in the canonical tools config.
1805
2041
  *
1806
2042
  * This is the generic placement companion to {@link updateToolConfig}:
1807
2043
  * it validates the next canonical tools config, keeps the policy
1808
2044
  * engine in lockstep, and emits the same policy-change event used by
1809
2045
  * live toolbars/debug panels.
1810
2046
  */
1811
- updateToolPlacement(level, toolIds) {
1812
- this.updateToolsPlacement({ [level]: [...toolIds] });
1813
- }
1814
- /**
1815
- * Patch one or more placement levels in the canonical tools config.
1816
- */
1817
2047
  updateToolsPlacement(partial) {
1818
2048
  if (!this.config.tools) {
1819
- this.config.tools = normalizeAndValidateToolsConfig(undefined, {
2049
+ this.config.tools = this.validateToolsConfig(undefined, {
1820
2050
  strictness: this.config.toolConfigStrictness ?? "error",
1821
2051
  source: "ToolkitCoordinator.updateToolsPlacement",
1822
2052
  toolRegistry: this.toolRegistry,
1823
- }).config;
2053
+ });
1824
2054
  }
1825
2055
  const currentPlacement = this.config.tools?.placement ?? {
1826
2056
  section: [],
@@ -1832,7 +2062,7 @@ export class ToolkitCoordinator {
1832
2062
  item: [...(partial.item ?? currentPlacement.item ?? [])],
1833
2063
  passage: [...(partial.passage ?? currentPlacement.passage ?? [])],
1834
2064
  };
1835
- const validated = normalizeAndValidateToolsConfig({
2065
+ const validated = this.validateToolsConfig({
1836
2066
  ...this.config.tools,
1837
2067
  placement: nextPlacement,
1838
2068
  }, {
@@ -1840,8 +2070,8 @@ export class ToolkitCoordinator {
1840
2070
  source: "ToolkitCoordinator.updateToolsPlacement",
1841
2071
  toolRegistry: this.toolRegistry,
1842
2072
  });
1843
- validated.config.placement = nextPlacement;
1844
- this.config.tools = validated.config;
2073
+ validated.placement = nextPlacement;
2074
+ this.config.tools = validated;
1845
2075
  this.policyEngine.updateInputs({
1846
2076
  tools: this.config.tools,
1847
2077
  });
@@ -1849,10 +2079,40 @@ export class ToolkitCoordinator {
1849
2079
  /**
1850
2080
  * Resolve the visible tool set for a given placement level + scope.
1851
2081
  *
1852
- * Thin shim over the owned tool-policy engine.
2082
+ * Delegates to the owned tool-policy engine and logs the decision's
2083
+ * diagnostics through {@link warnPolicyDiagnostics}.
1853
2084
  */
1854
2085
  decideToolPolicy(request) {
1855
- return this.policyEngine.decide(request);
2086
+ const decision = this.policyEngine.decide(request);
2087
+ this.warnPolicyDiagnostics(decision.diagnostics);
2088
+ return decision;
2089
+ }
2090
+ /**
2091
+ * Log each policy diagnostic as a console warning, once per code and tool
2092
+ * id, and per item for `tool-policy.itemSettingNotApplied`. Decisions re-run
2093
+ * on every input change, so a conflict would otherwise repeat on each.
2094
+ */
2095
+ warnPolicyDiagnostics(diagnostics) {
2096
+ for (const diagnostic of diagnostics) {
2097
+ const itemId = diagnostic.code === "tool-policy.itemSettingNotApplied"
2098
+ ? diagnostic.details.itemId
2099
+ : "";
2100
+ const key = `${diagnostic.code}\0${diagnostic.toolId}\0${itemId}`;
2101
+ if (this.warnedPolicyDiagnostics.has(key))
2102
+ continue;
2103
+ this.warnedPolicyDiagnostics.add(key);
2104
+ console.warn(`[ToolkitCoordinator] ${diagnostic.message}`);
2105
+ }
2106
+ }
2107
+ /**
2108
+ * File a mounted item's policy settings (`requiredTools`, `restrictedTools`,
2109
+ * `toolParameters`) under its canonical id. They govern the decisions scoped
2110
+ * to that item: its own item-level toolbar, and the feature decisions its
2111
+ * content asks with the item's scope. `pie-item-scope` registers them from
2112
+ * its `settings` property; the returned function withdraws them.
2113
+ */
2114
+ registerItemSettings(itemId, settings) {
2115
+ return this.policyEngine.registerItemSettings(itemId, settings);
1856
2116
  }
1857
2117
  /**
1858
2118
  * Resolve eligibility for one PNP/AfA feature id, independent of toolbar
@@ -1867,10 +2127,23 @@ export class ToolkitCoordinator {
1867
2127
  * capability it asks about, which is indistinguishable from a student who was
1868
2128
  * properly declined — so without this, forgetting {@link updateAssessment}
1869
2129
  * presents as an accommodation that silently never appears.
2130
+ *
2131
+ * A coordinator built with `assessmentOptional` reports only while PNP
2132
+ * enforcement is explicitly `"on"`. Its toolkit binds whatever assessment the
2133
+ * host gave, usually none, and a host that passes no profile and leaves
2134
+ * enforcement unset or `"off"` has asked for no accommodation.
2135
+ *
2136
+ * `scope` is the surface asking; an item's scope brings in that item's
2137
+ * registered settings ({@link registerItemSettings}).
1870
2138
  */
1871
- decideFeaturePolicy(featureId) {
1872
- const decision = this.policyEngine.decideFeature(featureId);
1873
- if (!decision.assessmentBound && !this.reportedUnboundFeaturePolicy) {
2139
+ decideFeaturePolicy(featureId, scope) {
2140
+ const decision = this.policyEngine.decideFeature(featureId, scope);
2141
+ this.warnPolicyDiagnostics(decision.diagnostics);
2142
+ const unboundIsMisconfigured = this.config.assessmentOptional !== true ||
2143
+ this.pnpEnforcementOverride === "on";
2144
+ if (!decision.assessmentBound &&
2145
+ unboundIsMisconfigured &&
2146
+ !this.reportedUnboundFeaturePolicy) {
1874
2147
  this.reportedUnboundFeaturePolicy = true;
1875
2148
  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.`);
1876
2149
  }
@@ -1879,9 +2152,10 @@ export class ToolkitCoordinator {
1879
2152
  /**
1880
2153
  * Subscribe to policy-engine change events. Fires whenever the
1881
2154
  * coordinator's bound inputs change (`updateToolConfig`,
1882
- * `updateToolPlacement`, `updateAssessment`, `updateCurrentItemRef`,
1883
- * `setPnpEnforcement`) or a custom `PolicySource` is registered /
1884
- * removed via {@link registerPolicySource}.
2155
+ * `updateToolsPlacement`, `updateAssessment`, `setPnpEnforcement`), an
2156
+ * item's settings are registered or withdrawn ({@link registerItemSettings}),
2157
+ * or a custom `PolicySource` is registered / removed via
2158
+ * {@link registerPolicySource}.
1885
2159
  *
1886
2160
  * The listener receives a `ToolPolicyChangeEvent` with the event
1887
2161
  * `reason` and a frozen snapshot of the engine inputs. Listeners
@@ -1911,49 +2185,26 @@ export class ToolkitCoordinator {
1911
2185
  /**
1912
2186
  * Bind (or clear) the active assessment for PNP/profile policy decisions.
1913
2187
  *
1914
- * Under auto-mode (no host override via {@link setPnpEnforcement}),
1915
- * the coordinator promotes the engine to `pnpEnforcement: "on"`
1916
- * iff the assessment carries any profile precedence material
1917
- * (`personalNeedsProfile`, `settings.districtPolicy`,
1918
- * `settings.testAdministration`) or the currently-bound item ref
1919
- * carries item-level profile policy inputs. A bare assessment record (just
2188
+ * Under auto-mode (no host override via {@link setPnpEnforcement}), a
2189
+ * decision enforces PNP/profile policy iff the assessment carries profile
2190
+ * precedence material (`personalNeedsProfile`, `settings.districtPolicy`,
2191
+ * `settings.testAdministration`), or the decision is scoped to an item whose
2192
+ * settings carry item-level policy inputs. A bare assessment record (just
1920
2193
  * `id` / `name`, no PNP, no settings) keeps `"off"`.
1921
2194
  *
1922
2195
  * The host override set via {@link setPnpEnforcement} is sticky
1923
- * across assessment swaps; calling with `null` clears the binding
1924
- * and re-runs the auto-mode helper.
2196
+ * across assessment swaps.
1925
2197
  */
1926
2198
  updateAssessment(assessment) {
1927
- this.boundAssessment = assessment;
1928
- this.policyEngine.updateInputs({
1929
- assessment,
1930
- pnpEnforcement: this.resolveEffectivePnpEnforcement(),
1931
- });
1932
- }
1933
- /**
1934
- * Bind (or clear) the current item reference for policy decisions.
1935
- *
1936
- * Used by item-level profile gates (item `requiredTools` /
1937
- * `restrictedTools` / `toolParameters`). Item-level profile material
1938
- * also feeds {@link resolveEffectivePnpEnforcement} — navigating
1939
- * to an item with profile settings can flip auto-mode to `"on"` even
1940
- * when the parent assessment carries no profile block of its own.
1941
- */
1942
- updateCurrentItemRef(itemRef) {
1943
- this.boundCurrentItemRef = itemRef;
1944
- this.policyEngine.updateInputs({
1945
- currentItemRef: itemRef,
1946
- pnpEnforcement: this.resolveEffectivePnpEnforcement(),
1947
- });
2199
+ this.policyEngine.updateInputs({ assessment });
1948
2200
  }
1949
2201
  /**
1950
- * Override the auto-mode PNP/profile enforcement decision.
2202
+ * Override the auto-mode PNP/profile enforcement decision; `null` returns
2203
+ * to auto-mode.
1951
2204
  */
1952
2205
  setPnpEnforcement(mode) {
1953
2206
  this.pnpEnforcementOverride = mode;
1954
- this.policyEngine.updateInputs({
1955
- pnpEnforcement: this.resolveEffectivePnpEnforcement(),
1956
- });
2207
+ this.policyEngine.updateInputs({ pnpEnforcement: mode });
1957
2208
  }
1958
2209
  /**
1959
2210
  * Get the policy engine inputs currently driving decisions. Useful
@@ -1977,29 +2228,6 @@ export class ToolkitCoordinator {
1977
2228
  registerPolicySource(source) {
1978
2229
  return this.policyEngine.registerPolicySource(source);
1979
2230
  }
1980
- /**
1981
- * Register a host-owned resolver for one tool's render context.
1982
- *
1983
- * Resolvers are evaluated only for tools that already survived the
1984
- * framework policy pipeline. They can hide the tool for the current
1985
- * scope or attach render params consumed by the packaged registration;
1986
- * they cannot re-enable tools blocked by placement, provider config,
1987
- * host policy, or PNP/profile rules.
1988
- */
1989
- registerToolContextResolver(toolId, resolver) {
1990
- this.assertCanonicalToolId(toolId);
1991
- if (typeof resolver !== "function") {
1992
- throw new Error(`Invalid tool context resolver for "${toolId}": expected a function.`);
1993
- }
1994
- this.toolContextResolvers.set(toolId, resolver);
1995
- this.notifyToolContextResolverChange();
1996
- return () => {
1997
- if (this.toolContextResolvers.get(toolId) !== resolver)
1998
- return;
1999
- this.toolContextResolvers.delete(toolId);
2000
- this.notifyToolContextResolverChange();
2001
- };
2002
- }
2003
2231
  hasToolContextResolver(toolId) {
2004
2232
  this.assertCanonicalToolId(toolId);
2005
2233
  return this.toolContextResolvers.has(toolId);
@@ -2047,6 +2275,9 @@ export class ToolkitCoordinator {
2047
2275
  this.toolContextResolverChangeListeners.delete(listener);
2048
2276
  };
2049
2277
  }
2278
+ getToolRegistry() {
2279
+ return this.toolRegistry;
2280
+ }
2050
2281
  /**
2051
2282
  * Claim requests for one placement level. Called by a toolbar on mount.
2052
2283
  */
@@ -2070,39 +2301,18 @@ export class ToolkitCoordinator {
2070
2301
  * surface, and a host that swapped the registry for one without the tool would
2071
2302
  * otherwise lose the whole surface to an exception over an absent action.
2072
2303
  */
2073
- canRequestTool(toolId, level) {
2304
+ canRequestTool(toolId, level, scopeId) {
2074
2305
  try {
2075
2306
  this.assertCanonicalToolId(toolId);
2076
2307
  }
2077
2308
  catch {
2078
2309
  return false;
2079
2310
  }
2080
- return this.toolRequests.canRequest(toolId, level);
2311
+ return this.toolRequests.canRequest(toolId, level, scopeId);
2081
2312
  }
2082
2313
  onToolRequestTargetsChange(listener) {
2083
2314
  return this.toolRequests.onTargetsChange(listener);
2084
2315
  }
2085
- /**
2086
- * Compute the effective PNP/profile enforcement mode given the explicit
2087
- * host override and the auto-mode helper.
2088
- *
2089
- * Auto-mode (no override) defers to
2090
- * {@link resolveDefaultPnpEnforcement}, which returns `"on"`
2091
- * exactly when the bound assessment or current item ref carries
2092
- * profile precedence material (PNP, district policy, test
2093
- * administration, item-level required/restricted/parameters), and
2094
- * `"off"` otherwise. Profile gates engage the moment profile material
2095
- * is present.
2096
- */
2097
- resolveEffectivePnpEnforcement() {
2098
- if (this.pnpEnforcementOverride !== null) {
2099
- return this.pnpEnforcementOverride;
2100
- }
2101
- return resolveDefaultPnpEnforcement({
2102
- assessment: this.boundAssessment,
2103
- currentItemRef: this.boundCurrentItemRef,
2104
- });
2105
- }
2106
2316
  resolveConfiguredPnpEnforcement() {
2107
2317
  const tools = this.config.tools;
2108
2318
  return tools?.pnpEnforcement ?? null;
@@ -2111,46 +2321,70 @@ export class ToolkitCoordinator {
2111
2321
  * Apply tool configuration changes to underlying services.
2112
2322
  * Called after updateToolConfig().
2113
2323
  */
2114
- _applyToolConfigChange(toolId, _updates) {
2324
+ _applyToolConfigChange(toolId) {
2115
2325
  if (this.disposePromise !== null)
2116
2326
  return;
2117
- // Apply configuration changes based on tool
2118
- switch (toolId) {
2119
- case "textToSpeech": {
2120
- const reconfigurePromise = this._reconfigureTTSProvider();
2121
- this.ttsReconfigurePromise = reconfigurePromise;
2122
- void reconfigurePromise.finally(() => {
2123
- if (this.ttsReconfigurePromise === reconfigurePromise) {
2124
- this.ttsReconfigurePromise = undefined;
2125
- }
2126
- });
2127
- void reconfigurePromise.then(async () => {
2128
- if (this.disposePromise !== null)
2129
- return;
2130
- const ttsConfig = this.getTTSConfigFromProviders();
2131
- if (!this.lazyInit && ttsConfig?.enabled !== false) {
2132
- await this.ensureTTSReady(ttsConfig);
2133
- }
2134
- });
2135
- break;
2136
- }
2137
- case "answerEliminator":
2138
- // Future: Could notify answer eliminator tools of strategy change
2139
- break;
2140
- // Add cases for other tools as needed
2327
+ if (toolId === "textToSpeech") {
2328
+ this.scheduleTTSReconfigure();
2329
+ return;
2141
2330
  }
2331
+ this._registerChangedToolProvider(toolId);
2332
+ }
2333
+ /**
2334
+ * Register the provider a tool's updated config builds, replacing the one
2335
+ * registered under the tool's id. Registration completes before this returns,
2336
+ * so a check on the policy change the update dispatched finds the new provider.
2337
+ */
2338
+ _registerChangedToolProvider(toolId) {
2339
+ const registration = this.getProviderDescriptorTools().find((tool) => tool.toolId === toolId);
2340
+ if (!registration)
2341
+ return;
2342
+ // A failure of the replaced provider no longer describes the tool.
2343
+ this.degradedTools.delete(toolId);
2344
+ void this.registerProviderFromTool(registration, true);
2345
+ }
2346
+ /**
2347
+ * Re-register the text-to-speech provider for the current config and registry, then
2348
+ * re-initialize unless initialization is lazy. An initialization already
2349
+ * running finishes first: it would otherwise mark its provider ready after
2350
+ * the reset.
2351
+ */
2352
+ scheduleTTSReconfigure() {
2353
+ const inFlight = this.ttsInitPromise;
2354
+ const reconfigurePromise = inFlight
2355
+ ? inFlight.catch(() => { }).then(() => this._reconfigureTTSProvider())
2356
+ : this._reconfigureTTSProvider();
2357
+ this.ttsReconfigurePromise = reconfigurePromise;
2358
+ void reconfigurePromise.finally(() => {
2359
+ if (this.ttsReconfigurePromise === reconfigurePromise) {
2360
+ this.ttsReconfigurePromise = undefined;
2361
+ }
2362
+ });
2363
+ void reconfigurePromise
2364
+ .then(async () => {
2365
+ if (this.disposePromise !== null)
2366
+ return;
2367
+ if (this.ttsRequiredForReadiness())
2368
+ await this.startTTSForReadiness();
2369
+ })
2370
+ // Reported where it failed; a granted failure has nowhere else to go.
2371
+ .catch(() => { });
2142
2372
  }
2143
2373
  async _reconfigureTTSProvider() {
2144
2374
  this.ttsInitialized = false;
2375
+ this.ttsDegraded = false;
2376
+ this.degradedTools.delete("textToSpeech");
2145
2377
  this.ttsInitPromise = undefined;
2378
+ this.notifyReadyChange();
2146
2379
  try {
2147
- this.ttsService.stop();
2380
+ // The next speak waits on readiness, which starts the new provider.
2381
+ this.ttsService.releaseProvider();
2148
2382
  }
2149
2383
  catch {
2150
- // noop: stop best effort
2384
+ // noop: release best effort
2151
2385
  }
2152
- if (this.toolProviderRegistry.has("tts")) {
2153
- await this.toolProviderRegistry.unregister("tts");
2386
+ if (this.toolProviderRegistry.has("textToSpeech")) {
2387
+ await this.toolProviderRegistry.unregister("textToSpeech");
2154
2388
  }
2155
2389
  if (this.disposePromise !== null)
2156
2390
  return;