package mcp import ( "context" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" "github.com/yan1h/agent-coding-workflow/internal/workspace" ) // ===== fakes ===== // fakeACPBridge implements ACPBridge. type fakeACPBridge struct { kinds []ACPAgentKind sessions []ACPSession perms []ACPPermission terminated []uuid.UUID turns map[uuid.UUID][]ACPTurn dashboard *ACPRunDashboard dashIn ACPRunDashboardInput } func (f *fakeACPBridge) ListAgentKinds(_ context.Context, _ uuid.UUID, _ bool) ([]ACPAgentKind, error) { return f.kinds, nil } func (f *fakeACPBridge) GetAgentKind(_ context.Context, _ uuid.UUID, _ bool, id uuid.UUID) (*ACPAgentKind, error) { for i := range f.kinds { if f.kinds[i].ID == id { return &f.kinds[i], nil } } return nil, errs.New(errs.CodeNotFound, "agent kind") } func (f *fakeACPBridge) CreateSession(_ context.Context, userID uuid.UUID, _ bool, in ACPCreateSessionInput) (*ACPSession, error) { s := ACPSession{ ID: uuid.New(), WorkspaceID: in.WorkspaceID, ProjectID: testPID, AgentKindID: in.AgentKindID, UserID: userID, Status: "starting", StartedAt: time.Now(), } if in.Branch != nil { s.Branch = *in.Branch } f.sessions = append(f.sessions, s) return &s, nil } func (f *fakeACPBridge) GetSession(_ context.Context, _ uuid.UUID, _ bool, id uuid.UUID) (*ACPSession, error) { for i := range f.sessions { if f.sessions[i].ID == id { return &f.sessions[i], nil } } return nil, errs.New(errs.CodeNotFound, "session") } func (f *fakeACPBridge) ListSessions(_ context.Context, _ uuid.UUID, _ bool, _ ACPSessionFilter) ([]ACPSession, error) { return f.sessions, nil } func (f *fakeACPBridge) TerminateSession(_ context.Context, _ uuid.UUID, _ bool, id uuid.UUID) error { f.terminated = append(f.terminated, id) return nil } func (f *fakeACPBridge) ListPendingPermissions(_ context.Context, _ uuid.UUID, _ bool, sessionID uuid.UUID) ([]ACPPermission, error) { out := make([]ACPPermission, 0, len(f.perms)) for _, p := range f.perms { if p.SessionID == sessionID { out = append(out, p) } } return out, nil } func (f *fakeACPBridge) ListPendingApprovals(_ context.Context, _ uuid.UUID, _ bool) ([]ACPPermission, error) { out := make([]ACPPermission, 0, len(f.perms)) out = append(out, f.perms...) return out, nil } func (f *fakeACPBridge) ListSessionTurns(_ context.Context, _ uuid.UUID, _ bool, sessionID uuid.UUID) ([]ACPTurn, error) { return f.turns[sessionID], nil } func (f *fakeACPBridge) GetRunDashboard(_ context.Context, _ uuid.UUID, _ bool, in ACPRunDashboardInput) (*ACPRunDashboard, error) { f.dashIn = in if f.dashboard != nil { return f.dashboard, nil } return &ACPRunDashboard{From: time.Now().Add(-24 * time.Hour), To: time.Now()}, nil } func acpTestDeps() (ServerDeps, *fakeACPBridge) { deps := testDeps() deps.Workspaces = &fakeWorkspaceSvc{items: []*workspace.Workspace{{ ID: testWSID, ProjectID: testPID, Slug: "ws-1", Name: "Workspace 1", DefaultBranch: "main", SyncStatus: workspace.SyncStatusIdle, CreatedAt: time.Now(), }}} bridge := &fakeACPBridge{} deps.ACP = bridge return deps, bridge } // ===== tests ===== func TestListAndGetAgentKinds(t *testing.T) { deps, bridge := acpTestDeps() bridge.kinds = []ACPAgentKind{{ ID: uuid.New(), Name: "claude_code", DisplayName: "Claude Code", Enabled: true, ToolAllowlist: []string{"*"}, }} result, err := callTool(ctxWithAuth(Scope{}), deps, "list_agent_kinds", map[string]any{}) require.NoError(t, err) require.False(t, result.IsError) var listOut listAgentKindsOut unmarshalStructured(t, result, &listOut) require.Len(t, listOut.AgentKinds, 1) assert.Equal(t, "claude_code", listOut.AgentKinds[0].Name) result, err = callTool(ctxWithAuth(Scope{}), deps, "get_agent_kind", map[string]any{ "agent_kind_id": bridge.kinds[0].ID.String(), }) require.NoError(t, err) var getOut agentKindDetail unmarshalStructured(t, result, &getOut) assert.Equal(t, "Claude Code", getOut.DisplayName) } func TestACPSessionLifecycle(t *testing.T) { deps, bridge := acpTestDeps() result, err := callTool(ctxWithAuth(Scope{}), deps, "create_acp_session", map[string]any{ "workspace_id": testWSID.String(), "agent_kind_id": uuid.New().String(), "branch": "feat/x", "initial_prompt": "do something", }) require.NoError(t, err) require.False(t, result.IsError) var createOut acpSessionCreateOut unmarshalStructured(t, result, &createOut) assert.True(t, createOut.OK) assert.Equal(t, "feat/x", createOut.Session.Branch) sid := createOut.Session.ID result, err = callTool(ctxWithAuth(Scope{}), deps, "get_acp_session", map[string]any{ "session_id": sid, }) require.NoError(t, err) var getOut acpSessionDetail unmarshalStructured(t, result, &getOut) assert.Equal(t, sid, getOut.ID) result, err = callTool(ctxWithAuth(Scope{}), deps, "list_acp_sessions", map[string]any{}) require.NoError(t, err) var listOut listACPSessionsOut unmarshalStructured(t, result, &listOut) require.Len(t, listOut.Sessions, 1) result, err = callTool(ctxWithAuth(Scope{}), deps, "terminate_acp_session", map[string]any{ "session_id": sid, }) require.NoError(t, err) require.False(t, result.IsError) require.Len(t, bridge.terminated, 1) assert.Equal(t, sid, bridge.terminated[0].String()) } func TestCreateACPSession_DeniedForSystemToken(t *testing.T) { deps, bridge := acpTestDeps() // system token(agent 会话签发)→ 不允许套娃创建嵌套 session ctx := context.WithValue(context.Background(), AuthContextKey, &AuthSession{ UserID: testUID.String(), TokenID: uuid.NewString(), Issuer: IssuerSystem, }) result, err := callTool(ctx, deps, "create_acp_session", map[string]any{ "workspace_id": testWSID.String(), "agent_kind_id": uuid.New().String(), }) require.NoError(t, err) assert.True(t, result.IsError) assert.Empty(t, bridge.sessions) // admin token(Issuer admin)→ 允许 ctx = context.WithValue(context.Background(), AuthContextKey, &AuthSession{ UserID: testUID.String(), TokenID: uuid.NewString(), Issuer: IssuerAdmin, }) result, err = callTool(ctx, deps, "create_acp_session", map[string]any{ "workspace_id": testWSID.String(), "agent_kind_id": uuid.New().String(), }) require.NoError(t, err) assert.False(t, result.IsError) assert.Len(t, bridge.sessions, 1) } func TestListACPSessions_ScopeFiltered(t *testing.T) { deps, bridge := acpTestDeps() otherProject := uuid.New() bridge.sessions = []ACPSession{ {ID: uuid.New(), WorkspaceID: testWSID, ProjectID: testPID, AgentKindID: uuid.New(), UserID: testUID, Status: "running", StartedAt: time.Now()}, {ID: uuid.New(), WorkspaceID: uuid.New(), ProjectID: otherProject, AgentKindID: uuid.New(), UserID: testUID, Status: "running", StartedAt: time.Now()}, } result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps, "list_acp_sessions", map[string]any{}) require.NoError(t, err) var out listACPSessionsOut unmarshalStructured(t, result, &out) require.Len(t, out.Sessions, 1, "scope 外项目的 session 应被过滤") assert.Equal(t, testPID.String(), out.Sessions[0].ProjectID) } func TestListPendingPermissions(t *testing.T) { deps, bridge := acpTestDeps() sid := uuid.New() bridge.sessions = []ACPSession{{ ID: sid, WorkspaceID: testWSID, ProjectID: testPID, AgentKindID: uuid.New(), UserID: testUID, Status: "running", StartedAt: time.Now(), }} bridge.perms = []ACPPermission{{ ID: uuid.New(), SessionID: sid, AgentRequestID: "req-1", ToolName: "fs/write", Status: "pending", CreatedAt: time.Now(), }} result, err := callTool(ctxWithAuth(Scope{}), deps, "list_pending_permissions", map[string]any{ "session_id": sid.String(), }) require.NoError(t, err) require.False(t, result.IsError) var out listPendingPermissionsOut unmarshalStructured(t, result, &out) require.Len(t, out.Permissions, 1) assert.Equal(t, "fs/write", out.Permissions[0].ToolName) assert.Equal(t, "pending", out.Permissions[0].Status) } func TestListPendingApprovals(t *testing.T) { deps, bridge := acpTestDeps() sidA := uuid.New() sidB := uuid.New() bridge.perms = []ACPPermission{ {ID: uuid.New(), SessionID: sidA, AgentRequestID: "r1", ToolName: "fs/write", Status: "pending", CreatedAt: time.Now()}, {ID: uuid.New(), SessionID: sidB, AgentRequestID: "r2", ToolName: "shell/exec", Status: "pending", CreatedAt: time.Now()}, } result, err := callTool(ctxWithAuth(Scope{}), deps, "list_pending_approvals", map[string]any{}) require.NoError(t, err) require.False(t, result.IsError) var out listPendingPermissionsOut unmarshalStructured(t, result, &out) // Cross-session: both pending requests returned regardless of session. require.Len(t, out.Permissions, 2) } func TestGetACPSession_IncludesLastStopReason(t *testing.T) { deps, bridge := acpTestDeps() sid := uuid.New() reason := "end_turn" bridge.sessions = []ACPSession{{ ID: sid, WorkspaceID: testWSID, ProjectID: testPID, AgentKindID: uuid.New(), UserID: testUID, Status: "running", LastStopReason: &reason, StartedAt: time.Now(), }} result, err := callTool(ctxWithAuth(Scope{}), deps, "get_acp_session", map[string]any{ "session_id": sid.String(), }) require.NoError(t, err) require.False(t, result.IsError) var out acpSessionDetail unmarshalStructured(t, result, &out) assert.Equal(t, "end_turn", out.LastStopReason) } func TestGetACPSessionTurns(t *testing.T) { deps, bridge := acpTestDeps() sid := uuid.New() bridge.sessions = []ACPSession{{ ID: sid, WorkspaceID: testWSID, ProjectID: testPID, AgentKindID: uuid.New(), UserID: testUID, Status: "running", StartedAt: time.Now(), }} stop := "end_turn" completed := time.Now() bridge.turns = map[uuid.UUID][]ACPTurn{ sid: { {TurnIndex: 0, Status: "completed", StopReason: &stop, UpdateCount: 3, StartedAt: time.Now(), CompletedAt: &completed}, {TurnIndex: 1, Status: "in_progress", UpdateCount: 0, StartedAt: time.Now()}, }, } result, err := callTool(ctxWithAuth(Scope{}), deps, "get_acp_session_turns", map[string]any{ "session_id": sid.String(), }) require.NoError(t, err) require.False(t, result.IsError) var out getACPSessionTurnsOut unmarshalStructured(t, result, &out) require.Len(t, out.Turns, 2) assert.Equal(t, 0, out.Turns[0].TurnIndex) assert.Equal(t, "completed", out.Turns[0].Status) assert.Equal(t, "end_turn", out.Turns[0].StopReason) assert.Equal(t, 3, out.Turns[0].UpdateCount) assert.Equal(t, "in_progress", out.Turns[1].Status) assert.Empty(t, out.Turns[1].StopReason) } func TestGetACPSessionTurns_CrossProjectDenied(t *testing.T) { deps, bridge := acpTestDeps() sid := uuid.New() otherProject := uuid.New() bridge.sessions = []ACPSession{{ ID: sid, WorkspaceID: testWSID, ProjectID: otherProject, AgentKindID: uuid.New(), UserID: testUID, Status: "running", StartedAt: time.Now(), }} // caller scope 限定到 testPID,session 属于 otherProject → 拒绝 result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps, "get_acp_session_turns", map[string]any{"session_id": sid.String()}) require.NoError(t, err) assert.True(t, result.IsError, "cross-project access must be denied") } func TestGetRunDashboard(t *testing.T) { deps, bridge := acpTestDeps() day := time.Now().UTC() bridge.dashboard = &ACPRunDashboard{ From: day.Add(-24 * time.Hour), To: day, Total: 6, Succeeded: 4, Crashed: 2, Active: 0, SuccessRate: 4.0 / 6.0, TotalCostUSD: 2.25, TotalTokens: 1500, AvgDurationSeconds: 42.5, ByDay: []ACPRunDayRow{ {Day: day, Total: 6, Succeeded: 4, Crashed: 2, TotalCostUSD: 2.25}, }, } result, err := callTool(ctxWithAuth(Scope{}), deps, "get_run_dashboard", map[string]any{}) require.NoError(t, err) require.False(t, result.IsError, "body=%v", result) var out getRunDashboardOut unmarshalStructured(t, result, &out) assert.Equal(t, int64(6), out.Total) assert.Equal(t, int64(4), out.Succeeded) assert.Equal(t, int64(2), out.Crashed) assert.InDelta(t, 4.0/6.0, out.SuccessRate, 1e-9) assert.InDelta(t, 2.25, out.TotalCostUSD, 1e-9) assert.Equal(t, int64(1500), out.TotalTokens) assert.InDelta(t, 42.5, out.AvgDurationSeconds, 1e-9) require.Len(t, out.ByDay, 1) assert.Equal(t, int64(6), out.ByDay[0].Total) } func TestGetRunDashboard_PassesFilters(t *testing.T) { deps, bridge := acpTestDeps() pid := uuid.New() reqID := uuid.New() from := time.Now().Add(-72 * time.Hour).UTC().Truncate(time.Second) to := time.Now().UTC().Truncate(time.Second) result, err := callTool(ctxWithAuth(Scope{}), deps, "get_run_dashboard", map[string]any{ "project_id": pid.String(), "requirement_id": reqID.String(), "from": from.Format(time.RFC3339), "to": to.Format(time.RFC3339), }) require.NoError(t, err) require.False(t, result.IsError, "body=%v", result) require.NotNil(t, bridge.dashIn.ProjectID) assert.Equal(t, pid, *bridge.dashIn.ProjectID) require.NotNil(t, bridge.dashIn.RequirementID) assert.Equal(t, reqID, *bridge.dashIn.RequirementID) assert.True(t, from.Equal(bridge.dashIn.From)) assert.True(t, to.Equal(bridge.dashIn.To)) } func TestGetRunDashboard_BadFilter(t *testing.T) { deps, _ := acpTestDeps() result, err := callTool(ctxWithAuth(Scope{}), deps, "get_run_dashboard", map[string]any{ "project_id": "not-a-uuid", }) require.NoError(t, err) assert.True(t, result.IsError, "invalid project_id must error") }