@modelprofile.com/flexharness 2.1.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ts/utils.json.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { FlexHarnessStoreFormatError, FlexHarnessValidationError } from './errors.js';
2
2
  import type {
3
- IFlexHarnessSnapshot,
4
3
  IFlexJsonLimits,
4
+ IFlexPermissionSnapshot,
5
+ IFlexProjectionSnapshot,
6
+ IFlexScopeSnapshot,
5
7
  TFlexAgentModelMessage,
6
8
  TJsonValue,
7
9
  } from './interfaces.js';
@@ -370,6 +372,17 @@ function requireRecord(value: unknown, path: string): Record<string, unknown> {
370
372
  return value as Record<string, unknown>;
371
373
  }
372
374
 
375
+ function requireOnlyKeys(
376
+ value: Record<string, unknown>,
377
+ allowedKeys: readonly string[],
378
+ path: string,
379
+ ): void {
380
+ const unexpectedKey = Object.keys(value).find((key) => !allowedKeys.includes(key));
381
+ if (unexpectedKey) {
382
+ throw new FlexHarnessStoreFormatError(`${path}.${unexpectedKey} is not supported.`);
383
+ }
384
+ }
385
+
373
386
  function requireString(value: unknown, path: string): string {
374
387
  if (typeof value !== 'string' || value.length === 0) {
375
388
  throw new FlexHarnessStoreFormatError(`${path} must be a non-empty string.`);
@@ -387,6 +400,12 @@ function requireNonNegativeNumber(value: unknown, path: string): void {
387
400
  }
388
401
  }
389
402
 
403
+ function requireNonNegativeInteger(value: unknown, path: string): void {
404
+ if (!Number.isSafeInteger(value) || Number(value) < 0) {
405
+ throw new FlexHarnessStoreFormatError(`${path} must be a non-negative integer.`);
406
+ }
407
+ }
408
+
390
409
  function requireStringArray(value: unknown, path: string): string[] {
391
410
  if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
392
411
  throw new FlexHarnessStoreFormatError(`${path} must be an array of strings.`);
@@ -394,187 +413,355 @@ function requireStringArray(value: unknown, path: string): string[] {
394
413
  return value;
395
414
  }
396
415
 
397
- function validateSessionSnapshot(value: unknown, path: string): string {
398
- const stored = requireRecord(value, path);
399
- const session = requireRecord(stored.session, `${path}.session`);
400
- const sessionId = requireString(session.sessionId, `${path}.session.sessionId`);
401
- requireString(session.scopeId, `${path}.session.scopeId`);
402
- requireString(session.createdAt, `${path}.session.createdAt`);
403
- requireString(session.updatedAt, `${path}.session.updatedAt`);
404
- requireOptionalString(session.title, `${path}.session.title`);
405
- requireOptionalString(session.archivedAt, `${path}.session.archivedAt`);
406
- if (!['idle', 'running', 'waiting_permission', 'failed', 'cancelled'].includes(String(session.status))) {
407
- throw new FlexHarnessStoreFormatError(`${path}.session.status is invalid.`);
408
- }
409
- const activity = requireRecord(session.activity, `${path}.session.activity`);
410
- if (!['idle', 'running', 'waiting_permission', 'failed', 'cancelled'].includes(String(activity.status))) {
411
- throw new FlexHarnessStoreFormatError(`${path}.session.activity.status is invalid.`);
412
- }
413
- requireOptionalString(activity.runId, `${path}.session.activity.runId`);
414
- requireOptionalString(activity.startedAt, `${path}.session.activity.startedAt`);
415
- requireOptionalString(activity.completedAt, `${path}.session.activity.completedAt`);
416
- requireOptionalString(activity.error, `${path}.session.activity.error`);
417
-
418
- if (!Array.isArray(stored.messages)) {
419
- throw new FlexHarnessStoreFormatError(`${path}.messages must be an array.`);
420
- }
421
- const messageIds = new Set<string>();
422
- for (let index = 0; index < stored.messages.length; index++) {
423
- const messagePath = `${path}.messages[${index}]`;
424
- const message = requireRecord(stored.messages[index], messagePath);
425
- const messageId = requireString(message.messageId, `${messagePath}.messageId`);
426
- if (messageIds.has(messageId)) {
427
- throw new FlexHarnessStoreFormatError(`${path}.messages contains duplicate message "${messageId}".`);
428
- }
429
- messageIds.add(messageId);
430
- if (requireString(message.sessionId, `${messagePath}.sessionId`) !== sessionId) {
431
- throw new FlexHarnessStoreFormatError(`${messagePath}.sessionId does not match its session.`);
432
- }
433
- requireString(message.runId, `${messagePath}.runId`);
434
- requireString(message.createdAt, `${messagePath}.createdAt`);
435
- requireOptionalString(message.completedAt, `${messagePath}.completedAt`);
436
- requireOptionalString(message.error, `${messagePath}.error`);
437
- if (!['user', 'assistant'].includes(String(message.role))) {
438
- throw new FlexHarnessStoreFormatError(`${messagePath}.role is invalid.`);
439
- }
440
- if (!['streaming', 'completed', 'failed', 'cancelled'].includes(String(message.status))) {
441
- throw new FlexHarnessStoreFormatError(`${messagePath}.status is invalid.`);
442
- }
443
- if (!Array.isArray(message.parts)) {
444
- throw new FlexHarnessStoreFormatError(`${messagePath}.parts must be an array.`);
445
- }
446
- if (message.model !== undefined) {
447
- const model = requireRecord(message.model, `${messagePath}.model`);
448
- requireString(model.provider, `${messagePath}.model.provider`);
449
- requireString(model.model, `${messagePath}.model.model`);
450
- requireOptionalString(model.displayName, `${messagePath}.model.displayName`);
451
- requireOptionalString(model.variant, `${messagePath}.model.variant`);
452
- }
453
- if (message.usage !== undefined) {
454
- const usage = requireRecord(message.usage, `${messagePath}.usage`);
455
- requireNonNegativeNumber(usage.inputTokens, `${messagePath}.usage.inputTokens`);
456
- requireNonNegativeNumber(usage.outputTokens, `${messagePath}.usage.outputTokens`);
457
- requireNonNegativeNumber(usage.totalTokens, `${messagePath}.usage.totalTokens`);
458
- requireNonNegativeNumber(usage.cacheReadTokens, `${messagePath}.usage.cacheReadTokens`);
459
- requireNonNegativeNumber(usage.cacheWriteTokens, `${messagePath}.usage.cacheWriteTokens`);
460
- }
461
- const partIds = new Set<string>();
462
- for (let partIndex = 0; partIndex < message.parts.length; partIndex++) {
463
- const partPath = `${messagePath}.parts[${partIndex}]`;
464
- const part = requireRecord(message.parts[partIndex], partPath);
465
- const partId = requireString(part.partId, `${partPath}.partId`);
466
- if (partIds.has(partId)) {
467
- throw new FlexHarnessStoreFormatError(`${messagePath}.parts contains duplicate part "${partId}".`);
416
+ const activityStatuses = [
417
+ 'idle',
418
+ 'scheduled',
419
+ 'running',
420
+ 'waiting_permission',
421
+ 'failed',
422
+ 'cancelled',
423
+ ] as const;
424
+
425
+ function validateSession(value: unknown, path: string): string {
426
+ const session = requireRecord(value, path);
427
+ requireOnlyKeys(
428
+ session,
429
+ [
430
+ 'scopeId',
431
+ 'sessionId',
432
+ 'title',
433
+ 'createdAt',
434
+ 'updatedAt',
435
+ 'archivedAt',
436
+ 'status',
437
+ 'activity',
438
+ ],
439
+ path,
440
+ );
441
+ requireString(session.scopeId, `${path}.scopeId`);
442
+ const sessionId = requireString(session.sessionId, `${path}.sessionId`);
443
+ requireOptionalString(session.title, `${path}.title`);
444
+ requireString(session.createdAt, `${path}.createdAt`);
445
+ requireString(session.updatedAt, `${path}.updatedAt`);
446
+ requireOptionalString(session.archivedAt, `${path}.archivedAt`);
447
+ if (!activityStatuses.includes(session.status as (typeof activityStatuses)[number])) {
448
+ throw new FlexHarnessStoreFormatError(`${path}.status is invalid.`);
449
+ }
450
+ const activity = requireRecord(session.activity, `${path}.activity`);
451
+ requireOnlyKeys(activity, ['runId', 'status', 'startedAt', 'completedAt', 'error'], `${path}.activity`);
452
+ if (!activityStatuses.includes(activity.status as (typeof activityStatuses)[number])) {
453
+ throw new FlexHarnessStoreFormatError(`${path}.activity.status is invalid.`);
454
+ }
455
+ requireOptionalString(activity.runId, `${path}.activity.runId`);
456
+ requireOptionalString(activity.startedAt, `${path}.activity.startedAt`);
457
+ requireOptionalString(activity.completedAt, `${path}.activity.completedAt`);
458
+ requireOptionalString(activity.error, `${path}.activity.error`);
459
+ return sessionId;
460
+ }
461
+
462
+ function validateModel(value: unknown, path: string): void {
463
+ const model = requireRecord(value, path);
464
+ requireOnlyKeys(model, ['provider', 'model', 'displayName', 'variant'], path);
465
+ requireString(model.provider, `${path}.provider`);
466
+ requireString(model.model, `${path}.model`);
467
+ requireOptionalString(model.displayName, `${path}.displayName`);
468
+ requireOptionalString(model.variant, `${path}.variant`);
469
+ }
470
+
471
+ function validateUsage(value: unknown, path: string): void {
472
+ const usage = requireRecord(value, path);
473
+ requireOnlyKeys(
474
+ usage,
475
+ ['inputTokens', 'outputTokens', 'totalTokens', 'cacheReadTokens', 'cacheWriteTokens'],
476
+ path,
477
+ );
478
+ requireNonNegativeNumber(usage.inputTokens, `${path}.inputTokens`);
479
+ requireNonNegativeNumber(usage.outputTokens, `${path}.outputTokens`);
480
+ requireNonNegativeNumber(usage.totalTokens, `${path}.totalTokens`);
481
+ requireNonNegativeNumber(usage.cacheReadTokens, `${path}.cacheReadTokens`);
482
+ requireNonNegativeNumber(usage.cacheWriteTokens, `${path}.cacheWriteTokens`);
483
+ }
484
+
485
+ interface IValidatedMessageIdentity {
486
+ messageId: string;
487
+ sessionId: string;
488
+ runId: string;
489
+ role: 'user' | 'assistant';
490
+ status: 'streaming' | 'completed' | 'failed' | 'cancelled';
491
+ }
492
+
493
+ function validateMessage(value: unknown, path: string): IValidatedMessageIdentity {
494
+ const message = requireRecord(value, path);
495
+ requireOnlyKeys(
496
+ message,
497
+ [
498
+ 'messageId',
499
+ 'sessionId',
500
+ 'runId',
501
+ 'role',
502
+ 'status',
503
+ 'createdAt',
504
+ 'completedAt',
505
+ 'parts',
506
+ 'model',
507
+ 'usage',
508
+ 'error',
509
+ ],
510
+ path,
511
+ );
512
+ const messageId = requireString(message.messageId, `${path}.messageId`);
513
+ const sessionId = requireString(message.sessionId, `${path}.sessionId`);
514
+ const runId = requireString(message.runId, `${path}.runId`);
515
+ if (message.role !== 'user' && message.role !== 'assistant') {
516
+ throw new FlexHarnessStoreFormatError(`${path}.role is invalid.`);
517
+ }
518
+ if (!['streaming', 'completed', 'failed', 'cancelled'].includes(String(message.status))) {
519
+ throw new FlexHarnessStoreFormatError(`${path}.status is invalid.`);
520
+ }
521
+ requireString(message.createdAt, `${path}.createdAt`);
522
+ requireOptionalString(message.completedAt, `${path}.completedAt`);
523
+ requireOptionalString(message.error, `${path}.error`);
524
+ if (message.model !== undefined) validateModel(message.model, `${path}.model`);
525
+ if (message.usage !== undefined) validateUsage(message.usage, `${path}.usage`);
526
+ if (!Array.isArray(message.parts)) {
527
+ throw new FlexHarnessStoreFormatError(`${path}.parts must be an array.`);
528
+ }
529
+ const partIds = new Set<string>();
530
+ for (let index = 0; index < message.parts.length; index++) {
531
+ const partPath = `${path}.parts[${index}]`;
532
+ const part = requireRecord(message.parts[index], partPath);
533
+ const partId = requireString(part.partId, `${partPath}.partId`);
534
+ if (partIds.has(partId)) {
535
+ throw new FlexHarnessStoreFormatError(`${path}.parts contains duplicate part "${partId}".`);
536
+ }
537
+ partIds.add(partId);
538
+ if (part.type === 'text') {
539
+ requireOnlyKeys(part, ['partId', 'type', 'text'], partPath);
540
+ if (typeof part.text !== 'string') {
541
+ throw new FlexHarnessStoreFormatError(`${partPath}.text must be a string.`);
468
542
  }
469
- partIds.add(partId);
470
- if (!['text', 'reasoning', 'tool', 'attachment'].includes(String(part.type))) {
471
- throw new FlexHarnessStoreFormatError(`${partPath}.type is invalid.`);
543
+ } else if (part.type === 'reasoning') {
544
+ requireOnlyKeys(part, ['partId', 'type', 'text', 'status'], partPath);
545
+ if (typeof part.text !== 'string') {
546
+ throw new FlexHarnessStoreFormatError(`${partPath}.text must be a string.`);
472
547
  }
473
- if (part.type === 'text') {
474
- if (typeof part.text !== 'string') {
475
- throw new FlexHarnessStoreFormatError(`${partPath}.text must be a string.`);
476
- }
477
- } else if (part.type === 'reasoning') {
478
- if (typeof part.text !== 'string') {
479
- throw new FlexHarnessStoreFormatError(`${partPath}.text must be a string.`);
480
- }
481
- if (!['running', 'completed', 'cancelled'].includes(String(part.status))) {
482
- throw new FlexHarnessStoreFormatError(`${partPath}.status is invalid.`);
483
- }
484
- } else if (part.type === 'tool') {
485
- requireString(part.toolCallId, `${partPath}.toolCallId`);
486
- requireString(part.toolName, `${partPath}.toolName`);
487
- if (!['running', 'completed', 'failed', 'cancelled'].includes(String(part.status))) {
488
- throw new FlexHarnessStoreFormatError(`${partPath}.status is invalid.`);
489
- }
490
- if (!Object.prototype.hasOwnProperty.call(part, 'input')) {
491
- throw new FlexHarnessStoreFormatError(`${partPath}.input is required.`);
492
- }
493
- requireOptionalString(part.error, `${partPath}.error`);
494
- } else {
495
- const unsupportedAttachmentField = Object.keys(part).find(
496
- (key) =>
497
- ![
498
- 'partId',
499
- 'type',
500
- 'attachmentType',
501
- 'source',
502
- 'sizeBytes',
503
- 'mediaType',
504
- 'name',
505
- ].includes(key),
506
- );
507
- if (unsupportedAttachmentField) {
508
- throw new FlexHarnessStoreFormatError(
509
- `${partPath}.${unsupportedAttachmentField} is not public attachment metadata.`,
510
- );
511
- }
512
- if (!['image', 'file'].includes(String(part.attachmentType))) {
513
- throw new FlexHarnessStoreFormatError(`${partPath}.attachmentType is invalid.`);
514
- }
515
- if (!['inline-base64', 'data-url', 'remote-url'].includes(String(part.source))) {
516
- throw new FlexHarnessStoreFormatError(`${partPath}.source is invalid.`);
517
- }
518
- if (
519
- part.sizeBytes !== undefined &&
520
- (!Number.isSafeInteger(part.sizeBytes) || Number(part.sizeBytes) < 0)
521
- ) {
522
- throw new FlexHarnessStoreFormatError(
523
- `${partPath}.sizeBytes must be a non-negative integer.`,
524
- );
525
- }
526
- requireOptionalString(part.mediaType, `${partPath}.mediaType`);
527
- requireOptionalString(part.name, `${partPath}.name`);
548
+ if (!['running', 'completed', 'cancelled'].includes(String(part.status))) {
549
+ throw new FlexHarnessStoreFormatError(`${partPath}.status is invalid.`);
550
+ }
551
+ } else if (part.type === 'tool') {
552
+ requireOnlyKeys(
553
+ part,
554
+ ['partId', 'type', 'toolCallId', 'toolName', 'status', 'input', 'output', 'error'],
555
+ partPath,
556
+ );
557
+ requireString(part.toolCallId, `${partPath}.toolCallId`);
558
+ requireString(part.toolName, `${partPath}.toolName`);
559
+ if (!['running', 'completed', 'failed', 'cancelled'].includes(String(part.status))) {
560
+ throw new FlexHarnessStoreFormatError(`${partPath}.status is invalid.`);
561
+ }
562
+ if (!Object.prototype.hasOwnProperty.call(part, 'input')) {
563
+ throw new FlexHarnessStoreFormatError(`${partPath}.input is required.`);
564
+ }
565
+ requireOptionalString(part.error, `${partPath}.error`);
566
+ } else if (part.type === 'attachment') {
567
+ requireOnlyKeys(
568
+ part,
569
+ ['partId', 'type', 'attachmentType', 'source', 'sizeBytes', 'mediaType', 'name'],
570
+ partPath,
571
+ );
572
+ if (!['image', 'file'].includes(String(part.attachmentType))) {
573
+ throw new FlexHarnessStoreFormatError(`${partPath}.attachmentType is invalid.`);
528
574
  }
575
+ if (!['inline-base64', 'data-url', 'remote-url'].includes(String(part.source))) {
576
+ throw new FlexHarnessStoreFormatError(`${partPath}.source is invalid.`);
577
+ }
578
+ if (part.sizeBytes !== undefined) {
579
+ requireNonNegativeInteger(part.sizeBytes, `${partPath}.sizeBytes`);
580
+ }
581
+ requireOptionalString(part.mediaType, `${partPath}.mediaType`);
582
+ requireOptionalString(part.name, `${partPath}.name`);
583
+ } else {
584
+ throw new FlexHarnessStoreFormatError(`${partPath}.type is invalid.`);
529
585
  }
530
586
  }
587
+ return {
588
+ messageId,
589
+ sessionId,
590
+ runId,
591
+ role: message.role,
592
+ status: message.status as IValidatedMessageIdentity['status'],
593
+ };
594
+ }
531
595
 
532
- if (!Array.isArray(stored.modelHistory)) {
533
- throw new FlexHarnessStoreFormatError(`${path}.modelHistory must be an array.`);
534
- }
535
- for (let index = 0; index < stored.modelHistory.length; index++) {
536
- const historyPath = `${path}.modelHistory[${index}]`;
537
- const message = requireRecord(stored.modelHistory[index], historyPath);
538
- if (!['system', 'user', 'assistant', 'tool'].includes(String(message.role))) {
539
- throw new FlexHarnessStoreFormatError(`${historyPath}.role is invalid.`);
596
+ function validateMessages(
597
+ value: unknown,
598
+ path: string,
599
+ expectedSessionId?: string,
600
+ ): IValidatedMessageIdentity[] {
601
+ if (!Array.isArray(value)) {
602
+ throw new FlexHarnessStoreFormatError(`${path} must be an array.`);
603
+ }
604
+ const identities = value.map((message, index) => validateMessage(message, `${path}[${index}]`));
605
+ const messageIds = new Set<string>();
606
+ for (const identity of identities) {
607
+ if (messageIds.has(identity.messageId)) {
608
+ throw new FlexHarnessStoreFormatError(`${path} contains duplicate message "${identity.messageId}".`);
540
609
  }
541
- if (typeof message.content !== 'string' && !Array.isArray(message.content)) {
542
- throw new FlexHarnessStoreFormatError(`${historyPath}.content is invalid.`);
610
+ messageIds.add(identity.messageId);
611
+ if (expectedSessionId !== undefined && identity.sessionId !== expectedSessionId) {
612
+ throw new FlexHarnessStoreFormatError(`${path} contains a message for another session.`);
543
613
  }
544
614
  }
545
- const rememberedPermissionKeys = requireStringArray(
546
- stored.rememberedPermissionKeys,
547
- `${path}.rememberedPermissionKeys`,
548
- );
549
- if (rememberedPermissionKeys.some((key) => key.length === 0)) {
550
- throw new FlexHarnessStoreFormatError(`${path}.rememberedPermissionKeys contains an empty key.`);
615
+ return identities;
616
+ }
617
+
618
+ function validatePermissionKeys(value: unknown, path: string): void {
619
+ const keys = requireStringArray(value, path);
620
+ if (keys.some((key) => key.length === 0)) {
621
+ throw new FlexHarnessStoreFormatError(`${path} contains an empty key.`);
551
622
  }
552
- if (new Set(rememberedPermissionKeys).size !== rememberedPermissionKeys.length) {
553
- throw new FlexHarnessStoreFormatError(`${path}.rememberedPermissionKeys contains duplicate keys.`);
623
+ if (new Set(keys).size !== keys.length) {
624
+ throw new FlexHarnessStoreFormatError(`${path} contains duplicate keys.`);
554
625
  }
555
- return sessionId;
556
626
  }
557
627
 
558
- export function assertFlexHarnessSnapshot(value: unknown): asserts value is IFlexHarnessSnapshot {
628
+ function validateSnapshotHeader(
629
+ value: unknown,
630
+ allowedKeys: readonly string[],
631
+ ): Record<string, unknown> {
559
632
  assertJsonSerializable(value, '$snapshot');
560
633
  const snapshot = requireRecord(value, '$snapshot');
634
+ requireOnlyKeys(snapshot, allowedKeys, '$snapshot');
561
635
  if (snapshot.schemaVersion !== 1) {
562
636
  throw new FlexHarnessStoreFormatError('Snapshot schemaVersion must be 1.');
563
637
  }
564
- if (!Number.isSafeInteger(snapshot.revision) || Number(snapshot.revision) < 0) {
565
- throw new FlexHarnessStoreFormatError('Snapshot revision must be a non-negative integer.');
566
- }
638
+ requireNonNegativeInteger(snapshot.revision, 'Snapshot revision');
639
+ return snapshot;
640
+ }
641
+
642
+ export function assertFlexScopeSnapshot(value: unknown): asserts value is IFlexScopeSnapshot {
643
+ const snapshot = validateSnapshotHeader(value, [
644
+ 'schemaVersion',
645
+ 'revision',
646
+ 'sessions',
647
+ 'tombstones',
648
+ ]);
567
649
  if (!Array.isArray(snapshot.sessions)) {
568
650
  throw new FlexHarnessStoreFormatError('Snapshot sessions must be an array.');
569
651
  }
570
652
  const sessionIds = new Set<string>();
571
653
  for (let index = 0; index < snapshot.sessions.length; index++) {
572
- const sessionId = validateSessionSnapshot(snapshot.sessions[index], `$snapshot.sessions[${index}]`);
654
+ const sessionId = validateSession(snapshot.sessions[index], `$snapshot.sessions[${index}]`);
573
655
  if (sessionIds.has(sessionId)) {
574
656
  throw new FlexHarnessStoreFormatError(`Snapshot contains duplicate session "${sessionId}".`);
575
657
  }
576
658
  sessionIds.add(sessionId);
577
659
  }
660
+ if (!Array.isArray(snapshot.tombstones)) {
661
+ throw new FlexHarnessStoreFormatError('Snapshot tombstones must be an array.');
662
+ }
663
+ const tombstoneIds = new Set<string>();
664
+ for (let index = 0; index < snapshot.tombstones.length; index++) {
665
+ const path = `$snapshot.tombstones[${index}]`;
666
+ const tombstone = requireRecord(snapshot.tombstones[index], path);
667
+ requireOnlyKeys(tombstone, ['sessionId', 'deletedAt'], path);
668
+ const sessionId = requireString(tombstone.sessionId, `${path}.sessionId`);
669
+ requireString(tombstone.deletedAt, `${path}.deletedAt`);
670
+ if (tombstoneIds.has(sessionId)) {
671
+ throw new FlexHarnessStoreFormatError(`Snapshot contains duplicate tombstone "${sessionId}".`);
672
+ }
673
+ if (sessionIds.has(sessionId)) {
674
+ throw new FlexHarnessStoreFormatError(
675
+ `Snapshot session "${sessionId}" cannot also have a tombstone.`,
676
+ );
677
+ }
678
+ tombstoneIds.add(sessionId);
679
+ }
680
+ }
681
+
682
+ export function assertFlexProjectionSnapshot(
683
+ value: unknown,
684
+ ): asserts value is IFlexProjectionSnapshot {
685
+ const snapshot = validateSnapshotHeader(value, [
686
+ 'schemaVersion',
687
+ 'revision',
688
+ 'messages',
689
+ 'stagedTerminals',
690
+ ]);
691
+ const visibleMessages = validateMessages(snapshot.messages, '$snapshot.messages');
692
+ let projectionSessionId = visibleMessages[0]?.sessionId;
693
+ if (visibleMessages.some((message) => message.sessionId !== projectionSessionId)) {
694
+ throw new FlexHarnessStoreFormatError('Snapshot messages must belong to one session.');
695
+ }
696
+ if (!Array.isArray(snapshot.stagedTerminals)) {
697
+ throw new FlexHarnessStoreFormatError('Snapshot stagedTerminals must be an array.');
698
+ }
699
+ const runIds = new Set<string>();
700
+ const stagedMessageIds = new Set<string>();
701
+ for (let index = 0; index < snapshot.stagedTerminals.length; index++) {
702
+ const path = `$snapshot.stagedTerminals[${index}]`;
703
+ const terminal = requireRecord(snapshot.stagedTerminals[index], path);
704
+ requireOnlyKeys(
705
+ terminal,
706
+ [
707
+ 'runId',
708
+ 'status',
709
+ 'userMessage',
710
+ 'assistantMessage',
711
+ 'model',
712
+ 'usage',
713
+ 'finishReason',
714
+ 'steps',
715
+ ],
716
+ path,
717
+ );
718
+ const runId = requireString(terminal.runId, `${path}.runId`);
719
+ if (runIds.has(runId)) {
720
+ throw new FlexHarnessStoreFormatError(`Snapshot contains duplicate staged run "${runId}".`);
721
+ }
722
+ runIds.add(runId);
723
+ if (!['completed', 'failed', 'cancelled'].includes(String(terminal.status))) {
724
+ throw new FlexHarnessStoreFormatError(`${path}.status is invalid.`);
725
+ }
726
+ const user = validateMessage(terminal.userMessage, `${path}.userMessage`);
727
+ const assistant = validateMessage(terminal.assistantMessage, `${path}.assistantMessage`);
728
+ if (user.role !== 'user' || assistant.role !== 'assistant') {
729
+ throw new FlexHarnessStoreFormatError(`${path} has invalid terminal message roles.`);
730
+ }
731
+ if (user.runId !== runId || assistant.runId !== runId || user.sessionId !== assistant.sessionId) {
732
+ throw new FlexHarnessStoreFormatError(`${path} has uncorrelated terminal messages.`);
733
+ }
734
+ if (assistant.status !== terminal.status) {
735
+ throw new FlexHarnessStoreFormatError(`${path}.status does not match its assistant message.`);
736
+ }
737
+ projectionSessionId ??= user.sessionId;
738
+ if (user.sessionId !== projectionSessionId) {
739
+ throw new FlexHarnessStoreFormatError(`${path} belongs to another session.`);
740
+ }
741
+ for (const messageId of [user.messageId, assistant.messageId]) {
742
+ if (stagedMessageIds.has(messageId)) {
743
+ throw new FlexHarnessStoreFormatError(
744
+ `Snapshot staged terminals contain duplicate message "${messageId}".`,
745
+ );
746
+ }
747
+ stagedMessageIds.add(messageId);
748
+ }
749
+ if (terminal.model !== undefined) validateModel(terminal.model, `${path}.model`);
750
+ if (terminal.usage !== undefined) validateUsage(terminal.usage, `${path}.usage`);
751
+ requireOptionalString(terminal.finishReason, `${path}.finishReason`);
752
+ if (terminal.steps !== undefined) requireNonNegativeInteger(terminal.steps, `${path}.steps`);
753
+ }
754
+ }
755
+
756
+ export function assertFlexPermissionSnapshot(
757
+ value: unknown,
758
+ ): asserts value is IFlexPermissionSnapshot {
759
+ const snapshot = validateSnapshotHeader(value, [
760
+ 'schemaVersion',
761
+ 'revision',
762
+ 'rememberedPermissionKeys',
763
+ ]);
764
+ validatePermissionKeys(snapshot.rememberedPermissionKeys, '$snapshot.rememberedPermissionKeys');
578
765
  }
579
766
 
580
767
  export function cloneSerializable<T>(value: T): T {
@@ -7,11 +7,13 @@ import type {
7
7
  TFlexPrompt,
8
8
  TFlexPromptPart,
9
9
  } from './interfaces.js';
10
- import { cloneSerializable } from './utils.json.js';
11
- import { assertJsonSerializable } from './utils.json.js';
10
+ import {
11
+ assertJsonSerializable,
12
+ cloneSerializable,
13
+ serializeAgentMessages,
14
+ } from './utils.json.js';
12
15
 
13
16
  export interface INormalizedFlexPrompt {
14
- agentPrompt: TFlexAgentPrompt;
15
17
  modelMessage: TFlexAgentModelMessage;
16
18
  parts: TFlexPromptPart[];
17
19
  }
@@ -99,27 +101,6 @@ function rejectUnknownKeys(
99
101
  }
100
102
  }
101
103
 
102
- function toAgentParts(parts: TFlexPromptPart[]): TFlexAgentPrompt {
103
- return parts.map((part) => {
104
- if (part.type === 'text') {
105
- return { type: 'text' as const, text: part.text };
106
- }
107
- if (part.type === 'image') {
108
- return {
109
- type: 'image' as const,
110
- image: toAgentData(part.data),
111
- ...(part.mediaType ? { mediaType: part.mediaType } : {}),
112
- };
113
- }
114
- return {
115
- type: 'file' as const,
116
- data: toAgentData(part.data),
117
- mediaType: part.mediaType,
118
- ...(part.name ? { filename: part.name } : {}),
119
- };
120
- }) as TFlexAgentPrompt;
121
- }
122
-
123
104
  function toStoredModelParts(parts: TFlexPromptPart[]): TFlexAgentPrompt {
124
105
  return parts.map((part) => {
125
106
  if (part.type === 'text') {
@@ -154,7 +135,6 @@ export function normalizeFlexPrompt(prompt: TFlexPrompt): INormalizedFlexPrompt
154
135
  throw new FlexHarnessValidationError('prompt must not be empty.');
155
136
  }
156
137
  return {
157
- agentPrompt: prompt,
158
138
  modelMessage: { role: 'user', content: prompt },
159
139
  parts: [{ type: 'text', text: prompt }],
160
140
  };
@@ -164,7 +144,6 @@ export function normalizeFlexPrompt(prompt: TFlexPrompt): INormalizedFlexPrompt
164
144
  }
165
145
  const parts = prompt.map(validatePart);
166
146
  return {
167
- agentPrompt: toAgentParts(parts),
168
147
  modelMessage: {
169
148
  role: 'user',
170
149
  content: toStoredModelParts(parts),
@@ -176,7 +155,7 @@ export function normalizeFlexPrompt(prompt: TFlexPrompt): INormalizedFlexPrompt
176
155
  export function hydrateAgentMessages(
177
156
  messages: TFlexAgentModelMessage[],
178
157
  ): TFlexAgentModelMessage[] {
179
- const hydrated = cloneSerializable(messages);
158
+ const hydrated = serializeAgentMessages(messages);
180
159
  for (const message of hydrated) {
181
160
  if (!Array.isArray(message.content)) {
182
161
  continue;
@@ -0,0 +1 @@
1
+ export * from './v3_legacyflexharness.js';