@qjp88995/agent-runtime 0.1.1 → 0.1.3

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.
@@ -1,6 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { describe, expect, it } from 'vitest';
3
3
 
4
+ import type {
5
+ ConversationStore,
6
+ InboxItem,
7
+ } from '../ports/conversation-store.js';
4
8
  import type {
5
9
  SessionLock,
6
10
  SubagentAwaitLease,
@@ -20,6 +24,7 @@ import type {
20
24
  VendorFileRecord,
21
25
  } from '../ports/vendor-file-cache.js';
22
26
  import type { AgentEvent } from '../schema/agent-event.js';
27
+ import { COMPACTION_CHECKPOINT_KIND } from '../schema/index.js';
23
28
 
24
29
  /**
25
30
  * 端口的契约用例:进程内实现与各适配包(Redis 等)跑同一套,保证换实现不换语义。
@@ -57,6 +62,48 @@ function withImpl<T extends object>(
57
62
 
58
63
  const id = () => randomUUID();
59
64
 
65
+ /**
66
+ * 宿主库的建行办法。数据库实现的表通常有外键与约束(消息、问题、跑次指向会话,问题指向
67
+ * 用户,子会话指向父跑次),契约用例里的这些 id 就不能是随机串——由宿主给出建一行、
68
+ * 返回 id 的办法,清理也归宿主(例如在 `afterAll` 里删掉自己建过的行)。
69
+ *
70
+ * 全部可选,缺省是随机 uuid 与示例值(进程内实现、无外键的后端)。
71
+ */
72
+ export interface ContractFixtures {
73
+ /** 建一个根会话,返回 id。 */
74
+ rootSession?(): Promise<string>;
75
+ /** 建一个用户,返回 id。 */
76
+ user?(): Promise<string>;
77
+ /** 在这个会话里建一条跑次,返回 id(子会话的 `parentRunId` 指向它)。 */
78
+ run?(sessionId: string): Promise<string>;
79
+ /**
80
+ * `RunOpenInput.attrs`:宿主把附加信息存成列、列上有非空或 CHECK 约束时,给一组能落库的值。
81
+ */
82
+ runAttrs?: Readonly<Record<string, unknown>>;
83
+ /** `NewSubSession.attrs`:要能**原样读回**(用例按 `toMatchObject` 比)。缺省 `{ a: 1 }`。 */
84
+ sessionAttrs?: Readonly<Record<string, unknown>>;
85
+ /** `NewSubSession.metadata`:同上,要能原样读回。缺省 `{ k: 'v' }`。 */
86
+ sessionMetadata?: Readonly<Record<string, unknown>>;
87
+ }
88
+
89
+ export interface ContractOptions {
90
+ fixtures?: ContractFixtures;
91
+ }
92
+
93
+ function fixturesOf(options: ContractOptions | undefined) {
94
+ const f = options?.fixtures ?? {};
95
+ return {
96
+ rootSession: () =>
97
+ f.rootSession ? f.rootSession() : Promise.resolve(id()),
98
+ user: () => (f.user ? f.user() : Promise.resolve(id())),
99
+ run: (sessionId: string) =>
100
+ f.run ? f.run(sessionId) : Promise.resolve(id()),
101
+ runAttrs: f.runAttrs,
102
+ sessionAttrs: f.sessionAttrs ?? { a: 1 },
103
+ sessionMetadata: f.sessionMetadata ?? { k: 'v' },
104
+ };
105
+ }
106
+
60
107
  function event(
61
108
  streamId: string,
62
109
  rootSessionId: string,
@@ -400,37 +447,44 @@ export function describeTreeBudgetContract(
400
447
  export function describeRunStoreContract(
401
448
  name: string,
402
449
  factory: Factory<RunStore>,
403
- /** 建一条能被 `findStaleRunning(0)` 看到的旧跑次所需的等待(毫秒),缺省 5。 */
404
- staleDelayMs = 5
450
+ /**
451
+ * 数字 = 旧写法的 `staleDelayMs`。`staleDelayMs`:建一条能被 `findStaleRunning(0)`
452
+ * 看到的旧跑次所需的等待(毫秒),缺省 5。
453
+ */
454
+ options: number | (ContractOptions & { staleDelayMs?: number }) = {}
405
455
  ) {
456
+ const opts =
457
+ typeof options === 'number' ? { staleDelayMs: options } : options;
458
+ const staleDelayMs = opts.staleDelayMs ?? 5;
459
+ const fx = fixturesOf(opts);
406
460
  describe(`RunStore contract: ${name}`, () => {
407
- const open = (
461
+ const open = async (
408
462
  store: RunStore,
409
463
  over: Partial<{
410
464
  id: string;
411
- sessionId: string | null;
465
+ sessionId: string;
412
466
  parentRunId: string | null;
413
467
  depth: number;
414
468
  }> = {}
415
469
  ) => {
416
470
  const runId = over.id ?? id();
417
- return store
418
- .open({
419
- id: runId,
420
- streamId: runId,
421
- parentRunId: over.parentRunId ?? null,
422
- depth: over.depth ?? 0,
423
- sessionId: over.sessionId === undefined ? id() : over.sessionId,
424
- prompt: null,
425
- model: { modelId: 'm', label: 'v / m', reasoningEffort: 'medium' },
426
- })
427
- .then(() => runId);
471
+ await store.open({
472
+ id: runId,
473
+ streamId: runId,
474
+ parentRunId: over.parentRunId ?? null,
475
+ depth: over.depth ?? 0,
476
+ sessionId: over.sessionId ?? (await fx.rootSession()),
477
+ prompt: null,
478
+ model: { modelId: 'm', label: 'v / m', reasoningEffort: 'medium' },
479
+ ...(fx.runAttrs ? { attrs: fx.runAttrs } : {}),
480
+ });
481
+ return runId;
428
482
  };
429
483
 
430
484
  it(
431
485
  'open 后可查会话与深度;缺记录给缺省值',
432
486
  withImpl(factory, async store => {
433
- const sessionId = id();
487
+ const sessionId = await fx.rootSession();
434
488
  const runId = await open(store, { sessionId, depth: 2 });
435
489
  expect(await store.getSessionId(runId)).toBe(sessionId);
436
490
  expect(await store.getDepth(runId)).toBe(2);
@@ -497,32 +551,34 @@ async function waitFor(cond: () => boolean, timeoutMs = 2000) {
497
551
  */
498
552
  export function describeSessionStoreContract(
499
553
  name: string,
500
- factory: Factory<SessionStore>
554
+ factory: Factory<SessionStore>,
555
+ options: ContractOptions = {}
501
556
  ) {
557
+ const fx = fixturesOf(options);
502
558
  describe(`SessionStore contract: ${name}`, () => {
503
- const child = (store: SessionStore, rootSessionId: string) =>
559
+ const child = async (store: SessionStore, rootSessionId: string) =>
504
560
  store.createSubSession({
505
561
  rootSessionId,
506
- parentRunId: id(),
562
+ parentRunId: await fx.run(rootSessionId),
507
563
  subagentName: 'general-purpose',
508
564
  userId: null,
509
565
  title: 't',
510
- metadata: { k: 'v' },
511
- attrs: { a: 1 },
566
+ metadata: fx.sessionMetadata,
567
+ attrs: fx.sessionAttrs,
512
568
  });
513
569
 
514
570
  it(
515
571
  '建子会话:存活(游标 null)、读回同一行;不存在的读回 null',
516
572
  withImpl(factory, async store => {
517
- const root = id();
573
+ const root = await fx.rootSession();
518
574
  const made = await child(store, root);
519
575
  expect(made).toMatchObject({
520
576
  kind: 'subagent',
521
577
  rootSessionId: root,
522
578
  subagentName: 'general-purpose',
523
579
  lastMilestoneSeq: null,
524
- metadata: { k: 'v' },
525
- attrs: { a: 1 },
580
+ metadata: fx.sessionMetadata,
581
+ attrs: fx.sessionAttrs,
526
582
  });
527
583
  expect(await store.getSession(made.id)).toEqual(made);
528
584
  expect(await store.getSession(id())).toBeNull();
@@ -532,7 +588,7 @@ export function describeSessionStoreContract(
532
588
  it(
533
589
  'advanceMilestone 是 CAS:只有期望值对上才写',
534
590
  withImpl(factory, async store => {
535
- const made = await child(store, id());
591
+ const made = await child(store, await fx.rootSession());
536
592
  expect(await store.advanceMilestone(made.id, 3, 5)).toBe(false);
537
593
  expect(await store.advanceMilestone(made.id, null, 5)).toBe(true);
538
594
  expect(await store.advanceMilestone(made.id, null, 6)).toBe(false);
@@ -544,7 +600,7 @@ export function describeSessionStoreContract(
544
600
  it(
545
601
  '并发推进同一个旧游标:恰好一个成功',
546
602
  withImpl(factory, async store => {
547
- const made = await child(store, id());
603
+ const made = await child(store, await fx.rootSession());
548
604
  const results = await Promise.all(
549
605
  Array.from({ length: 5 }, (_, i) =>
550
606
  store.advanceMilestone(made.id, null, 10 + i)
@@ -557,8 +613,8 @@ export function describeSessionStoreContract(
557
613
  it(
558
614
  'countAliveSubsByRoot 只数游标为 null 的子会话',
559
615
  withImpl(factory, async store => {
560
- const r1 = id();
561
- const r2 = id();
616
+ const r1 = await fx.rootSession();
617
+ const r2 = await fx.rootSession();
562
618
  const a = await child(store, r1);
563
619
  await child(store, r1);
564
620
  await child(store, r2);
@@ -578,8 +634,10 @@ export function describeSessionStoreContract(
578
634
 
579
635
  export function describeQuestionStoreContract(
580
636
  name: string,
581
- factory: Factory<QuestionStore>
637
+ factory: Factory<QuestionStore>,
638
+ options: ContractOptions = {}
582
639
  ) {
640
+ const fx = fixturesOf(options);
583
641
  describe(`QuestionStore contract: ${name}`, () => {
584
642
  const item = {
585
643
  question: '用哪种开场?',
@@ -588,9 +646,12 @@ export function describeQuestionStoreContract(
588
646
  multiSelect: false,
589
647
  defaultOption: 'A',
590
648
  };
591
- const input = (over: Partial<NewQuestion> = {}): NewQuestion => ({
592
- sessionId: id(),
593
- userId: id(),
649
+ // 会话与用户在宿主库里多半是外键:每次现建(缺省随机 id)。
650
+ const input = async (
651
+ over: Partial<NewQuestion> = {}
652
+ ): Promise<NewQuestion> => ({
653
+ sessionId: over.sessionId ?? (await fx.rootSession()),
654
+ userId: over.userId ?? (await fx.user()),
594
655
  runId: id(),
595
656
  toolCallId: id(),
596
657
  questions: [item],
@@ -602,7 +663,7 @@ export function describeQuestionStoreContract(
602
663
  withImpl(factory, async store => {
603
664
  const before = (await store.now()).getTime();
604
665
  const { question, swept } = await store.open(
605
- input({ timeoutSeconds: 300 })
666
+ await input({ timeoutSeconds: 300 })
606
667
  );
607
668
  const after = (await store.now()).getTime();
608
669
  expect(swept).toEqual([]);
@@ -623,7 +684,7 @@ export function describeQuestionStoreContract(
623
684
  overdue: false,
624
685
  });
625
686
 
626
- const untimed = await store.open(input());
687
+ const untimed = await store.open(await input());
627
688
  expect(untimed.question.deadlineAt).toBeNull();
628
689
  })
629
690
  );
@@ -631,7 +692,7 @@ export function describeQuestionStoreContract(
631
692
  it(
632
693
  'get 限定会话:别的会话当不存在;不存在的 get / probe 都是 null',
633
694
  withImpl(factory, async store => {
634
- const { question } = await store.open(input());
695
+ const { question } = await store.open(await input());
635
696
  expect(await store.get(question.id, question.sessionId)).not.toBeNull();
636
697
  expect(await store.get(question.id, id())).toBeNull();
637
698
  expect(await store.get(id())).toBeNull();
@@ -642,7 +703,7 @@ export function describeQuestionStoreContract(
642
703
  it(
643
704
  'probe:从落行起过了 maxWaitSeconds 即 overdue',
644
705
  withImpl(factory, async store => {
645
- const { question } = await store.open(input());
706
+ const { question } = await store.open(await input());
646
707
  expect((await store.probe(question.id, 0))?.overdue).toBe(true);
647
708
  })
648
709
  );
@@ -650,15 +711,15 @@ export function describeQuestionStoreContract(
650
711
  it(
651
712
  '同一会话同一轮已有 pending → QuestionConflictError,原行不动;别的轮次的 pending 作废让位并返回',
652
713
  withImpl(factory, async store => {
653
- const sessionId = id();
714
+ const sessionId = await fx.rootSession();
654
715
  const runId = id();
655
- const first = await store.open(input({ sessionId, runId }));
716
+ const first = await store.open(await input({ sessionId, runId }));
656
717
  await expect(
657
- store.open(input({ sessionId, runId }))
718
+ store.open(await input({ sessionId, runId }))
658
719
  ).rejects.toBeInstanceOf(QuestionConflictError);
659
720
  expect((await store.get(first.question.id))?.status).toBe('pending');
660
721
 
661
- const next = await store.open(input({ sessionId }));
722
+ const next = await store.open(await input({ sessionId }));
662
723
  expect(next.swept.map(r => [r.id, r.status])).toEqual([
663
724
  [first.question.id, 'cancelled'],
664
725
  ]);
@@ -666,8 +727,8 @@ export function describeQuestionStoreContract(
666
727
  expect((await store.get(first.question.id))?.status).toBe('cancelled');
667
728
 
668
729
  // 别的会话的 pending 不受影响。
669
- const other = await store.open(input());
670
- await store.open(input());
730
+ const other = await store.open(await input());
731
+ await store.open(await input());
671
732
  expect((await store.get(other.question.id))?.status).toBe('pending');
672
733
  })
673
734
  );
@@ -675,7 +736,7 @@ export function describeQuestionStoreContract(
675
736
  it(
676
737
  'settle 是 CAS:只有第一个生效,之后返回 null 且不改写',
677
738
  withImpl(factory, async store => {
678
- const { question } = await store.open(input());
739
+ const { question } = await store.open(await input());
679
740
  const answers = [{ header: '开场', labels: ['B'], other: null }];
680
741
  const won = await store.settle(question.id, 'answered', answers);
681
742
  expect(won).toMatchObject({ status: 'answered', answers });
@@ -689,7 +750,7 @@ export function describeQuestionStoreContract(
689
750
  it(
690
751
  '并发 settle 恰有一个生效',
691
752
  withImpl(factory, async store => {
692
- const { question } = await store.open(input());
753
+ const { question } = await store.open(await input());
693
754
  const results = await Promise.all([
694
755
  store.settle(question.id, 'answered', []),
695
756
  store.settle(question.id, 'expired', []),
@@ -702,11 +763,11 @@ export function describeQuestionStoreContract(
702
763
  it(
703
764
  'settleSession:只改这个会话的 pending(或只改点名的那一个),返回被改的行',
704
765
  withImpl(factory, async store => {
705
- const sessionId = id();
706
- const done = await store.open(input({ sessionId }));
766
+ const sessionId = await fx.rootSession();
767
+ const done = await store.open(await input({ sessionId }));
707
768
  await store.settle(done.question.id, 'answered', []);
708
- const live = await store.open(input({ sessionId }));
709
- const elsewhere = await store.open(input());
769
+ const live = await store.open(await input({ sessionId }));
770
+ const elsewhere = await store.open(await input());
710
771
 
711
772
  expect(
712
773
  await store.settleSession(sessionId, 'superseded', id())
@@ -726,9 +787,9 @@ export function describeQuestionStoreContract(
726
787
  it(
727
788
  'hold:仅 pending 且有期限时摘掉期限;不满足(不限时、已停表、已有结局、别的会话)返回 null',
728
789
  withImpl(factory, async store => {
729
- const sessionId = id();
790
+ const sessionId = await fx.rootSession();
730
791
  const { question } = await store.open(
731
- input({ sessionId, timeoutSeconds: 60 })
792
+ await input({ sessionId, timeoutSeconds: 60 })
732
793
  );
733
794
  expect(await store.hold(question.id, id())).toBeNull();
734
795
  expect(await store.hold(question.id, sessionId)).toMatchObject({
@@ -738,7 +799,7 @@ export function describeQuestionStoreContract(
738
799
  expect(await store.hold(question.id, sessionId)).toBeNull();
739
800
  expect((await store.probe(question.id, 3600))?.due).toBe(false);
740
801
 
741
- const untimed = await store.open(input());
802
+ const untimed = await store.open(await input());
742
803
  expect(
743
804
  await store.hold(untimed.question.id, untimed.question.sessionId)
744
805
  ).toBeNull();
@@ -748,10 +809,10 @@ export function describeQuestionStoreContract(
748
809
  it(
749
810
  'list:这个会话的全部问题,按落行顺序',
750
811
  withImpl(factory, async store => {
751
- const sessionId = id();
752
- const a = await store.open(input({ sessionId }));
753
- const b = await store.open(input({ sessionId }));
754
- await store.open(input());
812
+ const sessionId = await fx.rootSession();
813
+ const a = await store.open(await input({ sessionId }));
814
+ const b = await store.open(await input({ sessionId }));
815
+ await store.open(await input());
755
816
  expect(
756
817
  (await store.list(sessionId)).map(r => [r.id, r.status])
757
818
  ).toEqual([
@@ -880,3 +941,406 @@ export function describeVendorFileCacheContract(
880
941
  );
881
942
  });
882
943
  }
944
+
945
+ /**
946
+ * `ConversationStore` 契约(消息、inbox、两个上下文指针)。
947
+ *
948
+ * 只认端口上看得见的东西:`seq` 只要求会话内单调(数据库实现常是全表一条序列),
949
+ * 「有了新动静」记没记、记在哪是实现细节,不在这里测。
950
+ */
951
+ export function describeConversationStoreContract(
952
+ name: string,
953
+ factory: Factory<ConversationStore>,
954
+ options: ContractOptions = {}
955
+ ) {
956
+ const fx = fixturesOf(options);
957
+ const text = (t: string) => [{ type: 'text' as const, text: t }];
958
+
959
+ describe(`ConversationStore contract: ${name}`, () => {
960
+ const item = (sessionId: string, t: string, userId: string | null) => ({
961
+ sessionId,
962
+ kind: 'user_message' as const,
963
+ sourceKind: 'user' as const,
964
+ sourceUserId: userId,
965
+ source: null,
966
+ parts: text(t),
967
+ messageId: id(),
968
+ });
969
+ const asUser = (i: InboxItem) => ({
970
+ id: i.messageId,
971
+ sessionId: i.sessionId,
972
+ role: 'user' as const,
973
+ parts: i.parts,
974
+ });
975
+
976
+ it(
977
+ 'seq 在会话内单调递增;listMessages 按 seq 升序',
978
+ withImpl(factory, async store => {
979
+ const s = await fx.rootSession();
980
+ const a = await store.appendMessage({
981
+ sessionId: s,
982
+ role: 'user',
983
+ parts: text('1'),
984
+ });
985
+ const b = await store.appendMessage({
986
+ sessionId: s,
987
+ role: 'assistant',
988
+ parts: text('2'),
989
+ metadata: { k: 'v' },
990
+ tokenUsage: { inputTokens: 3 },
991
+ });
992
+ expect(b.seq).toBeGreaterThan(a.seq);
993
+ expect(b).toMatchObject({
994
+ sessionId: s,
995
+ role: 'assistant',
996
+ parts: text('2'),
997
+ metadata: { k: 'v' },
998
+ tokenUsage: { inputTokens: 3 },
999
+ });
1000
+ expect((await store.listMessages(s)).map(m => m.id)).toEqual([
1001
+ a.id,
1002
+ b.id,
1003
+ ]);
1004
+ })
1005
+ );
1006
+
1007
+ it(
1008
+ 'appendMessage 按 id 幂等:撞主键返回已存在的那一行',
1009
+ withImpl(factory, async store => {
1010
+ const s = await fx.rootSession();
1011
+ const mid = id();
1012
+ const first = await store.appendMessage({
1013
+ id: mid,
1014
+ sessionId: s,
1015
+ role: 'user',
1016
+ parts: text('1'),
1017
+ });
1018
+ const again = await store.appendMessage({
1019
+ id: mid,
1020
+ sessionId: s,
1021
+ role: 'user',
1022
+ parts: text('2'),
1023
+ });
1024
+ expect(first.id).toBe(mid);
1025
+ expect(again).toEqual(first);
1026
+ expect(await store.listMessages(s)).toHaveLength(1);
1027
+ })
1028
+ );
1029
+
1030
+ it(
1031
+ '基线取最后一条 metadata 带 contextTokens 的 assistant 行',
1032
+ withImpl(factory, async store => {
1033
+ const s = await fx.rootSession();
1034
+ const add = (role: 'user' | 'assistant', contextTokens?: number) =>
1035
+ store.appendMessage({
1036
+ sessionId: s,
1037
+ role,
1038
+ parts: text('x'),
1039
+ ...(contextTokens !== undefined
1040
+ ? { metadata: { contextTokens } }
1041
+ : {}),
1042
+ });
1043
+ await add('assistant', 10);
1044
+ await add('assistant', 20);
1045
+ await add('assistant');
1046
+ await add('user');
1047
+ const inputs = await store.loadContextInputs(s);
1048
+ expect(inputs.baselineContextTokens).toBe(20);
1049
+ expect(inputs.rows).toHaveLength(4);
1050
+ const empty = await store.loadContextInputs(await fx.rootSession());
1051
+ expect(empty).toEqual({
1052
+ rows: [],
1053
+ prunedUptoSeq: null,
1054
+ compactedUptoSeq: null,
1055
+ baselineContextTokens: null,
1056
+ });
1057
+ })
1058
+ );
1059
+
1060
+ it(
1061
+ '裁剪指针只增不减:晚到的较小切点不许把它拉回去',
1062
+ withImpl(factory, async store => {
1063
+ const s = await fx.rootSession();
1064
+ await store.advancePrunedUpto(s, 5);
1065
+ await store.advancePrunedUpto(s, 3);
1066
+ expect((await store.loadContextInputs(s)).prunedUptoSeq).toBe(5);
1067
+ })
1068
+ );
1069
+
1070
+ it(
1071
+ 'checkpoint 行与压缩指针一起落下、kind 摊平到行上;压缩指针只增不减',
1072
+ withImpl(factory, async store => {
1073
+ const s = await fx.rootSession();
1074
+ const rows = [];
1075
+ for (let i = 0; i < 4; i++) {
1076
+ rows.push(
1077
+ await store.appendMessage({
1078
+ sessionId: s,
1079
+ role: 'user',
1080
+ parts: text(String(i)),
1081
+ })
1082
+ );
1083
+ }
1084
+ const cut = rows[2].seq;
1085
+ const row = await store.appendCheckpoint({
1086
+ sessionId: s,
1087
+ seq: cut,
1088
+ summary: '摘要',
1089
+ });
1090
+ expect(row).toMatchObject({
1091
+ role: 'user',
1092
+ kind: COMPACTION_CHECKPOINT_KIND,
1093
+ parts: text('摘要'),
1094
+ });
1095
+ expect(row.seq).toBeGreaterThan(rows[3].seq);
1096
+ await store.appendCheckpoint({
1097
+ sessionId: s,
1098
+ seq: rows[1].seq,
1099
+ summary: '旧',
1100
+ });
1101
+ const inputs = await store.loadContextInputs(s);
1102
+ expect(inputs.compactedUptoSeq).toBe(cut);
1103
+ expect(inputs.rows.at(-2)).toMatchObject({
1104
+ seq: row.seq,
1105
+ kind: COMPACTION_CHECKPOINT_KIND,
1106
+ });
1107
+ expect(inputs.rows[0].kind ?? null).toBeNull();
1108
+ })
1109
+ );
1110
+
1111
+ it(
1112
+ 'loadContextInputs 给的是快照:之后的追加不漂进去',
1113
+ withImpl(factory, async store => {
1114
+ const s = await fx.rootSession();
1115
+ await store.appendMessage({
1116
+ sessionId: s,
1117
+ role: 'user',
1118
+ parts: text('1'),
1119
+ });
1120
+ const snap = await store.loadContextInputs(s);
1121
+ await store.appendMessage({
1122
+ sessionId: s,
1123
+ role: 'user',
1124
+ parts: text('2'),
1125
+ });
1126
+ expect(snap.rows).toHaveLength(1);
1127
+ })
1128
+ );
1129
+
1130
+ it(
1131
+ 'takeInbox:按投递序取出本会话的全部、落成消息,别的会话不动',
1132
+ withImpl(factory, async store => {
1133
+ const s = await fx.rootSession();
1134
+ const t = await fx.rootSession();
1135
+ const u = await fx.user();
1136
+ const a = item(s, 'a', u);
1137
+ const x = item(t, 'x', u);
1138
+ const b = item(s, 'b', u);
1139
+ await store.enqueue(a);
1140
+ await store.enqueue(x);
1141
+ await store.enqueue(b);
1142
+ expect(await store.listSessionIdsWithPending()).toEqual(
1143
+ expect.arrayContaining([s, t])
1144
+ );
1145
+ const pending = await store.listPending(s);
1146
+ expect(pending.map(p => p.messageId)).toEqual([
1147
+ a.messageId,
1148
+ b.messageId,
1149
+ ]);
1150
+ expect(pending[1].seq).toBeGreaterThan(pending[0].seq);
1151
+ expect(pending[0]).toMatchObject({
1152
+ sessionId: s,
1153
+ kind: 'user_message',
1154
+ sourceKind: 'user',
1155
+ sourceUserId: u,
1156
+ source: null,
1157
+ parts: text('a'),
1158
+ });
1159
+
1160
+ const taken = await store.takeInbox(s, asUser);
1161
+ expect(taken.map(e => e.message.id)).toEqual([
1162
+ a.messageId,
1163
+ b.messageId,
1164
+ ]);
1165
+ expect(taken[0].item).toEqual(pending[0]);
1166
+ expect(await store.hasPending(s)).toBe(false);
1167
+ expect(await store.hasPending(t)).toBe(true);
1168
+ expect((await store.listMessages(s)).map(m => m.id)).toEqual([
1169
+ a.messageId,
1170
+ b.messageId,
1171
+ ]);
1172
+ const withPending = await store.listSessionIdsWithPending();
1173
+ expect(withPending).toContain(t);
1174
+ expect(withPending).not.toContain(s);
1175
+ expect(await store.takeInbox(s, asUser)).toEqual([]);
1176
+ })
1177
+ );
1178
+
1179
+ it(
1180
+ 'takeInbox:换算抛错 = 整批不取(同事务回滚)',
1181
+ withImpl(factory, async store => {
1182
+ const s = await fx.rootSession();
1183
+ await store.enqueue(item(s, 'a', await fx.user()));
1184
+ await expect(
1185
+ store.takeInbox(s, () => {
1186
+ throw new Error('bad row');
1187
+ })
1188
+ ).rejects.toThrow('bad row');
1189
+ expect(await store.hasPending(s)).toBe(true);
1190
+ expect(await store.listMessages(s)).toEqual([]);
1191
+ })
1192
+ );
1193
+
1194
+ it(
1195
+ 'enqueue 带结构化来源、系统来源无发送人',
1196
+ withImpl(factory, async store => {
1197
+ const s = await fx.rootSession();
1198
+ await store.enqueue({
1199
+ sessionId: s,
1200
+ kind: 'system_reminder',
1201
+ sourceKind: 'system',
1202
+ sourceUserId: null,
1203
+ source: { kind: 'probe' },
1204
+ parts: text('r'),
1205
+ messageId: id(),
1206
+ });
1207
+ const [p] = await store.listPending(s);
1208
+ expect(p).toMatchObject({
1209
+ kind: 'system_reminder',
1210
+ sourceKind: 'system',
1211
+ sourceUserId: null,
1212
+ source: { kind: 'probe' },
1213
+ });
1214
+ })
1215
+ );
1216
+
1217
+ it(
1218
+ 'withdraw 只删自己的、还没被取走的',
1219
+ withImpl(factory, async store => {
1220
+ const s = await fx.rootSession();
1221
+ const u = await fx.user();
1222
+ const queued = item(s, 'a', u);
1223
+ await store.enqueue(queued);
1224
+ const [pending] = await store.listPending(s);
1225
+ expect(await store.withdraw(pending.id, id())).toBeNull();
1226
+ expect(await store.withdraw(pending.id, u)).toMatchObject({
1227
+ id: pending.id,
1228
+ messageId: queued.messageId,
1229
+ });
1230
+ expect(await store.hasPending(s)).toBe(false);
1231
+ expect(await store.withdraw(pending.id, u)).toBeNull();
1232
+ })
1233
+ );
1234
+
1235
+ it(
1236
+ 'assistant 段:首次建行、之后追加 parts 并合并 metadata;收尾整体重写',
1237
+ withImpl(factory, async store => {
1238
+ const s = await fx.rootSession();
1239
+ const r = id();
1240
+ await store.appendAssistantStep({
1241
+ messageId: r,
1242
+ runId: r,
1243
+ sessionId: s,
1244
+ parts: text('a'),
1245
+ metadataPatch: { flushedSeq: 1 },
1246
+ });
1247
+ await store.appendAssistantStep({
1248
+ messageId: r,
1249
+ runId: r,
1250
+ sessionId: s,
1251
+ parts: text('b'),
1252
+ metadataPatch: { flushedSeq: 3 },
1253
+ });
1254
+ let [row] = await store.listMessages(s);
1255
+ expect(row.role).toBe('assistant');
1256
+ expect(row.parts).toEqual([...text('a'), ...text('b')]);
1257
+ expect(row.metadata).toEqual({ runId: r, flushedSeq: 3 });
1258
+ await store.finalizeAssistantTurn({
1259
+ messageId: r,
1260
+ runId: r,
1261
+ metadata: { status: 'completed' },
1262
+ usage: { inputTokens: 1 },
1263
+ });
1264
+ [row] = await store.listMessages(s);
1265
+ expect(row.metadata).toEqual({ runId: r, status: 'completed' });
1266
+ expect(row.tokenUsage).toEqual({ inputTokens: 1 });
1267
+ })
1268
+ );
1269
+
1270
+ it(
1271
+ 'finalizeInterruptedTurn 只补没有终态的段、只认 metadata.runId',
1272
+ withImpl(factory, async store => {
1273
+ const s = await fx.rootSession();
1274
+ const r = id();
1275
+ const seg1 = id();
1276
+ const seg2 = id();
1277
+ await store.appendMessage({
1278
+ id: seg1,
1279
+ sessionId: s,
1280
+ role: 'assistant',
1281
+ parts: text('1'),
1282
+ metadata: { runId: r, status: 'completed' },
1283
+ });
1284
+ await store.appendMessage({
1285
+ id: seg2,
1286
+ sessionId: s,
1287
+ role: 'assistant',
1288
+ parts: text('2'),
1289
+ metadata: { runId: r },
1290
+ });
1291
+ await store.appendMessage({
1292
+ sessionId: s,
1293
+ role: 'assistant',
1294
+ parts: text('3'),
1295
+ metadata: { runId: id() },
1296
+ });
1297
+ expect(await store.listTurnSegmentIds(r)).toEqual([seg1, seg2]);
1298
+ expect(await store.finalizeInterruptedTurn(r)).toEqual([seg2]);
1299
+ expect(await store.finalizeInterruptedTurn(r)).toEqual([]);
1300
+ const rows = await store.listMessages(s);
1301
+ expect(rows.map(m => m.metadata?.status)).toEqual([
1302
+ 'completed',
1303
+ 'aborted',
1304
+ undefined,
1305
+ ]);
1306
+ expect(rows[1].metadata).toEqual({
1307
+ runId: r,
1308
+ status: 'aborted',
1309
+ stopReason: 'aborted',
1310
+ });
1311
+ })
1312
+ );
1313
+
1314
+ it(
1315
+ 'listMessages 按角色与 seq 过滤;reserveSeq 取走的号不再分配给任何行',
1316
+ withImpl(factory, async store => {
1317
+ const s = await fx.rootSession();
1318
+ await store.appendMessage({
1319
+ sessionId: s,
1320
+ role: 'user',
1321
+ parts: text('q'),
1322
+ });
1323
+ const a1 = await store.appendMessage({
1324
+ sessionId: s,
1325
+ role: 'assistant',
1326
+ parts: text('a1'),
1327
+ });
1328
+ const reserved = await store.reserveSeq(s);
1329
+ expect(reserved).toBeGreaterThan(a1.seq);
1330
+ const a2 = await store.appendMessage({
1331
+ sessionId: s,
1332
+ role: 'assistant',
1333
+ parts: text('a2'),
1334
+ });
1335
+ expect(a2.seq).toBeGreaterThan(reserved);
1336
+ expect(
1337
+ (await store.listMessages(s, { role: 'assistant' })).map(m => m.id)
1338
+ ).toEqual([a1.id, a2.id]);
1339
+ expect(
1340
+ (await store.listMessages(s, { afterSeq: reserved })).map(m => m.id)
1341
+ ).toEqual([a2.id]);
1342
+ expect(await store.listMessages(s, { afterSeq: null })).toHaveLength(3);
1343
+ })
1344
+ );
1345
+ });
1346
+ }