@cat-factory/app 0.206.0 → 0.208.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,11 +31,19 @@ import type { TutorialRequirement, TutorialTour } from '~/utils/tutorial'
31
31
  * anonymous predicate: the catalogue lists every tour this deployment ships and has to say
32
32
  * what a user must do before one it is holding back becomes available.
33
33
  *
34
- * Together the tours below walk the delivery loop end to end get a repo onto the board,
35
- * put a task on it, run it, answer it when it asks, read the result and merge it — with each
36
- * later tour requiring the state the previous one produces, so the launch prompt only ever
37
- * offers what this board can actually demonstrate, and the catalogue turns the rest into a
38
- * to-do list rather than an absence.
34
+ * The catalog is in two halves, and the split is what keeps the launch prompt answerable:
35
+ *
36
+ * - The DELIVERY LOOP, end to end get a repo onto the board, put a task on it, run it, answer
37
+ * it when it asks, read the result and merge it each tour requiring the state the previous
38
+ * one produces, so the prompt only ever offers what this board can actually demonstrate and
39
+ * the catalogue turns the rest into a to-do list rather than an absence.
40
+ * - The PLATFORM behind it (`offeredAtLaunch: false`) — the engine the agents run on, the
41
+ * pipelines that sequence them, the standards they read, the systems they talk to. Each is
42
+ * gated on a PERMISSION rather than on board state, so every one is startable on a brand-new
43
+ * board; offered at launch they would bury the two tours a first-time user can act on. They
44
+ * are reference material someone goes and gets from the catalogue when the question comes up,
45
+ * which is why each covers ONE surface and ends there rather than touring the sidebar: these
46
+ * surfaces open as modals, so a step after one cannot reach another sidebar entry anyway.
39
47
  */
40
48
 
