// plan_test.go - 模块 17 UltraPlan 测试 // // 测试覆盖: // - FilePlanStore: word slug 生成,读写,路径遍历防护,ClearSession // - MemoryPlanStore: 基本读写,并发安全 // - validatePlanSlug: 合法/非法字符检测 // - generateWordSlug: 格式验证 // - PlanModeManager: Enter/Exit 状态机,prePlanMode 恢复,ErrPlanRejected // - EnterPlanModeTool / ExitPlanModeTool: 工具接口行为 // - ApprovalPolicy: NoopApprovalPolicy,FuncApprovalPolicy package engine import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "strings" "sync" "testing" "git.flytoex.net/yuanwei/flyto-agent/pkg/permission" ) // ───────────────────────────────────────────── // generateWordSlug 测试 // ───────────────────────────────────────────── func TestGenerateWordSlug_Format(t *testing.T) { for i := 0; i < 50; i++ { slug := generateWordSlug() // slug 必须包含恰好一个连字符 parts := strings.Split(slug, "-") if len(parts) != 2 { t.Errorf("expected 2 parts separated by '-', got %q", slug) } // 每个部分只含小写字母 for _, part := range parts { for _, ch := range part { if ch < 'a' || ch > 'z' { t.Errorf("slug part contains non-lowercase char %q in slug %q", ch, slug) } } } } } func TestGenerateWordSlug_Randomness(t *testing.T) { seen := make(map[string]bool) for i := 0; i < 100; i++ { seen[generateWordSlug()] = true } // 100 次生成应该有相当多的不同值(组合数 40×40=1600) if len(seen) < 10 { t.Errorf("expected at least 10 distinct slugs in 100 tries, got %d", len(seen)) } } // ───────────────────────────────────────────── // validatePlanSlug 测试 // ───────────────────────────────────────────── func TestValidatePlanSlug_Valid(t *testing.T) { cases := []string{ "scenic-delta", "amber-brook", "abc-123", "a-b", } for _, slug := range cases { if err := validatePlanSlug(slug); err != nil { t.Errorf("expected valid slug %q, got error: %v", slug, err) } } } func TestValidatePlanSlug_Invalid(t *testing.T) { cases := []struct { slug string desc string }{ {"", "empty"}, {"../etc/passwd", "path traversal"}, {"foo/bar", "slash"}, {"FOO-bar", "uppercase"}, {"foo bar", "space"}, {"foo--bar", "consecutive dashes"}, {"foo.bar", "dot"}, } for _, tc := range cases { if err := validatePlanSlug(tc.slug); err == nil { t.Errorf("expected error for %s slug %q, got nil", tc.desc, tc.slug) } } } // ───────────────────────────────────────────── // FilePlanStore 测试 // ───────────────────────────────────────────── func TestFilePlanStore_WriteRead(t *testing.T) { dir := t.TempDir() store := &FilePlanStore{Dir: dir} // 写入计划 if err := store.WritePlan("session-1", "# My Plan\n\nStep 1: do things"); err != nil { t.Fatalf("WritePlan: %v", err) } // 读取计划 content, err := store.ReadPlan("session-1") if err != nil { t.Fatalf("ReadPlan: %v", err) } if !strings.Contains(content, "Step 1: do things") { t.Errorf("unexpected content: %q", content) } } func TestFilePlanStore_ReadNonExistent(t *testing.T) { dir := t.TempDir() store := &FilePlanStore{Dir: dir} content, err := store.ReadPlan("no-such-session") if err != nil { t.Fatalf("expected nil error for missing plan, got: %v", err) } if content != "" { t.Errorf("expected empty content, got: %q", content) } } func TestFilePlanStore_SlugStable(t *testing.T) { // 同一 sessionID 应该总是返回相同的 slug dir := t.TempDir() store := &FilePlanStore{Dir: dir} path1 := store.PlanPath("session-x") path2 := store.PlanPath("session-x") if path1 != path2 { t.Errorf("PlanPath not stable: %q vs %q", path1, path2) } } func TestFilePlanStore_DifferentSessions(t *testing.T) { // 不同 sessionID 应该使用不同路径 dir := t.TempDir() store := &FilePlanStore{Dir: dir} path1 := store.PlanPath("session-a") path2 := store.PlanPath("session-b") if path1 == path2 { t.Errorf("different sessions should have different plan paths") } } func TestFilePlanStore_ClearSession(t *testing.T) { dir := t.TempDir() store := &FilePlanStore{Dir: dir} // 第一次获取路径 path1 := store.PlanPath("session-clear") // 清除后重新获取(可能不同,也可能相同--关键是 ClearSession 不报错) store.ClearSession("session-clear") path2 := store.PlanPath("session-clear") // path2 可能 == path1(如果 slug 随机选中相同),不强求不同 // 只确认两次都是合法路径 if filepath.Dir(path1) != dir || filepath.Dir(path2) != dir { t.Errorf("plan paths should be in store dir") } } func TestFilePlanStore_PlanPathExtension(t *testing.T) { dir := t.TempDir() store := &FilePlanStore{Dir: dir} path := store.PlanPath("s1") if !strings.HasSuffix(path, ".md") { t.Errorf("plan path should end with .md, got: %q", path) } } func TestFilePlanStore_DefaultDir(t *testing.T) { // 不指定 Dir 时使用 tmpdir/flyto-plans store := &FilePlanStore{} dir := store.planDir() if !strings.Contains(dir, "flyto-plans") { t.Errorf("default dir should contain 'flyto-plans', got: %q", dir) } } func TestFilePlanStore_EnsuresDirOnWrite(t *testing.T) { dir := filepath.Join(t.TempDir(), "nested", "plans") store := &FilePlanStore{Dir: dir} if err := store.WritePlan("s1", "content"); err != nil { t.Fatalf("WritePlan in nested dir: %v", err) } if _, err := os.Stat(dir); err != nil { t.Errorf("dir should have been created: %v", err) } } // TestFilePlanStore_AtomicWrite 验证 L1205 修复: WritePlan 必须通过 tmp+rename 原子写入, // 并发 Read 永远不会看到半写状态. // // 早期方案直接 os.WriteFile 有两个问题: // 1. 中途崩溃留下截断文件 (不可见但真实) // 2. 并发 Read 可能读到半写 (可验证, 本测试就在验证这个) // // 精妙之处(CLEVER): 测试用"同一 session 反复覆盖写入 2 种长度悬殊的内容 + 并发 Read", // 如果 Read 看到除了这 2 种之外的任何内容 (截断/空/乱码), 说明 atomicity 被破坏. // 长度悬殊很重要--长→短会暴露"旧文件被截断中途"的场景, 短→长会暴露"新内容未写完"的场景. func TestFilePlanStore_AtomicWrite(t *testing.T) { store := &FilePlanStore{Dir: t.TempDir()} sessionID := "atomic-test" // 准备两种长度悬殊的内容, 测试读到的内容必须是这两种之一 (never partial). short := "S" long := strings.Repeat("L", 64*1024) // 64KB, 远大于 page size, 非原子 write 易被捕获 valid := map[string]bool{short: true, long: true} // 先写一次, 保证文件存在 (否则 concurrent Read 可能拿到 "不存在" 而非 partial). if err := store.WritePlan(sessionID, short); err != nil { t.Fatalf("initial write: %v", err) } done := make(chan struct{}) defer close(done) // 写 goroutine: 快速切换 short/long. writeErrCh := make(chan error, 1) go func() { for i := 0; i < 200; i++ { select { case <-done: return default: } content := short if i%2 == 0 { content = long } if err := store.WritePlan(sessionID, content); err != nil { writeErrCh <- err return } } writeErrCh <- nil }() // 读 goroutine: 并发 Read 1000 次, 每次必须拿到 valid 集合中的一个. var partialReads int for i := 0; i < 1000; i++ { got, err := store.ReadPlan(sessionID) if err != nil { t.Fatalf("ReadPlan at iter %d: %v", i, err) } if !valid[got] { partialReads++ if partialReads <= 3 { // 只打印前 3 条避免日志爆炸, 但继续累加计数 t.Errorf("partial read at iter %d: got len=%d (expected %d or %d)", i, len(got), len(short), len(long)) } } } // 等待写 goroutine 完成 if err := <-writeErrCh; err != nil { t.Fatalf("write goroutine error: %v", err) } if partialReads > 0 { t.Errorf("atomic write broken: saw %d partial reads out of 1000", partialReads) } } // TestFilePlanStore_NoTmpLeak 验证失败路径和成功路径都不会在 plan 目录留下 .tmp 文件. // // L1205 tmp+rename 的风险是 tmp 泄漏--rename 成功后 tmp 已消失, 但 rename 前任何 // 错误分支都必须 rm tmp.本测试正向验证"100 次 WritePlan 后目录只有 1 个 .md 文件". func TestFilePlanStore_NoTmpLeak(t *testing.T) { dir := t.TempDir() store := &FilePlanStore{Dir: dir} for i := 0; i < 100; i++ { if err := store.WritePlan("s1", fmt.Sprintf("content-%d", i)); err != nil { t.Fatalf("WritePlan iter %d: %v", i, err) } } entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("ReadDir: %v", err) } var mdCount, tmpCount int for _, e := range entries { name := e.Name() if strings.HasSuffix(name, ".md") { mdCount++ } if strings.Contains(name, ".tmp") { tmpCount++ } } if mdCount != 1 { t.Errorf("expected 1 .md file, got %d", mdCount) } if tmpCount != 0 { t.Errorf("tmp leak: found %d .tmp files in plan dir", tmpCount) } } // ───────────────────────────────────────────── // MemoryPlanStore 测试 // ───────────────────────────────────────────── func TestMemoryPlanStore_WriteRead(t *testing.T) { store := NewMemoryPlanStore("") if err := store.WritePlan("s1", "hello plan"); err != nil { t.Fatalf("WritePlan: %v", err) } content, err := store.ReadPlan("s1") if err != nil || content != "hello plan" { t.Errorf("ReadPlan: got %q, %v", content, err) } } func TestMemoryPlanStore_ReadEmpty(t *testing.T) { store := NewMemoryPlanStore("") content, err := store.ReadPlan("nonexistent") if err != nil || content != "" { t.Errorf("expected empty, got %q, %v", content, err) } } func TestMemoryPlanStore_PlanPath(t *testing.T) { store := NewMemoryPlanStore("memory://plans") path := store.PlanPath("session-42") if path != "memory://plans/session-42" { t.Errorf("unexpected path: %q", path) } } func TestMemoryPlanStore_DefaultPrefix(t *testing.T) { store := NewMemoryPlanStore("") path := store.PlanPath("s") if !strings.HasPrefix(path, "memory://") { t.Errorf("expected memory:// prefix, got: %q", path) } } func TestMemoryPlanStore_Overwrite(t *testing.T) { store := NewMemoryPlanStore("") _ = store.WritePlan("s1", "v1") _ = store.WritePlan("s1", "v2") content, _ := store.ReadPlan("s1") if content != "v2" { t.Errorf("expected v2 after overwrite, got %q", content) } } func TestMemoryPlanStore_Concurrent(t *testing.T) { store := NewMemoryPlanStore("") var wg sync.WaitGroup for i := 0; i < 50; i++ { wg.Add(1) go func(n int) { defer wg.Done() id := strings.Repeat("s", n%5+1) _ = store.WritePlan(id, "content") _, _ = store.ReadPlan(id) }(i) } wg.Wait() } // ───────────────────────────────────────────── // PlanModeManager 测试 // ───────────────────────────────────────────── func newTestManager() *PlanModeManager { store := NewMemoryPlanStore("") perms := permission.NewEngine(permission.ModeDefault, nil) return NewPlanModeManager(store, NoopApprovalPolicy{}, perms) } func TestPlanModeManager_Enter(t *testing.T) { m := newTestManager() m.SetSessionID("sess-1") if m.IsActive() { t.Fatal("should not be active before Enter") } if err := m.Enter(); err != nil { t.Fatalf("Enter: %v", err) } if !m.IsActive() { t.Fatal("should be active after Enter") } } func TestPlanModeManager_EnterSetsPermMode(t *testing.T) { store := NewMemoryPlanStore("") perms := permission.NewEngine(permission.ModeDefault, nil) m := NewPlanModeManager(store, NoopApprovalPolicy{}, perms) m.SetSessionID("s") if err := m.Enter(); err != nil { t.Fatalf("Enter: %v", err) } if perms.Mode() != permission.ModePlan { t.Errorf("expected ModePlan, got %q", perms.Mode()) } } func TestPlanModeManager_DoubleEnterFails(t *testing.T) { m := newTestManager() m.SetSessionID("s") _ = m.Enter() if err := m.Enter(); err == nil { t.Error("double Enter should return error") } } func TestPlanModeManager_ExitRestoresPermMode(t *testing.T) { store := NewMemoryPlanStore("") perms := permission.NewEngine(permission.ModeAcceptEdits, nil) m := NewPlanModeManager(store, NoopApprovalPolicy{}, perms) m.SetSessionID("s") _ = m.Enter() if perms.Mode() != permission.ModePlan { t.Fatal("should be ModePlan after Enter") } // 写入计划让 Exit 有内容读 _ = store.WritePlan("s", "my plan") _, err := m.Exit(context.Background(), nil) if err != nil { t.Fatalf("Exit: %v", err) } if perms.Mode() != permission.ModeAcceptEdits { t.Errorf("expected ModeAcceptEdits restored, got %q", perms.Mode()) } } func TestPlanModeManager_ExitReturnsApprovedPlan(t *testing.T) { store := NewMemoryPlanStore("") store.WritePlan("s", "# Plan\nStep 1") perms := permission.NewEngine(permission.ModeDefault, nil) m := NewPlanModeManager(store, NoopApprovalPolicy{}, perms) m.SetSessionID("s") _ = m.Enter() plan, err := m.Exit(context.Background(), nil) if err != nil { t.Fatalf("Exit: %v", err) } if !strings.Contains(plan, "Step 1") { t.Errorf("unexpected plan: %q", plan) } if m.IsActive() { t.Error("should not be active after approved Exit") } } func TestPlanModeManager_ExitWithRejection(t *testing.T) { store := NewMemoryPlanStore("") _ = store.WritePlan("s", "draft plan") perms := permission.NewEngine(permission.ModeDefault, nil) // 拒绝审批策略 rejectPolicy := FuncApprovalPolicy{ Fn: func(_ context.Context, _ PlanApprovalEvent) (bool, string, error) { return false, "", nil }, } m := NewPlanModeManager(store, rejectPolicy, perms) m.SetSessionID("s") _ = m.Enter() _, err := m.Exit(context.Background(), nil) if !errors.Is(err, ErrPlanRejected) { t.Errorf("expected ErrPlanRejected, got %v", err) } // 拒绝后仍然处于 plan 模式 if !m.IsActive() { t.Error("should remain active after rejection") } // 权限模式不应该恢复(仍然是 plan 模式) if perms.Mode() != permission.ModePlan { t.Errorf("expected ModePlan after rejection, got %q", perms.Mode()) } } func TestPlanModeManager_ExitWithEditedPlan(t *testing.T) { store := NewMemoryPlanStore("") _ = store.WritePlan("s", "original plan") perms := permission.NewEngine(permission.ModeDefault, nil) // 审批策略返回编辑后的版本 editPolicy := FuncApprovalPolicy{ Fn: func(_ context.Context, _ PlanApprovalEvent) (bool, string, error) { return true, "edited plan", nil }, } m := NewPlanModeManager(store, editPolicy, perms) m.SetSessionID("s") _ = m.Enter() plan, err := m.Exit(context.Background(), nil) if err != nil { t.Fatalf("Exit: %v", err) } if plan != "edited plan" { t.Errorf("expected edited plan, got %q", plan) } // 编辑后的版本应写回存储 stored, _ := store.ReadPlan("s") if stored != "edited plan" { t.Errorf("edited plan not persisted: %q", stored) } } func TestPlanModeManager_ExitNotActive(t *testing.T) { m := newTestManager() m.SetSessionID("s") // 没有 Enter 就 Exit _, err := m.Exit(context.Background(), nil) if err == nil { t.Error("Exit without Enter should return error") } } func TestPlanModeManager_ApprovalEventFields(t *testing.T) { store := NewMemoryPlanStore("") _ = store.WritePlan("sess-x", "my plan content") var capturedEvent PlanApprovalEvent capturePolicy := FuncApprovalPolicy{ Fn: func(_ context.Context, event PlanApprovalEvent) (bool, string, error) { capturedEvent = event return true, "", nil }, } perms := permission.NewEngine(permission.ModeDefault, nil) m := NewPlanModeManager(store, capturePolicy, perms) m.SetSessionID("sess-x") _ = m.Enter() _, _ = m.Exit(context.Background(), nil) if capturedEvent.SessionID != "sess-x" { t.Errorf("SessionID: expected sess-x, got %q", capturedEvent.SessionID) } if capturedEvent.Plan != "my plan content" { t.Errorf("Plan: unexpected %q", capturedEvent.Plan) } if capturedEvent.FilePath == "" { t.Error("FilePath should not be empty") } } func TestPlanModeManager_NilPermEngine(t *testing.T) { // nil perms 不应该 panic(允许不需要权限引擎的场景) store := NewMemoryPlanStore("") _ = store.WritePlan("s", "plan") m := NewPlanModeManager(store, NoopApprovalPolicy{}, nil) m.SetSessionID("s") if err := m.Enter(); err != nil { t.Fatalf("Enter with nil perms: %v", err) } if _, err := m.Exit(context.Background(), nil); err != nil { t.Fatalf("Exit with nil perms: %v", err) } } // ───────────────────────────────────────────── // EnterPlanModeTool 测试 // ───────────────────────────────────────────── func TestEnterPlanModeTool_Name(t *testing.T) { m := newTestManager() tool := NewEnterPlanModeTool(m) if tool.Name() != "EnterPlanMode" { t.Errorf("unexpected name: %q", tool.Name()) } } func TestEnterPlanModeTool_InputSchema(t *testing.T) { m := newTestManager() tool := NewEnterPlanModeTool(m) schema := tool.InputSchema() var obj map[string]any if err := json.Unmarshal(schema, &obj); err != nil { t.Fatalf("InputSchema not valid JSON: %v", err) } if obj["type"] != "object" { t.Errorf("expected type=object, got %v", obj["type"]) } } func TestEnterPlanModeTool_Execute(t *testing.T) { m := newTestManager() m.SetSessionID("s") tool := NewEnterPlanModeTool(m) result, err := tool.Execute(context.Background(), nil, nil) if err != nil { t.Fatalf("Execute: %v", err) } if result.IsError { t.Errorf("unexpected error: %q", result.Output) } if !strings.Contains(result.Output, "计划模式") { t.Errorf("output should mention plan mode, got: %q", result.Output) } if !m.IsActive() { t.Error("should be in plan mode after Execute") } } func TestEnterPlanModeTool_DoubleEnter(t *testing.T) { m := newTestManager() m.SetSessionID("s") tool := NewEnterPlanModeTool(m) _, _ = tool.Execute(context.Background(), nil, nil) result, _ := tool.Execute(context.Background(), nil, nil) if !result.IsError { t.Error("double Enter should return error result") } } // ───────────────────────────────────────────── // ExitPlanModeTool 测试 // ───────────────────────────────────────────── func TestExitPlanModeTool_Name(t *testing.T) { m := newTestManager() tool := NewExitPlanModeTool(m) if tool.Name() != "ExitPlanMode" { t.Errorf("unexpected name: %q", tool.Name()) } } func TestExitPlanModeTool_NotInPlanMode(t *testing.T) { m := newTestManager() m.SetSessionID("s") tool := NewExitPlanModeTool(m) result, err := tool.Execute(context.Background(), nil, nil) if err != nil { t.Fatalf("Execute: %v", err) } if !result.IsError { t.Error("should return error when not in plan mode") } } func TestExitPlanModeTool_ApprovedPlan(t *testing.T) { store := NewMemoryPlanStore("") _ = store.WritePlan("s", "# Plan\nDo the thing") perms := permission.NewEngine(permission.ModeDefault, nil) m := NewPlanModeManager(store, NoopApprovalPolicy{}, perms) m.SetSessionID("s") _ = m.Enter() tool := NewExitPlanModeTool(m) result, err := tool.Execute(context.Background(), nil, nil) if err != nil { t.Fatalf("Execute: %v", err) } if result.IsError { t.Errorf("unexpected error: %q", result.Output) } if !strings.Contains(result.Output, "批准") { t.Errorf("should mention approval in output: %q", result.Output) } } func TestExitPlanModeTool_RejectedPlan(t *testing.T) { store := NewMemoryPlanStore("") _ = store.WritePlan("s", "draft") perms := permission.NewEngine(permission.ModeDefault, nil) rejectPolicy := FuncApprovalPolicy{ Fn: func(_ context.Context, _ PlanApprovalEvent) (bool, string, error) { return false, "", nil }, } m := NewPlanModeManager(store, rejectPolicy, perms) m.SetSessionID("s") _ = m.Enter() tool := NewExitPlanModeTool(m) result, err := tool.Execute(context.Background(), nil, nil) if err != nil { t.Fatalf("Execute: %v", err) } // 拒绝不是 IsError,只是告知模型需要修改 if result.IsError { t.Errorf("rejection should not be IsError: %q", result.Output) } if !strings.Contains(result.Output, "拒绝") { t.Errorf("should mention rejection: %q", result.Output) } } // ───────────────────────────────────────────── // PlanStep 字段验证 // ───────────────────────────────────────────── func TestPlanStep_Fields(t *testing.T) { step := PlanStep{ ID: "step-1", Description: "Implement auth module", Tools: []string{"Edit", "Write"}, Complexity: ComplexityHigh, Deps: []string{}, } if step.Complexity != ComplexityHigh { t.Errorf("unexpected complexity: %q", step.Complexity) } if len(step.Deps) != 0 { t.Errorf("expected 0 deps") } } func TestPlanStep_DepsGraph(t *testing.T) { // 验证依赖关系可以表达有向无环图 steps := []PlanStep{ {ID: "s1", Deps: []string{}}, {ID: "s2", Deps: []string{"s1"}}, {ID: "s3", Deps: []string{"s1"}}, {ID: "s4", Deps: []string{"s2", "s3"}}, } // s1 先执行,s2/s3 并行,s4 最后 // 验证结构正确(仅检查字段,调度逻辑由消费方实现) if steps[3].ID != "s4" || len(steps[3].Deps) != 2 { t.Errorf("unexpected step structure") } } // ───────────────────────────────────────────── // FuncApprovalPolicy 测试 // ───────────────────────────────────────────── func TestFuncApprovalPolicy_Approve(t *testing.T) { policy := FuncApprovalPolicy{ Fn: func(_ context.Context, _ PlanApprovalEvent) (bool, string, error) { return true, "edited", nil }, } approved, edited, err := policy.RequestApproval(context.Background(), PlanApprovalEvent{}) if !approved || edited != "edited" || err != nil { t.Errorf("unexpected: %v, %q, %v", approved, edited, err) } } func TestFuncApprovalPolicy_Error(t *testing.T) { policy := FuncApprovalPolicy{ Fn: func(_ context.Context, _ PlanApprovalEvent) (bool, string, error) { return false, "", errors.New("timeout") }, } _, _, err := policy.RequestApproval(context.Background(), PlanApprovalEvent{}) if err == nil { t.Error("expected error from policy") } } // blockingPolicy blocks RequestApproval until ctx is cancelled. Used by // push-path tests to ensure the pull path doesn't resolve first; only the // ctx-embedded emitter calling event.Approve / event.Reject will win the // race inside PlanModeManager.Exit. // // blockingPolicy 让 RequestApproval 一直阻塞直到 ctx 取消. push 路径测 // 试用它保证 pull 路径不会先 resolve; 只有 ctx emitter 调 event.Approve // / event.Reject 才能赢 PlanModeManager.Exit 内部的 resolveCh 竞速. type blockingPolicy struct{} func (blockingPolicy) RequestApproval(ctx context.Context, _ PlanApprovalEvent) (bool, string, error) { <-ctx.Done() return false, "", ctx.Err() } // TestPlanMode_Exit_PushPath_EventApproveResolves verifies the push path // end-to-end: an EventEmitter injected via ctx receives PlanApprovalEvent // (with Steps populated from the Exit argument), the subscriber calls // event.Approve("edited"), and PlanModeManager.Exit returns the edited // plan — while the blocking policy never resolves on the pull side. // // This is the "internal wire" fix validation for Approve / Reject: // previously those func fields sat unpopulated on the event struct // (dead), because Exit only consumed policy.RequestApproval's return // value and the event wasn't forwarded anywhere. Now event.Approve is // real, the event flows through ctx emitter, and external subscribers // can race-resolve the approval. // // TestPlanMode_Exit_PushPath_EventApproveResolves 端到端验证 push 路径: // 经 ctx 注入的 EventEmitter 收到 PlanApprovalEvent (Steps 由 Exit 参 // 数填), 订阅者调 event.Approve("edited"), PlanModeManager.Exit 返回 // 编辑后的计划 — 此时 blocking policy 在 pull 侧一直没 resolve. // // 这是 Approve / Reject 的"内部 wire"修复验证: 之前 func 字段挂牌不 // 填 (dead), 因为 Exit 只用 policy.RequestApproval 返回值, event 也没 // 往外转. 现在 event.Approve 是实装的, event 经 ctx emitter 流出, 外 // 部订阅者能竞速赢批准. func TestPlanMode_Exit_PushPath_EventApproveResolves(t *testing.T) { store := NewMemoryPlanStore("") if err := store.WritePlan("sess-push", "# Plan\n1. analyze"); err != nil { t.Fatalf("WritePlan: %v", err) } m := NewPlanModeManager(store, blockingPolicy{}, nil) m.SetSessionID("sess-push") if err := m.Enter(); err != nil { t.Fatalf("Enter: %v", err) } var captured *PlanApprovalEvent emit := func(evt Event) { if pa, ok := evt.(*PlanApprovalEvent); ok { captured = pa _ = pa.Approve("edited-plan") } } ctx := WithEventEmitter(context.Background(), emit) steps := []PlanStep{{ID: "s1", Description: "analyze", Complexity: ComplexityLow}} result, err := m.Exit(ctx, steps) if err != nil { t.Fatalf("Exit: %v", err) } if result != "edited-plan" { t.Errorf("result = %q, want edited-plan", result) } if captured == nil { t.Fatal("emitter never received PlanApprovalEvent") } if captured.SessionID != "sess-push" { t.Errorf("event.SessionID = %q, want sess-push", captured.SessionID) } if len(captured.Steps) != 1 || captured.Steps[0].ID != "s1" { t.Errorf("event.Steps not propagated: %+v", captured.Steps) } if captured.Approve == nil || captured.Reject == nil { t.Error("event.Approve / Reject should be populated (push path)") } } // TestPlanMode_Exit_PushPath_EventRejectResolves mirrors the Approve // test for the Reject path: subscriber calls event.Reject(...) and Exit // returns ErrPlanRejected. // // TestPlanMode_Exit_PushPath_EventRejectResolves 对 Reject 路径的镜像 // 测试: 订阅者调 event.Reject(...), Exit 返回 ErrPlanRejected. func TestPlanMode_Exit_PushPath_EventRejectResolves(t *testing.T) { store := NewMemoryPlanStore("") if err := store.WritePlan("sess-reject", "plan text"); err != nil { t.Fatalf("WritePlan: %v", err) } m := NewPlanModeManager(store, blockingPolicy{}, nil) m.SetSessionID("sess-reject") if err := m.Enter(); err != nil { t.Fatalf("Enter: %v", err) } emit := func(evt Event) { if pa, ok := evt.(*PlanApprovalEvent); ok { _ = pa.Reject("needs more detail") } } ctx := WithEventEmitter(context.Background(), emit) _, err := m.Exit(ctx, nil) if err != ErrPlanRejected { t.Fatalf("err = %v, want ErrPlanRejected", err) } } // TestPlanMode_Exit_PullPath_RegressionWithSteps is a regression guard: // pull-mode (policy returns directly, no ctx emitter) continues to work // after the push-path refactor, and steps passed as Exit argument reach // the policy via event.Steps. // // TestPlanMode_Exit_PullPath_RegressionWithSteps 回归闸: push 路径重构 // 后, pull 模式 (policy 直接返回, 无 ctx emitter) 仍工作, 且 Exit 传入 // 的 steps 经 event.Steps 到达 policy. func TestPlanMode_Exit_PullPath_RegressionWithSteps(t *testing.T) { store := NewMemoryPlanStore("") if err := store.WritePlan("sess-pull", "pull plan"); err != nil { t.Fatalf("WritePlan: %v", err) } var policySawSteps []PlanStep policy := FuncApprovalPolicy{ Fn: func(_ context.Context, e PlanApprovalEvent) (bool, string, error) { policySawSteps = e.Steps return true, "", nil }, } m := NewPlanModeManager(store, policy, nil) m.SetSessionID("sess-pull") if err := m.Enter(); err != nil { t.Fatalf("Enter: %v", err) } steps := []PlanStep{{ID: "p1", Tools: []string{"Read"}}} result, err := m.Exit(context.Background(), steps) if err != nil { t.Fatalf("Exit: %v", err) } if result != "pull plan" { t.Errorf("result = %q, want pull plan", result) } if len(policySawSteps) != 1 || policySawSteps[0].ID != "p1" { t.Errorf("policy did not see Steps: %+v", policySawSteps) } }