Files
Agent-System/internal/projectinstance/message_scope_test.go
T
weichenandCursor 8d22fa3827 fix(projectinstance): resolve legacy task keys against namespaced snapshots
New workflow instances store bd_* task keys, but chat still sends the old keys. Scope and document access now treat those aliases as the same task so a live instance no longer fails closed on an exact string miss.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 05:03:08 +00:00

201 lines
6.8 KiB
Go

package projectinstance
import (
"context"
"errors"
"testing"
)
func TestResolveMessageScopeForTask_NeverActivatedPreActivation(t *testing.T) {
ctx := context.Background()
store := newMemStore()
_ = SeedProjectMeta(store, 6071, LifecyclePlanning, "", 86, 0)
svc := NewService(store, testResolver(), nil, nil)
scope, err := svc.ResolveMessageScopeForTask(ctx, 6071, "intake")
if err != nil {
t.Fatalf("never-activated must resolve: %v", err)
}
if scope == nil || scope.ScopeKind != ScopePreActivation {
t.Fatalf("want pre_activation, got %+v", scope)
}
if !MessageVisible(*scope, &StageAccess{Restricted: false}, 86, 86) {
t.Fatal("session owner must see pre_activation task messages")
}
if MessageVisible(*scope, &StageAccess{Restricted: false}, 86, 99) {
t.Fatal("non-owner must not see pre_activation task messages")
}
}
func TestResolveMessageScopeForTask_LiveStageScope(t *testing.T) {
ctx := context.Background()
store := newMemStore()
_ = SeedProjectMeta(store, 42, LifecyclePlanning, "", 1, 0)
svc := NewService(store, testResolver(), nil, nil)
mustActivateTestPlan(t, svc, 42, 1)
scope, err := svc.ResolveMessageScopeForTask(ctx, 42, "intake")
if err != nil {
t.Fatalf("live resolve: %v", err)
}
if scope.ScopeKind != ScopeWorkflowStage || scope.StageKey == "" || scope.WorkflowInstanceID <= 0 {
t.Fatalf("want workflow_stage with ids, got %+v", scope)
}
}
func TestResolveMessageScopeForTask_ClosedNotPreActivation(t *testing.T) {
ctx := context.Background()
for _, status := range []string{InstanceCompleted, InstanceTerminated} {
t.Run(status, func(t *testing.T) {
store := newMemStore()
_ = SeedProjectMeta(store, 71, LifecyclePlanning, "", 1, 0)
svc := NewService(store, testResolver(), nil, nil)
mustActivateTestPlan(t, svc, 71, 1)
live, err := store.GetLiveInstance(ctx, 71)
if err != nil || live == nil {
t.Fatalf("live before close: %v", err)
}
live.Status = status
ms := store
ms.mu.Lock()
ms.live[71] = live
ms.mu.Unlock()
// access gate 先拒
d := svc.CheckDocTaskAccess(ctx, 71, 1, "intake", DocAccessActionRun, true)
if d.Allowed || d.Code != AccessCodeInstanceClosed {
t.Fatalf("access gate must close, got %+v", d)
}
// 直接调用层也不得当预激活
_, scopeErr := svc.ResolveMessageScopeForTask(ctx, 71, "intake")
if scopeErr == nil || !errors.Is(scopeErr, ErrTaskScopeNotResolved) {
t.Fatalf("closed must ErrTaskScopeNotResolved, got %v", scopeErr)
}
})
}
}
func TestResolveMessageScopeForTask_StorageErrorPropagates(t *testing.T) {
want := errors.New("db down")
svc := NewService(&errGoverningStore{err: want}, testResolver(), nil, nil)
_, err := svc.ResolveMessageScopeForTask(context.Background(), 1, "intake")
if err == nil || !errors.Is(err, want) {
t.Fatalf("want storage error %v, got %v", want, err)
}
}
func TestMessageVisible_PreActivationOwnerOnly(t *testing.T) {
access := &StageAccess{Restricted: true, AllowedStageKeys: map[string]bool{"V0.1_triage": true}}
scope := MessageScope{ScopeKind: ScopePreActivation}
if !MessageVisible(scope, access, 9, 9) {
t.Fatal("owner should see pre_activation messages")
}
if MessageVisible(scope, access, 9, 99) {
t.Fatal("non-owner must not see pre_activation messages")
}
}
func TestMessageVisible_WorkflowStageRestricted(t *testing.T) {
access := &StageAccess{
Restricted: true,
AllowedStageKeys: map[string]bool{"V0.1_triage": true},
}
scope := MessageScope{ScopeKind: ScopeWorkflowStage, StageKey: "V0.1_triage"}
if !MessageVisible(scope, access, 9, 9) {
t.Fatal("allowed stage should be visible")
}
scope.StageKey = "V0.5_feasibility"
if MessageVisible(scope, access, 9, 9) {
t.Fatal("disallowed stage must be hidden")
}
}
func TestMessageVisible_LegacyUnscopedOwnerWhenRestricted(t *testing.T) {
access := &StageAccess{Restricted: true}
scope := MessageScope{ScopeKind: ScopeLegacyUnscoped}
if !MessageVisible(scope, access, 9, 9) {
t.Fatal("owner should see legacy_unscoped when restricted")
}
if MessageVisible(scope, access, 9, 99) {
t.Fatal("non-owner must not see legacy_unscoped when restricted")
}
}
func TestResolveMessageScopeForTask_LegacyKeyMatchesCanonicalSnapshot(t *testing.T) {
ctx := context.Background()
store := newMemStore()
_ = SeedProjectMeta(store, 42, LifecyclePlanning, "", 1, 0)
svc := NewService(store, testResolver(), nil, nil)
mustActivateTestPlan(t, svc, 42, 1)
store.mu.Lock()
live := store.live[42]
live.ConfigSnapshot.Stages[0].DocTasks[0].TaskKey = "bd_intake"
for i := range store.stages[live.ID] {
for j := range store.stages[live.ID][i].tasks {
if store.stages[live.ID][i].tasks[j].taskKey == "intake" {
store.stages[live.ID][i].tasks[j].taskKey = "bd_intake"
}
}
}
store.mu.Unlock()
scope, err := svc.ResolveMessageScopeForTask(ctx, 42, "intake")
if err != nil {
t.Fatalf("legacy intake must resolve against bd_intake snapshot: %v", err)
}
if scope.ScopeKind != ScopeWorkflowStage || scope.StageKey != "V0.1_triage" {
t.Fatalf("want V0.1_triage workflow_stage, got %+v", scope)
}
}
func TestResolveMessageScopeForTask_CanonicalAliasAmbiguousAcrossStages(t *testing.T) {
ctx := context.Background()
store := newMemStore()
_ = SeedProjectMeta(store, 43, LifecyclePlanning, "", 1, 0)
svc := NewService(store, testResolver(), nil, nil)
mustActivateTestPlan(t, svc, 43, 1)
store.mu.Lock()
live := store.live[43]
live.ConfigSnapshot.Stages = append(live.ConfigSnapshot.Stages, StagePlan{
StageKey: "V0.5_feasibility", Enabled: true,
DocTasks: []DocTaskPlan{{TaskKey: "bd_intake", Enabled: true}},
})
store.stages[live.ID] = append(store.stages[live.ID], memStageRecord{
id: live.ID + 100, stageKey: "V0.5_feasibility", position: 1, enabled: true, status: StagePending,
tasks: []memDocTaskRecord{{id: 900, taskKey: "bd_intake", enabled: true, status: "pending"}},
})
store.mu.Unlock()
_, err := svc.ResolveMessageScopeForTask(ctx, 43, "intake")
if err == nil || !errors.Is(err, ErrTaskScopeNotResolved) {
t.Fatalf("same task in two stages must stay unresolved, got %v", err)
}
}
func TestTaskKeyToStage_LegacyAlias(t *testing.T) {
plan := validPlan()
plan.Stages[0].DocTasks[0].TaskKey = "bd_user_requirements"
if got := TaskKeyToStage(plan, "user_requirements"); got != "V0.1_triage" {
t.Fatalf("TaskKeyToStage=%q", got)
}
if got := TaskKeyToStage(plan, "intake"); got != "" {
t.Fatalf("unrelated legacy key must miss, got %q", got)
}
}
func TestScopeFromMetadata_RoundTrip(t *testing.T) {
meta := map[string]any{
"scope_kind": ScopeWorkflowStage,
"workflow_instance_id": float64(89),
"stage_instance_id": float64(201),
"stage_key": "V0.1_triage",
}
scope := ScopeFromMetadata(meta)
if scope.ScopeKind != ScopeWorkflowStage || scope.WorkflowInstanceID != 89 || scope.StageInstanceID != 201 || scope.StageKey != "V0.1_triage" {
t.Fatalf("unexpected scope: %+v", scope)
}
}