41
49
  /**
@@ -90,6 +98,28 @@ export const TUTORIAL_REQUIREMENTS = {
90
98
  labelKey: 'tutorial.requirements.finishedRun',
91
99
  met: (gates) => gates.boardHasFinishedRun,
92
100
  },
101
+ // The platform half's requirements. Each mirrors, exactly, the `gate` of the sidebar entry the
102
+ // tour clicks (`nav-model-providers` / `nav-integrations`, `nav-fragments`). A requirement
103
+ // WEAKER than the gate of the control a step points at offers the tour to a user who has no
104
+ // such control: it then hunts for the anchor, skips every step behind it, and reports itself
105
+ // abridged — which is the state this mechanism exists to prevent. They are permissions and
106
+ // deployment wiring rather than board state, which is why those tours are startable on a board
107
+ // with nothing on it, and therefore why they are kept out of the launch offer.
108
+ integrationsManage: {
109
+ id: 'integrations-manage',
110
+ labelKey: 'tutorial.requirements.integrationsManage',
111
+ met: (gates) => gates.canManageIntegrations,
112
+ },
113
+ settingsManage: {
114
+ id: 'settings-manage',
115
+ labelKey: 'tutorial.requirements.settingsManage',
116
+ met: (gates) => gates.canManageSettings,
117
+ },
118
+ library: {
119
+ id: 'library',
120
+ labelKey: 'tutorial.requirements.library',
121
+ met: (gates) => gates.libraryAvailable,
122
+ },
93
123
  } as const satisfies Record<string, TutorialRequirement>
94
124
 
95
125
  export const TUTORIAL_TOURS: readonly TutorialTour[] = [
@@ -460,6 +490,197 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
460
490
  },
461
491
  ],
462
492
  },
493
+ // ---------------------------------------------------------------------------------------
494
+ // The platform half. Ordered after the whole delivery loop so the catalogue reads in the
495
+ // order someone meets these things: learn the loop, then the machinery under it.
496
+ // ---------------------------------------------------------------------------------------
497
+ {
498
+ id: 'wire-models',
499
+ order: 60,
500
+ icon: 'i-lucide-plug-zap',
501
+ titleKey: 'tutorial.tours.wireModels.title',
502
+ descriptionKey: 'tutorial.tours.wireModels.description',
503
+ // The one connection a deployment cannot live without: every pipeline step is a model call,
504
+ // so with no provider the whole product is inert. It is nonetheless catalogue-only, because a
505
+ // deployment with nothing wired already gets its own first-launch nudge (the provider
506
+ // onboarding advisory, which the launch prompt stands down for) — this is for the person who
507
+ // meets the question later, or who wants to know where the answer lives.
508
+ offeredAtLaunch: false,
509
+ requires: [TUTORIAL_REQUIREMENTS.integrationsManage],
510
+ steps: [
511
+ {
512
+ id: 'intro',
513
+ titleKey: 'tutorial.tours.wireModels.steps.intro.title',
514
+ bodyKey: 'tutorial.tours.wireModels.steps.intro.body',
515
+ },
516
+ {
517
+ id: 'open',
518
+ target: 'nav-model-providers',
519
+ advanceOn: 'target-click',
520
+ placement: 'right',
521
+ titleKey: 'tutorial.tours.wireModels.steps.open.title',
522
+ bodyKey: 'tutorial.tours.wireModels.steps.open.body',
523
+ },
524
+ {
525
+ id: 'hub',
526
+ target: 'model-providers-hub',
527
+ // Inside the modal the previous click opens.
528
+ waitForTargetMs: 8000,
529
+ placement: 'bottom',
530
+ titleKey: 'tutorial.tours.wireModels.steps.hub.title',
531
+ bodyKey: 'tutorial.tours.wireModels.steps.hub.body',
532
+ },
533
+ {
534
+ // Deliberately prose rather than a step pointing at Model configuration: that entry is a
535
+ // sibling in the same sidebar section, and by now the hub modal is open over it, so a
536
+ // step anchored there would spend its wait budget on a control the user cannot reach.
537
+ id: 'finish',
538
+ titleKey: 'tutorial.tours.wireModels.steps.finish.title',
539
+ bodyKey: 'tutorial.tours.wireModels.steps.finish.body',
540
+ },
541
+ ],
542
+ },
543
+ {
544
+ id: 'design-pipeline',
545
+ order: 70,
546
+ icon: 'i-lucide-workflow',
547
+ titleKey: 'tutorial.tours.designPipeline.title',
548
+ descriptionKey: 'tutorial.tours.designPipeline.description',
549
+ // `run-task` teaches picking a pipeline; nothing teaches that the sequence is yours to
550
+ // change. A user who never finds the builder treats the built-in catalog as the product's
551
+ // fixed shape and works around it in task descriptions instead.
552
+ offeredAtLaunch: false,
553
+ requires: [TUTORIAL_REQUIREMENTS.boardWrite],
554
+ steps: [
555
+ {
556
+ id: 'intro',
557
+ titleKey: 'tutorial.tours.designPipeline.steps.intro.title',
558
+ bodyKey: 'tutorial.tours.designPipeline.steps.intro.body',
559
+ },
560
+ {
561
+ id: 'open',
562
+ target: 'nav-build-pipeline',
563
+ advanceOn: 'target-click',
564
+ placement: 'right',
565
+ titleKey: 'tutorial.tours.designPipeline.steps.open.title',
566
+ bodyKey: 'tutorial.tours.designPipeline.steps.open.body',
567
+ },
568
+ {
569
+ id: 'palette',
570
+ target: 'pipeline-builder-palette',
571
+ // Inside the slideover the previous click opens.
572
+ waitForTargetMs: 8000,
573
+ placement: 'right',
574
+ titleKey: 'tutorial.tours.designPipeline.steps.palette.title',
575
+ bodyKey: 'tutorial.tours.designPipeline.steps.palette.body',
576
+ },
577
+ {
578
+ id: 'chain',
579
+ target: 'pipeline-builder-draft',
580
+ placement: 'right',
581
+ titleKey: 'tutorial.tours.designPipeline.steps.chain.title',
582
+ bodyKey: 'tutorial.tours.designPipeline.steps.chain.body',
583
+ },
584
+ {
585
+ // NOT `target-click`, and not for `run-task`'s budget reason: Save is DISABLED until the
586
+ // draft holds a step, and a click-to-advance step on a control that cannot be clicked
587
+ // strands the tour — the tooltip drops its Next button, so there is no way forward.
588
+ //
589
+ // Which is also why the copy DESCRIBES saving rather than instructing it, and says what
590
+ // lights the button up. The previous step invites a click on the palette but doesn't
591
+ // require one, so this step is routinely read with Save greyed out; an imperative title
592
+ // over a dead control reads as a tour pointing at something broken.
593
+ id: 'save',
594
+ target: 'pipeline-builder-save',
595
+ placement: 'top',
596
+ titleKey: 'tutorial.tours.designPipeline.steps.save.title',
597
+ bodyKey: 'tutorial.tours.designPipeline.steps.save.body',
598
+ },
599
+ {
600
+ id: 'finish',
601
+ titleKey: 'tutorial.tours.designPipeline.steps.finish.title',
602
+ bodyKey: 'tutorial.tours.designPipeline.steps.finish.body',
603
+ },
604
+ ],
605
+ },
606
+ {
607
+ id: 'agent-standards',
608
+ order: 80,
609
+ icon: 'i-lucide-book-marked',
610
+ titleKey: 'tutorial.tours.agentStandards.title',
611
+ descriptionKey: 'tutorial.tours.agentStandards.description',
612
+ // How you steer output without restating your conventions in every task description, which
613
+ // is what people do instead when they never find this.
614
+ offeredAtLaunch: false,
615
+ requires: [TUTORIAL_REQUIREMENTS.library, TUTORIAL_REQUIREMENTS.settingsManage],
616
+ steps: [
617
+ {
618
+ id: 'intro',
619
+ titleKey: 'tutorial.tours.agentStandards.steps.intro.title',
620
+ bodyKey: 'tutorial.tours.agentStandards.steps.intro.body',
621
+ },
622
+ {
623
+ id: 'open',
624
+ target: 'nav-fragments',
625
+ advanceOn: 'target-click',
626
+ placement: 'right',
627
+ titleKey: 'tutorial.tours.agentStandards.steps.open.title',
628
+ bodyKey: 'tutorial.tours.agentStandards.steps.open.body',
629
+ },
630
+ {
631
+ id: 'library',
632
+ target: 'fragment-library',
633
+ waitForTargetMs: 8000,
634
+ placement: 'bottom',
635
+ titleKey: 'tutorial.tours.agentStandards.steps.library.title',
636
+ bodyKey: 'tutorial.tours.agentStandards.steps.library.body',
637
+ },
638
+ {
639
+ id: 'finish',
640
+ titleKey: 'tutorial.tours.agentStandards.steps.finish.title',
641
+ bodyKey: 'tutorial.tours.agentStandards.steps.finish.body',
642
+ },
643
+ ],
644
+ },
645
+ {
646
+ id: 'connect-systems',
647
+ order: 90,
648
+ icon: 'i-lucide-blocks',
649
+ titleKey: 'tutorial.tours.connectSystems.title',
650
+ descriptionKey: 'tutorial.tours.connectSystems.description',
651
+ // Each integration changes what a run can SEE or SAY, and none of them announces itself:
652
+ // a board with no tracker linked simply never mentions that issues could arrive on their own.
653
+ offeredAtLaunch: false,
654
+ requires: [TUTORIAL_REQUIREMENTS.integrationsManage],
655
+ steps: [
656
+ {
657
+ id: 'intro',
658
+ titleKey: 'tutorial.tours.connectSystems.steps.intro.title',
659
+ bodyKey: 'tutorial.tours.connectSystems.steps.intro.body',
660
+ },
661
+ {
662
+ id: 'open',
663
+ target: 'nav-integrations',
664
+ advanceOn: 'target-click',
665
+ placement: 'right',
666
+ titleKey: 'tutorial.tours.connectSystems.steps.open.title',
667
+ bodyKey: 'tutorial.tours.connectSystems.steps.open.body',
668
+ },
669
+ {
670
+ id: 'hub',
671
+ target: 'integrations-hub',
672
+ waitForTargetMs: 8000,
673
+ placement: 'bottom',
674
+ titleKey: 'tutorial.tours.connectSystems.steps.hub.title',
675
+ bodyKey: 'tutorial.tours.connectSystems.steps.hub.body',
676
+ },
677
+ {
678
+ id: 'finish',
679
+ titleKey: 'tutorial.tours.connectSystems.steps.finish.title',
680
+ bodyKey: 'tutorial.tours.connectSystems.steps.finish.body',
681
+ },
682
+ ],
683
+ },
463
684
  ]
464
685
 
465
686
  /** The module that contributes the catalog; registered by `createAppRegistry`. */
