@qjp88995/agent-runtime 0.1.0 → 0.1.2
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/dist/testing/contracts.d.ts +43 -4
- package/dist/testing/contracts.d.ts.map +1 -1
- package/dist/testing/contracts.js +402 -48
- package/dist/testing/contracts.js.map +1 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/testing/index.js.map +1 -1
- package/package.json +5 -1
- package/src/testing/contracts.ts +519 -55
- package/src/testing/index.ts +3 -0
package/src/testing/contracts.ts
CHANGED
|
@@ -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
|
-
/**
|
|
404
|
-
|
|
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
|
|
465
|
+
sessionId: string;
|
|
412
466
|
parentRunId: string | null;
|
|
413
467
|
depth: number;
|
|
414
468
|
}> = {}
|
|
415
469
|
) => {
|
|
416
470
|
const runId = over.id ?? id();
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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 =
|
|
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:
|
|
562
|
+
parentRunId: await fx.run(rootSessionId),
|
|
507
563
|
subagentName: 'general-purpose',
|
|
508
564
|
userId: null,
|
|
509
565
|
title: 't',
|
|
510
|
-
metadata:
|
|
511
|
-
attrs:
|
|
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 =
|
|
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:
|
|
525
|
-
attrs:
|
|
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,
|
|
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,
|
|
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 =
|
|
561
|
-
const r2 =
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|