@zhushanwen/pi-session-reader 0.1.0 → 0.2.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.
- package/package.json +1 -1
- package/src/__tests__/execution-tree.test.ts +792 -0
- package/src/__tests__/family.test.ts +150 -0
- package/src/__tests__/find.test.ts +342 -1
- package/src/__tests__/index.test.ts +50 -0
- package/src/__tests__/subagents.test.ts +291 -14
- package/src/__tests__/tool-handler.test.ts +862 -3
- package/src/__tests__/tree.test.ts +20 -0
- package/src/__tests__/workflow.test.ts +282 -0
- package/src/core/execution-tree.ts +517 -0
- package/src/core/family.ts +40 -0
- package/src/core/tree.ts +14 -5
- package/src/core/workflow.ts +329 -0
- package/src/discovery/find.ts +167 -20
- package/src/discovery/subagents.ts +113 -152
- package/src/discovery/workflows.ts +162 -0
- package/src/index.ts +22 -4
- package/src/tool-handler.ts +334 -21
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
2
|
import { existsSync } from 'node:fs'
|
|
3
3
|
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
|
4
|
-
import { tmpdir } from 'node:os'
|
|
4
|
+
import { tmpdir, homedir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { handleSessionRead, renderExtractItems, type SessionReadParams } from '../tool-handler.js'
|
|
7
|
-
import {
|
|
7
|
+
import { listRecordManifests } from '../discovery/subagents.js'
|
|
8
|
+
import {
|
|
9
|
+
REAL_AGENT_DIR as REAL,
|
|
10
|
+
E6,
|
|
11
|
+
FAM,
|
|
12
|
+
HAS_E6,
|
|
13
|
+
HAS_REAL,
|
|
14
|
+
HAS_REAL_SUBAGENTS_DIR,
|
|
15
|
+
hasRealSession,
|
|
16
|
+
} from './real-data.js'
|
|
8
17
|
|
|
9
18
|
/**
|
|
10
19
|
* M3 tool-handler 集成测试。
|
|
@@ -73,8 +82,15 @@ describe.skipIf(!HAS_REAL)('handleSessionRead', () => {
|
|
|
73
82
|
expect(d.sizeBytes).toBeGreaterThan(0)
|
|
74
83
|
})
|
|
75
84
|
|
|
85
|
+
// w1 合并 subagent 候选后,本机 subagent task 文本(含本用例 query 字符串本身,因当前
|
|
86
|
+
// wave 的 subagent task 引用了它)会触发 name-keyword fallback 命中 → 零匹配断言失败。
|
|
87
|
+
// w2 实现 handler source 透传后,用 source:'main' 收窄到 main 侧避开 subagent 干扰
|
|
88
|
+
//(w1 test 注释原预言的修复路径),同时恢复默认 timeout(main 单侧扫描快)。
|
|
76
89
|
it('7. F1 find zero match returns empty matches + 👉 hint (no throw)', async () => {
|
|
77
|
-
const r = await handleSessionRead(
|
|
90
|
+
const r = await handleSessionRead(
|
|
91
|
+
{ action: 'find', query: 'zzz-nonexistent-session-9q8x2', source: 'main' },
|
|
92
|
+
REAL,
|
|
93
|
+
)
|
|
78
94
|
const d = r.details as { matches: unknown[]; truncated: boolean }
|
|
79
95
|
expect(d.matches).toEqual([])
|
|
80
96
|
expect(d.truncated).toBe(false)
|
|
@@ -508,3 +524,846 @@ describe('renderExtractItems F9 截断(S3 首项超大 + S4 文案)', () =>
|
|
|
508
524
|
expect(text).toMatch(/已显示 \d+\/\d+ 项/)
|
|
509
525
|
})
|
|
510
526
|
})
|
|
527
|
+
|
|
528
|
+
// ============================================================
|
|
529
|
+
// w2 新增:resolveSessionId 三形态(① 绝对路径 / ② sa-id / ③ findSessions 透传 source)
|
|
530
|
+
// + ES1/ES2 错误契约 + source 透传 + 真实数据守卫(design TC2-TC18)
|
|
531
|
+
// ============================================================
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* 造 subagent manifest + session 文件(TC7/TC8/TC10/CQ3 用)。
|
|
535
|
+
* sessionFileExists:false 模拟 GC(manifest 存在但 session 文件不存在 → ES1)。
|
|
536
|
+
* 返回 session 文件绝对路径(= manifest.sessionFile)。
|
|
537
|
+
*/
|
|
538
|
+
async function makeFixtureSubagent(
|
|
539
|
+
dir: string,
|
|
540
|
+
saId: string,
|
|
541
|
+
opts: {
|
|
542
|
+
realSessionId: string
|
|
543
|
+
rootSessionId?: string
|
|
544
|
+
agentName?: string
|
|
545
|
+
sessionFileExists?: boolean
|
|
546
|
+
firstUserText?: string
|
|
547
|
+
},
|
|
548
|
+
): Promise<string> {
|
|
549
|
+
const slug = '--demo-cwd--'
|
|
550
|
+
const sessionFile = join(dir, 'subagents', slug, 'sessions', `${opts.realSessionId}.jsonl`)
|
|
551
|
+
if (opts.sessionFileExists !== false) {
|
|
552
|
+
await mkdir(join(dir, 'subagents', slug, 'sessions'), { recursive: true })
|
|
553
|
+
const lines = [
|
|
554
|
+
JSON.stringify({ type: 'session', id: opts.realSessionId, cwd: '/demo' }),
|
|
555
|
+
JSON.stringify({
|
|
556
|
+
type: 'message',
|
|
557
|
+
id: opts.realSessionId + '-m1',
|
|
558
|
+
parentId: opts.realSessionId,
|
|
559
|
+
message: {
|
|
560
|
+
role: 'user',
|
|
561
|
+
content: [{ type: 'text', text: opts.firstUserText ?? 'subagent work' }],
|
|
562
|
+
},
|
|
563
|
+
}),
|
|
564
|
+
]
|
|
565
|
+
await writeFile(sessionFile, lines.join('\n') + '\n')
|
|
566
|
+
}
|
|
567
|
+
const recordsDir = join(dir, 'subagents', slug, 'records')
|
|
568
|
+
await mkdir(recordsDir, { recursive: true })
|
|
569
|
+
await writeFile(
|
|
570
|
+
join(recordsDir, `${saId}.json`),
|
|
571
|
+
JSON.stringify({
|
|
572
|
+
id: saId,
|
|
573
|
+
rootSessionId: opts.rootSessionId ?? 'root-session-1',
|
|
574
|
+
agentName: opts.agentName ?? 'explorer',
|
|
575
|
+
sessionFile,
|
|
576
|
+
}),
|
|
577
|
+
)
|
|
578
|
+
return sessionFile
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
describe('resolveSessionId ① 绝对路径形态(w2 TC2-TC6)', () => {
|
|
582
|
+
let dir: string
|
|
583
|
+
|
|
584
|
+
beforeEach(async () => {
|
|
585
|
+
dir = await mkdtemp(join(tmpdir(), 'tool-handler-path-'))
|
|
586
|
+
})
|
|
587
|
+
afterEach(async () => {
|
|
588
|
+
await rm(dir, { recursive: true, force: true })
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
it('TC2: 绝对路径 outline → sessionId=header 真实 id(非文件名)', async () => {
|
|
592
|
+
const fileId = '019e6c96-dddd-eeee-ffff-000000000b1'
|
|
593
|
+
// 文件名 arbitrary-name 与 header id 不同,验 sessionId 取 header id
|
|
594
|
+
const filePath = join(dir, 'sessions', '--demo-cwd--', 'arbitrary-name.jsonl')
|
|
595
|
+
await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
|
|
596
|
+
const lines = [
|
|
597
|
+
JSON.stringify({ type: 'session', id: fileId, cwd: '/demo' }),
|
|
598
|
+
JSON.stringify({
|
|
599
|
+
type: 'message',
|
|
600
|
+
id: fileId + '-m1',
|
|
601
|
+
parentId: fileId,
|
|
602
|
+
message: { role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
|
603
|
+
}),
|
|
604
|
+
]
|
|
605
|
+
await writeFile(filePath, lines.join('\n') + '\n')
|
|
606
|
+
// export 的 details.path = session-view-<sessionId>.md,含 header 真实 id,不含文件名
|
|
607
|
+
const r = await handleSessionRead(
|
|
608
|
+
{ action: 'export', session: filePath, format: 'outline' },
|
|
609
|
+
dir,
|
|
610
|
+
)
|
|
611
|
+
const d = r.details as { path: string }
|
|
612
|
+
expect(d.path).toContain(fileId)
|
|
613
|
+
expect(d.path).not.toContain('arbitrary-name')
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
it('TC3: 绝对路径文件不存在 → F6 风格错误(含 👉)', async () => {
|
|
617
|
+
const filePath = join(
|
|
618
|
+
dir,
|
|
619
|
+
'sessions',
|
|
620
|
+
'--demo-cwd--',
|
|
621
|
+
`not-exist-${Date.now()}.jsonl`,
|
|
622
|
+
)
|
|
623
|
+
await expect(
|
|
624
|
+
handleSessionRead({ action: 'outline', session: filePath }, dir),
|
|
625
|
+
).rejects.toThrow(/读取失败.*文件不存在/)
|
|
626
|
+
await expect(
|
|
627
|
+
handleSessionRead({ action: 'outline', session: filePath }, dir),
|
|
628
|
+
).rejects.toThrow('👉')
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
it('TC4: 绝对路径非 .jsonl → F6 风格错误', async () => {
|
|
632
|
+
const filePath = join(dir, 'sessions', '--demo-cwd--', 'x.txt')
|
|
633
|
+
await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
|
|
634
|
+
await writeFile(filePath, 'not jsonl')
|
|
635
|
+
await expect(
|
|
636
|
+
handleSessionRead({ action: 'outline', session: filePath }, dir),
|
|
637
|
+
).rejects.toThrow(/读取失败.*非 \.jsonl/)
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
it('TC5: 绝对路径 header 读不出(首行非 session header)→ F6 风格错误', async () => {
|
|
641
|
+
const filePath = join(dir, 'sessions', '--demo-cwd--', 'bad.jsonl')
|
|
642
|
+
await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
|
|
643
|
+
await writeFile(filePath, JSON.stringify({ type: 'custom', customType: 'x' }) + '\n')
|
|
644
|
+
await expect(
|
|
645
|
+
handleSessionRead({ action: 'outline', session: filePath }, dir),
|
|
646
|
+
).rejects.toThrow(/读取失败.*首行非合法 session header/)
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
it('TC5 变体: 绝对路径空文件 → F6 风格错误', async () => {
|
|
650
|
+
const filePath = join(dir, 'sessions', '--demo-cwd--', 'empty.jsonl')
|
|
651
|
+
await mkdir(join(dir, 'sessions', '--demo-cwd--'), { recursive: true })
|
|
652
|
+
await writeFile(filePath, '')
|
|
653
|
+
await expect(
|
|
654
|
+
handleSessionRead({ action: 'outline', session: filePath }, dir),
|
|
655
|
+
).rejects.toThrow(/读取失败.*首行非合法 session header/)
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
it('TC6: ~ 前缀展开到 homedir(文件实际在 homedir 下)', async () => {
|
|
659
|
+
const home = homedir()
|
|
660
|
+
const tmpUnderHome = await mkdtemp(join(home, '.sr-w2-test-'))
|
|
661
|
+
try {
|
|
662
|
+
const fileId = '019e6c96-dddd-eeee-ffff-0000000006c1'
|
|
663
|
+
const sessionFile = join(tmpUnderHome, 's.jsonl')
|
|
664
|
+
await writeFile(
|
|
665
|
+
sessionFile,
|
|
666
|
+
JSON.stringify({ type: 'session', id: fileId, cwd: '/demo' }) + '\n',
|
|
667
|
+
)
|
|
668
|
+
// ~/开头的相对 homedir 路径
|
|
669
|
+
const tildePath = '~/' + sessionFile.slice(home.length + 1)
|
|
670
|
+
const r = await handleSessionRead(
|
|
671
|
+
{ action: 'export', session: tildePath, format: 'outline' },
|
|
672
|
+
dir,
|
|
673
|
+
)
|
|
674
|
+
expect((r.details as { path: string }).path).toContain(fileId)
|
|
675
|
+
} finally {
|
|
676
|
+
await rm(tmpUnderHome, { recursive: true, force: true })
|
|
677
|
+
}
|
|
678
|
+
})
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
describe('resolveSessionId ② sa-id 形态(w2 TC7-TC10 + CQ3)', () => {
|
|
682
|
+
let dir: string
|
|
683
|
+
|
|
684
|
+
beforeEach(async () => {
|
|
685
|
+
dir = await mkdtemp(join(tmpdir(), 'tool-handler-said-'))
|
|
686
|
+
})
|
|
687
|
+
afterEach(async () => {
|
|
688
|
+
await rm(dir, { recursive: true, force: true })
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
it('TC7: sa-id 恰 1 命中 + sessionFile 存在 → 成功,sessionId=header 真实 id(非 sa-)', async () => {
|
|
692
|
+
const realId = '019e6c96-dddd-eeee-ffff-0000000007a1'
|
|
693
|
+
await makeFixtureSubagent(dir, 'sa-aaa', { realSessionId: realId, firstUserText: 'do task' })
|
|
694
|
+
const r = await handleSessionRead(
|
|
695
|
+
{ action: 'export', session: 'sa-aaa', format: 'outline' },
|
|
696
|
+
dir,
|
|
697
|
+
)
|
|
698
|
+
const d = r.details as { path: string }
|
|
699
|
+
expect(d.path).toContain(realId)
|
|
700
|
+
expect(d.path).not.toContain('sa-aaa')
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
it('TC8: sa-id 命中但 sessionFile 不存在(GC/未写入)→ ES1(含 manifest 元数据 + 👉)', async () => {
|
|
704
|
+
await makeFixtureSubagent(dir, 'sa-gc', {
|
|
705
|
+
realSessionId: '019e6c96-dddd-eeee-ffff-0000000008a2',
|
|
706
|
+
rootSessionId: 'root-1',
|
|
707
|
+
agentName: 'explorer',
|
|
708
|
+
sessionFileExists: false,
|
|
709
|
+
})
|
|
710
|
+
await expect(
|
|
711
|
+
handleSessionRead({ action: 'outline', session: 'sa-gc' }, dir),
|
|
712
|
+
).rejects.toThrow('session 文件不存在')
|
|
713
|
+
// 错误含 manifest 全部元数据 + 👉
|
|
714
|
+
try {
|
|
715
|
+
await handleSessionRead({ action: 'outline', session: 'sa-gc' }, dir)
|
|
716
|
+
} catch (e) {
|
|
717
|
+
const msg = (e as Error).message
|
|
718
|
+
expect(msg).toContain('sa-gc')
|
|
719
|
+
expect(msg).toContain('root-1')
|
|
720
|
+
expect(msg).toContain('explorer')
|
|
721
|
+
expect(msg).toContain('sessionFile:')
|
|
722
|
+
expect(msg).toContain('👉')
|
|
723
|
+
expect(msg).toContain('action:"family"')
|
|
724
|
+
}
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
it('TC9: sa-id 0 命中(可能 running)→ ES2(含 family 指引 + 完整 id 提示 + 👉)', async () => {
|
|
728
|
+
await expect(
|
|
729
|
+
handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, dir),
|
|
730
|
+
).rejects.toThrow('无匹配 record')
|
|
731
|
+
try {
|
|
732
|
+
await handleSessionRead({ action: 'outline', session: 'sa-nonexist-9999' }, dir)
|
|
733
|
+
} catch (e) {
|
|
734
|
+
const msg = (e as Error).message
|
|
735
|
+
expect(msg).toContain('可能仍在运行')
|
|
736
|
+
expect(msg).toContain('action:"family"')
|
|
737
|
+
expect(msg).toContain('👉')
|
|
738
|
+
}
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
it('TC10: sa-id 片段输入(精确相等不命中)→ ES2', async () => {
|
|
742
|
+
await makeFixtureSubagent(dir, 'sa-c8c8dfa8', {
|
|
743
|
+
realSessionId: '019e6c96-dddd-eeee-ffff-0000000010a3',
|
|
744
|
+
})
|
|
745
|
+
// 片段 sa-c8c8 不等于完整 sa-c8c8dfa8 → 精确相等不命中 → ES2
|
|
746
|
+
await expect(
|
|
747
|
+
handleSessionRead({ action: 'outline', session: 'sa-c8c8' }, dir),
|
|
748
|
+
).rejects.toThrow('无匹配 record')
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
it('sa-id 多 manifest 命中(数据异常)→ ES2 ambiguous(C4)', async () => {
|
|
752
|
+
// 同 sa-id 的两个 manifest(不同 cwd slug 目录,模拟数据异常)
|
|
753
|
+
for (const [slug, realId] of [
|
|
754
|
+
['--demo-cwd--', '019e6c96-dddd-eeee-ffff-0000000004a4'],
|
|
755
|
+
['--other-cwd--', '019e6c96-dddd-eeee-ffff-0000000004a5'],
|
|
756
|
+
] as const) {
|
|
757
|
+
const sessionFile = join(dir, 'subagents', slug, 'sessions', `${realId}.jsonl`)
|
|
758
|
+
await mkdir(join(dir, 'subagents', slug, 'sessions'), { recursive: true })
|
|
759
|
+
await writeFile(
|
|
760
|
+
sessionFile,
|
|
761
|
+
JSON.stringify({ type: 'session', id: realId, cwd: '/demo' }) + '\n',
|
|
762
|
+
)
|
|
763
|
+
const recordsDir = join(dir, 'subagents', slug, 'records')
|
|
764
|
+
await mkdir(recordsDir, { recursive: true })
|
|
765
|
+
await writeFile(
|
|
766
|
+
join(recordsDir, 'sa-dup.json'),
|
|
767
|
+
JSON.stringify({
|
|
768
|
+
id: 'sa-dup',
|
|
769
|
+
rootSessionId: 'r',
|
|
770
|
+
agentName: 'a',
|
|
771
|
+
sessionFile,
|
|
772
|
+
}),
|
|
773
|
+
)
|
|
774
|
+
}
|
|
775
|
+
await expect(
|
|
776
|
+
handleSessionRead({ action: 'outline', session: 'sa-dup' }, dir),
|
|
777
|
+
).rejects.toThrow(/匹配 2 个 record.*数据异常/)
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
it('CQ3: sa-id 命中但 sessionFile header 读不出 → F6 风格(不降级 record.id 当 sessionId)', async () => {
|
|
781
|
+
// sessionFile 存在但首行非 session header → readSessionHeaderId 返 undefined
|
|
782
|
+
const slug = '--demo-cwd--'
|
|
783
|
+
const sessionFile = join(dir, 'subagents', slug, 'sessions', 'bad.jsonl')
|
|
784
|
+
await mkdir(join(dir, 'subagents', slug, 'sessions'), { recursive: true })
|
|
785
|
+
await writeFile(sessionFile, JSON.stringify({ type: 'custom', customType: 'x' }) + '\n')
|
|
786
|
+
const recordsDir = join(dir, 'subagents', slug, 'records')
|
|
787
|
+
await mkdir(recordsDir, { recursive: true })
|
|
788
|
+
await writeFile(
|
|
789
|
+
join(recordsDir, 'sa-bad.json'),
|
|
790
|
+
JSON.stringify({
|
|
791
|
+
id: 'sa-bad',
|
|
792
|
+
rootSessionId: 'r',
|
|
793
|
+
agentName: 'a',
|
|
794
|
+
sessionFile,
|
|
795
|
+
}),
|
|
796
|
+
)
|
|
797
|
+
// 抛 F6 风格(读取失败 + 首行非合法 session header),不降级返回 sa-bad 当 sessionId
|
|
798
|
+
await expect(
|
|
799
|
+
handleSessionRead({ action: 'outline', session: 'sa-bad' }, dir),
|
|
800
|
+
).rejects.toThrow(/读取失败.*首行非合法 session header/)
|
|
801
|
+
})
|
|
802
|
+
})
|
|
803
|
+
|
|
804
|
+
describe('source 透传(w2 TC12-TC13,依赖 w1 findSessions opts.source)', () => {
|
|
805
|
+
let dir: string
|
|
806
|
+
|
|
807
|
+
beforeEach(async () => {
|
|
808
|
+
dir = await mkdtemp(join(tmpdir(), 'tool-handler-src-'))
|
|
809
|
+
})
|
|
810
|
+
afterEach(async () => {
|
|
811
|
+
await rm(dir, { recursive: true, force: true })
|
|
812
|
+
})
|
|
813
|
+
|
|
814
|
+
it('TC12: find source 过滤——subagent 只返回 subagent 候选,main 只返回 main', async () => {
|
|
815
|
+
const sharedFragment = '019e6c96'
|
|
816
|
+
const mainId = `${sharedFragment}-aaaa-bbbb-cccc-0000000012d1`
|
|
817
|
+
const subId = `${sharedFragment}-aaaa-bbbb-cccc-0000000012d2`
|
|
818
|
+
await makeFixtureSession(dir, mainId, 'main content')
|
|
819
|
+
await makeFixtureSubagent(dir, 'sa-sub12', { realSessionId: subId, firstUserText: 'sub content' })
|
|
820
|
+
|
|
821
|
+
// 无 source → 两者
|
|
822
|
+
const rBoth = await handleSessionRead(
|
|
823
|
+
{ action: 'find', query: sharedFragment },
|
|
824
|
+
dir,
|
|
825
|
+
)
|
|
826
|
+
const dBoth = rBoth.details as {
|
|
827
|
+
matches: Array<{ source: string; sessionId: string }>
|
|
828
|
+
}
|
|
829
|
+
expect(dBoth.matches.some((m) => m.sessionId === mainId)).toBe(true)
|
|
830
|
+
expect(dBoth.matches.some((m) => m.sessionId === subId)).toBe(true)
|
|
831
|
+
|
|
832
|
+
// source:subagent → 只 sub
|
|
833
|
+
const rSub = await handleSessionRead(
|
|
834
|
+
{ action: 'find', query: sharedFragment, source: 'subagent' },
|
|
835
|
+
dir,
|
|
836
|
+
)
|
|
837
|
+
const dSub = rSub.details as {
|
|
838
|
+
matches: Array<{ source: string; sessionId: string }>
|
|
839
|
+
}
|
|
840
|
+
expect(dSub.matches.every((m) => m.source === 'subagent')).toBe(true)
|
|
841
|
+
expect(dSub.matches.some((m) => m.sessionId === subId)).toBe(true)
|
|
842
|
+
expect(dSub.matches.some((m) => m.sessionId === mainId)).toBe(false)
|
|
843
|
+
|
|
844
|
+
// source:main → 只 main
|
|
845
|
+
const rMain = await handleSessionRead(
|
|
846
|
+
{ action: 'find', query: sharedFragment, source: 'main' },
|
|
847
|
+
dir,
|
|
848
|
+
)
|
|
849
|
+
const dMain = rMain.details as {
|
|
850
|
+
matches: Array<{ source: string; sessionId: string }>
|
|
851
|
+
}
|
|
852
|
+
expect(dMain.matches.every((m) => m.source === 'main')).toBe(true)
|
|
853
|
+
expect(dMain.matches.some((m) => m.sessionId === mainId)).toBe(true)
|
|
854
|
+
expect(dMain.matches.some((m) => m.sessionId === subId)).toBe(false)
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
it('TC13: outline source:main → resolveSessionId ③ 收窄到 main 候选(无 source 时多匹配 F2)', async () => {
|
|
858
|
+
const sharedFragment = '019e6c96'
|
|
859
|
+
const mainId = `${sharedFragment}-aaaa-bbbb-cccc-0000000013e1`
|
|
860
|
+
const subId = `${sharedFragment}-aaaa-bbbb-cccc-0000000013e2`
|
|
861
|
+
await makeFixtureSession(dir, mainId, 'main content')
|
|
862
|
+
await makeFixtureSubagent(dir, 'sa-sub13', { realSessionId: subId, firstUserText: 'sub content' })
|
|
863
|
+
|
|
864
|
+
// 无 source → main+sub 共享片段 → 2 匹配 → F2 消歧
|
|
865
|
+
const rMulti = await handleSessionRead(
|
|
866
|
+
{ action: 'outline', session: sharedFragment },
|
|
867
|
+
dir,
|
|
868
|
+
)
|
|
869
|
+
expect((rMulti.details as { ambiguous: boolean }).ambiguous).toBe(true)
|
|
870
|
+
|
|
871
|
+
// source:main → 收窄到 main → 唯一匹配 → outline 成功,且是 main(export path 含 mainId)
|
|
872
|
+
const rExp = await handleSessionRead(
|
|
873
|
+
{ action: 'export', session: sharedFragment, source: 'main', format: 'outline' },
|
|
874
|
+
dir,
|
|
875
|
+
)
|
|
876
|
+
expect((rExp.details as { path: string }).path).toContain(mainId)
|
|
877
|
+
})
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
describe.skipIf(!HAS_REAL_SUBAGENTS_DIR)('真实数据:subagent sa-id(w2 TC14-TC18)', () => {
|
|
881
|
+
it('TC14: completed subagent sa-id outline 成功(场景 1,sessionId=header 真实 id)', async () => {
|
|
882
|
+
const manifests = await listRecordManifests(REAL)
|
|
883
|
+
const alive = manifests.filter((m) => existsSync(m.sessionFile))
|
|
884
|
+
if (alive.length === 0) return // 本机无存活 manifest 则跳过(skipIf 只守卫目录存在)
|
|
885
|
+
const r = await handleSessionRead({ action: 'outline', session: alive[0].id }, REAL)
|
|
886
|
+
const d = r.details as { turns: unknown[] }
|
|
887
|
+
expect(d.turns.length).toBeGreaterThan(0)
|
|
888
|
+
})
|
|
889
|
+
|
|
890
|
+
it('TC15: worktree 编码目录 subagent 读取(场景 1b,递归扫描覆盖)', async () => {
|
|
891
|
+
const manifests = await listRecordManifests(REAL)
|
|
892
|
+
const wt = manifests.filter(
|
|
893
|
+
(m) => m.sessionFile.includes('--private-var-folders-') && existsSync(m.sessionFile),
|
|
894
|
+
)
|
|
895
|
+
if (wt.length === 0) return // 本机无 worktree 编码目录数据则跳过
|
|
896
|
+
const r = await handleSessionRead({ action: 'outline', session: wt[0].id }, REAL)
|
|
897
|
+
expect(((r.details as { turns: unknown[] }).turns).length).toBeGreaterThan(0)
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
it('TC16: 嵌套后代直接 outline(场景 1c,绝对路径形态读任意节点)', async () => {
|
|
901
|
+
const manifests = await listRecordManifests(REAL)
|
|
902
|
+
const alive = manifests.filter((m) => existsSync(m.sessionFile))
|
|
903
|
+
if (alive.length === 0) return
|
|
904
|
+
// 绝对路径形态直接读(M0 入口不依赖 findSessions)
|
|
905
|
+
const r = await handleSessionRead(
|
|
906
|
+
{ action: 'outline', session: alive[0].sessionFile },
|
|
907
|
+
REAL,
|
|
908
|
+
)
|
|
909
|
+
expect(((r.details as { turns: unknown[] }).turns).length).toBeGreaterThan(0)
|
|
910
|
+
})
|
|
911
|
+
|
|
912
|
+
it('TC17: GC/failed manifest → ES1(场景 4,sessionFile 不存在)', async () => {
|
|
913
|
+
const manifests = await listRecordManifests(REAL)
|
|
914
|
+
const gc = manifests.filter((m) => !existsSync(m.sessionFile))
|
|
915
|
+
if (gc.length === 0) return // 本机无 GC 数据则跳过
|
|
916
|
+
await expect(
|
|
917
|
+
handleSessionRead({ action: 'outline', session: gc[0].id }, REAL),
|
|
918
|
+
).rejects.toThrow('session 文件不存在')
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
it('TC18: 不存在 sa-id → ES2(场景 4,可能 running 指引)', async () => {
|
|
922
|
+
const fakeId = `sa-nonexist-${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 8)}`
|
|
923
|
+
await expect(
|
|
924
|
+
handleSessionRead({ action: 'outline', session: fakeId }, REAL),
|
|
925
|
+
).rejects.toThrow('无匹配 record')
|
|
926
|
+
})
|
|
927
|
+
})
|
|
928
|
+
|
|
929
|
+
// ============================================================
|
|
930
|
+
// w6: doWorkflow action(TC-w6-single-run/multi-run/runid-filter/runid-not-found/no-runs/snapshot-skip/call-jump)
|
|
931
|
+
// ============================================================
|
|
932
|
+
|
|
933
|
+
// ---- workflow fixture 常量(uuid 特征,互不为子串)----
|
|
934
|
+
const WF_ROOT = '019w6aaa-0000-7000-b000-000000000001' // 发起 workflow 的 main session
|
|
935
|
+
const WF_CALL = '019w6bbb-0000-7000-b000-000000000002' // workflow call 的目标 session(call-jump)
|
|
936
|
+
const SUB_WF_ROOT = '019w6ccc-0000-7000-b000-000000000003' // 发起 workflow 的 subagent session(MF-2)
|
|
937
|
+
|
|
938
|
+
/** 写 main session(header + 1 条 user message,让 outline 有 turn)。返回绝对路径。 */
|
|
939
|
+
async function wfMainSession(
|
|
940
|
+
dir: string,
|
|
941
|
+
slug: string,
|
|
942
|
+
id: string,
|
|
943
|
+
opts?: { cwd?: string },
|
|
944
|
+
): Promise<string> {
|
|
945
|
+
const sessionDir = join(dir, 'sessions', slug)
|
|
946
|
+
await mkdir(sessionDir, { recursive: true })
|
|
947
|
+
const path = join(sessionDir, `${id}.jsonl`)
|
|
948
|
+
const lines = [
|
|
949
|
+
JSON.stringify({ type: 'session', id, cwd: opts?.cwd ?? `/proj/${slug}` }),
|
|
950
|
+
JSON.stringify({
|
|
951
|
+
type: 'message',
|
|
952
|
+
id: `${id}-m1`,
|
|
953
|
+
parentId: id,
|
|
954
|
+
message: { role: 'user', content: [{ type: 'text', text: 'run workflow' }] },
|
|
955
|
+
}),
|
|
956
|
+
]
|
|
957
|
+
await writeFile(path, lines.join('\n') + '\n')
|
|
958
|
+
return path
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** 写 wf-state 文件(每行一个快照 JSON)。返回绝对路径。 */
|
|
962
|
+
async function wfStateFile(
|
|
963
|
+
dir: string,
|
|
964
|
+
slug: string,
|
|
965
|
+
fileName: string,
|
|
966
|
+
lines: string[],
|
|
967
|
+
): Promise<string> {
|
|
968
|
+
const wfDir = join(dir, 'sessions', slug, 'workflow-state')
|
|
969
|
+
await mkdir(wfDir, { recursive: true })
|
|
970
|
+
const path = join(wfDir, fileName)
|
|
971
|
+
await writeFile(path, lines.join('\n') + '\n')
|
|
972
|
+
return path
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** 向 main session 追加 workflow-state-link custom entry(resolveWorkflows 的输入)。 */
|
|
976
|
+
async function wfLink(
|
|
977
|
+
dir: string,
|
|
978
|
+
slug: string,
|
|
979
|
+
id: string,
|
|
980
|
+
link: { runId: string; path: string },
|
|
981
|
+
): Promise<void> {
|
|
982
|
+
const sessionPath = join(dir, 'sessions', slug, `${id}.jsonl`)
|
|
983
|
+
const line = JSON.stringify({
|
|
984
|
+
type: 'custom',
|
|
985
|
+
id: `wf-link-${link.runId}`,
|
|
986
|
+
parentId: id,
|
|
987
|
+
customType: 'workflow-state-link',
|
|
988
|
+
data: { runId: link.runId, path: link.path, updatedAt: '2026-08-12T00:00:00Z' },
|
|
989
|
+
timestamp: '2026-08-12T00:00:00Z',
|
|
990
|
+
})
|
|
991
|
+
await writeFile(sessionPath, line + '\n', { flag: 'a' })
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** 构造 NEW 格式 wf-state 快照 JSON 行(calls 含 sessionId/sessionFile,parseRunSnapshot 透传)。 */
|
|
995
|
+
function wfSnapshotNew(
|
|
996
|
+
runId: string,
|
|
997
|
+
calls: Array<{ sessionId: string; sessionFile: string; description?: string }>,
|
|
998
|
+
): string {
|
|
999
|
+
return JSON.stringify({
|
|
1000
|
+
v: 'wf-run-v1',
|
|
1001
|
+
runId,
|
|
1002
|
+
spec: { scriptName: 'test-wf', name: 'Test' },
|
|
1003
|
+
state: {
|
|
1004
|
+
status: 'done',
|
|
1005
|
+
reason: 'completed',
|
|
1006
|
+
budget: {
|
|
1007
|
+
usedTokens: 100,
|
|
1008
|
+
usedCost: 0,
|
|
1009
|
+
totalCallCount: calls.length,
|
|
1010
|
+
maxTokens: 10000,
|
|
1011
|
+
},
|
|
1012
|
+
calls: calls.map((c, i) => ({
|
|
1013
|
+
id: i,
|
|
1014
|
+
opts: {
|
|
1015
|
+
prompt: 'do work',
|
|
1016
|
+
model: 'test-model',
|
|
1017
|
+
description: c.description ?? `step-${i}`,
|
|
1018
|
+
},
|
|
1019
|
+
status: 'done',
|
|
1020
|
+
attempts: 1,
|
|
1021
|
+
result: {
|
|
1022
|
+
content: 'ok',
|
|
1023
|
+
durationMs: 100,
|
|
1024
|
+
sessionId: c.sessionId,
|
|
1025
|
+
sessionFile: c.sessionFile,
|
|
1026
|
+
},
|
|
1027
|
+
sessionId: c.sessionId,
|
|
1028
|
+
sessionFile: c.sessionFile,
|
|
1029
|
+
})),
|
|
1030
|
+
},
|
|
1031
|
+
meta: { startedAt: '2026-01-01T00:00:00Z', completedAt: '2026-01-01T00:01:00Z' },
|
|
1032
|
+
})
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
describe('doWorkflow(w6,fixture)', () => {
|
|
1036
|
+
let dir: string
|
|
1037
|
+
|
|
1038
|
+
beforeEach(async () => {
|
|
1039
|
+
dir = await mkdtemp(join(tmpdir(), 'tool-handler-wf-'))
|
|
1040
|
+
})
|
|
1041
|
+
afterEach(async () => {
|
|
1042
|
+
await rm(dir, { recursive: true, force: true })
|
|
1043
|
+
})
|
|
1044
|
+
|
|
1045
|
+
it('TC-w6-single-run:单 run 概览,content 含 run 头行/budget/step,details.runs/runIds 非空', async () => {
|
|
1046
|
+
const slug = '--wf-single--'
|
|
1047
|
+
await wfMainSession(dir, slug, WF_ROOT)
|
|
1048
|
+
const callSession = join(dir, 'sessions', slug, `${WF_CALL}.jsonl`)
|
|
1049
|
+
const wfPath = await wfStateFile(dir, slug, 'wf-single.jsonl', [
|
|
1050
|
+
wfSnapshotNew('wf-single-1', [
|
|
1051
|
+
{ sessionId: WF_CALL, sessionFile: callSession, description: 'probe-step' },
|
|
1052
|
+
]),
|
|
1053
|
+
])
|
|
1054
|
+
await wfLink(dir, slug, WF_ROOT, { runId: 'wf-single-1', path: wfPath })
|
|
1055
|
+
|
|
1056
|
+
const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
|
|
1057
|
+
const d = r.details as {
|
|
1058
|
+
runs: Array<{
|
|
1059
|
+
runId: string
|
|
1060
|
+
status: string
|
|
1061
|
+
steps: Array<{ sessionId: string; sessionFile: string }>
|
|
1062
|
+
}>
|
|
1063
|
+
runIds: string[]
|
|
1064
|
+
}
|
|
1065
|
+
expect(d.runs).toHaveLength(1)
|
|
1066
|
+
expect(d.runs[0].runId).toBe('wf-single-1')
|
|
1067
|
+
expect(d.runs[0].status).toBe('done')
|
|
1068
|
+
expect(d.runs[0].steps).toHaveLength(1)
|
|
1069
|
+
expect(d.runs[0].steps[0].sessionId).toBe(WF_CALL)
|
|
1070
|
+
expect(d.runs[0].steps[0].sessionFile).toBe(callSession)
|
|
1071
|
+
expect(d.runIds).toEqual(['wf-single-1'])
|
|
1072
|
+
// content 含 renderWorkflowOverview 输出
|
|
1073
|
+
const text = r.content[0].text
|
|
1074
|
+
expect(text).toContain('run: wf-single-1')
|
|
1075
|
+
expect(text).toContain('[done]')
|
|
1076
|
+
expect(text).toContain('budget:')
|
|
1077
|
+
expect(text).toContain('#0')
|
|
1078
|
+
expect(text).toContain('call=' + WF_CALL.slice(0, 12))
|
|
1079
|
+
expect(text).toContain(callSession)
|
|
1080
|
+
})
|
|
1081
|
+
|
|
1082
|
+
it('TC-w6-multi-run:多 run 拼接,content 含两段 overview,runs.length===2', async () => {
|
|
1083
|
+
const slug = '--wf-multi--'
|
|
1084
|
+
await wfMainSession(dir, slug, WF_ROOT)
|
|
1085
|
+
const wf1 = await wfStateFile(dir, slug, 'wf-1.jsonl', [
|
|
1086
|
+
wfSnapshotNew('wf-multi-1', [{ sessionId: WF_CALL, sessionFile: '/abs/a.jsonl' }]),
|
|
1087
|
+
])
|
|
1088
|
+
const wf2 = await wfStateFile(dir, slug, 'wf-2.jsonl', [
|
|
1089
|
+
wfSnapshotNew('wf-multi-2', [{ sessionId: WF_CALL, sessionFile: '/abs/b.jsonl' }]),
|
|
1090
|
+
])
|
|
1091
|
+
await wfLink(dir, slug, WF_ROOT, { runId: 'wf-multi-1', path: wf1 })
|
|
1092
|
+
await wfLink(dir, slug, WF_ROOT, { runId: 'wf-multi-2', path: wf2 })
|
|
1093
|
+
|
|
1094
|
+
const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
|
|
1095
|
+
const d = r.details as { runs: Array<{ runId: string }>; runIds: string[] }
|
|
1096
|
+
expect(d.runs).toHaveLength(2)
|
|
1097
|
+
expect(d.runIds).toHaveLength(2)
|
|
1098
|
+
expect(d.runIds).toContain('wf-multi-1')
|
|
1099
|
+
expect(d.runIds).toContain('wf-multi-2')
|
|
1100
|
+
const text = r.content[0].text
|
|
1101
|
+
expect(text).toContain('run: wf-multi-1')
|
|
1102
|
+
expect(text).toContain('run: wf-multi-2')
|
|
1103
|
+
})
|
|
1104
|
+
|
|
1105
|
+
it('TC-w6-runid-filter:runId 过滤命中单 run', async () => {
|
|
1106
|
+
const slug = '--wf-filter--'
|
|
1107
|
+
await wfMainSession(dir, slug, WF_ROOT)
|
|
1108
|
+
const wf1 = await wfStateFile(dir, slug, 'wf-1.jsonl', [
|
|
1109
|
+
wfSnapshotNew('wf-filter-1', [{ sessionId: WF_CALL, sessionFile: '/abs/a.jsonl' }]),
|
|
1110
|
+
])
|
|
1111
|
+
const wf2 = await wfStateFile(dir, slug, 'wf-2.jsonl', [
|
|
1112
|
+
wfSnapshotNew('wf-filter-2', [{ sessionId: WF_CALL, sessionFile: '/abs/b.jsonl' }]),
|
|
1113
|
+
])
|
|
1114
|
+
await wfLink(dir, slug, WF_ROOT, { runId: 'wf-filter-1', path: wf1 })
|
|
1115
|
+
await wfLink(dir, slug, WF_ROOT, { runId: 'wf-filter-2', path: wf2 })
|
|
1116
|
+
|
|
1117
|
+
const r = await handleSessionRead(
|
|
1118
|
+
{ action: 'workflow', session: WF_ROOT, runId: 'wf-filter-1' },
|
|
1119
|
+
dir,
|
|
1120
|
+
)
|
|
1121
|
+
const d = r.details as {
|
|
1122
|
+
runs: Array<{ runId: string }>
|
|
1123
|
+
runIds: string[]
|
|
1124
|
+
requestedRunId?: string
|
|
1125
|
+
}
|
|
1126
|
+
expect(d.runs).toHaveLength(1)
|
|
1127
|
+
expect(d.runIds).toEqual(['wf-filter-1'])
|
|
1128
|
+
expect(d.requestedRunId).toBe('wf-filter-1')
|
|
1129
|
+
const text = r.content[0].text
|
|
1130
|
+
expect(text).toContain('run: wf-filter-1')
|
|
1131
|
+
expect(text).not.toContain('run: wf-filter-2')
|
|
1132
|
+
})
|
|
1133
|
+
|
|
1134
|
+
it('TC-w6-runid-not-found:runId 无匹配→ES-wf-runid-not-found(列候选+👉,不抛错)', async () => {
|
|
1135
|
+
const slug = '--wf-notfound--'
|
|
1136
|
+
await wfMainSession(dir, slug, WF_ROOT)
|
|
1137
|
+
const wf1 = await wfStateFile(dir, slug, 'wf-1.jsonl', [
|
|
1138
|
+
wfSnapshotNew('wf-nf-1', [{ sessionId: WF_CALL, sessionFile: '/abs/a.jsonl' }]),
|
|
1139
|
+
])
|
|
1140
|
+
const wf2 = await wfStateFile(dir, slug, 'wf-2.jsonl', [
|
|
1141
|
+
wfSnapshotNew('wf-nf-2', [{ sessionId: WF_CALL, sessionFile: '/abs/b.jsonl' }]),
|
|
1142
|
+
])
|
|
1143
|
+
await wfLink(dir, slug, WF_ROOT, { runId: 'wf-nf-1', path: wf1 })
|
|
1144
|
+
await wfLink(dir, slug, WF_ROOT, { runId: 'wf-nf-2', path: wf2 })
|
|
1145
|
+
|
|
1146
|
+
const r = await handleSessionRead(
|
|
1147
|
+
{ action: 'workflow', session: WF_ROOT, runId: 'wf-nonexist' },
|
|
1148
|
+
dir,
|
|
1149
|
+
)
|
|
1150
|
+
const d = r.details as { runs: unknown[]; runIds: string[]; requestedRunId?: string }
|
|
1151
|
+
expect(d.runs).toEqual([])
|
|
1152
|
+
expect(d.runIds).toContain('wf-nf-1')
|
|
1153
|
+
expect(d.runIds).toContain('wf-nf-2')
|
|
1154
|
+
expect(d.requestedRunId).toBe('wf-nonexist')
|
|
1155
|
+
const text = r.content[0].text
|
|
1156
|
+
expect(text).toContain('wf-nonexist')
|
|
1157
|
+
expect(text).toContain('wf-nf-1')
|
|
1158
|
+
expect(text).toContain('wf-nf-2')
|
|
1159
|
+
expect(text).toContain('👉')
|
|
1160
|
+
})
|
|
1161
|
+
|
|
1162
|
+
it('TC-w6-no-runs:无 workflow run→ES-wf-no-runs(提示+👉family,不抛错)', async () => {
|
|
1163
|
+
const slug = '--wf-noruns--'
|
|
1164
|
+
await wfMainSession(dir, slug, WF_ROOT)
|
|
1165
|
+
// 无 wf-link(session 存在但未发起 workflow)
|
|
1166
|
+
|
|
1167
|
+
const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
|
|
1168
|
+
const d = r.details as { runs: unknown[]; runIds: unknown[]; sessionId?: string }
|
|
1169
|
+
expect(d.runs).toEqual([])
|
|
1170
|
+
expect(d.runIds).toEqual([])
|
|
1171
|
+
expect(d.sessionId).toBe(WF_ROOT)
|
|
1172
|
+
const text = r.content[0].text
|
|
1173
|
+
expect(text).toContain('无 workflow run')
|
|
1174
|
+
expect(text).toContain('👉')
|
|
1175
|
+
expect(text).toContain('family')
|
|
1176
|
+
})
|
|
1177
|
+
|
|
1178
|
+
it('TC-w6-snapshot-skip:run1 wf-state 不存在→跳过,run2 正常(ES-wf-snapshot-read-fail)', async () => {
|
|
1179
|
+
const slug = '--wf-skip--'
|
|
1180
|
+
await wfMainSession(dir, slug, WF_ROOT)
|
|
1181
|
+
// run1 的 wf-state 文件不存在(link 指向不存在路径,模拟 GC)
|
|
1182
|
+
const ghostPath = join(dir, 'sessions', slug, 'workflow-state', 'wf-ghost.jsonl')
|
|
1183
|
+
const wf2 = await wfStateFile(dir, slug, 'wf-2.jsonl', [
|
|
1184
|
+
wfSnapshotNew('wf-skip-2', [{ sessionId: WF_CALL, sessionFile: '/abs/b.jsonl' }]),
|
|
1185
|
+
])
|
|
1186
|
+
await wfLink(dir, slug, WF_ROOT, { runId: 'wf-skip-1', path: ghostPath })
|
|
1187
|
+
await wfLink(dir, slug, WF_ROOT, { runId: 'wf-skip-2', path: wf2 })
|
|
1188
|
+
|
|
1189
|
+
const r = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
|
|
1190
|
+
const d = r.details as {
|
|
1191
|
+
runs: Array<{ runId: string }>
|
|
1192
|
+
runIds: string[]
|
|
1193
|
+
skippedRuns?: Array<{ runId: string; stateFile: string; reason: string }>
|
|
1194
|
+
}
|
|
1195
|
+
expect(d.runs).toHaveLength(1)
|
|
1196
|
+
expect(d.runs[0].runId).toBe('wf-skip-2')
|
|
1197
|
+
expect(d.runIds).toEqual(['wf-skip-2'])
|
|
1198
|
+
expect(d.skippedRuns).toBeDefined()
|
|
1199
|
+
expect(d.skippedRuns).toHaveLength(1)
|
|
1200
|
+
expect(d.skippedRuns![0].runId).toBe('wf-skip-1')
|
|
1201
|
+
expect(d.skippedRuns![0].reason).toBe('snapshot-unreadable')
|
|
1202
|
+
const text = r.content[0].text
|
|
1203
|
+
expect(text).toContain('wf-skip-1')
|
|
1204
|
+
expect(text).toContain('已跳过')
|
|
1205
|
+
expect(text).toContain('run: wf-skip-2')
|
|
1206
|
+
})
|
|
1207
|
+
|
|
1208
|
+
it('TC-w6-call-jump:workflow 概览 call sessionId 可被 resolveSessionId 深读(outline 跳转,§7 场景 2)', async () => {
|
|
1209
|
+
const slug = '--wf-jump--'
|
|
1210
|
+
await wfMainSession(dir, slug, WF_ROOT)
|
|
1211
|
+
// 真实存在的 call session(main session 形态,findSessions 可匹配)
|
|
1212
|
+
const callPath = await wfMainSession(dir, slug, WF_CALL, { cwd: '/proj/call' })
|
|
1213
|
+
const wfPath = await wfStateFile(dir, slug, 'wf-jump.jsonl', [
|
|
1214
|
+
wfSnapshotNew('wf-jump-1', [{ sessionId: WF_CALL, sessionFile: callPath }]),
|
|
1215
|
+
])
|
|
1216
|
+
await wfLink(dir, slug, WF_ROOT, { runId: 'wf-jump-1', path: wfPath })
|
|
1217
|
+
|
|
1218
|
+
// 第一次:workflow 概览,拿 call sessionId
|
|
1219
|
+
const rWf = await handleSessionRead({ action: 'workflow', session: WF_ROOT }, dir)
|
|
1220
|
+
const dWf = rWf.details as {
|
|
1221
|
+
runs: Array<{ steps: Array<{ sessionId: string; sessionFile: string }> }>
|
|
1222
|
+
}
|
|
1223
|
+
expect(dWf.runs).toHaveLength(1)
|
|
1224
|
+
const callSessionId = dWf.runs[0].steps[0].sessionId
|
|
1225
|
+
expect(callSessionId).toBe(WF_CALL)
|
|
1226
|
+
|
|
1227
|
+
// 第二次:用 call sessionId 调 outline(m0 resolveSessionId 三形态复用)
|
|
1228
|
+
const rOutline = await handleSessionRead(
|
|
1229
|
+
{ action: 'outline', session: callSessionId },
|
|
1230
|
+
dir,
|
|
1231
|
+
)
|
|
1232
|
+
const dOutline = rOutline.details as { turns: unknown[] }
|
|
1233
|
+
expect(dOutline.turns.length).toBeGreaterThan(0)
|
|
1234
|
+
})
|
|
1235
|
+
|
|
1236
|
+
it('TC-w6-subagent-session:workflow action 对 subagent session 直读 wf-link 不抛错(MF-2)', async () => {
|
|
1237
|
+
const slug = '--wf-subagent--'
|
|
1238
|
+
// subagent session 放 subagents/ 下(不在 sessions/——buildFamilyFromFs 的 main byId
|
|
1239
|
+
// 索引外,旧实现 resolveFamily 找不到会抛「session not found in family index」)
|
|
1240
|
+
const subDir = join(dir, 'subagents', slug, 'sessions')
|
|
1241
|
+
await mkdir(subDir, { recursive: true })
|
|
1242
|
+
const subPath = join(subDir, `${SUB_WF_ROOT}.jsonl`)
|
|
1243
|
+
await writeFile(
|
|
1244
|
+
subPath,
|
|
1245
|
+
JSON.stringify({ type: 'session', id: SUB_WF_ROOT, cwd: `/proj/${slug}` }) + '\n',
|
|
1246
|
+
)
|
|
1247
|
+
const callSession = join(dir, 'sessions', slug, `${WF_CALL}.jsonl`)
|
|
1248
|
+
const wfPath = await wfStateFile(dir, slug, 'wf-sub.jsonl', [
|
|
1249
|
+
wfSnapshotNew('wf-sub-1', [
|
|
1250
|
+
{ sessionId: WF_CALL, sessionFile: callSession, description: 'sub-step' },
|
|
1251
|
+
]),
|
|
1252
|
+
])
|
|
1253
|
+
// subagent session 的 workflow-state-link(wfLink helper 只写 sessions/,此处直接追加)
|
|
1254
|
+
await writeFile(
|
|
1255
|
+
subPath,
|
|
1256
|
+
JSON.stringify({
|
|
1257
|
+
type: 'custom',
|
|
1258
|
+
id: 'wf-link-wf-sub-1',
|
|
1259
|
+
parentId: SUB_WF_ROOT,
|
|
1260
|
+
customType: 'workflow-state-link',
|
|
1261
|
+
data: { runId: 'wf-sub-1', path: wfPath, updatedAt: '2026-08-12T00:00:00Z' },
|
|
1262
|
+
timestamp: '2026-08-12T00:00:00Z',
|
|
1263
|
+
}) + '\n',
|
|
1264
|
+
{ flag: 'a' },
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1267
|
+
const r = await handleSessionRead({ action: 'workflow', session: SUB_WF_ROOT }, dir)
|
|
1268
|
+
const d = r.details as { runs: Array<{ runId: string }>; runIds: string[] }
|
|
1269
|
+
expect(d.runs).toHaveLength(1)
|
|
1270
|
+
expect(d.runs[0].runId).toBe('wf-sub-1')
|
|
1271
|
+
expect(d.runIds).toEqual(['wf-sub-1'])
|
|
1272
|
+
expect(r.content[0].text).toContain('run: wf-sub-1')
|
|
1273
|
+
expect(r.content[0].text).toContain('call=')
|
|
1274
|
+
})
|
|
1275
|
+
})
|
|
1276
|
+
|
|
1277
|
+
// ============================================================
|
|
1278
|
+
// 真实数据守卫:doWorkflow(CI 无本机数据时 skipIf 跳过)
|
|
1279
|
+
// ============================================================
|
|
1280
|
+
|
|
1281
|
+
const REAL_WF_SESSION = '019fdcda-75c7-74b7-a160-f67f6bf88384'
|
|
1282
|
+
const HAS_REAL_WF_SESSION = HAS_REAL && hasRealSession(REAL_WF_SESSION)
|
|
1283
|
+
|
|
1284
|
+
describe.skipIf(!HAS_REAL_WF_SESSION)('doWorkflow - 真实数据守卫', () => {
|
|
1285
|
+
it('TC-w6-real-data-guard:真实 workflow session doWorkflow 返回 run 概览', async () => {
|
|
1286
|
+
const r = await handleSessionRead(
|
|
1287
|
+
{ action: 'workflow', session: REAL_WF_SESSION },
|
|
1288
|
+
REAL,
|
|
1289
|
+
)
|
|
1290
|
+
const d = r.details as {
|
|
1291
|
+
runs: Array<{ runId: string; status: string; steps: unknown[] }>
|
|
1292
|
+
runIds: string[]
|
|
1293
|
+
}
|
|
1294
|
+
expect(d.runs.length).toBeGreaterThan(0)
|
|
1295
|
+
expect(d.runIds.length).toBe(d.runs.length)
|
|
1296
|
+
// content 含 run 头行 + budget 行
|
|
1297
|
+
expect(r.content[0].text).toContain('run:')
|
|
1298
|
+
expect(r.content[0].text).toContain('budget:')
|
|
1299
|
+
}, 30000)
|
|
1300
|
+
})
|
|
1301
|
+
|
|
1302
|
+
// ============================================================
|
|
1303
|
+
// m3b:doFamily recursive false/true(TC-m3b-dofamily-recursive-false/true)
|
|
1304
|
+
// ============================================================
|
|
1305
|
+
|
|
1306
|
+
describe('doFamily recursive(m3b U8 接入)', () => {
|
|
1307
|
+
let dir: string
|
|
1308
|
+
const MAIN = '019e6c96-cccc-dddd-eeee-000000000001'
|
|
1309
|
+
const SUB = '019e6c96-cccc-dddd-eeee-000000000002'
|
|
1310
|
+
|
|
1311
|
+
beforeEach(async () => {
|
|
1312
|
+
dir = await mkdtemp(join(tmpdir(), 'tool-handler-recursive-'))
|
|
1313
|
+
await makeFixtureSession(dir, MAIN, 'main session 内容')
|
|
1314
|
+
// 顶层 subagent(rootSessionId=MAIN,无 parentRecordId → flat 回退)
|
|
1315
|
+
await makeFixtureSubagent(dir, `sa-${SUB.slice(0, 8)}`, {
|
|
1316
|
+
realSessionId: SUB,
|
|
1317
|
+
rootSessionId: MAIN,
|
|
1318
|
+
agentName: 'explorer',
|
|
1319
|
+
firstUserText: 'subagent task',
|
|
1320
|
+
})
|
|
1321
|
+
})
|
|
1322
|
+
afterEach(async () => {
|
|
1323
|
+
await rm(dir, { recursive: true, force: true })
|
|
1324
|
+
})
|
|
1325
|
+
|
|
1326
|
+
it('TC-m3b-dofamily-recursive-false:不传 recursive → flat family(m0-m2 零回归)', async () => {
|
|
1327
|
+
const r = await handleSessionRead({ action: 'family', session: MAIN }, dir)
|
|
1328
|
+
// details 是 Family 对象(root/subagents/workflows),非 { tree }
|
|
1329
|
+
const d = r.details as {
|
|
1330
|
+
root: { sessionId: string }
|
|
1331
|
+
subagents: Array<{ sessionId: string; rootSessionId: string }>
|
|
1332
|
+
workflows: unknown[]
|
|
1333
|
+
}
|
|
1334
|
+
expect(d.root.sessionId).toBe(MAIN)
|
|
1335
|
+
expect(d.subagents.some((s) => s.sessionId === SUB)).toBe(true)
|
|
1336
|
+
expect(Array.isArray(d.workflows)).toBe(true)
|
|
1337
|
+
// content 是 formatFamilyText(非 formatExecutionTreeText)
|
|
1338
|
+
expect(r.content[0].text).toContain('root:')
|
|
1339
|
+
expect(r.content[0].text).not.toContain('execution tree')
|
|
1340
|
+
})
|
|
1341
|
+
|
|
1342
|
+
it('TC-m3b-dofamily-recursive-true:recursive=true → ExecutionTree(details.tree)', async () => {
|
|
1343
|
+
const r = await handleSessionRead(
|
|
1344
|
+
{ action: 'family', session: MAIN, recursive: true },
|
|
1345
|
+
dir,
|
|
1346
|
+
)
|
|
1347
|
+
const d = r.details as {
|
|
1348
|
+
tree: {
|
|
1349
|
+
root: { type: string; sessionId: string; children: unknown[] }
|
|
1350
|
+
totalNodes: number
|
|
1351
|
+
maxDepth: number
|
|
1352
|
+
sourceMode: string
|
|
1353
|
+
truncated: boolean
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
expect(d.tree).toBeDefined()
|
|
1357
|
+
expect(d.tree.root.type).toBe('main')
|
|
1358
|
+
expect(d.tree.root.sessionId).toBe(MAIN)
|
|
1359
|
+
// subagent 挂 root(flat 回退,无 parentRecordId)
|
|
1360
|
+
expect(d.tree.root.children).toHaveLength(1)
|
|
1361
|
+
expect(d.tree.totalNodes).toBe(2) // main + subagent
|
|
1362
|
+
expect(d.tree.sourceMode).toBe('flat-fallback') // 旧机制无 parentRecordId
|
|
1363
|
+
expect(d.tree.truncated).toBe(false)
|
|
1364
|
+
// content 是 formatExecutionTreeText(含 execution tree 头部)
|
|
1365
|
+
expect(r.content[0].text).toContain('execution tree')
|
|
1366
|
+
expect(r.content[0].text).toContain('node(s)')
|
|
1367
|
+
expect(r.content[0].text).toContain('👉')
|
|
1368
|
+
})
|
|
1369
|
+
})
|