@@ -311,6 +311,66 @@ describe('the generative half of the read model', () => {
311
311
  })
312
312
  })
313
313
 
314
+ // The one judgement this surface can make that admission cannot: admission checked what the
315
+ // selected integrations CAN emit, this checks what the run actually came back with.
316
+ describe('the delivered-format check', () => {
317
+ const required = (mediaTypes: string[], stored: { location: string; contentType?: string }[]) =>
318
+ binaryOutputView(
319
+ step({
320
+ stepOptions: { binaryOutput: { storageServiceId: 'files', mediaTypes } },
321
+ binaryOutputs: report({
322
+ stored: stored.map((entry) => ({ service: 'files', ...entry })),
323
+ }),
324
+ }),
325
+ )
326
+
327
+ it('names a required format no declared artifact reports', () => {
328
+ const view = required(
329
+ ['model/gltf-binary', 'model/fbx'],
330
+ [{ location: 'a.glb', contentType: 'model/gltf-binary' }],
331
+ )
332
+ expect(view?.undeliveredMediaTypes).toEqual(['model/fbx'])
333
+ expect(binaryOutputHasWarnings(view!)).toBe(true)
334
+ })
335
+
336
+ it('reduces the agent’s own spelling before comparing, and only then', () => {
337
+ // The requirement came through `mediaTypeSchema`; the artifact's content type is the model's
338
+ // prose. Comparing them raw reports a format as undelivered while the file sits where it was
339
+ // asked for.
340
+ expect(
341
+ required(['model/gltf-binary'], [{ location: 'a.glb', contentType: 'Model/GLTF-Binary' }])
342
+ ?.undeliveredMediaTypes,
343
+ ).toEqual([])
344
+ })
345
+
346
+ it('does not accept a near neighbour of the required format', () => {
347
+ // The entire point of requiring a format rather than a content type: both of these are 3D.
348
+ expect(
349
+ required(['model/gltf-binary'], [{ location: 'a.fbx', contentType: 'model/fbx' }])
350
+ ?.undeliveredMediaTypes,
351
+ ).toEqual(['model/gltf-binary'])
352
+ })
353
+
354
+ it('counts an artifact that reports no content type as covering nothing', () => {
355
+ expect(required(['model/gltf-binary'], [{ location: 'a.glb' }])?.undeliveredMediaTypes).toEqual(
356
+ ['model/gltf-binary'],
357
+ )
358
+ })
359
+
360
+ it('stays silent when there are no artifacts, because the state line already said so', () => {
361
+ // "It did not deliver a GLB" on top of "it declared nothing" is one fact stated twice as if
362
+ // it were two, and the second one adds nothing a reader can act on.
363
+ const view = binaryOutputView(
364
+ step({
365
+ stepOptions: { binaryOutput: { storageServiceId: 'files', mediaTypes: ['model/obj'] } },
366
+ binaryOutputs: report({ undeclared: true }),
367
+ }),
368
+ )
369
+ expect(view?.mediaTypes).toEqual(['model/obj'])
370
+ expect(view?.undeliveredMediaTypes).toEqual([])
371
+ })
372
+ })
373
+
314
374
  describe('binaryOutputPickIssues, generative half', () => {
315
375
  const catalog = [{ id: 'files', capabilities: ['asset-storage'] }]
316
376
  const generators = [
@@ -368,6 +428,51 @@ describe('binaryOutputPickIssues, generative half', () => {
368
428
  expect(pick.issues).toEqual([])
369
429
  })
370
430
 
431
+ // The FORMAT half, mirroring kernel's `binaryFormatCoverage` — and its three outcomes, which
432
+ // are what a second copy of the rule most easily loses.
433
+ const meshy = {
434
+ id: 'meshy',
435
+ modalities: ['3d-model' as const],
436
+ mediaTypes: ['model/gltf-binary'],
437
+ }
438
+
439
+ it('mirrors the refusal for a format no DECLARING integration emits', () => {
440
+ const pick = binaryOutputPickIssues(
441
+ { storageServiceId: 'files', generatorIds: ['meshy'], mediaTypes: ['model/fbx'] },
442
+ catalog,
443
+ true,
444
+ [meshy],
445
+ )
446
+ expect(pick.issues).toContain('media_type_uncovered')
447
+ expect(pick.uncoveredMediaTypes).toEqual(['model/fbx'])
448
+ expect(pick.unverifiableMediaTypes).toEqual([])
449
+ })
450
+
451
+ it('keeps an UNCHECKABLE format apart from a refused one, because the step still starts', () => {
452
+ // `retro` declares no formats — "only my modality is known". Flagging this as a refusal would
453
+ // send someone editing a selection the backend admits; saying nothing would present an
454
+ // unchecked requirement as a checked one.
455
+ const pick = binaryOutputPickIssues(
456
+ { storageServiceId: 'files', generatorIds: ['retro'], mediaTypes: ['image/webp'] },
457
+ catalog,
458
+ true,
459
+ generators,
460
+ )
461
+ expect(pick.issues).toContain('media_type_unverifiable')
462
+ expect(pick.issues).not.toContain('media_type_uncovered')
463
+ expect(pick.unverifiableMediaTypes).toEqual(['image/webp'])
464
+ })
465
+
466
+ it('accepts a format the selection covers, however many other formats it emits', () => {
467
+ const pick = binaryOutputPickIssues(
468
+ { storageServiceId: 'files', generatorIds: ['meshy'], mediaTypes: ['model/gltf-binary'] },
469
+ catalog,
470
+ true,
471
+ [meshy],
472
+ )
473
+ expect(pick.issues).toEqual([])
474
+ })
475
+
371
476
  it('reports an UNREADABLE set as an outage and makes no claim about the selection', () => {
372
477
  // The picker's half of the mothership-mode disposition. A failed read arrives as the same
373
478
  // empty list an unregistering deployment produces, so judging the selection against it would
@@ -1,4 +1,4 @@
1
- import { ASSET_STORAGE_CAPABILITY } from '@cat-factory/contracts'
1
+ import { ASSET_STORAGE_CAPABILITY, normalizeMediaType } from '@cat-factory/contracts'
2
2
  import type { BinaryModality, RegisteredBinaryGenerator } from '@cat-factory/contracts'
3
3
  import type {
4
4
  BinaryOutputArtifact,
@@ -118,6 +118,28 @@ export interface BinaryOutputView {
118
118
  * Empty ⇒ the step imposes no requirement, so nothing is uncovered by construction.
119
119
  */
120
120
  modalities: readonly BinaryModality[]
121
+ /**
122
+ * The concrete FORMATS the step declares it must deliver
123
+ * (`stepOptions.binaryOutput.mediaTypes`), for the deliverables where the container is the
124
+ * requirement rather than a preference — a mesh the engine can import.
125
+ */
126
+ mediaTypes: readonly string[]
127
+ /**
128
+ * Required formats no DECLARED artifact reports a matching `contentType` for.
129
+ *
130
+ * The one judgement this surface can make that admission cannot: admission checked what the
131
+ * selected integrations CAN emit, and this checks what the run actually came back with. It is
132
+ * derived in code from the two records the step already carries — never read off the agent's
133
+ * prose — and it is the question a human opens this panel to answer once a mesh is supposed to
134
+ * load in a build.
135
+ *
136
+ * Computed only when there ARE artifacts to compare against: with none, the state line above
137
+ * already says nothing was recorded, and "it did not deliver a GLB" on top of "it declared
138
+ * nothing" is the same fact stated twice as if it were two. An artifact that reports no
139
+ * `contentType` covers nothing — the platform does not guess a format from a filename — so a
140
+ * report with formats required and none reported says so rather than passing.
141
+ */
142
+ undeliveredMediaTypes: readonly string[]
121
143
  /**
122
144
  * Integration ids the AGENT named that the deployment does not register. The generative twin of
123
145
  * {@link unknownDeclaredServices}, and it needs no exclusion to stay disjoint from anything —
@@ -168,6 +190,7 @@ export function binaryOutputView(step: PipelineStep | null | undefined): BinaryO
168
190
  const contextServices = config?.contextServiceIds ?? []
169
191
  const generators = config?.generatorIds ?? []
170
192
  const modalities = config?.modalities ?? []
193
+ const mediaTypes = config?.mediaTypes ?? []
171
194
  if (!report) {
172
195
  return {
173
196
  // A step still queued has not had the chance to record anything, which is a different
@@ -180,6 +203,8 @@ export function binaryOutputView(step: PipelineStep | null | undefined): BinaryO
180
203
  unknownDeclaredServices: [],
181
204
  generators,
182
205
  modalities,
206
+ mediaTypes,
207
+ undeliveredMediaTypes: [],
183
208
  unknownDeclaredGenerators: [],
184
209
  generatorsUnverified: false,
185
210
  invalidEntries: 0,
@@ -208,6 +233,8 @@ export function binaryOutputView(step: PipelineStep | null | undefined): BinaryO
208
233
  unknownDeclaredServices: report.unknownServices.filter((id) => id !== target),
209
234
  generators,
210
235
  modalities,
236
+ mediaTypes,
237
+ undeliveredMediaTypes: undeliveredMediaTypes(mediaTypes, rows),
211
238
  unknownDeclaredGenerators: report.unknownGenerators,
212
239
  generatorsUnverified: report.generatorsUnverified === true,
213
240
  invalidEntries: report.invalidEntries,
@@ -216,6 +243,30 @@ export function binaryOutputView(step: PipelineStep | null | undefined): BinaryO
216
243
  }
217
244
  }
218
245
 
246
+ /**
247
+ * The required formats {@link BinaryOutputRow.contentType} does not account for.
248
+ *
249
+ * Compared through `normalizeMediaType` on the DECLARED side only: the step's requirement already
250
+ * came through `mediaTypeSchema` at the write boundary, while the artifact's content type is the
251
+ * agent's own prose and matches nothing until it is reduced the same way. Exact match after that,
252
+ * never a modality fallback — an artifact reported as `model/fbx` does not satisfy a requirement
253
+ * for `model/gltf-binary` just because both are 3D, and that is the entire point of requiring a
254
+ * format rather than a content type.
255
+ */
256
+ function undeliveredMediaTypes(
257
+ required: readonly string[],
258
+ rows: readonly BinaryOutputRow[],
259
+ ): string[] {
260
+ if (required.length === 0 || rows.length === 0) return []
261
+ const delivered = new Set(
262
+ rows.flatMap((row) => {
263
+ const normalized = row.contentType ? normalizeMediaType(row.contentType) : null
264
+ return normalized ? [normalized] : []
265
+ }),
266
+ )
267
+ return required.filter((mediaType) => !delivered.has(mediaType))
268
+ }
269
+
219
270
  /**
220
271
  * Which failure the report records, in the order the parser can produce them. `parseFailed`
221
272
  * and `undeclared` are checked BEFORE the (always empty in those cases) `stored` list, so a
@@ -298,6 +349,7 @@ export function binaryOutputHasWarnings(view: BinaryOutputView): boolean {
298
349
  view.unknownDeclaredServices.length > 0 ||
299
350
  view.unknownDeclaredGenerators.length > 0 ||
300
351
  view.generatorsUnverified ||
352
+ view.undeliveredMediaTypes.length > 0 ||
301
353
  view.invalidEntries > 0 ||
302
354
  view.omitted > 0 ||
303
355
  view.misdirected > 0
@@ -345,6 +397,21 @@ export type BinaryOutputPickIssue =
345
397
  | 'unknown_generator'
346
398
  /** A content type the step declares it delivers is produced by NO selected integration. */
347
399
  | 'modality_uncovered'
400
+ /**
401
+ * A concrete FORMAT the step declares it delivers is emitted by no selected integration that
402
+ * declared its formats (kernel's `media_type_uncovered` spelling verbatim). A refusal, like the
403
+ * two above it.
404
+ */
405
+ | 'media_type_uncovered'
406
+ /**
407
+ * A declared format nothing selected claims, where a selected integration declares no formats
408
+ * at all — so it MIGHT be met and nothing may say otherwise. ADVISORY: unlike every other
409
+ * member here it is not a refusal and must not be styled as one, or a step that is going to
410
+ * start perfectly well reads as broken. It is here rather than nowhere because the alternative
411
+ * is silence about a requirement the platform could not check, which is how "nobody looked"
412
+ * comes to look exactly like "this is fine".
413
+ */
414
+ | 'media_type_unverifiable'
348
415
 
349
416
  /** What the builder found wrong with one step's selection, and which ids to name. */
350
417
  export interface BinaryOutputPickState {
@@ -355,6 +422,10 @@ export interface BinaryOutputPickState {
355
422
  unknownGeneratorIds: readonly string[]
356
423
  /** The declared content types nothing selected can produce, for the message that names them. */
357
424
  uncoveredModalities: readonly BinaryModality[]
425
+ /** The declared formats no DECLARING integration emits — the refusal's own list. */
426
+ uncoveredMediaTypes: readonly string[]
427
+ /** The declared formats that could not be judged, kept apart from the refusal above. */
428
+ unverifiableMediaTypes: readonly string[]
358
429
  }
359
430
 
360
431
  /**
@@ -374,24 +445,72 @@ export interface BinaryOutputPickState {
374
445
  */
375
446
  function generatorPickIssues(
376
447
  config: BinaryOutputConfig | undefined,
377
- generators: readonly Pick<RegisteredBinaryGenerator, 'id' | 'modalities'>[],
448
+ generators: readonly Pick<RegisteredBinaryGenerator, 'id' | 'modalities' | 'mediaTypes'>[],
378
449
  unavailable: boolean,
379
- ): { issues: BinaryOutputPickIssue[]; unknownGeneratorIds: string[]; uncovered: BinaryModality[] } {
380
- if (unavailable) {
381
- return { issues: ['generators_unavailable'], unknownGeneratorIds: [], uncovered: [] }
450
+ ): {
451
+ issues: BinaryOutputPickIssue[]
452
+ unknownGeneratorIds: string[]
453
+ uncovered: BinaryModality[]
454
+ uncoveredMediaTypes: string[]
455
+ unverifiableMediaTypes: string[]
456
+ } {
457
+ const none = {
458
+ unknownGeneratorIds: [],
459
+ uncovered: [],
460
+ uncoveredMediaTypes: [],
461
+ unverifiableMediaTypes: [],
382
462
  }
463
+ if (unavailable) return { issues: ['generators_unavailable'], ...none }
383
464
  const byId = new Map(generators.map((g) => [g.id, g]))
384
465
  const selectedIds = config?.generatorIds ?? []
385
466
  const unknownGeneratorIds = selectedIds.filter((id) => !byId.has(id))
386
467
  // Coverage is judged against what RESOLVED, exactly as admission judges it: an unknown id
387
468
  // contributes no content types, so a step whose only audio generator is unregistered is told
388
469
  // BOTH things — the id is gone, and the requirement it was covering is now uncovered.
389
- const covered = new Set(selectedIds.flatMap((id) => byId.get(id)?.modalities ?? []))
470
+ const selected = selectedIds.flatMap((id) => byId.get(id) ?? [])
471
+ const covered = new Set(selected.flatMap((g) => g.modalities))
390
472
  const uncovered = (config?.modalities ?? []).filter((m) => !covered.has(m))
473
+ const format = formatCoverage(config?.mediaTypes ?? [], selected)
391
474
  const issues: BinaryOutputPickIssue[] = []
392
475
  if (unknownGeneratorIds.length) issues.push('unknown_generator')
393
476
  if (uncovered.length) issues.push('modality_uncovered')
394
- return { issues, unknownGeneratorIds, uncovered }
477
+ if (format.uncovered.length) issues.push('media_type_uncovered')
478
+ if (format.unverifiable.length) issues.push('media_type_unverifiable')
479
+ return {
480
+ issues,
481
+ unknownGeneratorIds,
482
+ uncovered,
483
+ uncoveredMediaTypes: format.uncovered,
484
+ unverifiableMediaTypes: format.unverifiable,
485
+ }
486
+ }
487
+
488
+ /**
489
+ * The SPA's copy of kernel's `binaryFormatCoverage`, restated for the reason the two `*_service`
490
+ * members above are: the builder cannot see kernel, and the wire vocabulary that does cross
491
+ * (`@cat-factory/contracts`) carries the schema, not the rule.
492
+ *
493
+ * The THIRD outcome is what must not be lost in the copying. A generator that declares no formats
494
+ * has said "only my modality is known" — a documented state, not an empty answer — so a
495
+ * requirement it cannot be judged against is unverifiable and the step still starts. Collapsing
496
+ * that into `uncovered` would flag steps the backend admits (and send someone editing a selection
497
+ * that is fine); collapsing it into silence would present an unchecked requirement as a checked
498
+ * one.
499
+ */
500
+ function formatCoverage(
501
+ required: readonly string[],
502
+ selected: readonly Pick<RegisteredBinaryGenerator, 'mediaTypes'>[],
503
+ ): { uncovered: string[]; unverifiable: string[] } {
504
+ const emitted = new Set(selected.flatMap((g) => g.mediaTypes ?? []))
505
+ const undeclared = selected.some((g) => (g.mediaTypes ?? []).length === 0)
506
+ const uncovered: string[] = []
507
+ const unverifiable: string[] = []
508
+ for (const mediaType of required) {
509
+ if (emitted.has(mediaType)) continue
510
+ if (undeclared) unverifiable.push(mediaType)
511
+ else uncovered.push(mediaType)
512
+ }
513
+ return { uncovered, unverifiable }
395
514
  }
396
515
 
397
516
  /**
@@ -425,7 +544,7 @@ export function binaryOutputPickIssues(
425
544
  // that registers no integrations cannot satisfy a step that selects one. So a call site that
426
545
  // omits this FLAGS a selection rather than passing it — the loud direction — and the default
427
546
  // stays a legitimate value rather than a hole.
428
- generators: readonly Pick<RegisteredBinaryGenerator, 'id' | 'modalities'>[] = [],
547
+ generators: readonly Pick<RegisteredBinaryGenerator, 'id' | 'modalities' | 'mediaTypes'>[] = [],
429
548
  // Whether the deployment's integrations could not be READ. Defaulted to `false` — the honest
430
549
  // default, since every deployment but a mothership-mode node reads them in-process and cannot
431
550
  // fail — so an omitting call site judges the list it was given rather than claiming an outage.
@@ -451,6 +570,8 @@ export function binaryOutputPickIssues(
451
570
  unknownContextIds: [],
452
571
  unknownGeneratorIds: generative.unknownGeneratorIds,
453
572
  uncoveredModalities: generative.uncovered,
573
+ uncoveredMediaTypes: generative.uncoveredMediaTypes,
574
+ unverifiableMediaTypes: generative.unverifiableMediaTypes,
454
575
  }
455
576
  }
456
577
 
@@ -472,5 +593,7 @@ export function binaryOutputPickIssues(
472
593
  unknownContextIds,
473
594
  unknownGeneratorIds: generative.unknownGeneratorIds,
474
595
  uncoveredModalities: generative.uncovered,
596
+ uncoveredMediaTypes: generative.uncoveredMediaTypes,
597
+ unverifiableMediaTypes: generative.unverifiableMediaTypes,
475
598
  }
476
599
  }
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
2
2
  import en from '../../i18n/locales/en.json'
3
3
  import {
4
4
  computeCoachMarkLayout,
5
+ isLaunchOffer,
5
6
  launchActionFor,
6
7
  needsReveal,
7
8
  resolveTourCatalogue,
@@ -92,6 +93,24 @@ describe('resolveTours', () => {
92
93
  })
93
94
  })
94
95
 
96
+ describe('isLaunchOffer', () => {
97
+ it('offers a tour that declares nothing, and only withholds an explicit opt-out', () => {
98
+ // The DEFAULT is the whole point: a consumer deployment contributes a tour with no extra
99
+ // field and it appears in the launch prompt beside the built-ins, exactly as documented.
100
+ // Only `false` withholds it, so a tour cannot fall out of the offer by omission.
101
+ expect(isLaunchOffer(tour('a', 10))).toBe(true)
102
+ expect(isLaunchOffer({ ...tour('a', 10), offeredAtLaunch: true })).toBe(true)
103
+ expect(isLaunchOffer({ ...tour('a', 10), offeredAtLaunch: false })).toBe(false)
104
+ })
105
+
106
+ it('is orthogonal to availability, so an un-offered tour still resolves as ready', () => {
107
+ // It thins an OFFER, never the library: the catalogue lists, counts and starts these.
108
+ const t = { ...withSteps('a', [step('one')]), offeredAtLaunch: false }
109
+ expect(resolveTours([t], gates(true)).map((x) => x.id)).toEqual(['a'])
110
+ expect(resolveTourCatalogue([t], gates(true))[0]?.availability).toBe('ready')
111
+ })
112
+ })
113
+
95
114
  describe('resolveTourCatalogue', () => {
96
115
  it('keeps an unavailable tour, saying which requirements are unmet', () => {
97
116
  // The whole reason the catalogue resolves rather than filters: a tour dropped from the